2.0 RC docsView 1.x docs
Concept · Telemetry

Opt-in telemetry with a fixed, small surface

Clients emit nothing until you pass a sink. With one, writes become named spans and clients report one init event.

v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.

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#

SinkDoes
NoopTelemetryNothing. span runs fn and returns its result; scope returns the same instance. What a client uses when you pass no sink.
ConsoleTelemetryLogs 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.
TokenOpsTelemetryPOSTs a JSON payload per event, span and error to the endpoint you configure. Failed sends are swallowed. No scope.

TokenOpsTelemetryOptions and SDK_VERSION#

OptionNotes
endpointRequired. The URL the payloads are POSTed to. There is no default endpoint.
sdkVersionRequired. Pass SDK_VERSION from the root @tokenops/sdk; in the installed build it is 2.0.0-rc.1.
installationIdOptional 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 812ms

Your 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#

SignalNameCarries
Init event<product>.client.initOn 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 spanfhe.setOperatorAlso fhe.revokeOperator, fhe.isOperator, fhe.ensureOperator and fhe.mintMockERC7984, when you pass a sink to the helper.
Errorerror(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 TokenOpsTelemetry has 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, plus sdkVersion, installationId and 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: encryptUint64 range errors, scaleRatio / share numerator errors and so splitVesting errors carry no amount, and InsufficientBalanceError leaves requested / available unset 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.

See also