Every SDK client, every product hook and the operator hooks take an optional telemetry option (useDecryptedHandle does not). It defaults to a no-op, so a client built without one has zero overhead and sends nothing anywhere. The sinks live in @tokenops/sdk/telemetry; the SdkTelemetry and SdkTelemetryTags types are also exported from the root.
interface SdkTelemetry {
event(name: string, props?: Record<string, unknown>): void; // must not throw
span<T>(name: string, fn: () => Promise<T>): Promise<T>;
error(err: Error, context?: Record<string, unknown>): void; // must not re-throw
scope?(tags: SdkTelemetryTags): SdkTelemetry; // optional child sink
}
type SdkTelemetryTags = Record<string, string | number | boolean>;The three sinks#
| Sink | Does |
|---|---|
NoopTelemetry | Nothing. span runs fn and returns its result; scope returns the same instance. What a client uses when you pass no sink. |
ConsoleTelemetry | Logs every event, span start / end / error with duration, and error to the console, prefixed [tokenops-sdk]. scope(tags) returns a child that prefixes the merged tags; the child's value wins on a key collision. |
TokenOpsTelemetry | POSTs a JSON payload per event, span and error to the endpoint you configure. Failed sends are swallowed. No scope. |
TokenOpsTelemetryOptions and SDK_VERSION#
| Option | Notes |
|---|---|
endpoint | Required. The URL the payloads are POSTed to. There is no default endpoint. |
sdkVersion | Required. Pass SDK_VERSION from the root @tokenops/sdk; in the installed build it is 2.0.0-rc.1. |
installationId | Optional anonymous id you manage and persist across runs. |
import { SDK_VERSION } from "@tokenops/sdk";
import { TokenOpsTelemetry } from "@tokenops/sdk/telemetry";
import { createConfidentialDisperseClient } from "@tokenops/sdk/fhe-disperse";
const telemetry = new TokenOpsTelemetry({
endpoint: "https://telemetry.example.com/ingest", // your collector
sdkVersion: SDK_VERSION,
installationId: persistedAnonymousId, // optional, caller-managed
});
const disperse = createConfidentialDisperseClient({ publicClient, walletClient, encryptor, telemetry });SDK_VERSION is baked in at build time rather than read from package.json, so it works in serverless and browser bundles, and a unit test fails the release if it drifts from the package version.
ConsoleTelemetry with per-request tags#
import { ConsoleTelemetry } from "@tokenops/sdk/telemetry";
import { createConfidentialVestingFactoryClient } from "@tokenops/sdk/fhe-vesting";
const telemetry = new ConsoleTelemetry();
// Per request: a child sink that prefixes every line with the tags.
const requestTelemetry = telemetry.scope({ requestId });
const factory = createConfidentialVestingFactoryClient({
publicClient,
walletClient,
telemetry: requestTelemetry,
});
// [tokenops-sdk] [requestId=abc] fhe-vesting.factory.createManager.start
// [tokenops-sdk] [requestId=abc] fhe-vesting.factory.createManager.end 812msYour own sink#
Any object with event, span and error satisfies SdkTelemetry. Implementations must not throw from event or error. A scope that you implement should return a new instance rather than mutate this, or tags leak between consumers sharing a client; the SDK never calls scope itself.
import type { SdkTelemetry } from "@tokenops/sdk/telemetry";
export const otelTelemetry: SdkTelemetry = {
event(name, props) {
tracer.startSpan(name, { attributes: flatten(props) }).end();
},
span(name, fn) {
return tracer.startActiveSpan(name, async (span) => {
try {
return await fn();
} finally {
span.end();
}
});
},
error(err, context) {
logger.error({ code: (err as { code?: string }).code, ...context }, err.message);
},
};What the SDK emits#
| Signal | Name | Carries |
|---|---|---|
| Init event | <product>.client.init | On client construction: chainId, hasWallet and sdkVersion; surface on the vesting, airdrop and disperse clients; aclResolved on the vesting and disperse clients. |
| Write span | <product>.<surface>.<method> | Brackets the write, for example fhe-vesting.manager.claim or fhe-disperse.singleton.disperse. Every public write on the vesting manager and the disperse client, including claims, transfers, disclosures, withdrawals, role writes and encrypted views. |
| Helper span | fhe.setOperator | Also fhe.revokeOperator, fhe.isOperator, fhe.ensureOperator and fhe.mintMockERC7984, when you pass a sink to the helper. |
| Error | error(err, { span }) | When a span's function throws: the error and the span name. The error is then re-thrown to your code. |
React hooks forward their telemetry option into the headless client they build, on every product subpath, so hook writes produce the same spans as headless ones.
What is never sent#
- Nothing at all without a sink: the default is a no-op, and
TokenOpsTelemetryhas no built-in endpoint. TokenOpsTelemetrypayloads hold only the kind, the event or span name, event props, a span's duration and success flag, an error's message and context, plussdkVersion,installationIdand a timestamp. Span arguments and results are never serialized.- Decrypted amounts never appear in spans. Encrypted handles may, since a handle is a public identifier.
- Plaintext confidential amounts are kept out of error messages and
context, which a custom sink receives on the error object:encryptUint64range errors,scaleRatio/sharenumerator errors and sosplitVestingerrors carry no amount, andInsufficientBalanceErrorleavesrequested/availableunset for confidential balances.
The SDK cannot police what your code adds. Hooks that take a plaintext amount document it as the one confidential value they see: do not pass it to telemetry, an onError context or a console statement.