Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
- multichain-scan
- stellar-nextjs-app-router
- stellar-chrome-extension
- otel
steps:
- uses: actions/checkout@v4

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 93 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
@@ -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<string, string | number | boolean>): 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
```
19 changes: 18 additions & 1 deletion etc/sdk-solana.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const DEPLOYMENTS: Record<string, SolanaChainDeployment>;
// 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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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.";

Expand All @@ -254,6 +266,11 @@ export interface StealthMetaAddress {
viewingPubKey: Uint8Array;
}

// @public
export interface Tracer {
startSpan(name: string, attributes?: Record<string, string | number | boolean>): Span;
}

// (No @packageDocumentation comment for this package)

```
35 changes: 32 additions & 3 deletions etc/sdk-stellar.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,10 @@ export const DEFAULT_BATCH_SENDER_THRESHOLD = 10;
export const DEPLOYMENTS: Record<string, StellarChainDeployment>;

// @public
export function deriveStealthKeys(signature: Uint8Array): StealthKeys;
export function deriveStealthKeys(signature: Uint8Array, opts?: KeyDerivationOptions): StealthKeys;

// @public
export function deriveStealthKeysFromSigner(signer: StellarStealthSigner): Promise<StealthKeys>;
export function deriveStealthKeysFromSigner(signer: StellarStealthSigner, opts?: KeyDerivationOptions): Promise<StealthKeys>;

// @public
export function deriveStealthPrivateScalar(spendingScalar: bigint, viewingKey: Uint8Array, ephemeralPubKey: Uint8Array): bigint;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -539,7 +544,7 @@ export interface RpcClient {
reason: string;
}) => void): void;
// (undocumented)
request<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
request<T = unknown>(method: string, path: string, body?: unknown, opts?: RpcRequestOptions): Promise<T>;
}

// @public (undocumented)
Expand All @@ -561,6 +566,7 @@ export interface RpcClientConfig {
baseDelayMs: number;
maxDelayMs: number;
};
tracer?: Tracer;
}

// @public (undocumented)
Expand All @@ -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<Announcement>, viewingKey: Uint8Array, spendingPubKey: Uint8Array, spendingScalar: bigint, opts?: {
window?: number;
tracer?: Tracer;
}): AsyncGenerator<MatchedAnnouncement>;

// @public
export const SCHEME_ID = 1;

Expand Down Expand Up @@ -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.";

Expand Down Expand Up @@ -690,6 +714,11 @@ export interface SwapAndStealthResult {
// @public
export const TEXT_MEMO_MAX_BYTES = 28;

// @public
export interface Tracer {
startSpan(name: string, attributes?: Record<string, string | number | boolean>): Span;
}

// @public
export interface TypedMemo {
type: MemoType;
Expand Down
24 changes: 24 additions & 0 deletions etc/sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ export interface FreighterWalletApi {
}>;
}

// @public
export function getTracer(): Tracer;

// @public (undocumented)
export function installReactNativePolyfills(): void;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -443,6 +452,13 @@ export interface SolanaWalletAdapterLike {
signMessage?: (message: Uint8Array) => Promise<Uint8Array>;
}

// @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
Expand Down Expand Up @@ -473,6 +489,11 @@ export interface ToolCall {
status: string;
}

// @public
export interface Tracer {
startSpan(name: string, attributes?: Record<string, string | number | boolean>): Span;
}

// @public (undocumented)
export interface TxResult {
// (undocumented)
Expand Down Expand Up @@ -531,6 +552,9 @@ export type WalletAdapter = StellarWalletAdapter | EvmWalletAdapter | SolanaChai
// @public
export type WalletAdapterChain = 'stellar' | 'evm' | 'solana';

// @public
export function withSpan<T>(name: string, attributes: Record<string, string | number | boolean> | undefined, fn: (span: Span) => T, tracer?: Tracer): T;

// @public (undocumented)
export class Wraith {
constructor(config: WraithConfig);
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions examples/otel/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading