Every Solana transaction comes with program logs. Most people ignore them. I used to be one of those people.
Then I needed to figure out why a swap failed, and the only useful information was buried in the logs. After that, I started treating logs as the primary debugging tool on Solana. Not Solana Explorer. Not the raw transaction JSON. The logs.
Here’s everything I’ve learned about reading them.
1. How Solana Logs Actually Work
Every program on Solana can emit log messages using msg!() in Rust or sol_log in native code. These messages show up in the transaction metadata under meta.logMessages.
const tx = await connection.getParsedTransaction(signature, {
maxSupportedTransactionVersion: 0,
});
const logs = tx?.meta?.logMessages || [];
logs.forEach((log, i) => console.log(`${i}: ${log}`));
A typical swap transaction might give you 30-50 log lines. Most of them look like garbage at first. They’re not.
2. The Log Structure You Need to Know
Every Solana log follows a pattern:
Program <PROGRAM_ID> invoke [depth]
Program log: <actual message>
Program <PROGRAM_ID> consumed <X> of <Y> compute units
Program <PROGRAM_ID> success
The invoke [depth] tells you how deep in the call stack you are. [1] is the top-level instruction. [2] means a program called another program. [3] means that inner program called yet another one.
This is huge when you’re debugging cross-program invocations. If a Jupiter swap fails, the actual error is usually at depth [3] or [4] inside the underlying AMM.
3. Extracting Swap Details from Raydium Logs
Raydium logs actual swap amounts. Most people don’t know this.
function extractRaydiumSwap(logs: string[]) {
const swapLog = logs.find((l) => l.includes("ray_log"));
if (!swapLog) return null;
// Raydium encodes swap data in base64 after "ray_log: "
const match = swapLog.match(/ray_log: (.+)/);
if (!match) return null;
const decoded = Buffer.from(match[1], "base64");
// The layout depends on the instruction, but for swaps:
// bytes 0-7: amount in
// bytes 8-15: amount out
const amountIn = decoded.readBigUInt64LE(1);
const amountOut = decoded.readBigUInt64LE(9);
return {
amountIn: Number(amountIn),
amountOut: Number(amountOut),
};
}
I use this constantly. Instead of calculating swap amounts from balance diffs, the log gives you the exact numbers the AMM used internally. Way more reliable.
4. Catching Errors Before They Cost You
Failed transactions on Solana still cost fees. Reading the error logs before retrying saves money.
function parseError(logs: string[]) {
// Custom program errors
const customErr = logs.find((l) => l.includes("custom program error"));
if (customErr) {
const code = customErr.match(/0x([0-9a-fA-F]+)/);
return {
type: "custom",
code: code ? parseInt(code[1], 16) : null,
raw: customErr,
};
}
// Slippage errors from Jupiter/Raydium
const slippage = logs.find(
(l) =>
l.includes("SlippageToleranceExceeded") ||
l.includes("ExceededSlippage")
);
if (slippage) {
return { type: "slippage", raw: slippage };
}
// Insufficient funds
const funds = logs.find((l) => l.includes("insufficient"));
if (funds) {
return { type: "insufficient_funds", raw: funds };
}
return null;
}
The error codes are program-specific. Raydium uses one set, Orca uses another. I keep a map of common ones:
const RAYDIUM_ERRORS: Record<number, string> = {
0x00: "AlreadyInUse",
0x01: "InvalidProgramAddress",
0x06: "InsufficientFunds",
0x12: "InvalidInput",
0x1e: "SlippageExceeded",
};
You build this map over time. Every time you hit an unknown error code, look it up in the program’s source and add it.
5. Watching Logs in Real-Time
You don’t need to fetch transactions to read logs. Subscribe directly:
connection.onLogs(
new PublicKey("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"), // Raydium
(logInfo) => {
if (logInfo.err) {
console.log("Failed tx:", logInfo.signature);
}
const swap = extractRaydiumSwap(logInfo.logs);
if (swap) {
console.log(`Swap: ${swap.amountIn} → ${swap.amountOut}`);
}
},
"confirmed"
);
I use this to monitor swap volume on specific pools. It’s faster than querying transaction history, and you get the data the moment it lands.
6. Parsing Custom Program Events
If you’re working with your own Anchor programs or any Anchor-based protocol, events are encoded in the logs too.
Anchor events look like this in the logs:
Program data: <base64-encoded-event>
Here’s how to decode them:
import { BorshCoder } from "@coral-xyz/anchor";
function decodeAnchorEvents(logs: string[], idl: any) {
const coder = new BorshCoder(idl);
const events = [];
for (const log of logs) {
if (!log.startsWith("Program data: ")) continue;
const data = log.replace("Program data: ", "");
try {
const event = coder.events.decode(data);
if (event) events.push(event);
} catch {
// Not an event we recognize — skip
}
}
return events;
}
This is how you read custom events from any Anchor program — lending protocols, NFT marketplaces, whatever. Get the IDL, pass it to the coder, and every event in the logs becomes a typed JavaScript object.
7. The Compute Budget Trick
Every log line includes compute unit consumption. This is actually useful.
function getComputeUsage(logs: string[]) {
const consumed = logs.filter((l) => l.includes("consumed"));
return consumed.map((log) => {
const match = log.match(/consumed (\d+) of (\d+)/);
if (!match) return null;
return {
used: parseInt(match[1]),
budget: parseInt(match[2]),
program: log.match(/Program (\w+)/)?.[1],
};
}).filter(Boolean);
}
When your transactions keep failing with “exceeded compute budget”, this tells you exactly which program is eating all the units. I’ve caught bugs in my own programs this way — one bad loop was burning 400k compute units when it should’ve been 20k.
What Logs Won’t Tell You
Logs have limits. Each transaction gets a maximum log buffer, and programs can choose not to log anything useful. Some programs intentionally minimize logging to save compute units.
For those cases, you’re back to decoding instructions and comparing account state. But for 80% of debugging and monitoring tasks, the logs have everything you need.
Start reading them. You’ll wonder how you ever debugged without them.