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"
}
FieldWhat it isAlways
schema_versionThe protocol version this event conforms to. Ingestion accepts every version it has ever published.yes
event_idUnique per event, and the idempotency key for a retried send.yes
trace_idShared by every event in one agent run.yes
span_idIdentifies the unit of work. On a trace event, the root span.yes
parent_span_idThe span that was open when this one started. Null on a trace, required everywhere else.when it applies
timestampWhen the span started, RFC 3339 with a UTC offset. end_timestamp is its optional partner.yes
event_typetrace, span, model_call, tool_call, api_call, mcp_call, db_call, milestone, error.yes
nameStable and low-cardinality: the agent name on a trace, the operation name on a span.yes
duration_msElapsed wall-clock time, or elapsed-so-far while the status is running.yes
statusok, error, timeout, cancelled, running. The outcome of the work, not of the HTTP call that carried it.yes
attributesMetadata. Never arguments, prompts or payloads. Redacted in the SDK and again on ingest.yes
errorType, message, source and retryability. Required whenever the status is error or timeout.when it applies
sdk_name / sdk_versionWhich library produced the event, so a fleet-wide question stops needing a guess.yes

Event types

traceOne complete agent execution. The root of everything else, and the only event that carries the agent name and environment.
spanA unit of work with no more specific type — a phase of your own logic.
model_callA call to an LLM.
tool_callA call to a tool your agent owns.
api_callAn outbound HTTP call.
mcp_callA call through an MCP server. The one whose contract we watch independently.
db_callA database query.
milestoneA zero-duration marker — an approval, a hand-off, a decision point.
errorA 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.

BehaviourDefaultDetail
HTTP timeout10sPer attempt, not per flush.
Retries3 attempts500ms base, doubling, full jitter, capped at 5s.
Retryable408, 429, 5xx, networkEverything else in 4xx is permanent and the batch is dropped.
Batch size50 eventsA larger buffer splits across requests.
Flush interval5sA background timer that never keeps the process alive.
Buffer500 eventsAt capacity the oldest is dropped. Telemetry is never why a process dies.
Sampling1.0, head-basedDecided once per trace, so a partial trace is never sent.
Shutdownflush, 5s capIdempotent. Short-lived processes that skip it lose what is buffered.
Exceptionsrecorded, re-raisedUnchanged, 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.

VariableDefaultMeaning
ZEVRUNA_TOKEN-Ingest token. Without it nothing is sent and one warning is emitted.
ZEVRUNA_ENDPOINThttps://zevruna.com/apiTrailing slash stripped.
ZEVRUNA_ENVIRONMENTproductionFalls back to NODE_ENV or ENVIRONMENT.
ZEVRUNA_DISABLEDunset1 makes every entry point a pass-through.
ZEVRUNA_SAMPLE_RATE1Float in (0, 1].
ZEVRUNA_BATCH_SIZE50Clamped to 1–500.
ZEVRUNA_FLUSH_INTERVAL_MS5000Clamped to 100–300000.
ZEVRUNA_TIMEOUT_MS10000Clamped to 1000–60000.
ZEVRUNA_MAX_RETRIES2Retries after the first attempt.
ZEVRUNA_MAX_BUFFER500Clamped to 10–10000.
ZEVRUNA_CAPTURE_ERROR_TEXTunset1 transmits error text a remote server authored.
ZEVRUNA_PROTOCOLv1legacy 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.

SDKPackageEmitsStatus
Node@zevruna/observe 0.2.01.0Stable
Node@zevruna/observe ≤ 0.1.3legacySupported
Pythonzevruna 0.2.01.0Beta
Gozevruna-go 0.2.01.0Beta

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.

Quickstart and CLI reference · Data collected