@zevruna/observe
v0.2.0
Published
Execution traces for AI agents on the Zevruna protocol — models, tools, APIs, databases and MCP on one timeline. Timings and step names only, never arguments.
Maintainers
Readme
@zevruna/observe
Execution traces for AI agents, models, tools, APIs, databases and MCP, on one timeline.
One run, one trace: what the agent did, how long each step took, and whether the run actually
achieved anything. Including the runs that failed while every call returned 200.
The zevruna CLI covers the other half, the tool
contracts your agents depend on, and CI gating when one moves.
Since 0.2.0 this SDK speaks the Zevruna protocol — one versioned event schema shared with the Python SDK and the ingestion API, down to retry backoff, batch size and redaction. The API surface below is unchanged.
Install
npm i @zevruna/observeIn a monorepo, install it in the workspace that imports it, not at the root. A root install
still resolves by hoisting, so the import works and tsc says nothing, until a clean install
somewhere else, where it does not. zevruna doctor checks for exactly this.
npm i @zevruna/observe -w @your/apiNode only
This package imports node:async_hooks and node:crypto, so it cannot run in a browser
bundle: a bundler stubs those builtins to an empty object, the build passes, and the chunk
throws on evaluation, new undefined.AsyncLocalStorage.
If your agent's turn loop runs client-side, wrap the server route it calls instead. That is the honest boundary anyway: the server is where the model call happens, and where a token can live.
Usage
One boundary, wrapped. Instrumenting the MCP client covers every tool call it makes, so these two lines are usually the whole integration.
import { observeAgent, instrumentMcpClient } from "@zevruna/observe";
const client = instrumentMcpClient(mcpClient, "acme-crm");
await observeAgent({ name: "support-agent" }, () => runSupportAgent(input));observeAgent({ name, runId?, attributes? }, fn)
One complete agent execution. Everything instrumented inside fn, including MCP calls through a
wrapped client, attaches to this run automatically. Returns whatever fn returns; a throw is
recorded as a failed run and rethrown.
instrumentMcpClient(client, serverName)
Wraps an MCP client's callTool so every call becomes a step, tool name, timing, outcome. Call
sites don't change. A 200 carrying isError is recorded as a failure, because it is one.
observeStep({ kind, name, attempt?, attributes? }, fn)
Any other boundary worth timing. kind is model, tool, mcp, http, db, agent or
approval. Nested calls become child steps automatically. Outside an observeAgent there is no
run to attach to, so the step runs untraced rather than starting one.
await observeStep({ kind: "model", name: "plan", attributes: { model: "claude-opus-5" } }, () =>
callModel(prompt)
);markSuccess({ outcome? }) / markFailure({ reason, outcome? })
The business outcome, which is not the HTTP status. A run where every request succeeded and the ticket never got resolved is a failed run, and only your code knows that.
milestone(name, attributes?)
A zero-duration marker on the timeline, handoff, human approval, escalation.
flush() / shutdown()
Runs are batched and flushed on a timer. On serverless or any short-lived process, await
shutdown() before exit or the queued runs are lost. flush() returns
{ sent, queued, events?, rejected?, dropped?, error?, errorKind? } and never throws, check it if
you need to know delivery actually happened. errorKind is the machine-readable form, and is the
same set of values in every Zevruna SDK: none, no_token, network, timeout,
http_retryable, http_permanent, quota, rejected.
A 202 is not proof the runs were kept: over your plan's monthly run quota the API accepts the
request and drops the batch. flush() reads the response, so that comes back as sent: 0 with the
reason in error, and warns once on the console. It is not requeued, the quota will still be
there on the next flush, and a growing queue would evict live runs.
Configuration
ZEVRUNA_TOKEN=zv_live_… # project token
ZEVRUNA_ENDPOINT=https://zevruna.com/api
ZEVRUNA_ENVIRONMENT=production # else NODE_ENV
ZEVRUNA_DISABLED=1 # no-op every wrapper
ZEVRUNA_SAMPLE_RATE=0.25 # head-based, decided once per run
ZEVRUNA_BATCH_SIZE=50
ZEVRUNA_FLUSH_INTERVAL_MS=5000
ZEVRUNA_TIMEOUT_MS=10000
ZEVRUNA_MAX_RETRIES=2 # after the first attempt
ZEVRUNA_MAX_BUFFER=500 # events, oldest evicted at capacity
ZEVRUNA_CAPTURE_ERROR_TEXT=1 # opt in to server-authored error text (see below)
ZEVRUNA_PROTOCOL=legacy # force the pre-0.2 bodyOr pass them to init({ token, endpoint, environment, enabled, flushIntervalMs, batchSize,
maxBuffer, timeoutMs, maxRetries, sampleRate, protocol, redact, captureErrorText }). init is
optional, the first wrapper call configures from the environment. Every name, default and clamp is
identical in the Python SDK — see the behavioural spec.
Pointing at an older backend
0.2.0 posts to /events. An endpoint that has never heard of it answers 404, and the SDK falls
back to the pre-protocol /runs body for the life of the process and warns once — so upgrading the
SDK before the backend costs you a line on stderr rather than your telemetry. ZEVRUNA_PROTOCOL=legacy
forces it from the start.
What is collected
Step names, kinds, timings, attempt counts, error classes, and the attributes you pass.
Never tool arguments, never tool results, never payloads.
Two things are worth knowing before a security review:
- Attributes are yours, so they carry whatever you put in them. Pass
redact(key, value)toinitto scrub them centrally. - Error text written by an MCP server can quote the arguments it rejected, so it is not
transmitted by default, a failed step records a generic reason instead.
captureErrorTextopts in. Either way your own code always receives the server's full message.
Details: what we collect.
License
MIT
