Skip to content

Observability: what the package says about its own work ​

src/observability/ — typed events, a sink the host installs, and nothing else. The CLI's --log is one consumer of it; an app's Axiom or Datadog client is another.

This is the answer to "what did it do, and why did that fail" after the fact. Its sibling, the progress stream (SDK), answers "what is it doing right now" for the person waiting. They are deliberately separate streams with opposite defaults: progress is on until silenced, telemetry is off until asked for.

The shape ​

ts
import { configure } from "@swarm-ai-labs/turnkey-extensions/swarm";
import type { SwarmEvent } from "@swarm-ai-labs/turnkey-extensions/types";

configure({
  observability: {
    level: "info", // "off" | "info" | "debug"
    traceId: crypto.randomUUID(), // correlates one logical run
    sink: (event) => queue.push(event), // never awaited
  },
});

Three rules the module is built on, and they explain most of its API:

  • The library never writes to stdout or stderr. The same code runs in a CLI, in a browser and in a Next.js server; a console.log is noise in one host and a leak in another. It emits data, the host decides where it goes.
  • No logging client ships in the package. Shipping Axiom or Datadog would make every consumer carry a dependency they may not use. The two sinks it does ship are package-internal: memorySink() (tests, an in-app debug panel) and formatEvent() (one event → one line), both in src/observability/sinks.ts.
  • The default is silence. sink: null, level: "off" — every emit is a no-op and the bulky fields are never even built.

The sink is called synchronously and never awaited: a host that ships events over the network buffers inside its own sink rather than making the package wait on a payment path. Anything thrown inside a sink is swallowed — logging must not be able to fail a transaction.

What is in an event ​

Every event carries an envelope — at, traceId, phase (start / ok / fail), plus spanId and durationMs for the bracketed ones, and error on a failure — and one typed body from a closed union.

The bodies carry an allow-list of fields, never a free-form payload. "Log the request object" is how a session's apiPrivateKey ends up on disk; every field in src/observability/types.ts was chosen because it helps answer what happened without carrying a credential. What that rules out, concretely:

  • no query strings (http.request keeps host and path only — a key or an address is routinely in the query);
  • no private keys, no session credentials, no OTP codes, no email — auth events name the sub-org and nothing else;
  • signing payloads and response bodies only at level: "debug", and only the struct being signed, never the key that signs it.
EventFires when
http.requestevery outgoing HTTP call, through loggedFetch
http.retrya 429 is being waited out — the seconds a log otherwise loses
rpc.callan EVM JSON-RPC call
sign.requesta signature is requested, with which backend answered
tx.preparedan EVM transaction is built (nonce, gas, fee)
tx.broadcasta transaction goes out, on any chain
tx.receiptit mined — status separates success from a revert
eip3009.domaina token's EIP-712 domain was resolved, and by which branch
eip3009.authorizationa gasless transfer is authorized
quotea swap provider quoted, refused, or was chosen
venue.actiona venue-specific step: orders, relayer submits, 0G turns
authOTP init/verify, session minted, saved, expired
budget.reservea spend was reserved — or refused, which is why a command stopped
cachea cached read: hit, miss, or stale
swallowedan error was caught and deliberately ignored
command.runthe CLI started and finished, with the exit code

Levels ​

LevelWhat it adds
offnothing — every emit returns immediately
infowhat was done: requests, signatures, transactions, decisions
debugthe bulky fields: signing payloads, truncated response bodies

Guard anything expensive with debugging() so a caller never builds a string nothing will read.

Using it from a host ​

A network sink batches and flushes on its own clock:

ts
let queue: SwarmEvent[] = [];

configure({
  observability: {
    level: "info",
    traceId: crypto.randomUUID(),
    sink: (e) => {
      queue.push(e);
      scheduleFlush(); // POSTs the batch; the sink itself returns immediately
    },
  },
});

In a test, assert on what the code actually reported. memorySink() is package-internal today — configure and the event types are the public surface, so a consumer collects into its own array with the same three lines:

ts
const events: SwarmEvent[] = [];
const collector = { sink: (e: SwarmEvent) => events.push(e) };
configure({ observability: { sink: collector.sink, level: "info", traceId: "t" } });

await swarm.predictions.bet({ … });

expect(events.filter((e) => e.type === "venue.action").map((e) => e.action)).toEqual(
  ["order.place", "order.place"],
);

src/observability/emission.test.ts is that pattern applied to the real call paths — each case there was a blind spot first.

From the CLI ​

FlagEffect
--log [info|debug]events to stderr, one human-readable line each
--log-jsonthe same events as JSON lines — the shape a collector receives
--log-file <path>append the stream to a file instead

Everything goes to stderr, never stdout: --json must stay a clean machine-readable document. The log and the progress lines share one writer, so their interleaving is truthful — → sign really did come before sign.request.

A run ends with its trace id on stderr, on both paths:

$ swarm send ethereum:USDC --to 0x… --amount 10 --log
[63d9b2ee] command.run start send --to 0x… --amount 10 --log
[63d9b2ee] http.request 412ms GET api.example/v1/gas → 200
[63d9b2ee] command.run fail 197ms send … — CliError: Missing asset to send
error: Missing asset to send
hint: Use `swarm send <chain:SYMBOL> --to <address> --amount N`.
[63d9b2ee] command.run send → exit 2
run 63d9b2ee

The failed run is the one whose id someone actually goes looking for, so it is printed last — after the human-readable error, where it can be copied into a bug report.

Credentials in the command line are redacted before command.run records it: the flags named in SECRET_FLAGS, plus anything ending in -key or containing secret. A key's name is not a secret, but it shares the suffix rule — masking it costs a little context and is the safe side of the trade.

Where the events come from ​

Instrumented today, roughly in order of how much they matter:

  • Money leaving: every sender (senders/observe.ts wraps all seven chains), EVM contract calls, EIP-3009 gasless transfers.
  • Venues: Polymarket orders, cancels and relayer submits; Hyperliquid exchange actions and signal opens; 0G Compute turns, failovers and ledger writes; ADI and 1Click deposits and status polls.
  • Routing: every swap quote, every refusal, and the choice between them — the decision nobody could see afterwards.
  • Identity: OTP login, session minted / saved / expired.
  • Everything upstream: all outgoing HTTP, its retries, and the caches that make a run do no HTTP at all.

Known gaps, stated rather than implied: memorySink and formatEvent are not part of the public export map, so a consumer writing the same test collects into its own array; zg inference is reported per turn but not per token; the CEX client (src/cex/) reports only as http.request; and the pure read modules (prices, indexer, inventory) have no events of their own beyond their HTTP and cache lines — by design, since a read that returns data has already said everything it can.

Released under the MIT License.