Concept · Telemetry

A flat event interface, any sink

SdkTelemetry is a four-member interface: event() for fire-and-forget metrics, span() to wrap async operations, error() to report failures, and the optional scope() to bind tags onto a child telemetry. You decide where the data goes — Plausible, PostHog, Datadog, your own /telemetry endpoint — all behind the same shape.

The interface#

SdkTelemetry ships at @tokenops/sdk/telemetry:

  • event(name, props?) — sync, fire-and-forget; implementations must not throw
  • span<T>(name, fn) — wraps an async operation; implementations decide what to time/log
  • error(err, context?) — reports an error; implementations must not re-throw
  • scope?(tags) — optional; returns a child telemetry that auto-prepends tags to every subsequent call. The SDK never calls scope internally — adapters may omit it.

Names follow <product>.<surface>.<action> (e.g. fhe-vesting.claim.submitted). Event props are typed as Record<string, unknown>, so you can attach any serialisable value. The flat string | number | boolean restriction applies to scope(tags) tags, not to event props.

Built-in adapters#

NoopTelemetry — the default. Zero work, zero cost. Production code that never wires telemetry pays nothing.

ConsoleTelemetry — dev-time logger. Prefixes every event with the SDK product so noise is easy to filter.

TokenOpsTelemetry — POSTs to the hosted ingestion at telemetry.tokenops.xyz. Useful if you want bounty-cohort friction to land in our analytics for feedback prioritisation. Unlike NoopTelemetry and ConsoleTelemetry, it implements only event(), span(), and error() — it omits the optional scope(), so calling scope() on it throws. Callers that need child-telemetry correlation must use ConsoleTelemetry or a custom adapter.

Plug your own#

lib/telemetry.ts
ts
import { type SdkTelemetry } from "@tokenops/sdk/telemetry";
import { createConfidentialVestingManagerClient } from "@tokenops/sdk/fhe-vesting";

// Implement the interface against your sink — Datadog, OpenTelemetry,
// Sentry, PostHog, your own /telemetry endpoint, whatever. Built as a
// factory so scope() can fold tags into EVERY event, span, and error the
// child emits — not just event().
function makeTelemetry(tags: Record<string, unknown> = {}): SdkTelemetry {
  const post = (body: unknown) =>
    fetch("/internal/telemetry", { method: "POST", body: JSON.stringify(body) });
  return {
    event(name, props) {
      post({ kind: "event", name, ...tags, ...props });
    },
    async span(name, fn) {
      const start = Date.now();
      try {
        return await fn();
      } finally {
        post({ kind: "span", name, durationMs: Date.now() - start, ...tags });
      }
    },
    error(err, context) {
      post({ kind: "error", message: err.message, ...tags, ...context });
    },
    // scope is optional — returns a child that prepends tags to every
    // subsequent event, span, and error.
    scope(extra) {
      return makeTelemetry({ ...tags, ...extra });
    },
  };
}

const adapter = makeTelemetry();

// Pass the adapter via the telemetry option — no wrapper needed.
const manager = createConfidentialVestingManagerClient({
  publicClient,
  walletClient,
  address,
  telemetry: adapter,
});

Pass telemetry: adapter directly to the client factory (e.g. createConfidentialVestingManagerClient). The SDK threads it through every internal operation automatically — no wrapper, no extra call-sites.

What gets emitted#

The SDK emits events at the natural breakpoints: tx submitted, tx mined, encryptor invoked, preflight result, error caught. The full list is in the API reference. Its built-in events carry only coarse, non-sensitive fields (e.g. surface, chainId, hasWallet, sdkVersion) — never addresses, hashes, or handles. The SDK does not scrub or transform props, though: your adapter receives exactly what is passed and forwards it.

See also