From 89d1b7a319ff169ec3e71d4368a57d0ffe79c996 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 26 Aug 2026 12:53:02 +0100 Subject: [PATCH 1/4] feat(telemetry): add pluggable Tracer/Span interface --- src/index.ts | 2 + src/telemetry.ts | 136 +++++++++++++++++++++++++++++ test/bench/telemetry.bench.ts | 26 ++++++ test/telemetry.test.ts | 157 ++++++++++++++++++++++++++++++++++ vitest.config.ts | 2 +- 5 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 src/telemetry.ts create mode 100644 test/bench/telemetry.bench.ts create mode 100644 test/telemetry.test.ts diff --git a/src/index.ts b/src/index.ts index a37af3b..58894b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,8 @@ export type { ViemWalletClient, SolanaWalletAdapterLike, } from './wallet'; +export { setTracer, getTracer, withSpan, NOOP_TRACER } from './telemetry'; +export type { Tracer, Span } from './telemetry'; export type { WraithConfig, AgentConfig, diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 0000000..6729f51 --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,136 @@ +/** + * A single unit of traced work, as emitted by a {@link Tracer}. + * + * Implementations typically wrap a tracer-specific span object (an + * OpenTelemetry `Span`, a Sentry span, a Datadog span, ...). SDK call sites + * never depend on any tracer package directly, only on this shape. + */ +export interface Span { + /** Attaches or overwrites one attribute on the span. */ + setAttribute(key: string, value: string | number | boolean): void; + /** Records an exception on the span. Does not end the span. */ + recordException(error: unknown): void; + /** Ends the span. Call exactly once, regardless of success or failure. */ + end(): void; +} + +/** + * Pluggable tracer interface instrumented SDK call sites use to create spans. + * + * Adapt any tracing library (OpenTelemetry, Sentry, Datadog, a custom logger) + * to this shape and pass it to {@link setTracer}, or as a per-call `tracer` + * option on an instrumented function — the SDK has no runtime dependency on + * any specific tracing package. + * + * @see {@link setTracer} + * @see {@link NOOP_TRACER} + * + * @example + * ```ts + * import { setTracer, type Tracer, type Span } from '@wraith-protocol/sdk'; + * import { trace, type Span as OtelSpan } from '@opentelemetry/api'; + * + * const otelTracer = trace.getTracer('wraith-sdk'); + * + * const tracer: Tracer = { + * startSpan(name, attributes) { + * const otelSpan: OtelSpan = otelTracer.startSpan(name, { attributes }); + * const span: Span = { + * setAttribute: (key, value) => void otelSpan.setAttribute(key, value), + * recordException: (error) => void otelSpan.recordException(error as Error), + * end: () => otelSpan.end(), + * }; + * return span; + * }, + * }; + * + * setTracer(tracer); + * ``` + */ +export interface Tracer { + /** Starts (and returns) a new span. `attributes` seeds its initial attributes. */ + startSpan(name: string, attributes?: Record): Span; +} + +const NOOP_SPAN: Span = { + setAttribute() {}, + recordException() {}, + end() {}, +}; + +/** + * Tracer that creates no-op spans. + * + * This is the default tracer until {@link setTracer} is called, so instrumented + * call sites carry negligible overhead (one object allocation, no-op method + * calls) when nobody has configured tracing. + */ +export const NOOP_TRACER: Tracer = { + startSpan() { + return NOOP_SPAN; + }, +}; + +let globalTracer: Tracer = NOOP_TRACER; + +/** + * Sets the tracer instrumented SDK call sites use by default. + * + * Pass `null` (or omit) to reset to the no-op tracer. Instrumented functions + * that accept a `tracer` option override this global for that call only. + * + * @see {@link getTracer} + */ +export function setTracer(tracer?: Tracer | null): void { + globalTracer = tracer ?? NOOP_TRACER; +} + +/** Returns the currently configured tracer — the one set via {@link setTracer}, or the no-op tracer. */ +export function getTracer(): Tracer { + return globalTracer; +} + +/** + * Runs `fn` inside a span named `name`, recording any thrown or rejected error + * and always ending the span exactly once. Instrumented call sites use this so + * span lifecycle handling isn't duplicated at every call site. + * + * `fn` may return a plain value or a promise; either way the span ends when + * the work finishes (synchronously, or once the returned promise settles). + * + * @param tracer Per-call override. Falls back to {@link getTracer} when omitted. + */ +export function withSpan( + name: string, + attributes: Record | undefined, + fn: (span: Span) => T, + tracer?: Tracer, +): T { + const span = (tracer ?? globalTracer).startSpan(name, attributes); + + let result: T; + try { + result = fn(span); + } catch (err) { + span.recordException(err); + span.end(); + throw err; + } + + if (result instanceof Promise) { + return result.then( + (value) => { + span.end(); + return value; + }, + (err) => { + span.recordException(err); + span.end(); + throw err; + }, + ) as T; + } + + span.end(); + return result; +} diff --git a/test/bench/telemetry.bench.ts b/test/bench/telemetry.bench.ts new file mode 100644 index 0000000..a07b3ff --- /dev/null +++ b/test/bench/telemetry.bench.ts @@ -0,0 +1,26 @@ +import { bench, describe } from 'vitest'; +import { withSpan } from '../../src/telemetry'; + +const BENCH_OPTIONS = { time: 500 }; + +function work(x: number): number { + return x * 2 + 1; +} + +describe('telemetry: no-op tracer overhead', () => { + bench( + 'uninstrumented call', + () => { + work(41); + }, + BENCH_OPTIONS, + ); + + bench( + 'withSpan with the default no-op tracer', + () => { + withSpan('bench.op', { a: 1 }, () => work(41)); + }, + BENCH_OPTIONS, + ); +}); diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts new file mode 100644 index 0000000..4769bb8 --- /dev/null +++ b/test/telemetry.test.ts @@ -0,0 +1,157 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import { + setTracer, + getTracer, + withSpan, + NOOP_TRACER, + type Tracer, + type Span, +} from '../src/telemetry'; + +function makeRecordingTracer() { + const spans: Array<{ + name: string; + attributes: Record | undefined; + ended: boolean; + exceptions: unknown[]; + extraAttributes: Record; + }> = []; + + const tracer: Tracer = { + startSpan(name, attributes) { + const record = { + name, + attributes, + ended: false, + exceptions: [] as unknown[], + extraAttributes: {}, + }; + spans.push(record); + const span: Span = { + setAttribute(key, value) { + record.extraAttributes[key] = value; + }, + recordException(error) { + record.exceptions.push(error); + }, + end() { + record.ended = true; + }, + }; + return span; + }, + }; + + return { tracer, spans }; +} + +describe('telemetry', () => { + afterEach(() => { + setTracer(null); + }); + + test('getTracer defaults to NOOP_TRACER', () => { + expect(getTracer()).toBe(NOOP_TRACER); + }); + + test('NOOP_TRACER spans are safe no-ops', () => { + const span = NOOP_TRACER.startSpan('anything', { a: 1 }); + expect(() => { + span.setAttribute('x', 'y'); + span.recordException(new Error('boom')); + span.end(); + }).not.toThrow(); + }); + + test('setTracer configures the global tracer used by getTracer', () => { + const { tracer } = makeRecordingTracer(); + setTracer(tracer); + expect(getTracer()).toBe(tracer); + }); + + test('setTracer(null) resets to the no-op tracer', () => { + const { tracer } = makeRecordingTracer(); + setTracer(tracer); + setTracer(null); + expect(getTracer()).toBe(NOOP_TRACER); + }); + + test('withSpan runs a sync function and ends the span with its result', () => { + const { tracer, spans } = makeRecordingTracer(); + setTracer(tracer); + + const result = withSpan('op', { a: 1 }, () => 42); + + expect(result).toBe(42); + expect(spans).toHaveLength(1); + expect(spans[0].name).toBe('op'); + expect(spans[0].attributes).toEqual({ a: 1 }); + expect(spans[0].ended).toBe(true); + expect(spans[0].exceptions).toHaveLength(0); + }); + + test('withSpan records and rethrows a sync exception, still ending the span', () => { + const { tracer, spans } = makeRecordingTracer(); + setTracer(tracer); + const err = new Error('sync boom'); + + expect(() => + withSpan('op', undefined, () => { + throw err; + }), + ).toThrow(err); + + expect(spans[0].ended).toBe(true); + expect(spans[0].exceptions).toEqual([err]); + }); + + test('withSpan awaits an async function and ends the span once it resolves', async () => { + const { tracer, spans } = makeRecordingTracer(); + setTracer(tracer); + + const result = await withSpan('op', undefined, async () => { + expect(spans[0].ended).toBe(false); + return 'done'; + }); + + expect(result).toBe('done'); + expect(spans[0].ended).toBe(true); + }); + + test('withSpan records and rethrows an async rejection, still ending the span', async () => { + const { tracer, spans } = makeRecordingTracer(); + setTracer(tracer); + const err = new Error('async boom'); + + await expect( + withSpan('op', undefined, async () => { + throw err; + }), + ).rejects.toThrow(err); + + expect(spans[0].ended).toBe(true); + expect(spans[0].exceptions).toEqual([err]); + }); + + test('withSpan uses a per-call tracer override instead of the global one', () => { + const global = makeRecordingTracer(); + const override = makeRecordingTracer(); + setTracer(global.tracer); + + withSpan('op', undefined, () => 1, override.tracer); + + expect(global.spans).toHaveLength(0); + expect(override.spans).toHaveLength(1); + }); + + test('span.setAttribute inside the callback is visible on the recorded span', () => { + const { tracer, spans } = makeRecordingTracer(); + setTracer(tracer); + + withSpan('op', undefined, (span) => { + span.setAttribute('count', 3); + }); + + expect(spans[0].extraAttributes).toEqual({ count: 3 }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e0bed4e..01b3e4b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ testTimeout: 1200000, // 20 minutes for high-run nightly fuzz tests }, benchmark: { - include: ['test/chains/**/bench/**/*.bench.ts'], + include: ['test/chains/**/bench/**/*.bench.ts', 'test/bench/**/*.bench.ts'], outputFile: './bench/results.json', }, }); From 809012a2640c5c957f2c466b5a941f853a1f12d2 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 26 Aug 2026 13:09:41 +0100 Subject: [PATCH 2/4] feat(stellar): instrument key derivation and scan with tracer spans --- src/chains/stellar/index.ts | 4 +- src/chains/stellar/keys.ts | 83 +++++++++++++++--------- src/chains/stellar/scan.ts | 65 ++++++++++++++++--- test/chains/stellar/keys.test.ts | 41 +++++++++++- test/chains/stellar/scan.test.ts | 104 ++++++++++++++++++++++++++++++- 5 files changed, 258 insertions(+), 39 deletions(-) diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index 100cce5..e4e6631 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -1,4 +1,6 @@ export { deriveStealthKeys, deriveStealthKeysFromSigner } from './keys'; +export type { KeyDerivationOptions } from './keys'; +export type { Tracer, Span } from '../../telemetry'; export { FreighterStealthSigner, WebAuthnPasskeyStealthSigner } from './signer'; export type { StellarStealthSigner, @@ -22,7 +24,7 @@ export { generateStealthAddress } from './stealth'; * @internal */ export { computeSharedSecret, computeAnnouncementViewTag, computeViewTag } from './stealth'; -export { checkStealthAddress, scanAnnouncements } from './scan'; +export { checkStealthAddress, scanAnnouncements, scanAnnouncementsStream } from './scan'; /** * @internal */ diff --git a/src/chains/stellar/keys.ts b/src/chains/stellar/keys.ts index e29ca39..7da6354 100644 --- a/src/chains/stellar/keys.ts +++ b/src/chains/stellar/keys.ts @@ -5,6 +5,13 @@ import type { StealthKeys } from './types'; import { seedToScalar } from './scalar'; import { STEALTH_SIGNING_MESSAGE } from './constants'; import type { StellarStealthSigner } from './signer'; +import { withSpan, type Tracer } from '../../telemetry'; + +/** Optional per-call telemetry override for key-derivation functions. */ +export interface KeyDerivationOptions { + /** Overrides the global tracer (set via `setTracer`) for this call only. */ + tracer?: Tracer; +} /** * Derives Stellar stealth spending and viewing keys from a wallet signature. @@ -19,39 +26,49 @@ import type { StellarStealthSigner } from './signer'; * * @throws {InvalidSignatureError} If signature length is not 64. */ -export function deriveStealthKeys(signature: Uint8Array): StealthKeys { - if (signature.length !== 64) { - throw new InvalidSignatureError(signature, 64, signature.length); - } +export function deriveStealthKeys( + signature: Uint8Array, + opts: KeyDerivationOptions = {}, +): StealthKeys { + return withSpan( + 'stellar.deriveStealthKeys', + { 'wraith.chain': 'stellar' }, + () => { + if (signature.length !== 64) { + throw new InvalidSignatureError(signature, 64, signature.length); + } - const spendingPrefix = new TextEncoder().encode('wraith:spending:'); - const viewingPrefix = new TextEncoder().encode('wraith:viewing:'); + const spendingPrefix = new TextEncoder().encode('wraith:spending:'); + const viewingPrefix = new TextEncoder().encode('wraith:viewing:'); - const spendingInput = new Uint8Array(spendingPrefix.length + signature.length); - spendingInput.set(spendingPrefix); - spendingInput.set(signature, spendingPrefix.length); + const spendingInput = new Uint8Array(spendingPrefix.length + signature.length); + spendingInput.set(spendingPrefix); + spendingInput.set(signature, spendingPrefix.length); - const viewingInput = new Uint8Array(viewingPrefix.length + signature.length); - viewingInput.set(viewingPrefix); - viewingInput.set(signature, viewingPrefix.length); + const viewingInput = new Uint8Array(viewingPrefix.length + signature.length); + viewingInput.set(viewingPrefix); + viewingInput.set(signature, viewingPrefix.length); - const spendingKey = sha256(spendingInput); - const viewingKey = sha256(viewingInput); + const spendingKey = sha256(spendingInput); + const viewingKey = sha256(viewingInput); - const spendingScalar = seedToScalar(spendingKey); - const viewingScalar = seedToScalar(viewingKey); + const spendingScalar = seedToScalar(spendingKey); + const viewingScalar = seedToScalar(viewingKey); - const spendingPubKey = ed25519.getPublicKey(spendingKey); - const viewingPubKey = ed25519.getPublicKey(viewingKey); + const spendingPubKey = ed25519.getPublicKey(spendingKey); + const viewingPubKey = ed25519.getPublicKey(viewingKey); - return { - spendingKey, - spendingScalar, - viewingKey, - viewingScalar, - spendingPubKey, - viewingPubKey, - }; + return { + spendingKey, + spendingScalar, + viewingKey, + viewingScalar, + spendingPubKey, + viewingPubKey, + }; + }, + opts.tracer, + ); } /** @@ -71,8 +88,16 @@ export function deriveStealthKeys(signature: Uint8Array): StealthKeys { */ export async function deriveStealthKeysFromSigner( signer: StellarStealthSigner, + opts: KeyDerivationOptions = {}, ): Promise { - const message = new TextEncoder().encode(STEALTH_SIGNING_MESSAGE); - const signature = await signer.signMessage(message); - return deriveStealthKeys(signature); + return withSpan( + 'stellar.deriveStealthKeysFromSigner', + { 'wraith.chain': 'stellar' }, + async () => { + const message = new TextEncoder().encode(STEALTH_SIGNING_MESSAGE); + const signature = await signer.signMessage(message); + return deriveStealthKeys(signature, opts); + }, + opts.tracer, + ); } diff --git a/src/chains/stellar/scan.ts b/src/chains/stellar/scan.ts index 4712bff..ccc24b3 100644 --- a/src/chains/stellar/scan.ts +++ b/src/chains/stellar/scan.ts @@ -6,6 +6,7 @@ import { SCHEME_ID, SCHEME_ID_V2 } from './constants'; import type { Announcement, MatchedAnnouncement } from './types'; import { hexToBytes } from './utils'; import { pipeline } from './scanner/pipeline'; +import { getTracer, type Tracer } from '../../telemetry'; const HEX_TO_BYTE = (() => { const table = new Uint8Array(256).fill(255); @@ -81,14 +82,22 @@ export async function* scanAnnouncementsStream( viewingKey: Uint8Array, spendingPubKey: Uint8Array, spendingScalar: bigint, - opts: { window?: number } = {}, + opts: { window?: number; tracer?: Tracer } = {}, ): AsyncGenerator { const windowSize = Math.max(1, opts.window ?? 64); const viewingPubKey = ed25519.getPublicKey(viewingKey); const piped = pipeline(source, windowSize); + const span = (opts.tracer ?? getTracer()).startSpan('stellar.scan', { + 'wraith.chain': 'stellar', + 'wraith.scan.window': windowSize, + }); + let scanned = 0; + let matched = 0; + try { for await (const ann of piped) { + scanned++; if (ann.schemeId !== SCHEME_ID && ann.schemeId !== SCHEME_ID_V2) continue; const metadataBytes = hexToBytes(ann.metadata); @@ -112,17 +121,59 @@ export async function* scanAnnouncementsStream( result.hashScalar !== null && result.stealthPubKeyBytes !== null ) { - const stealthPrivateScalar = ((spendingScalar % L) + result.hashScalar) % L; - yield { - ...ann, - stealthPrivateScalar, - stealthPubKeyBytes: result.stealthPubKeyBytes, - }; + matched++; + const matchedAnnouncement = decryptMatch( + ann, + result.hashScalar, + result.stealthPubKeyBytes, + spendingScalar, + opts.tracer, + ); + yield matchedAnnouncement; } } + span.setAttribute('wraith.scan.scanned_count', scanned); + span.setAttribute('wraith.scan.matched_count', matched); + } catch (err) { + span.recordException(err); + throw err; } finally { // Signal the pipeline (and transitively the source) to stop when consumer cancels early await piped.return(undefined); + span.end(); + } +} + +/** + * Derives the spendable private scalar for one matched announcement. + * + * Split out of {@link scanAnnouncementsStream} so this (comparatively rare) + * "decrypt" step gets its own span, separate from the continuous per-candidate + * scan loop. + */ +function decryptMatch( + ann: Announcement, + hashScalar: bigint, + stealthPubKeyBytes: Uint8Array, + spendingScalar: bigint, + tracer: Tracer | undefined, +): MatchedAnnouncement { + const span = (tracer ?? getTracer()).startSpan('stellar.scan.match', { + 'wraith.chain': 'stellar', + 'wraith.scan.scheme_id': ann.schemeId, + }); + try { + const stealthPrivateScalar = ((spendingScalar % L) + hashScalar) % L; + return { + ...ann, + stealthPrivateScalar, + stealthPubKeyBytes, + }; + } catch (err) { + span.recordException(err); + throw err; + } finally { + span.end(); } } diff --git a/test/chains/stellar/keys.test.ts b/test/chains/stellar/keys.test.ts index aac1d79..350b264 100644 --- a/test/chains/stellar/keys.test.ts +++ b/test/chains/stellar/keys.test.ts @@ -1,7 +1,8 @@ import { InvalidSignatureError } from '../../../src/errors'; -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, afterEach } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; import { scalarToBytes } from '../../../src/chains/stellar/scalar'; +import { setTracer, type Tracer, type Span } from '../../../src/telemetry'; const testSig = new Uint8Array(64).fill(0xaa); @@ -76,3 +77,41 @@ describe('deriveStealthKeys', () => { expect(bytes[0] & 0x07).toBe(0); }); }); + +function makeRecordingTracer() { + const spanNames: string[] = []; + const tracer: Tracer = { + startSpan(name) { + spanNames.push(name); + const span: Span = { setAttribute() {}, recordException() {}, end() {} }; + return span; + }, + }; + return { tracer, spanNames }; +} + +describe('deriveStealthKeys telemetry', () => { + afterEach(() => { + setTracer(null); + }); + + test('emits a span via the global tracer', () => { + const { tracer, spanNames } = makeRecordingTracer(); + setTracer(tracer); + + deriveStealthKeys(testSig); + + expect(spanNames).toContain('stellar.deriveStealthKeys'); + }); + + test('honors a per-call tracer override over the global one', () => { + const globalTracer = makeRecordingTracer(); + const override = makeRecordingTracer(); + setTracer(globalTracer.tracer); + + deriveStealthKeys(testSig, { tracer: override.tracer }); + + expect(globalTracer.spanNames).toHaveLength(0); + expect(override.spanNames).toContain('stellar.deriveStealthKeys'); + }); +}); diff --git a/test/chains/stellar/scan.test.ts b/test/chains/stellar/scan.test.ts index 6ca6ccf..b778834 100644 --- a/test/chains/stellar/scan.test.ts +++ b/test/chains/stellar/scan.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect } from 'vitest'; +import { describe, test, expect, afterEach } from 'vitest'; import { deriveStealthKeys } from '../../../src/chains/stellar/keys'; +import { setTracer, type Tracer, type Span } from '../../../src/telemetry'; import { computeAnnouncementViewTag, computeSharedSecret, @@ -583,3 +584,104 @@ describe('scanAnnouncementsStream', () => { expect(mem100k).toBeLessThan(Math.max(mem1k * 10, 5 * 1024 * 1024)); }, 30_000); }); + +function makeRecordingTracer() { + const spanNames: string[] = []; + const tracer: Tracer = { + startSpan(name) { + spanNames.push(name); + const span: Span = { setAttribute() {}, recordException() {}, end() {} }; + return span; + }, + }; + return { tracer, spanNames }; +} + +describe('scanAnnouncementsStream telemetry', () => { + afterEach(() => { + setTracer(null); + }); + + test('emits a scan span and a match span per hit', async () => { + const keys = deriveStealthKeys(testSig); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + const announcements: Announcement[] = [ + { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + }, + ]; + + const { tracer, spanNames } = makeRecordingTracer(); + + const matched = await collectStream( + scanAnnouncementsStream( + announcementsFrom(announcements), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + { tracer }, + ), + ); + + expect(matched).toHaveLength(1); + expect(spanNames).toEqual(['stellar.scan', 'stellar.scan.match']); + }); + + test('a scan with no matches only emits the scan span', async () => { + const keys = deriveStealthKeys(testSig); + const announcements: Announcement[] = [ + { + schemeId: 99, + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: '00'.repeat(32), + metadata: '00', + }, + ]; + + const { tracer, spanNames } = makeRecordingTracer(); + + await collectStream( + scanAnnouncementsStream( + announcementsFrom(announcements), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + { tracer }, + ), + ); + + expect(spanNames).toEqual(['stellar.scan']); + }); + + test('global setTracer is used when no per-call tracer is given', async () => { + const keys = deriveStealthKeys(testSig); + const announcements: Announcement[] = [ + { + schemeId: 99, + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: '00'.repeat(32), + metadata: '00', + }, + ]; + + const { tracer, spanNames } = makeRecordingTracer(); + setTracer(tracer); + + await collectStream( + scanAnnouncementsStream( + announcementsFrom(announcements), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + ), + ); + + expect(spanNames).toEqual(['stellar.scan']); + }); +}); From 4171b1aa3ce61c695eab8877267723eec9f2441a Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 26 Aug 2026 13:10:11 +0100 Subject: [PATCH 3/4] feat(agent): instrument RPC client and agent tools; add otel example and docs --- .github/workflows/examples.yml | 1 + CHANGELOG.md | 5 + docs/observability.md | 93 ++++++++++++++++ examples/README.md | 1 + examples/otel/README.md | 43 ++++++++ examples/otel/console-otel-tracer.ts | 43 ++++++++ examples/otel/index.ts | 62 +++++++++++ examples/otel/otel-adapter.ts | 48 +++++++++ examples/otel/package.json | 16 +++ pnpm-lock.yaml | 13 +++ src/agent/tools.ts | 155 ++++++++++++++++----------- src/chains/stellar/rpc.ts | 41 ++++++- test/agent/tools.test.ts | 32 ++++++ test/chains/stellar/rpc.test.ts | 53 +++++++++ 14 files changed, 541 insertions(+), 65 deletions(-) create mode 100644 docs/observability.md create mode 100644 examples/otel/README.md create mode 100644 examples/otel/console-otel-tracer.ts create mode 100644 examples/otel/index.ts create mode 100644 examples/otel/otel-adapter.ts create mode 100644 examples/otel/package.json diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 7d4e5dd..97c1be8 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -21,6 +21,7 @@ jobs: - multichain-scan - stellar-nextjs-app-router - stellar-chrome-extension + - otel steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d68186..b29194c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All notable changes to the Wraith Protocol SDK will be documented in this file. - `FreighterStealthSigner` wraps the existing Freighter-style wallet API; the raw `deriveStealthKeys(signature)` path is unchanged. - `WebAuthnPasskeyStealthSigner` is a reference passkey adapter that uses the WebAuthn `prf` extension to derive stable key material across sessions, since raw WebAuthn assertion signatures are non-deterministic. - `useStellarStealthKeys()` in `@wraith-protocol/sdk-react` gained a `generateFromSigner()` method alongside the existing `generate()`. +- **OpenTelemetry-compatible Instrumentation Hooks** (issue #177): `src/telemetry.ts` introduces a minimal `Tracer`/`Span` interface plus `setTracer()`/`getTracer()`, exported from the package root. Zero runtime dependency on `@opentelemetry/*` or any tracing library — nothing is traced until `setTracer()` is called, and every instrumented call site defaults to a no-op tracer. + - Instrumented: `deriveStealthKeys()`, `deriveStealthKeysFromSigner()`, `scanAnnouncementsStream()` (`stellar.scan` plus a `stellar.scan.match` span per match), `RpcClient.request()` (`stellar.rpc.request`, covering internal retries/failover), and every `ClaudeAgentTools` method (`agent.tool.*`). + - Every instrumented function accepts a `tracer` option that overrides the global tracer for that call only. + - `scanAnnouncementsStream` is now exported from `@wraith-protocol/sdk/chains/stellar` (it previously wasn't part of the public API surface, only reachable via a relative import). + - Reference `@opentelemetry/api`-shaped adapter under `examples/otel/`; stable attribute names documented in `docs/observability.md`. ### Performance diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..b2c75c1 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,93 @@ +# Observability + +## Background + +Production users running the SDK inside a long-lived Node service (indexers, notification +workers, agent backends) want spans on scanning, RPC calls, key derivation, and agent tool +calls — without the SDK pulling in a specific tracing package. `src/telemetry.ts` defines a +minimal `Tracer`/`Span` interface that any tracer (OpenTelemetry, Sentry, Datadog, a custom +logger) can implement, and instrumented call sites use it internally. + +The SDK has **no runtime dependency on any tracing library**. Nothing is traced until you +call `setTracer()`; until then every span is a no-op (one object allocation, empty method +calls). + +## API + +### `Tracer` and `Span` + +```ts +interface Span { + setAttribute(key: string, value: string | number | boolean): void; + recordException(error: unknown): void; + end(): void; +} + +interface Tracer { + startSpan(name: string, attributes?: Record): Span; +} +``` + +### `setTracer` / `getTracer` + +```ts +import { setTracer } from '@wraith-protocol/sdk'; + +setTracer(myTracer); // configures the global tracer used by instrumented call sites +setTracer(null); // resets to the no-op tracer +``` + +`setTracer` is exported from the package root (`@wraith-protocol/sdk`) but affects +instrumented call sites in every entry point (`chains/stellar`, the agent client, ...) — they +all import the same underlying telemetry module. + +### Per-call overrides + +Every instrumented function accepts a `tracer` option that takes precedence over the global +tracer for that one call, without needing a global `setTracer()` first: + +```ts +import { deriveStealthKeys } from '@wraith-protocol/sdk/chains/stellar'; + +const keys = deriveStealthKeys(signature, { tracer: requestScopedTracer }); +``` + +## Instrumented call sites + +| Span name | Where | Key attributes | +| ------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| `stellar.deriveStealthKeys` | `deriveStealthKeys()` | `wraith.chain` | +| `stellar.deriveStealthKeysFromSigner` | `deriveStealthKeysFromSigner()` | `wraith.chain` | +| `stellar.scan` | `scanAnnouncementsStream()` — one span per scan call | `wraith.chain`, `wraith.scan.window`, `wraith.scan.scanned_count`, `wraith.scan.matched_count` | +| `stellar.scan.match` | Per-match private-scalar derivation ("decrypt") | `wraith.chain`, `wraith.scan.scheme_id` | +| `stellar.rpc.request` | `RpcClient.request()` — covers all internal retries/failover | `wraith.rpc.method`, `wraith.rpc.path`, `wraith.rpc.endpoint`, `wraith.rpc.attempt`, `wraith.rpc.status` | +| `agent.tool.sendToMetaAddress` | `ClaudeAgentTools.sendToMetaAddress()` | `wraith.agent.tool` | +| `agent.tool.scan` | `ClaudeAgentTools.scan()` | `wraith.agent.tool`, `wraith.scan.candidate_count` | +| `agent.tool.withdraw` | `ClaudeAgentTools.withdraw()` | `wraith.agent.tool` | +| `agent.tool.resolveName` | `ClaudeAgentTools.resolveName()` | `wraith.agent.tool` | + +Attribute names are stable across releases; new attributes may be added, but existing ones +won't be renamed or removed without a major version bump (see `CONTRIBUTING.md`'s semver +policy). + +`stellar.scan` intentionally does **not** create a span per candidate announcement — a cold +scan can touch tens of thousands of announcements, and a span per candidate would dwarf the +cost of the scan itself. Instead it emits one span for the whole call with aggregate counts, +plus a `stellar.scan.match` span for each (comparatively rare) match, which is the actual +"decrypt" step the issue this shipped for was about. + +## Adapting a tracer + +Any tracer that exposes something shaped like `startSpan(name) -> { setAttribute, end }` can +be wrapped in a few lines. See `examples/otel/` for a full adapter targeting +`@opentelemetry/api`'s `Tracer`/`Span` shape. + +## Benchmark + +`test/bench/telemetry.bench.ts` compares calling an instrumented function with the default +no-op tracer against calling the un-instrumented body directly, to confirm the no-op path +adds negligible overhead. Run it with: + +```bash +pnpm exec vitest bench test/bench/telemetry.bench.ts --run +``` diff --git a/examples/README.md b/examples/README.md index bc9e158..600fe18 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,6 +12,7 @@ Five self-contained examples demonstrating the `@wraith-protocol/sdk` across dif | `stellar-spectre-agent/` | Connect to the Wraith managed agent platform — create/retrieve an agent, chat, check balance, scan payments, send via natural language | Agent | | `multichain-scan/` | Scan for stealth payments on all 4 chains (Stellar, EVM, Solana, CKB) in parallel via `Promise.all` | CLI | | `stellar-chrome-extension/` | MV3 Chrome extension — scans Stellar in the background service worker, notifies on incoming stealth payments, no webapp required | Extension | +| `otel/` | Adapts the SDK's `Tracer`/`Span` interface to an OpenTelemetry-shaped tracer and scans a canned announcement batch | CLI | ## Running an Example diff --git a/examples/otel/README.md b/examples/otel/README.md new file mode 100644 index 0000000..eb362d3 --- /dev/null +++ b/examples/otel/README.md @@ -0,0 +1,43 @@ +# OpenTelemetry-shaped tracer adapter + +Demonstrates adapting the SDK's minimal `Tracer`/`Span` interface (see `docs/observability.md`) +to something shaped like `@opentelemetry/api`, and shows spans covering an end-to-end scan: +key derivation, generating a stealth address, and scanning a canned announcement batch. + +## Why this doesn't depend on `@opentelemetry/api` + +`otel-adapter.ts` only needs `@opentelemetry/api`'s `Tracer`/`Span` **shape** +(`startSpan(name, { attributes }) -> { setAttribute, recordException, end }`), so it's +written against local structural types (`OtelTracerLike`/`OtelSpanLike`) instead of importing +the package. A real `@opentelemetry/api` tracer already satisfies that shape, so: + +```ts +import { trace } from '@opentelemetry/api'; +import { setTracer } from '@wraith-protocol/sdk'; +import { createOtelTracerAdapter } from './otel-adapter'; + +setTracer(createOtelTracerAdapter(trace.getTracer('wraith-sdk'))); +``` + +works with zero changes to `otel-adapter.ts` once you've installed `@opentelemetry/api` (and +an SDK like `@opentelemetry/sdk-trace-node` plus an exporter) in your own app. + +`console-otel-tracer.ts` is a tiny stand-in implementing the same shape with `console.log`, +so this example runs standalone without any tracing package installed. + +## How it works + +1. Wires up `setTracer()` globally with the OTel-shaped adapter. +2. Derives stealth keys (`stellar.deriveStealthKeys` span). +3. Generates a stealth address for itself (pure crypto, not instrumented — no I/O). +4. Scans a single canned announcement through `scanAnnouncementsStream` (`stellar.scan` and + `stellar.scan.match` spans). + +## Usage + +```bash +npm start +``` + +Each line prefixed `[span:...]` is one span the console tracer recorded, with its duration +and attributes. diff --git a/examples/otel/console-otel-tracer.ts b/examples/otel/console-otel-tracer.ts new file mode 100644 index 0000000..7d1271c --- /dev/null +++ b/examples/otel/console-otel-tracer.ts @@ -0,0 +1,43 @@ +import type { OtelSpanLike, OtelTracerLike } from './otel-adapter'; + +/** + * Minimal stand-in for an `@opentelemetry/api` `Tracer`, so this example runs + * without installing `@opentelemetry/api`. It implements the exact same + * `startSpan(name, { attributes }) -> { setAttribute, recordException, end }` + * shape, so swapping it for a real one is a one-line change: + * + * ```diff + * - const otelTracer = createConsoleOtelTracer(); + * + import { trace } from '@opentelemetry/api'; + * + const otelTracer = trace.getTracer('wraith-sdk'); + * ``` + * + * A real deployment would also install `@opentelemetry/sdk-trace-node` (or + * `-web`) plus an exporter for wherever spans should end up (console, OTLP + * collector, Jaeger, ...) and register it before calling `trace.getTracer()`. + */ +export function createConsoleOtelTracer(): OtelTracerLike { + return { + startSpan(name, options) { + const start = performance.now(); + const attributes: Record = { ...options?.attributes }; + + const span: OtelSpanLike = { + setAttribute(key, value) { + attributes[key] = value; + return span; + }, + recordException(exception) { + console.error(` [span:${name}] exception:`, exception); + }, + end() { + const durationMs = performance.now() - start; + console.log( + ` [span:${name}] ${durationMs.toFixed(2)}ms attributes=${JSON.stringify(attributes)}`, + ); + }, + }; + return span; + }, + }; +} diff --git a/examples/otel/index.ts b/examples/otel/index.ts new file mode 100644 index 0000000..e7fed76 --- /dev/null +++ b/examples/otel/index.ts @@ -0,0 +1,62 @@ +import { setTracer } from '@wraith-protocol/sdk'; +import { + deriveStealthKeys, + generateStealthAddress, + scanAnnouncementsStream, + bytesToHex, + SCHEME_ID, + type Announcement, +} from '@wraith-protocol/sdk/chains/stellar'; +import { createConsoleOtelTracer } from './console-otel-tracer'; +import { createOtelTracerAdapter } from './otel-adapter'; + +async function* announcementsFrom(items: Announcement[]): AsyncGenerator { + for (const item of items) yield item; +} + +async function main() { + console.log('=== Wraith SDK — OpenTelemetry-shaped tracer example ===\n'); + + // Swap `createConsoleOtelTracer()` for a real `@opentelemetry/api` tracer — + // see console-otel-tracer.ts for the one-line swap. Everything downstream + // works unchanged either way, since createOtelTracerAdapter only depends on + // the OTel Tracer/Span *shape*, not the package itself. + const otelTracer = createConsoleOtelTracer(); + setTracer(createOtelTracerAdapter(otelTracer)); + + console.log('1. Deriving stealth keys (span: stellar.deriveStealthKeys)'); + const keys = deriveStealthKeys(new Uint8Array(64).fill(0x42)); + + console.log( + '\n2. Generating a stealth address for ourselves (uninstrumented — pure crypto, no I/O)', + ); + const stealth = generateStealthAddress(keys.spendingPubKey, keys.viewingPubKey); + + const announcement: Announcement = { + schemeId: SCHEME_ID, + stealthAddress: stealth.stealthAddress, + caller: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), + metadata: stealth.viewTag.toString(16).padStart(2, '0'), + }; + + console.log( + '\n3. Scanning a canned announcement batch (spans: stellar.scan, stellar.scan.match)', + ); + const matches = []; + for await (const match of scanAnnouncementsStream( + announcementsFrom([announcement]), + keys.viewingKey, + keys.spendingPubKey, + keys.spendingScalar, + )) { + matches.push(match); + } + + console.log(`\nFound ${matches.length} match(es) for our own announcement.`); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/examples/otel/otel-adapter.ts b/examples/otel/otel-adapter.ts new file mode 100644 index 0000000..bb594d0 --- /dev/null +++ b/examples/otel/otel-adapter.ts @@ -0,0 +1,48 @@ +import type { Span, Tracer } from '@wraith-protocol/sdk'; + +/** + * Structural shape of `@opentelemetry/api`'s `Span`. + * + * Duck-typed on purpose: this example (and the SDK itself) has no dependency + * on `@opentelemetry/api`. If you've installed the real package, a real + * `opentelemetry.Span` already satisfies this shape — no adapter-side + * changes needed. + */ +export interface OtelSpanLike { + setAttribute(key: string, value: string | number | boolean): unknown; + recordException(exception: unknown): void; + end(): void; +} + +/** Structural shape of `@opentelemetry/api`'s `Tracer`. */ +export interface OtelTracerLike { + startSpan( + name: string, + options?: { attributes?: Record }, + ): OtelSpanLike; +} + +/** + * Adapts an OpenTelemetry-shaped tracer to the SDK's minimal {@link Tracer} interface. + * + * ```ts + * import { trace } from '@opentelemetry/api'; + * import { setTracer } from '@wraith-protocol/sdk'; + * import { createOtelTracerAdapter } from './otel-adapter'; + * + * setTracer(createOtelTracerAdapter(trace.getTracer('wraith-sdk'))); + * ``` + */ +export function createOtelTracerAdapter(otelTracer: OtelTracerLike): Tracer { + return { + startSpan(name, attributes) { + const otelSpan = otelTracer.startSpan(name, { attributes }); + const span: Span = { + setAttribute: (key, value) => void otelSpan.setAttribute(key, value), + recordException: (error) => otelSpan.recordException(error), + end: () => otelSpan.end(), + }; + return span; + }, + }; +} diff --git a/examples/otel/package.json b/examples/otel/package.json new file mode 100644 index 0000000..d52b3fb --- /dev/null +++ b/examples/otel/package.json @@ -0,0 +1,16 @@ +{ + "name": "@wraith-protocol/example-otel", + "private": true, + "type": "module", + "main": "index.ts", + "scripts": { + "start": "npx tsx index.ts" + }, + "dependencies": { + "@wraith-protocol/sdk": "file:../.." + }, + "devDependencies": { + "tsx": "^4.0.0", + "typescript": "^5.7.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d41f3d..9f13b47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,19 @@ importers: specifier: ^5.7.0 version: 5.9.3 + examples/otel: + dependencies: + '@wraith-protocol/sdk': + specifier: file:../.. + version: file:(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(@stellar/stellar-sdk@13.3.0)(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) + devDependencies: + tsx: + specifier: ^4.0.0 + version: 4.23.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + examples/react-native-stellar: dependencies: '@stellar/stellar-sdk': diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 6333418..0f0d18a 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -4,10 +4,13 @@ import { generateStealthAddress } from '../chains/stellar/stealth'; import { scanAnnouncements } from '../chains/stellar/scan'; import { hexToBytes, bytesToHex } from '../chains/stellar/utils'; import type { Announcement } from '../chains/stellar/types'; +import { withSpan, type Tracer } from '../telemetry'; export interface ClaudeAgentToolContext { apiKey?: string; baseUrl?: string; + /** Default tracer for spans created by these tools. Overridable per call via `withSpan`-instrumented internals. */ + tracer?: Tracer; } export interface SendToMetaAddressInput { @@ -70,80 +73,106 @@ function parseBigInt(value: string | undefined): bigint { return BigInt(`0x${clean}`); } -export function createClaudeAgentTools(_context: ClaudeAgentToolContext = {}): ClaudeAgentTools { +export function createClaudeAgentTools(context: ClaudeAgentToolContext = {}): ClaudeAgentTools { + const tracer = context.tracer; + return { - async sendToMetaAddress(input) { - const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(input.metaAddress); - const stealthResult = generateStealthAddress(spendingPubKey, viewingPubKey); - return { - kind: 'send', - signingRequired: true, - metaAddress: input.metaAddress, - tx: { - intent: 'send-to-meta-address', - asset: input.asset ?? 'XLM', - amount: input.amount, - memo: input.memo ?? '', - stealthAddress: stealthResult.stealthAddress, - ephemeralPubKey: bytesToHex(stealthResult.ephemeralPubKey), - metadata: stealthResult.viewTag.toString(16).padStart(2, '0'), - destination: input.destination ?? stealthResult.stealthAddress, + sendToMetaAddress(input) { + return withSpan( + 'agent.tool.sendToMetaAddress', + { 'wraith.agent.tool': 'sendToMetaAddress' }, + async () => { + const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(input.metaAddress); + const stealthResult = generateStealthAddress(spendingPubKey, viewingPubKey); + return { + kind: 'send', + signingRequired: true, + metaAddress: input.metaAddress, + tx: { + intent: 'send-to-meta-address', + asset: input.asset ?? 'XLM', + amount: input.amount, + memo: input.memo ?? '', + stealthAddress: stealthResult.stealthAddress, + ephemeralPubKey: bytesToHex(stealthResult.ephemeralPubKey), + metadata: stealthResult.viewTag.toString(16).padStart(2, '0'), + destination: input.destination ?? stealthResult.stealthAddress, + }, + note: 'The agent prepares a stealth send plan. The sender must sign the transaction locally with their wallet.', + }; }, - note: 'The agent prepares a stealth send plan. The sender must sign the transaction locally with their wallet.', - }; + tracer, + ); }, - async scan(input) { - const viewingKey = parseHexBytes(input.viewingKeyHex); - const spendingPubKey = parseHexBytes(input.spendingPubKeyHex); - const spendingScalar = parseBigInt(input.spendingScalarHex); - const matches = scanAnnouncements( - input.announcements, - viewingKey, - spendingPubKey, - spendingScalar, + scan(input) { + return withSpan( + 'agent.tool.scan', + { 'wraith.agent.tool': 'scan', 'wraith.scan.candidate_count': input.announcements.length }, + async () => { + const viewingKey = parseHexBytes(input.viewingKeyHex); + const spendingPubKey = parseHexBytes(input.spendingPubKeyHex); + const spendingScalar = parseBigInt(input.spendingScalarHex); + const matches = scanAnnouncements( + input.announcements, + viewingKey, + spendingPubKey, + spendingScalar, + ); + return { + kind: 'scan', + signingRequired: false, + matches: matches.map((item) => ({ + stealthAddress: item.stealthAddress, + ephemeralPubKey: item.ephemeralPubKey, + metadata: item.metadata, + stealthPrivateScalar: item.stealthPrivateScalar.toString(), + })), + count: matches.length, + }; + }, + tracer, ); - return { - kind: 'scan', - signingRequired: false, - matches: matches.map((item) => ({ - stealthAddress: item.stealthAddress, - ephemeralPubKey: item.ephemeralPubKey, - metadata: item.metadata, - stealthPrivateScalar: item.stealthPrivateScalar.toString(), - })), - count: matches.length, - }; }, - async withdraw(input) { - return { - kind: 'withdraw', - signingRequired: true, - tx: { - intent: 'withdraw', - stealthAddress: input.stealthAddress, - asset: input.asset ?? 'XLM', - amount: input.amount, - destination: input.destination ?? input.stealthAddress, - memo: input.memo ?? '', - }, - note: 'The agent prepares a withdrawal plan. The stealth account owner must sign the transaction with their local wallet or key manager.', - }; + withdraw(input) { + return withSpan( + 'agent.tool.withdraw', + { 'wraith.agent.tool': 'withdraw' }, + async () => ({ + kind: 'withdraw', + signingRequired: true, + tx: { + intent: 'withdraw', + stealthAddress: input.stealthAddress, + asset: input.asset ?? 'XLM', + amount: input.amount, + destination: input.destination ?? input.stealthAddress, + memo: input.memo ?? '', + }, + note: 'The agent prepares a withdrawal plan. The stealth account owner must sign the transaction with their local wallet or key manager.', + }), + tracer, + ); }, - async resolveName(input) { - return { - kind: 'resolve-name', - signingRequired: false, - name: input.name, - chain: input.chain ?? 'stellar', - tx: { - intent: 'resolve-name', + resolveName(input) { + return withSpan( + 'agent.tool.resolveName', + { 'wraith.agent.tool': 'resolveName' }, + async () => ({ + kind: 'resolve-name', + signingRequired: false, name: input.name, chain: input.chain ?? 'stellar', - }, - }; + tx: { + intent: 'resolve-name', + name: input.name, + chain: input.chain ?? 'stellar', + }, + }), + tracer, + ); }, }; } diff --git a/src/chains/stellar/rpc.ts b/src/chains/stellar/rpc.ts index c8691ff..17f1b0a 100644 --- a/src/chains/stellar/rpc.ts +++ b/src/chains/stellar/rpc.ts @@ -1,4 +1,5 @@ import { RPCRequestError, RPCRetryExhaustedError } from '../../errors'; +import { withSpan, type Tracer, type Span } from '../../telemetry'; export interface RpcEndpoint { url: string; @@ -17,10 +18,23 @@ export interface RpcClientConfig { maxDelayMs: number; }; fetchImpl?: typeof fetch; + /** Default tracer for spans created by this client. Overridable per call. */ + tracer?: Tracer; +} + +/** Optional per-call telemetry override for {@link RpcClient.request}. */ +export interface RpcRequestOptions { + /** Overrides the client's configured tracer (and the global one) for this call only. */ + tracer?: Tracer; } export interface RpcClient { - request(method: string, path: string, body?: unknown): Promise; + request( + method: string, + path: string, + body?: unknown, + opts?: RpcRequestOptions, + ): Promise; getHealthyEndpoint(): string; on( event: 'endpointFailover', @@ -62,6 +76,7 @@ export function createRpcClient(config: RpcClientConfig): RpcClient { const baseDelayMs = config.retry?.baseDelayMs ?? 500; const maxDelayMs = config.retry?.maxDelayMs ?? 10_000; const fetchImpl = config.fetchImpl ?? globalThis.fetch; + const clientTracer = config.tracer; const states: EndpointState[] = config.endpoints.map((ep) => ({ url: ep.url, @@ -127,7 +142,26 @@ export function createRpcClient(config: RpcClientConfig): RpcClient { return true; } - async function request(method: string, path: string, body?: unknown): Promise { + async function request( + method: string, + path: string, + body?: unknown, + opts: RpcRequestOptions = {}, + ): Promise { + return withSpan( + 'stellar.rpc.request', + { 'wraith.rpc.method': method, 'wraith.rpc.path': path }, + (span) => requestInner(method, path, body, span), + opts.tracer ?? clientTracer, + ); + } + + async function requestInner( + method: string, + path: string, + body: unknown, + span: Span, + ): Promise { // Each endpoint needs at least `failureThreshold` attempts for the circuit // breaker to trip and trigger failover — otherwise a low maxRetries could // exhaust the loop before failover is ever reachable. @@ -154,6 +188,8 @@ export function createRpcClient(config: RpcClientConfig): RpcClient { } const url = `${state.url.replace(/\/$/, '')}${path}`; + span.setAttribute('wraith.rpc.endpoint', state.url); + span.setAttribute('wraith.rpc.attempt', attempt + 1); try { const init: RequestInit = { method }; @@ -166,6 +202,7 @@ export function createRpcClient(config: RpcClientConfig): RpcClient { if (response.ok) { markHealthy(state); + span.setAttribute('wraith.rpc.status', response.status); return (await response.json()) as T; } diff --git a/test/agent/tools.test.ts b/test/agent/tools.test.ts index 9852f19..441d234 100644 --- a/test/agent/tools.test.ts +++ b/test/agent/tools.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import { createClaudeAgentTools } from '../../src/agent/tools'; import { deriveStealthKeys } from '../../src/chains/stellar/keys'; import { encodeStealthMetaAddress } from '../../src/chains/stellar/meta-address'; +import { type Tracer, type Span } from '../../src/telemetry'; describe('Claude agent tools', () => { test('send-to-meta-address builds a signing plan without signing', async () => { @@ -73,3 +74,34 @@ describe('Claude agent tools', () => { expect(result.chain).toBe('stellar'); }); }); + +function makeRecordingTracer() { + const spanNames: string[] = []; + const tracer: Tracer = { + startSpan(name) { + spanNames.push(name); + const span: Span = { setAttribute() {}, recordException() {}, end() {} }; + return span; + }, + }; + return { tracer, spanNames }; +} + +describe('Claude agent tools telemetry', () => { + test('each tool method emits its own span when a tracer is configured', async () => { + const { tracer, spanNames } = makeRecordingTracer(); + const tools = createClaudeAgentTools({ tracer }); + + await tools.resolveName({ name: 'alice' }); + await tools.withdraw({ + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + }); + + expect(spanNames).toEqual(['agent.tool.resolveName', 'agent.tool.withdraw']); + }); + + test('no tracer configured means no error and no spans recorded elsewhere', async () => { + const tools = createClaudeAgentTools(); + await expect(tools.resolveName({ name: 'alice' })).resolves.toBeDefined(); + }); +}); diff --git a/test/chains/stellar/rpc.test.ts b/test/chains/stellar/rpc.test.ts index e679dc7..cfd85d0 100644 --- a/test/chains/stellar/rpc.test.ts +++ b/test/chains/stellar/rpc.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createRpcClient, type RpcClientConfig } from '../../../src/chains/stellar/rpc'; import { RPCRetryExhaustedError, RPCRequestError } from '../../../src/errors'; +import { type Tracer, type Span } from '../../../src/telemetry'; function mockFetch( responses: Map, @@ -287,3 +288,55 @@ describe('createRpcClient', () => { expect(result).toEqual({ received: true }); }); }); + +function makeRecordingTracer() { + const spanNames: string[] = []; + const tracer: Tracer = { + startSpan(name) { + spanNames.push(name); + const span: Span = { setAttribute() {}, recordException() {}, end() {} }; + return span; + }, + }; + return { tracer, spanNames }; +} + +describe('createRpcClient telemetry', () => { + const primaryUrl = 'https://rpc-primary.test'; + + it('emits a request span covering the whole call, including retries', async () => { + const { tracer, spanNames } = makeRecordingTracer(); + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + + const client = createRpcClient({ + endpoints: [{ url: primaryUrl }], + fetchImpl, + tracer, + }); + + await client.request('GET', '/health'); + + expect(spanNames).toEqual(['stellar.rpc.request']); + }); + + it('a per-call tracer override takes precedence over the client tracer', async () => { + const clientTracer = makeRecordingTracer(); + const override = makeRecordingTracer(); + const fetchImpl = vi.fn( + async () => new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + + const client = createRpcClient({ + endpoints: [{ url: primaryUrl }], + fetchImpl, + tracer: clientTracer.tracer, + }); + + await client.request('GET', '/health', undefined, { tracer: override.tracer }); + + expect(clientTracer.spanNames).toHaveLength(0); + expect(override.spanNames).toEqual(['stellar.rpc.request']); + }); +}); From f15fcd38b22e7896b26e0ded7737180819cfb54c Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Thu, 27 Aug 2026 20:30:22 +0100 Subject: [PATCH 4/4] chore: refresh api-extractor baseline for telemetry exports --- etc/sdk-solana.api.md | 19 ++++++++++++++++++- etc/sdk-stellar.api.md | 35 ++++++++++++++++++++++++++++++++--- etc/sdk.api.md | 24 ++++++++++++++++++++++++ src/chains/solana/index.ts | 2 ++ src/chains/solana/keys.ts | 1 + src/chains/stellar/index.ts | 2 +- 6 files changed, 78 insertions(+), 5 deletions(-) diff --git a/etc/sdk-solana.api.md b/etc/sdk-solana.api.md index c5b8c83..5513021 100644 --- a/etc/sdk-solana.api.md +++ b/etc/sdk-solana.api.md @@ -137,7 +137,7 @@ export const DEPLOYMENTS: Record; // Warning: (ae-forgotten-export) The symbol "StealthKeys$1" needs to be exported by the entry point index.d.ts // // @public -export function deriveStealthKeys(signature: Uint8Array): StealthKeys$1; +export function deriveStealthKeys(signature: Uint8Array, opts?: KeyDerivationOptions): StealthKeys$1; // @public export function deriveStealthPrivateScalar(spendingScalar: bigint, viewingKey: Uint8Array, ephemeralPubKey: Uint8Array): bigint; @@ -173,6 +173,11 @@ export type HexString = `0x${string}`; // @public export function hexToBytes(hex: string): Uint8Array; +// @public +export interface KeyDerivationOptions { + tracer?: Tracer; +} + // @public export const L: bigint; @@ -233,6 +238,13 @@ export interface SolanaInstruction { programId: string; } +// @public +export interface Span { + end(): void; + recordException(error: unknown): void; + setAttribute(key: string, value: string | number | boolean): void; +} + // @public export const STEALTH_SIGNING_MESSAGE = "Sign this message to generate your Wraith stealth keys.\n\nChain: Solana\nNote: This signature is used for key derivation only and does not authorize any transaction."; @@ -254,6 +266,11 @@ export interface StealthMetaAddress { viewingPubKey: Uint8Array; } +// @public +export interface Tracer { + startSpan(name: string, attributes?: Record): Span; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/etc/sdk-stellar.api.md b/etc/sdk-stellar.api.md index 38de9fb..eb63590 100644 --- a/etc/sdk-stellar.api.md +++ b/etc/sdk-stellar.api.md @@ -277,10 +277,10 @@ export const DEFAULT_BATCH_SENDER_THRESHOLD = 10; export const DEPLOYMENTS: Record; // @public -export function deriveStealthKeys(signature: Uint8Array): StealthKeys; +export function deriveStealthKeys(signature: Uint8Array, opts?: KeyDerivationOptions): StealthKeys; // @public -export function deriveStealthKeysFromSigner(signer: StellarStealthSigner): Promise; +export function deriveStealthKeysFromSigner(signer: StellarStealthSigner, opts?: KeyDerivationOptions): Promise; // @public export function deriveStealthPrivateScalar(spendingScalar: bigint, viewingKey: Uint8Array, ephemeralPubKey: Uint8Array): bigint; @@ -435,6 +435,11 @@ export class IndexedDBCache implements AnnouncementCache { // @public export function isStealthMultisigReady(tx: Transaction): boolean; +// @public +export interface KeyDerivationOptions { + tracer?: Tracer; +} + // @public export const L: bigint; @@ -539,7 +544,7 @@ export interface RpcClient { reason: string; }) => void): void; // (undocumented) - request(method: string, path: string, body?: unknown): Promise; + request(method: string, path: string, body?: unknown, opts?: RpcRequestOptions): Promise; } // @public (undocumented) @@ -561,6 +566,7 @@ export interface RpcClientConfig { baseDelayMs: number; maxDelayMs: number; }; + tracer?: Tracer; } // @public (undocumented) @@ -569,12 +575,23 @@ export interface RpcEndpoint { url: string; } +// @public +export interface RpcRequestOptions { + tracer?: Tracer; +} + // @public @deprecated export function scanAnnouncements(announcements: Announcement[], viewingKey: Uint8Array, spendingPubKey: Uint8Array, spendingScalar: bigint): MatchedAnnouncement[]; // @public export function scanAnnouncementsLegacySharedSecretTag(announcements: Announcement[], viewingKey: Uint8Array, spendingPubKey: Uint8Array, spendingScalar: bigint): MatchedAnnouncement[]; +// @public +export function scanAnnouncementsStream(source: AsyncIterable, viewingKey: Uint8Array, spendingPubKey: Uint8Array, spendingScalar: bigint, opts?: { + window?: number; + tracer?: Tracer; +}): AsyncGenerator; + // @public export const SCHEME_ID = 1; @@ -606,6 +623,13 @@ export interface SorobanEventFilter { // @public export type SorobanTopicMatcher = string[]; +// @public +export interface Span { + end(): void; + recordException(error: unknown): void; + setAttribute(key: string, value: string | number | boolean): void; +} + // @public export const STEALTH_SIGNING_MESSAGE = "Sign this message to generate your Wraith stealth keys.\n\nChain: Stellar\nNote: This signature is used for key derivation only and does not authorize any transaction."; @@ -690,6 +714,11 @@ export interface SwapAndStealthResult { // @public export const TEXT_MEMO_MAX_BYTES = 28; +// @public +export interface Tracer { + startSpan(name: string, attributes?: Record): Span; +} + // @public export interface TypedMemo { type: MemoType; diff --git a/etc/sdk.api.md b/etc/sdk.api.md index c3b3a39..94a5efe 100644 --- a/etc/sdk.api.md +++ b/etc/sdk.api.md @@ -197,6 +197,9 @@ export interface FreighterWalletApi { }>; } +// @public +export function getTracer(): Tracer; + // @public (undocumented) export function installReactNativePolyfills(): void; @@ -308,6 +311,9 @@ export class NameNotFoundError extends WraithContractError { readonly code = "WRAITH/CONTRACT/NAME_NOT_FOUND"; } +// @public +export const NOOP_TRACER: Tracer; + // @public (undocumented) interface Notification_2 { // (undocumented) @@ -405,6 +411,9 @@ export interface Schedule { status: 'active' | 'paused' | 'cancelled'; } +// @public +export function setTracer(tracer?: Tracer | null): void; + // @public (undocumented) export interface SolanaChainInput { // Warning: (ae-forgotten-export) The symbol "Announcement_2" needs to be exported by the entry point index.d.ts @@ -443,6 +452,13 @@ export interface SolanaWalletAdapterLike { signMessage?: (message: Uint8Array) => Promise; } +// @public +export interface Span { + end(): void; + recordException(error: unknown): void; + setAttribute(key: string, value: string | number | boolean): void; +} + // @public (undocumented) export interface StellarChainInput { // Warning: (ae-forgotten-export) The symbol "Announcement$1" needs to be exported by the entry point index.d.ts @@ -473,6 +489,11 @@ export interface ToolCall { status: string; } +// @public +export interface Tracer { + startSpan(name: string, attributes?: Record): Span; +} + // @public (undocumented) export interface TxResult { // (undocumented) @@ -531,6 +552,9 @@ export type WalletAdapter = StellarWalletAdapter | EvmWalletAdapter | SolanaChai // @public export type WalletAdapterChain = 'stellar' | 'evm' | 'solana'; +// @public +export function withSpan(name: string, attributes: Record | undefined, fn: (span: Span) => T, tracer?: Tracer): T; + // @public (undocumented) export class Wraith { constructor(config: WraithConfig); diff --git a/src/chains/solana/index.ts b/src/chains/solana/index.ts index 72d1029..fde79f4 100644 --- a/src/chains/solana/index.ts +++ b/src/chains/solana/index.ts @@ -1,4 +1,6 @@ export { deriveStealthKeys } from './keys'; +export type { KeyDerivationOptions } from './keys'; +export type { Tracer, Span } from '../../telemetry'; export { STEALTH_SIGNING_MESSAGE, SCHEME_ID, META_ADDRESS_PREFIX } from './constants'; export { encodeStealthMetaAddress, decodeStealthMetaAddress } from './meta-address'; export { generateStealthAddress, computeSharedSecret, computeViewTag } from './stealth'; diff --git a/src/chains/solana/keys.ts b/src/chains/solana/keys.ts index e8c2c32..e089760 100644 --- a/src/chains/solana/keys.ts +++ b/src/chains/solana/keys.ts @@ -1 +1,2 @@ export { deriveStealthKeys } from '../stellar/keys'; +export type { KeyDerivationOptions } from '../stellar/keys'; diff --git a/src/chains/stellar/index.ts b/src/chains/stellar/index.ts index e4e6631..5cd80f5 100644 --- a/src/chains/stellar/index.ts +++ b/src/chains/stellar/index.ts @@ -138,4 +138,4 @@ export { createHorizonClient } from './horizon'; export type { RetryPolicy, HorizonClient, HorizonClientConfig } from './horizon'; export { createRpcClient } from './rpc'; -export type { RpcClient, RpcClientConfig, RpcEndpoint } from './rpc'; +export type { RpcClient, RpcClientConfig, RpcEndpoint, RpcRequestOptions } from './rpc';