Skip to content

GarvitDadheech/SolTrace

Repository files navigation

SolTrace

Structured, Borsh-serialized Anchor events as a drop-in replacement for msg! in Solana programs.


Overview

Solana programs use msg! for logging, but msg! formats strings on-chain. For a common operation like logging a swap — an amount and a pubkey — msg! allocates and formats a string at execution time, consuming roughly 11,000 compute units per call.

SolTrace replaces that string with a Borsh-serialized struct emitted as an Anchor event. The same data takes ~781 compute units. The event is encoded as a Program data: log in the transaction metadata and is decodable by any Borsh-aware off-chain client, including TypeScript, Python, and Go indexers.

This is not a wrapper around msg!. SolTrace bypasses string formatting entirely. The payload is a typed struct that the user defines, serialized by Borsh directly into the event's payload field. Off-chain consumers deserialize it back into the same struct shape. There is no runtime schema reflection and no dynamic dispatch.


Benchmark

Measured on a local solana-test-validator using identical inputs (a u64 amount and a Pubkey).

Standard msg!      11086 CU
SolTrace sol_log!    781 CU
Savings            10305 CU  (92.96%)

Methodology. Two instructions are defined in programs/benchmark: one calls msg! with the amount and user formatted as a string; the other calls sol_log! with the same values serialized into a SwapExecuted struct. Both instructions are submitted in separate transactions to a local validator. Compute units are read from getTransaction on each confirmed signature. The TypeScript test suite in tests/soltrace_workspace.ts runs the benchmark and also decodes the emitted event to verify the payload round-trips correctly.

To reproduce locally:

# terminal 1
solana-test-validator --reset

# terminal 2
cargo build-sbf --manifest-path programs/benchmark/Cargo.toml
solana program deploy target/deploy/benchmark.so
yarn install
env ANCHOR_PROVIDER_URL=http://localhost:8899 \
    ANCHOR_WALLET=~/.config/solana/id.json \
    npx ts-mocha -p ./tsconfig.json tests/soltrace_workspace.ts

Installation

[dependencies]
soltrace = "0.1"
anchor-lang = "0.30"

SolTrace is tested against anchor-lang 0.30.x. Later versions are expected to be compatible but are not yet CI-verified.


Usage

Basic logging

use anchor_lang::prelude::*;
use soltrace::sol_info;

#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct SwapExecuted {
    pub amount: u64,
    pub user: Pubkey,
}

pub fn swap(ctx: Context<Swap>, amount: u64) -> Result<()> {
    sol_info!("swap_executed", SwapExecuted {
        amount,
        user: ctx.accounts.user.key(),
    });
    Ok(())
}

Convenience macros

Each macro is a thin wrapper over sol_log! with the level pre-set:

Macro Level
sol_trace!(name, payload) Trace
sol_debug!(name, payload) Debug
sol_info!(name, payload) Info
sol_warn!(name, payload) Warn
sol_error!(name, payload) Error

Serialization failure handling

If Borsh serialization of the payload fails, SolTrace emits a sentinel event:

event_name = "__soltrace_serialization_error"
payload    = []

This keeps the instruction from panicking while leaving the failure observable off-chain. No compute budget is wasted on a panic unwinding that would revert the transaction anyway.

devnet-only feature

To compile out all logging in production builds:

# mainnet/production Cargo.toml
[dependencies]
soltrace = { version = "0.1", features = ["devnet-only"] }
# devnet/test Cargo.toml
[dependencies]
soltrace = "0.1"   # no devnet-only — logging is active

When devnet-only is enabled, the emit! call and all payload serialization are removed at compile time. The macro arguments are still syntactically evaluated to avoid unused-variable warnings, but no code path reaches the serializer.


Payload Deserialization

The payload field of a SolTraceEvent contains the raw Borsh bytes of the caller's struct. To decode it in TypeScript:

import * as anchor from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import BN from "bn.js";

// Fetch the transaction and find the Program data log
const txDetails = await connection.getTransaction(signature, {
  commitment: "confirmed",
  maxSupportedTransactionVersion: 0,
});
const logs = txDetails?.meta?.logMessages ?? [];
const dataLog = logs.find((l) => l.includes("Program data: "));
const buffer = Buffer.from(dataLog!.split("Program data: ")[1], "base64");

// Layout: [discriminator:8][level:1][name_len:4][name:n][payload_len:4][payload:m]
let offset = 8;                              // skip 8-byte Anchor discriminator
const level = buffer.readUInt8(offset);      offset += 1;
const nameLen = buffer.readUInt32LE(offset); offset += 4;
const eventName = buffer.subarray(offset, offset + nameLen).toString("utf8");
offset += nameLen;
const payloadLen = buffer.readUInt32LE(offset); offset += 4;
const payload = buffer.subarray(offset, offset + payloadLen);

// Decode SwapExecuted: u64 (8 bytes LE) + Pubkey (32 bytes)
const amount = new BN(payload.subarray(0, 8), "le").toNumber();
const user   = new PublicKey(payload.subarray(8, 40)).toBase58();

The integration test in crates/soltrace/tests/integration.rs proves the binary layout is stable: any change to the Borsh format will fail that test before it reaches an indexer.


API Reference

See docs.rs/soltrace once published.

Export Kind Description
LogLevel enum Severity level; #[repr(u8)] with stable discriminants
SolTraceEvent struct The Anchor event type emitted by every sol_log! call
sol_log! macro Core macro; takes level, event name, and payload
sol_trace! macro sol_log! at Trace
sol_debug! macro sol_log! at Debug
sol_info! macro sol_log! at Info
sol_warn! macro sol_log! at Warn
sol_error! macro sol_log! at Error

Feature Flags

Flag Default Effect
devnet-only off Compiles out all emit! calls. Enable for mainnet production builds.
no-entrypoint off Passes anchor-lang/no-entrypoint through. Required when the workspace has another crate that defines the program entrypoint.

Stability and Versioning

MSRV: Rust 1.75.

Wire format stability: The LogLevel discriminants (Trace=0, Debug=1, Info=2, Warn=3, Error=4) are part of the public API and will not change without a major version bump. Any off-chain decoder that reads the raw byte at offset 9 (after the 8-byte Anchor discriminator) can rely on these values.

Semver: This crate follows standard Rust semver. Adding a new LogLevel variant is a breaking change. Changing the field order of SolTraceEvent is a breaking change. Both require a major version bump.


Contributing

Run cargo fmt and cargo clippy -- -D warnings before opening a pull request. The CI pipeline enforces both on every push to main. Tests are in crates/soltrace/tests/integration.rs and inline in each module; run them with cargo test --manifest-path crates/soltrace/Cargo.toml.


License

MIT — see LICENSE.

About

Datadog for Solana — structured logging, real-time monitoring, and alerts for on-chain programs.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors