Metadata-Version: 2.4
Name: zevruna
Version: 0.2.0
Summary: 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.
License: MIT
Project-URL: Homepage, https://zevruna.com
Project-URL: Documentation, https://zevruna.com/docs/protocol
Keywords: mcp,model-context-protocol,agents,ai-agents,observability,tracing,instrumentation
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# zevruna (Python)

**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`.

This is the Python half of the [Zevruna protocol](https://zevruna.com/docs/protocol). It emits the
same versioned event schema as `@zevruna/observe` and behaves identically down to retry backoff,
batch size, buffer eviction and redaction. That is enforced, not asserted: both SDKs replay the same
ten fixtures in CI and the build fails if they diverge.

Status: **beta**. The protocol is stable; the ergonomics may still move.

## Install

```bash
pip install zevruna
```

No runtime dependencies, deliberately. An observability SDK that drags a dependency tree into your
process has made your build a place your monitoring can break.

Python 3.8+.

## Usage

One boundary, wrapped. Instrumenting the MCP client covers every tool call it makes, so these two
lines are usually the whole integration.

```python
from zevruna import observe_agent, instrument_mcp_client

client = instrument_mcp_client(mcp_client, "acme-crm")

async with observe_agent("support-agent"):
    await run_support_agent(user_input)
```

Every entry point works in sync code too — `with` instead of `async with`. Context propagation uses
`contextvars`, so a span opened inside `asyncio.gather` finds the span that was open when *it*
started, not whichever sibling happened to finish last.

### `observe_agent(name, run_id=None, attributes=None)`

One complete agent execution. Everything instrumented inside, including MCP calls through a wrapped
client, attaches to this run automatically. An exception is recorded as a failed run and re-raised
unchanged — your error handling is never degraded.

### `instrument_mcp_client(client, server_name)`

Wraps `call_tool` so every call becomes a step: tool name, timing, outcome. Call sites do not
change. A `200` carrying `isError` is recorded as a failure, because it is one, and raised as
`zevruna.McpToolError`.

### `observe_step(kind, name, attempt=1, attributes=None)`

Any other boundary worth timing. `kind` is `model`, `tool`, `mcp`, `http`, `db`, `agent` or
`approval`. Nested calls become child steps automatically. Outside an `observe_agent` there is no
run to attach to, so the step runs untraced rather than starting one.

```python
async with observe_step("model", "plan", attributes={"model": "claude-opus-5", "input_tokens": 812}):
    await call_model(prompt)
```

### `mark_success(outcome=None)` / `mark_failure(reason, outcome=None)`

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=None)`

A zero-duration marker on the timeline — hand-off, human approval, escalation.

### `flush()` / `shutdown()`

Runs are batched and flushed on a daemon thread. On serverless or any short-lived process, call
`shutdown()` before exit or the buffered runs are lost. `flush()` returns a `FlushResult` with
`sent`, `queued`, `events`, `rejected`, `dropped`, `error` and `error_kind`, and never raises.

`await aflush()` and `await ashutdown()` run the same work in a thread — `urllib` is blocking, and
an SDK that stalls your event loop to deliver telemetry has broken the one promise it makes.

A `202` is not proof the runs were kept: over your plan's monthly 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 `error_kind="quota"`, and warns once on stderr. It is not requeued — the quota will still
be there on the next flush, and a growing buffer would evict live runs.

## Configuration

```
ZEVRUNA_TOKEN=zv_live_…
ZEVRUNA_ENDPOINT=https://zevruna.com/api
ZEVRUNA_ENVIRONMENT=production
ZEVRUNA_DISABLED=1
ZEVRUNA_SAMPLE_RATE=0.25
ZEVRUNA_BATCH_SIZE=50
ZEVRUNA_FLUSH_INTERVAL_MS=5000
ZEVRUNA_TIMEOUT_MS=10000
ZEVRUNA_MAX_RETRIES=2
ZEVRUNA_MAX_BUFFER=500
ZEVRUNA_CAPTURE_ERROR_TEXT=1
ZEVRUNA_PROTOCOL=legacy
```

Or pass them to `init(...)`. `init` is optional; the first wrapper call configures from the
environment. Every name, default and clamp is identical in the Node SDK.

## What is collected

Step names, kinds, timings, attempt counts, error classes, and the `attributes` **you** pass. Never
tool arguments, never tool results, never payloads.

- 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 the process. Token *counts* — `input_tokens` and friends — are
  explicitly exempt.
- Your `redact(key, value)` hook runs after it.
- Error text written by an MCP server can quote the arguments it rejected, so it is **not**
  transmitted by default. `capture_error_text=True` opts in. Either way your own code always
  receives the server's full message.

Details: [what we collect](https://zevruna.com/docs/data-collected).

## Tests

```bash
python -m unittest discover -s tests -t .          # this SDK
node ../../protocol/contract-tests/run.mjs         # this SDK against every other one
```

## License

MIT
