Docs
The protocol
One versioned wire contract, shared by every Zevruna SDK and by the ingestion API. It exists so that a Python agent and a Node agent are not quietly two different products.
Looking to install an SDK? This page is the wire contract, not the setup. Install and wrap your agent is an install and two lines in Node, Python or Go, or start with the CLI.
The short version
An SDK captures execution data, redacts it, buffers and batches it, retries temporary failures, and sends it. That is the whole job. Incident detection, classification, root-cause analysis and alerting all run on our side — detection logic inside your process is logic we cannot fix without you upgrading, and logic that becomes three subtly different implementations the moment there is a second SDK.
The canonical event
Everything is one shape. A trace is the root event; every other event is a span beneath it, identified byevent_type. Nothing is nested, so a long run can send its steps before it ends and the backend still assembles the tree.
{
"schema_version": "1.0",
"event_id": "8f3c…",
"trace_id": "b21a…",
"span_id": "4d90…",
"parent_span_id": "b21a…",
"timestamp": "2026-08-20T10:00:00.000Z",
"end_timestamp": "2026-08-20T10:00:04.812Z",
"event_type": "mcp_call",
"name": "salesforce.update_contact",
"duration_ms": 4812,
"status": "error",
"attempt": 1,
"attributes": { "server": "salesforce", "tool": "update_contact" },
"error": { "type": "McpToolError", "message": "…", "source": "server" },
"sdk_name": "zevruna-python",
"sdk_version": "0.2.0"
}| Field | What it is | Always |
|---|---|---|
| schema_version | The protocol version this event conforms to. Ingestion accepts every version it has ever published. | yes |
| event_id | Unique per event, and the idempotency key for a retried send. | yes |
| trace_id | Shared by every event in one agent run. | yes |
| span_id | Identifies the unit of work. On a trace event, the root span. | yes |
| parent_span_id | The span that was open when this one started. Null on a trace, required everywhere else. | when it applies |
| timestamp | When the span started, RFC 3339 with a UTC offset. end_timestamp is its optional partner. | yes |
| event_type | trace, span, model_call, tool_call, api_call, mcp_call, db_call, milestone, error. | yes |
| name | Stable and low-cardinality: the agent name on a trace, the operation name on a span. | yes |
| duration_ms | Elapsed wall-clock time, or elapsed-so-far while the status is running. | yes |
| status | ok, error, timeout, cancelled, running. The outcome of the work, not of the HTTP call that carried it. | yes |
| attributes | Metadata. Never arguments, prompts or payloads. Redacted in the SDK and again on ingest. | yes |
| error | Type, message, source and retryability. Required whenever the status is error or timeout. | when it applies |
| sdk_name / sdk_version | Which library produced the event, so a fleet-wide question stops needing a guess. | yes |
Event types
| trace | One complete agent execution. The root of everything else, and the only event that carries the agent name and environment. |
| span | A unit of work with no more specific type — a phase of your own logic. |
| model_call | A call to an LLM. |
| tool_call | A call to a tool your agent owns. |
| api_call | An outbound HTTP call. |
| mcp_call | A call through an MCP server. The one whose contract we watch independently. |
| db_call | A database query. |
| milestone | A zero-duration marker — an approval, a hand-off, a decision point. |
| error | A failure that is not attached to a span you opened. |
Identical behaviour, not just an identical schema
Matching JSON is the easy half. What a customer actually depends on is when telemetry arrives and what it costs them when it cannot, so every SDK implements this table to the letter.
| Behaviour | Default | Detail |
|---|---|---|
| HTTP timeout | 10s | Per attempt, not per flush. |
| Retries | 3 attempts | 500ms base, doubling, full jitter, capped at 5s. |
| Retryable | 408, 429, 5xx, network | Everything else in 4xx is permanent and the batch is dropped. |
| Batch size | 50 events | A larger buffer splits across requests. |
| Flush interval | 5s | A background timer that never keeps the process alive. |
| Buffer | 500 events | At capacity the oldest is dropped. Telemetry is never why a process dies. |
| Sampling | 1.0, head-based | Decided once per trace, so a partial trace is never sent. |
| Shutdown | flush, 5s cap | Idempotent. Short-lived processes that skip it lose what is buffered. |
| Exceptions | recorded, re-raised | Unchanged, including the message. Your error handling is never degraded. |
Redaction
Two layers, both on by default. A built-in key denylist — password, secret, token, api_key, authorization, credential, cookie, ssn, card_number and their neighbours — is matched case-insensitively as a substring and replaced before anything leaves your process, then applied again on ingest so a pinned SDK or a hand-rolled client cannot get around it. Your own redact(key, value) hook runs after it. Error text that a remote server wrote is withheld and replaced with a line we write, unless you opt in — that text can quote the arguments the server rejected. Your code still receives the original message either way. Field by field.
Environment variables
Identical names and identical parsing in every SDK. Explicit configuration always wins.
| Variable | Default | Meaning |
|---|---|---|
| ZEVRUNA_TOKEN | - | Ingest token. Without it nothing is sent and one warning is emitted. |
| ZEVRUNA_ENDPOINT | https://zevruna.com/api | Trailing slash stripped. |
| ZEVRUNA_ENVIRONMENT | production | Falls back to NODE_ENV or ENVIRONMENT. |
| ZEVRUNA_DISABLED | unset | 1 makes every entry point a pass-through. |
| ZEVRUNA_SAMPLE_RATE | 1 | Float in (0, 1]. |
| ZEVRUNA_BATCH_SIZE | 50 | Clamped to 1–500. |
| ZEVRUNA_FLUSH_INTERVAL_MS | 5000 | Clamped to 100–300000. |
| ZEVRUNA_TIMEOUT_MS | 10000 | Clamped to 1000–60000. |
| ZEVRUNA_MAX_RETRIES | 2 | Retries after the first attempt. |
| ZEVRUNA_MAX_BUFFER | 500 | Clamped to 10–10000. |
| ZEVRUNA_CAPTURE_ERROR_TEXT | unset | 1 transmits error text a remote server authored. |
| ZEVRUNA_PROTOCOL | v1 | legacy forces the pre-protocol body. |
Rate limits
Ingest is bounded per project at 120 requests per 10 seconds — roughly six times what a hundred processes flushing on the default interval produce. Past it the API answers 429 with a Retry-After, which every Zevruna SDK already treats as retryable: the batch stays buffered and goes out on the next flush, so a burst is slowed rather than lost. Quota is separate and unaffected — you are metered on what is stored, not on how often you ask.
Already running OpenTelemetry?
Then you do not need an SDK at all. Point an exporter at us and your existing traces arrive as runs. Two environment variables, no code change:
OTEL_EXPORTER_OTLP_ENDPOINT=https://zevruna.com/api/otlp OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer zv_live_…
A span with no parent becomes the run, and its agent name comes from service.name. What kind of work each span covers is read from the semantic conventions rather than from SpanKind, which does not separate a model call from a database query. GenAI token counts are mapped onto the names the console reads, so gen_ai.usage.input_tokens lands as input_tokens and the cost panel fills in. Ids arrive hex or base64 depending on your exporter; both resolve to the same trace.
JSON only for now — set http/json, since protobuf is the exporter default. Accepting it and mis-parsing it would be worse than refusing it, so the endpoint refuses it and says which setting to change.
Compatibility
The rule that keeps this readable: the backend accepts every version it has ever accepted. Rows are added, never removed. An SDK you pinned two years ago keeps ingesting, and a new SDK pointed at an older self-hosted backend detects the skew on its first flush and falls back rather than dropping your telemetry on the floor.
| SDK | Package | Emits | Status |
|---|---|---|---|
| Node | @zevruna/observe 0.2.0 | 1.0 | Stable |
| Node | @zevruna/observe ≤ 0.1.3 | legacy | Supported |
| Python | zevruna 0.2.0 | 1.0 | Beta |
| Go | zevruna-go 0.2.0 | 1.0 | Beta |
How we keep the SDKs honest
Ten shared fixtures — a successful run, a failed model call, a failed tool call, an MCP contract mismatch, nested spans, async fan-out, a timeout, a retry, redacted data, and a partial ingestion failure — describe what an agent does, in a language-neutral script. All three SDKs replay all ten through their own public APIs, and CI fails if any two produce different normalised JSON. Identifiers and clocks are erased before comparison; the relationships between them are not, so "this span's parent is that span" is still asserted.
It earns its keep. The async fixture caught a shared mutable span stack in the Node SDK that handed concurrent siblings each other's parents; the successful-run fixture caught input_tokens being redacted as a credential, because token is a denylist substring; and adding Go caught it emitting outcome on spans where the other two omit it — three bugs no single-language test suite was ever going to ask about.