@thesight/sdk
v0.11.2
Published
Sight SDK — OpenTelemetry-native observability for Solana. One call (initSight) wires a NodeTracerProvider + Sight exporter, then InstrumentedConnection turns every transaction into a span with per-CPI compute-unit attribution, decoded Anchor errors, and
Maintainers
Readme
@thesight/sdk
See the truth of your Solana program.
Sight is the first OpenTelemetry-native
observability SDK for Solana. One package install, one initSight()
call, and every transaction becomes a span with per-CPI compute-unit
attribution, decoded Anchor errors, and full wallet↔RPC↔program
correlation — consumable by the Sight dashboard or any APM backend
(Datadog, Grafana, Honeycomb, Tempo).
Install
pnpm add @thesight/sdk
# or npm install @thesight/sdk
# or yarn add @thesight/sdkAll OpenTelemetry dependencies are bundled transitively — you do not
need to pnpm add @opentelemetry/* separately.
Ships both ESM and CJS builds. Works from modern TypeScript projects,
Node ESM, legacy CJS via require(), bundlers, and serverless runtimes.
Quickstart — InstrumentedConnection (automatic spans)
The simplest pattern: swap your Connection for InstrumentedConnection
and every transaction you send through it gets a span automatically —
no matter whether you're calling it directly, going through an Anchor
provider.sendAndConfirm, using web3.js's top-level
sendAndConfirmTransaction, or letting a wallet adapter drive the
flow.
import { initSight, InstrumentedConnection } from '@thesight/sdk';
import { clusterApiUrl } from '@solana/web3.js';
// Call once near the top of your process entry point.
// Get your DSN from the Sight dashboard when you create a project.
initSight({
dsn: process.env.SIGHT_DSN!,
serviceName: 'my-service',
});
// Drop-in replacement for @solana/web3.js Connection
const connection = new InstrumentedConnection(clusterApiUrl('mainnet'));
// Anywhere a Solana tx goes out, a span goes with it:
await connection.sendRawTransaction(tx.serialize());
// or...
await sendAndConfirmTransaction(connection, tx, signers);
// or...
const program = new Program(idl, programId, new AnchorProvider(connection, wallet, {}));
await program.methods.foo().rpc();
// or...
await wallet.sendTransaction(tx, connection);Under the hood, InstrumentedConnection overrides the single method
every Solana send-path eventually calls — sendRawTransaction — so you
get spans regardless of which layer is driving the transaction.
What the span does, step by step
- On the synchronous submit path, a span named
solana.sendRawTransactionstarts as a child of whatever OTel context is active. super.sendRawTransaction(rawTx, options)is called verbatim — no byte mutation, no interception of signing, no private key access.- The returned signature is attached to the span and the caller gets it back immediately.
- In the background (never blocking the caller), a task polls
getTransaction(signature)with exponential backoff until the on-chain record is available, parses the program logs into a CPI tree via@thesight/core, enriches with registered Anchor IDLs, and addscpi.invokeevents, per-program CU attribution, and decoded error details to the span. - The span ends and flushes via the Sight exporter configured by
initSight.
Next.js
Sight's initSight() requires Node's native require to load OTel modules
at runtime — this conflicts with Next.js's webpack bundler if you initialize
Sight inside a Server Component or API route directly.
The easiest fix is wrapping your next.config.js with the provided helper,
which tells Next.js to leave Sight and OTel out of its bundles:
// next.config.js
const { withSight } = require('@thesight/sdk/next');
module.exports = withSight({
// ...your existing config
});Then call initSight as normal in your server code or API routes.
Alternative: instrumentation.ts
Next.js also supports an instrumentation.ts file (app root) that runs in
Node before webpack bundling. No config wrapper needed at all:
// instrumentation.ts (at the root of your Next.js app, next to package.json)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initSight } = await import('@thesight/sdk');
initSight({
dsn: process.env.SIGHT_DSN!,
serviceName: 'my-app',
});
}
}Enable it in next.config.js:
module.exports = {
experimental: { instrumentationHook: true },
};Note:
instrumentation.tsis stable in Next.js 15+ and no longer requiresexperimental.instrumentationHook.
Alternative — trackSolanaTransaction (non-wrapping)
If you don't want to wrap your Connection at all, trackSolanaTransaction
observes by signature alone after you've already sent the transaction
through any connection:
import { Connection } from '@solana/web3.js';
import { initSight, trackSolanaTransaction } from '@thesight/sdk';
initSight({ dsn: process.env.SIGHT_DSN!, serviceName: 'checkout' });
// Use your normal Connection, unchanged.
const connection = new Connection(rpcUrl);
const signature = await wallet.sendTransaction(tx, connection);
// Fire-and-forget — span flushes on its own in the background.
void trackSolanaTransaction({
signature,
connection,
serviceName: 'checkout',
idls: { [swapProgramId]: swapIdl },
});This helper never touches the send path. It only observes the
transaction after the fact by calling getTransaction(signature).
Recommended when:
- You're security-conscious and don't want any SDK code on the critical transaction path
- Your send path goes through a wallet adapter or a wrapper SDK you don't control
- You want to instrument a specific high-value transaction rather than everything a connection sends
Custom user-defined spans
sight.span() wraps a unit of work and emits it as an OTel span with
your own name + tags. Auto-nests sequentially — a sight.span inside
another sight.span's callback becomes its child in the trace tree
without needing zone.js or a registered OTel context manager.
import { sight } from '@thesight/sdk';
await sight.span('place-bet', async (s) => {
s.tag({ market: 'sol-perp', size: 10, leverage: 3 });
const quote = await fetchQuote();
const signed = await wallet.signTransaction(tx);
return connection.sendRawTransaction(signed.serialize());
});Errors thrown inside the callback mark the span as errored (OTel
status.code = ERROR, sight.error = true) and re-throw so your own
error handling still runs. For concurrent nesting (Promise.all) pass
opts.parent explicitly since the active-span stack is sequential-
only.
Wallet sign instrumentation — three integration tiers
Sight exposes three ways to get wallet.sign spans and decoded error
kinds (user_rejected, simulation_failed, timeout, etc) out of
Phantom / Backpack / Solflare / MWA signing flows. Each tier trades
ergonomics for trust surface — pick what matches your threat model.
The doctrine Sight
enforces in all three: the SDK never mutates transaction bytes,
never intercepts signing, never recommends skipPreflight: true, and
never changes send-path semantics. The tiers differ only in how much
SDK code sits between your code and the wallet adapter — not in what
that code does.
Tier 1 — useSightWallet() (turnkey, heaviest)
Drop-in replacement for @solana/wallet-adapter-react's useWallet.
Every sign method on the returned object is wrapped in a
wallet.sign / wallet.sign_all / wallet.sign_and_send /
wallet.sign_message span, tagged with wallet.name, wallet.method,
and wallet.error.kind on failure.
// Before
import { useWallet } from '@solana/wallet-adapter-react';
// After
import { useSightWallet as useWallet } from '@thesight/sdk/react';
function PlaceBetButton() {
const { signTransaction } = useWallet(); // now auto-instrumented
return <button onClick={async () => {
const signed = await signTransaction!(tx); // span emitted here
...
}}>Bet</button>;
}What the SDK does on the sign path: receives the unsigned
transaction, calls wallet.signTransaction(tx) verbatim with
byte-identical input, returns the signed result unchanged. No
mutation, no inspection, no keypair access.
Trust surface: the wrapper is on the wallet method — a future
compromised release could in principle see the unsigned tx before the
wallet. Mitigate with lockfile pinning, npm publish --provenance
once the source is public, and reading the ~100 LoC implementation in
packages/sdk/src/wallet.tsx.
Tier 2 — traceWalletSign() (helper, lighter)
Thunk-based — the caller passes a zero-arg function; Sight wraps the invocation, not the wallet. The SDK never receives a reference to the wallet adapter or to the unsigned transaction.
import { traceWalletSign } from '@thesight/sdk';
const signed = await traceWalletSign(
() => wallet.signTransaction(tx),
{ method: 'signTransaction', walletName: wallet.wallet?.adapter?.name },
);What the SDK does: starts a span, runs your closure (which
dereferences wallet in your scope — the SDK never sees it), passes
the result through by identity, classifies any thrown error via
decodeWalletError, re-throws verbatim.
Trust surface: strictly smaller than useSightWallet — the SDK
can't read or modify the transaction because you invoked the wallet
yourself inside a closure it doesn't receive. The SDK sees only the
result (to pass through) or the error (to classify).
Tier 3 — Roll your own with sight.span + decodeWalletError
No helper at all — compose the primitives yourself. This is literally
what traceWalletSign does internally; using it directly means the
only code paths that run are ones you can see line-by-line at the call
site.
import { sight, decodeWalletError } from '@thesight/sdk';
const signed = await sight.span('wallet.sign', async (s) => {
s.tag({ 'wallet.method': 'signTransaction', 'wallet.name': walletName });
try {
return await wallet.signTransaction(tx);
} catch (err) {
s.tag('wallet.error.kind', decodeWalletError(err).kind);
throw err;
}
});What the SDK does:
sight.span— creates an OTel span, pushes it on the active-span stack, runs your callback, pops on exit. No tx interaction.decodeWalletError— pure function on the thrownErrorobject's shape (message, code, attachedlogsarrays). Returns a kind string- optional sim logs. Never reads transaction state.
Trust surface: the minimum possible while still emitting decoded
spans. You control every line that touches the wallet — Sight code
only runs inside the sight.span wrapper and the decodeWalletError
classifier on the error object.
Read the source:
packages/sdk/src/span.ts— ~220 LoCpackages/core/src/wallet/index.ts— ~130 LoC
Which tier should I use?
| Priority | Pick |
|---|---|
| Fastest onboarding, minimal code changes | Tier 1 useSightWallet |
| Ergonomic + light touch, a good default | Tier 2 traceWalletSign |
| Security-conscious / regulated / audit-prone | Tier 3 manual |
| Already have your own tracing framework | Tier 3 (just use decodeWalletError) |
All three emit the same wallet.error.kind taxonomy, so dashboard
filters and alert rules written against one work for all three.
Anchor IDL registration
Sight never guesses program names from chain. Tell it what you know:
import { Program } from '@coral-xyz/anchor';
const program = new Program(idl, programId, provider);
connection.registerIdl(program.programId.toBase58(), program.idl);Or pass a map at init:
const connection = new InstrumentedConnection(rpcUrl, {
idls: {
[swapProgramId]: swapIdl,
[lendingProgramId]: lendingIdl,
},
});Unknown programs surface as short-ids in the dashboard rather than being fabricated.
Self-host the ingest
By default spans export to https://ingest.thesight.dev/ingest.
Override via ingestUrl to point at a self-hosted Sight ingest (or a
local Docker stack for development):
initSight({
dsn: 'http://sk_live_...@localhost:3007',
serviceName: 'my-service',
});What's on the span
Every instrumented transaction produces an OTel span with the following attributes when they're available:
solana.tx.signature— base58 signaturesolana.tx.status—submitted→confirmed/failed/timeoutsolana.tx.slotsolana.tx.cu_used/solana.tx.cu_budget/solana.tx.cu_utilizationsolana.tx.program— root program name (from registered IDL or the curated known-programs list)solana.tx.instruction— extracted fromProgram log: Instruction: …solana.tx.error/solana.tx.error_code/solana.tx.error_program— decoded from on-chain return data via the registered IDL's error table or the hardcoded native-error tableserror.reason— short human-readable failure string (e.g."Insufficient funds","Transaction expired. Please try again.", the extracted AnchorError Message:). Set on failedsendRawTransactionanduseSightWalletsign/send spans. Sight interprets for dashboard filter chips only; the original thrown error is re-thrown untouched so your UI code owns its own banner copy.solana.tx.fee_lamportssolana.tx.submit_ms/solana.tx.enrichment_ms— client-observed latencies- Per-CPI
cpi.invokeevents on the span: program, instruction, depth, CU consumed, CU self, percentage
Configuration
InstrumentedConnection accepts these extra options alongside the
standard web3.js ConnectionConfig:
new InstrumentedConnection(rpcUrl, {
// Standard ConnectionConfig fields work as before
commitment: 'confirmed',
// Sight-specific
tracer: customTracer,
skipIdlResolution: false, // skip log parsing + IDL decoding
idls: { [programId]: idl },
allowOnChainIdlFetch: false, // off by default — never guess
enrichmentTimeoutMs: 30_000, // background enrichment wall time
enrichmentPollIntervalMs: 500, // base poll interval with 1.5x backoff
disableAutoSpan: false, // escape hatch for tests
});Shutdown
Call shutdown() on the handle returned by initSight at graceful
shutdown time so pending spans flush before the process exits:
const sight = initSight({ dsn: process.env.SIGHT_DSN!, serviceName });
process.on('SIGTERM', async () => {
await sight.shutdown();
process.exit(0);
});License
MIT — see LICENSE.
