> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vexlo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Configuration reference

> Every init() argument and environment variable, what raises, the capture policy file, flush() and shutdown, and the in-memory bounds.

Everything the SDK can be told is an argument to `vexlo.init()`. Seven of those arguments also
read an environment variable when the argument is not passed, so a deployment can set them
without a code change. How a captured value is rendered and bounded is on
[what gets captured](https://docs.vexlo.ai/sdk/what-gets-captured.md).

## `init()`

```python theme={null}
vexlo.init(
    service=None,
    spool_dir=None,
    enabled=None,
    value_sample_rate=None,
    tag_keys=None,
    capture_logs=False,
    adapters=None,
    correlate_otel=True,
    redact_keys=None,
    strict=False,
    capture_values=None,
    capture_error_message=None,
)
```

An argument passed explicitly always wins over its environment variable. `None` means "not
passed"; an explicit `service=""` is an empty name and raises, while an explicit
`spool_dir=""` counts as absent.

| Argument | Environment variable | Default | Accepts |
| - | - | - | - |
| `service` | `VEXLO_SERVICE` | none; required | 1 to 128 printable ASCII characters. The name every trace from this process carries. |
| `spool_dir` | `VEXLO_SPOOL_DIR` | `~/.vexlo` | A path. An empty argument or an empty variable counts as absent. The default is `$HOME/.vexlo`, or the home directory from the password database when `HOME` is unset. |
| `enabled` | `VEXLO_ENABLED` | `True` | A boolean. See [Disabling the SDK](#disabling-the-sdk). |
| `value_sample_rate` | `VEXLO_VALUE_SAMPLE_RATE` | `1.0` | A number from `0.0` to `1.0`; the share of `ok` traces whose values are kept. |
| `tag_keys` | none | `[]` | Argument names harvested from the root call as tags. A name also matched by a redaction rule raises. |
| `capture_logs` | none | `False` | A boolean. See [Log capture](#log-capture). |
| `adapters` | `VEXLO_ADAPTERS` | `True` | A boolean; whether a dataclass, namedtuple, or `model_dump()` object is converted for capture. |
| `correlate_otel` | none | `True` | A boolean; whether an active OpenTelemetry span's id becomes the `otel_trace_id` tag. |
| `redact_keys` | none | the 12 names in `vexlo.DEFAULT_REDACT_KEYS` | A list of names whose values are redacted. Replaces the default set. |
| `strict` | none | `False` | A boolean. See [`strict`](#strict). |
| `capture_values` | `VEXLO_CAPTURE_VALUES` | `True` | A boolean. `False` ships `input`, `output`, and `error` as `not_captured` and runs no walk over a value. |
| `capture_error_message` | `VEXLO_CAPTURE_ERROR_MESSAGE` | `True` | A boolean. `False` withholds a failed attempt's exception message; its class and stack still ship. |

For `VEXLO_ENABLED`, `VEXLO_ADAPTERS`, `VEXLO_CAPTURE_VALUES`, and
`VEXLO_CAPTURE_ERROR_MESSAGE`, the values `0`, `false`, `no`, `off`, and the empty string turn
the setting off, compared case-insensitively after stripping whitespace; any other value turns
it on.

Those seven are the only environment variables the SDK reads, apart from `HOME` for the default
spool directory. `VEXLO_ENDPOINT` and `VEXLO_INGEST_TOKEN` do not exist in the SDK: the server
address and the API token belong to [the relay daemon](https://docs.vexlo.ai/server/relay-daemon.md), which is the
only process on the host that sends anything.

## Calling `init()` again

A second `init()` call replaces the configuration wholesale: a new `instance_id` is minted, a
new writer is started, and the previous writer is drained (for at most 2 seconds) and then
stopped. Nothing carries over from the earlier call, so a process that calls `init()` twice
must pass every setting both times. All eleven counters are cumulative for the life of the
process and a second `init()` does not reset them; see
[reading what the SDK absorbed](#reading-what-the-sdk-absorbed).

## Disabling the SDK

`enabled=False` or `VEXLO_ENABLED=0` makes every marker a plain pass-through: the marked
function is called and nothing else happens. No `vexlo-writer` thread starts, no `atexit` hook
is registered, no spool directory is created, and `tag()` and `flush()` return without doing
anything. Marked code can ship before a relay daemon or a server exists.

One shape is not a complete pass-through. A marked async generator's driver is chosen when the
decorator runs, which can be before `init()`, and an async generator has no `yield from` to
delegate the rest of the drive with, so a disabled one still passes each item back out through
the driver's frame at about 0.12 microseconds an item. Take the marker off a hot async
generator rather than disabling the SDK to quiet it.

```python disabled.py theme={null}
import os
import threading

import vexlo

vexlo.init(service="delivery-service")


@vexlo.trace
def assign_dasher(order_id):
    return {"order_id": order_id}


print(assign_dasher("o-5001"))
vexlo.flush()
print("threads:", sorted(t.name for t in threading.enumerate()))
print("spool dir exists:", os.path.exists(os.environ["VEXLO_SPOOL_DIR"]))
```

```bash VEXLO_ENABLED=0 theme={null}
VEXLO_ENABLED=0 VEXLO_SPOOL_DIR=spool-disabled python disabled.py
```

```bash enabled theme={null}
VEXLO_SPOOL_DIR=spool-enabled python disabled.py
```

```text VEXLO_ENABLED=0 theme={null}
{'order_id': 'o-5001'}
threads: ['MainThread']
spool dir exists: False
```

```text enabled theme={null}
{'order_id': 'o-5001'}
threads: ['MainThread', 'vexlo-writer']
spool dir exists: True
```

## Log capture

`capture_logs=True` appends one `logging.Handler` to the root logger. It never calls
`basicConfig`, never sets a level, and never touches another handler, so every handler the host
configured keeps receiving every record. The handler buffers each record emitted while a step is
active against that step's attempt: at most 100 entries per attempt, each message cut at 4 KiB,
16 KiB per attempt in total. A record emitted inside a marked child belongs to the child, not to
the call above it. A record emitted with no step active is dropped. A later
`init(capture_logs=False)` removes that same handler.

**The buffered entries ship.** While capture is on, every attempt carries a `logs` slot
alongside its other slots, and the entries kept for it travel in a `value` record like any other
value. One entry is `{"level", "logged_at_us", "logger_name", "message", "truncated"}`, where
`logged_at_us` is the offset in microseconds from the start of the trace. Log text therefore
leaves the process, and [redaction](https://docs.vexlo.ai/sdk/what-gets-captured.md#redaction) matches names and cannot
reach inside a message the host already formatted. Read what your service logs before turning
this on.

A `logs` handle differs from the handle on any other slot in three ways:

* `preview` is the last entry's message, not the first 64 code points of the shipped bytes.
* `bytes_count` is the length of the blob that ships, not the size before a cut. `truncated` is
  the field that says entries were dropped.
* An attempt that logged nothing ships `state: "stored"` with `bytes_count: 0`, no `content_id`,
  and no `preview`. Two further states mean the sink kept nothing for a reason: `too_large` when
  the first entry alone did not fit 16 KiB, and `not_captured` when `capture_logs` is on but the
  handler is no longer on the root logger, which is what a host that rebuilds the root logger's
  handler list after `init()` leaves behind.

```python capture_logs.py theme={null}
import logging

import vexlo

vexlo.init(service="delivery-service", capture_logs=True)
print("root logger handlers:", logging.getLogger().handlers)

log = logging.getLogger("delivery")


@vexlo.step
def find_order(order_id):
    log.warning("order %s not in cache", order_id)
    return {"order_id": order_id}


@vexlo.trace
def assign_dasher(order_id):
    return find_order(order_id)


print(assign_dasher("o-5001"))
vexlo.flush()
```

```bash theme={null}
VEXLO_SPOOL_DIR=spool-logs python capture_logs.py
```

```text theme={null}
root logger handlers: [<_LogHandler (NOTSET)>]
{'order_id': 'o-5001'}
```

The root logger holds the one handler the SDK added. The `find_order` record, whose body logged
a warning, carries three slots:

```json theme={null}
... 11 lines omitted
  "slots": {
    "input": {
      "state": "pending",
      "content_id": "2bc6cbcdd520baa89a88af87774ec52c94283bcf655ff3aabb97042192df0227",
      "bytes_count": 21,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"order_id\":\"o-5001\"}"
    },
    "output": {
      "state": "pending",
      "content_id": "2bc6cbcdd520baa89a88af87774ec52c94283bcf655ff3aabb97042192df0227",
      "bytes_count": 21,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"order_id\":\"o-5001\"}"
    },
    "logs": {
      "state": "pending",
      "content_id": "4bb64fdb9b0aeb8af3b6679158791fb9df1bb4daef2276fb1351b2a00e51f4e0",
      "bytes_count": 120,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "order o-5001 not in cache"
    }
  },
  "parent_name": "__main__.assign_dasher"
}
```

The entry behind that `logs` handle travels in its own `value` record:

```json theme={null}
{
  "kind": "value",
  "trace_id": "8be9bfc4deff15e51d4dd63bf74beeea",
  "content_id": "4bb64fdb9b0aeb8af3b6679158791fb9df1bb4daef2276fb1351b2a00e51f4e0",
  "encoding": "json",
  "bytes_count": 120,
  "truncated": false,
  "value": "[{\"level\":\"WARNING\",\"logged_at_us\":93,\"logger_name\":\"delivery\",\"message\":\"order o-5001 not in cache\",\"truncated\":false}]"
}
```

The root logged nothing of its own, so its `logs` slot says so with no bytes to fetch:

```json theme={null}
... 30 lines omitted
    "logs": {
      "state": "stored",
      "bytes_count": 0,
      "truncated": false,
      "redacted": false,
      "encoding": "json"
    }
  }
}
```

## `strict`

`strict=True` re-raises the SDK's own internal faults instead of counting and swallowing them.
It re-raises at four places: a marker's entry, a marker's success path, inside `tag()`, and
inside `flush()`. It is for a test suite that wants to know the SDK is misbehaving. Everywhere
else the fault is still swallowed, including the error path, where an exception the marked
function raised is always the one that propagates and a fault while recording it is absorbed;
the writer thread's start; and the drain at process exit.

`strict` does not govern what `init()` raises, and it does not govern the refusal a marker
makes at decoration. Both raise whatever it is set to.

## Reading what the SDK absorbed

A fault the SDK swallows is counted, and `vexlo.diagnostics()` is how a host reads the counts.
It returns a `vexlo.Diagnostics` holding eleven integers, cumulative for the life of the process
and never reset by a later `init()`.

```python diagnostics.py theme={null}
import vexlo

vexlo.init(service="delivery-service")
print(vexlo.diagnostics())
```

```text theme={null}
Diagnostics(internal_errors=0, adapter_errors=0, steps_outside_trace=0, redacted_tags=0, steps_after_trace_close=0, attempts_outlived_trace=0, drives_lost_to_trace_end=0, drives_degraded=0, attempts_dropped_hidden_coroutine=0, traces_evicted=0, markers_declined=0)
```

Four of the eleven are about capture itself:

| Counter | Counts |
| - | - |
| `internal_errors` | Every fault the [never-raise boundary](https://docs.vexlo.ai/sdk/mark-functions.md#the-never-raise-boundary) swallowed, on any path. |
| `adapter_errors` | Every adapter conversion that raised or did not return a `dict`. Each one left that value captured as a bare type marker and broke nothing. |
| `steps_outside_trace` | Marked functions entered with no trace active. Not a fault: the SDK records no edge rather than guessing one. |
| `redacted_tags` | Tag pairs dropped because a redaction rule matched the key. Not a fault either: it is the SDK doing what `redact_keys` asked. |

The other seven each count a marked callable that ran without a complete step reaching the wire,
so that a missing step is never silent:

| Counter | Counts |
| - | - |
| `steps_after_trace_close` | Entered after the trace it was running under had already closed. |
| `attempts_outlived_trace` | Entered inside a live trace and finished after it closed. The fire-and-forget task and the carried-context thread are the two patterns that reach it. |
| `drives_lost_to_trace_end` | A marked generator or async generator still unfinished when its trace closed. |
| `drives_degraded` | A drive whose own entry faulted. The rest of the host's iteration ran untouched. |
| `attempts_dropped_hidden_coroutine` | A synchronously marked function that returned a coroutine. |
| `traces_evicted` | Traces dropped by the 10,000-concurrent-trace cap. |
| `markers_declined` | A marker applied to a class, which is handed back untouched. |

Two of those seven cost the step's outcome rather than the step itself. An attempt is reported
the moment it is entered, so one that was still open when its trace closed, which is
`attempts_outlived_trace` and `drives_lost_to_trace_end`, leaves a
[`step-open`](https://docs.vexlo.ai/sdk/initialise#the-fifth-record-kind) in the graph with no outcome ever arriving.
The other five put nothing on the wire at all.

`internal_errors`, `adapter_errors`, and `steps_outside_trace` are `0` in a process where
nothing has gone wrong, which makes those three worth asserting on in a test suite and worth
putting on a health route. Four of the rest rise on code working exactly as designed, so
asserting a whole `Diagnostics` against zeros fails: `redacted_tags` on every redacted key
passed to `tag()`, `markers_declined` once per decorated class at import,
`attempts_outlived_trace` for every task or thread that finishes after its trace closed, and
`drives_lost_to_trace_end` for every `async for` loop that leaves a marked async generator
unfinished.

The eleven are read one after another, so a fault landing between two reads shows on one and
not the other. Each count is correct for itself.

One loss these counters do not report: a tag dropped at one of its [bounds](https://docs.vexlo.ai/sdk/tags.md#bounds),
which nothing counts. A record the outbox evicted is counted, and rides out on the
`trace-close` record as `records_lost`.

## What `init()` raises

`init()` raises `ConfigurationError`, a subclass of `vexlo.VexloError`, before anything is
started; `ConfigurationError` itself is not exported, so catch `vexlo.VexloError`. The five
conditions and their messages, from one script that triggers each in turn:

```python config_errors.py theme={null}
import os

import vexlo


def attempt(label, **kwargs):
    try:
        vexlo.init(**kwargs)
    except vexlo.VexloError as err:
        print(f"{label}\n  {type(err).__name__}: {err}")


os.environ.pop("VEXLO_SERVICE", None)
attempt("init()")
attempt("init(service='delivery-service', value_sample_rate=1.5)",
        service="delivery-service", value_sample_rate=1.5)

os.environ["VEXLO_VALUE_SAMPLE_RATE"] = "half"
attempt("VEXLO_VALUE_SAMPLE_RATE=half init(service='delivery-service')",
        service="delivery-service")
del os.environ["VEXLO_VALUE_SAMPLE_RATE"]

os.environ.pop("VEXLO_SPOOL_DIR", None)
os.environ["HOME"] = ""
attempt("HOME= init(service='delivery-service')", service="delivery-service")

attempt("init(service='delivery-service', tag_keys=['password'])",
        service="delivery-service", spool_dir="spool-config", tag_keys=["password"])
```

```text theme={null}
init()
  ConfigurationError: service must be 1-128 printable ASCII characters, got ''
init(service='delivery-service', value_sample_rate=1.5)
  ConfigurationError: value_sample_rate must be between 0.0 and 1.0, got 1.5
VEXLO_VALUE_SAMPLE_RATE=half init(service='delivery-service')
  ConfigurationError: VEXLO_VALUE_SAMPLE_RATE must be a float, got 'half'
HOME= init(service='delivery-service')
  ConfigurationError: spool_dir: could not resolve a home directory for the default spool location; set spool_dir or VEXLO_SPOOL_DIR explicitly
init(service='delivery-service', tag_keys=['password'])
  ConfigurationError: tag_keys and redact_keys both name 'password': a key harvested as a searchable tag cannot also be one whose value is never captured. Remove it from one of the two lists. Redaction matches a key as written and its lower-, upper- and capitalized spellings, so a casing variant counts as the same name.
```

| Condition | Message |
| - | - |
| `service` not passed and `VEXLO_SERVICE` unset, or outside 1 to 128 printable ASCII characters | `service must be 1-128 printable ASCII characters, got '...'` |
| `value_sample_rate` outside `0.0` to `1.0`, from either source | `value_sample_rate must be between 0.0 and 1.0, got ...` |
| `VEXLO_VALUE_SAMPLE_RATE` not parseable as a float | `VEXLO_VALUE_SAMPLE_RATE must be a float, got '...'` |
| no `spool_dir`, no `VEXLO_SPOOL_DIR`, and no home directory (`HOME` set but empty, or unset with no password-database entry) | `spool_dir: could not resolve a home directory for the default spool location; set spool_dir or VEXLO_SPOOL_DIR explicitly` |
| one name in both `tag_keys` and `redact_keys`, including by a casing variant | `tag_keys and redact_keys both name '...': ...` |

`init()` is not the only call that raises. A marker applied to a `classmethod`, `property`,
`cached_property`, or `partialmethod` object, or to something not callable, raises
`UnsupportedCallable` where the decorator runs; see
[what a marker refuses to wrap](https://docs.vexlo.ai/sdk/mark-functions.md#what-a-marker-refuses-to-wrap). Every other
public call raises only under [`strict`](#strict).

Once `init()` has returned, a spool directory that turns out to be unwritable is not an error
the host sees: each record whose write fails is counted as lost and dropped, and the marked
functions keep running.

## The capture policy file

The SDK never talks to the server, so the capture policy for a service reaches it through the
relay daemon, which writes `<spool_dir>/policy/<service>.json`:

```json theme={null}
{"enabled": true, "value_sample_rate": 0.0, "max_value_bytes": 16384, "capture_logs": true, "updated_at": "2026-10-20T01:57:46Z"}
```

The `vexlo-writer` thread re-reads the file at most once a second and caches the result, so no
marker call touches the disk to find out. The file only ever lowers what `init()` configured.
Five settings are read from it: `enabled`, `capture_logs`, `capture_values`, and
`capture_error_message` are on only if both the file and `init()` say so, and
`value_sample_rate` is the smaller of the two. A field the file omits, or gives the wrong type,
keeps the `init()` value. `max_value_bytes` is parsed and clamped the same way, but this version
of the SDK applies a fixed 16 KiB slot cap and does not lower it from the file.

Two of the five are what an operator reaches for after finding credentials in a service's
exception messages: `capture_values` and `capture_error_message` turn that capture off without a
deploy. The relay daemon shipping today writes `enabled`, `value_sample_rate`, and
`capture_logs`; the other two take effect when something writes them.

A file whose `updated_at` is more than 5 minutes old is stale, and the SDK reverts to its
`init()` values until a fresh one appears. A missing, unreadable, or malformed file is not an
error: the `init()` values apply.

The script below calls `init()` with the rate given as its first argument, writes a policy file
with the rate and the age given as its second and third, waits 1.5 seconds for the writer's next
read, and runs one trace.

```python policy.py theme={null}
import datetime
import json
import os
import pathlib
import sys
import time

import vexlo

spool_dir = pathlib.Path(os.environ["VEXLO_SPOOL_DIR"])
policy_path = spool_dir / "policy" / "delivery-service.json"


def write_policy(value_sample_rate, age_seconds):
    updated = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=age_seconds)
    policy_path.parent.mkdir(parents=True, exist_ok=True)
    policy_path.write_text(json.dumps({
        "enabled": True,
        "value_sample_rate": value_sample_rate,
        "max_value_bytes": 16384,
        "capture_logs": True,
        "updated_at": updated.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }))


@vexlo.trace
def assign_dasher(order_id):
    return {"order_id": order_id}


init_rate = float(sys.argv[1])
policy_rate = float(sys.argv[2])
age_seconds = int(sys.argv[3])
vexlo.init(service="delivery-service", value_sample_rate=init_rate)
write_policy(policy_rate, age_seconds)
time.sleep(1.5)
assign_dasher("o-5001")
vexlo.flush()
```

Reading the segment file back takes one more script, which every tab below uses and which the
pages in this section reuse:

```python spool_records.py theme={null}
"""Pretty-print the records in every segment file under a spool directory, in file order.

Optional filters: a record kind, then a step name.
"""

import json
import pathlib
import sys

root = pathlib.Path(sys.argv[1])
kind = sys.argv[2] if len(sys.argv) > 2 else None
name = sys.argv[3] if len(sys.argv) > 3 else None
for path in sorted(root.glob("spool/*/*.jsonl")):
    for line in path.read_text().splitlines():
        record = json.loads(line)
        if kind and record["kind"] != kind:
            continue
        if name and record.get("name") != name:
            continue
        print(json.dumps(record, indent=2))
```

Three runs of the policy script. Each tab shows the policy file the run wrote, then the two
`state` lines of the root's `step-attempt` record in the segment file.

> [tab] init 1.0, file 0.0, fresh
>
> ```bash theme={null}
> VEXLO_SPOOL_DIR=spool-policy-a python policy.py 1.0 0.0 0
> cat spool-policy-a/policy/delivery-service.json
> python spool_records.py spool-policy-a step-attempt | grep '"state"'
> ```
>
> ```text theme={null}
> {"enabled": true, "value_sample_rate": 0.0, "max_value_bytes": 16384, "capture_logs": true, "updated_at": "2026-10-20T01:57:46Z"}
> ```
>
> ```text theme={null}
>       "state": "sampled_out",
>       "state": "sampled_out",
> ```
>
> The file lowered the rate from `1.0` to `0.0`, so the root's values were sampled out.

> [tab] init 1.0, file 0.0, 10 minutes old
>
> ```bash theme={null}
> VEXLO_SPOOL_DIR=spool-policy-b python policy.py 1.0 0.0 600
> cat spool-policy-b/policy/delivery-service.json
> python spool_records.py spool-policy-b step-attempt | grep '"state"'
> ```
>
> ```text theme={null}
> {"enabled": true, "value_sample_rate": 0.0, "max_value_bytes": 16384, "capture_logs": true, "updated_at": "2026-10-20T01:47:48Z"}
> ```
>
> ```text theme={null}
>       "state": "stored",
>       "state": "stored",
> ```
>
> The same file, stale: the `init()` rate of `1.0` applied and the values were stored.

> [tab] init 0.0, file 1.0, fresh
>
> ```bash theme={null}
> VEXLO_SPOOL_DIR=spool-policy-c python policy.py 0.0 1.0 0
> cat spool-policy-c/policy/delivery-service.json
> python spool_records.py spool-policy-c step-attempt | grep '"state"'
> ```
>
> ```text theme={null}
> {"enabled": true, "value_sample_rate": 1.0, "max_value_bytes": 16384, "capture_logs": true, "updated_at": "2026-10-20T01:57:49Z"}
> ```
>
> ```text theme={null}
>       "state": "sampled_out",
>       "state": "sampled_out",
> ```
>
> A file cannot raise the rate: `init()` said `0.0`, and `0.0` stood.

How the policy is set on the server, and how the daemon resolves it per service, is on
[the capture policy page](https://docs.vexlo.ai/server/capture-policy.md).

## `flush()` and shutdown

`vexlo.flush()` drains the outbox to the spool directory on the calling thread and returns
when the outbox is empty or 2 seconds have passed, whichever comes first. It does nothing when
`init()` was never called or the SDK is disabled. Anything still buffered at the deadline is
counted as lost. A failed write, from a full or read-only disk, is counted too, and swallowed,
unless `init(strict=True)` is set, in which case `flush()` re-raises it.

The same drain, with the same 2-second deadline, runs from the `atexit` hook `init()`
registers, so an ordinary interpreter exit writes what is buffered without any call from the
host. That drain is outside `strict`: it swallows a failed write whatever `strict` is set to.
The SDK installs no signal handler: a host's own `SIGTERM` handling is untouched, and a
process killed by a signal that bypasses `atexit` loses whatever the writer had not yet flushed.
Between flushes the writer thread writes on its own cadence: every second, or as soon as 512
records or 1 MiB are waiting, with one `fsync` per batch.

## In-memory bounds

Marked functions never write to disk themselves. They append to the outbox, and the writer
thread drains it. Two bounds keep the outbox from growing without limit:

| Held in memory | Bound | At the bound |
| - | - | - |
| Shape records (`trace-open`, `step-open`, `step-attempt`, `trace-close`, released `value`) | 10,000 records or 16 MiB, whichever comes first; 5% reserved for `trace-open` and `trace-close` | The oldest open trace is evicted: every record of it still in the outbox goes at once, and each later record of that trace is dropped as it arrives. No individual step is singled out. |
| Held values (a trace's `value` records, held until its root returns) | 256 KiB per trace, 24 MiB per process | One trace's values are evicted, oldest trace first; its shape records stay. The affected slots have no `value` record to resolve to. |

A held `step-open` is never evicted to make room, and no trace is evicted on its account, but it
does occupy the buffer until it drains, so it counts toward the occupancy bound above.

Eviction removes what is still in the outbox, so whatever the writer thread already put on disk
stays there. A trace big enough to span a drain therefore arrives partial rather than absent.
The root's completion is the last record a trace writes, which makes it the likeliest casualty,
and when it is lost the root's held `step-open` is what ships instead. One root calling one leaf
10,200 times, then `flush()`, leaves this on disk:

```python overflow.py theme={null}
import json
import os
import pathlib
from collections import Counter

import vexlo

vexlo.init(service="delivery-service")


@vexlo.step
def in_zone(dasher_id):
    return {"dasher_id": dasher_id, "in_zone": True}


@vexlo.trace
def assign_dasher(order_id):
    for index in range(10_200):
        in_zone(f"d-{index:05d}")
    return {"order_id": order_id}


assign_dasher("o-5001")
vexlo.flush()

kinds = Counter()
at_root = Counter()
close = None
for path in sorted(pathlib.Path(os.environ["VEXLO_SPOOL_DIR"]).glob("spool/*/*.jsonl")):
    for line in path.read_text().splitlines():
        record = json.loads(line)
        kinds[record["kind"]] += 1
        if record.get("path") == "":
            at_root[record["kind"]] += 1
        if record["kind"] == "trace-close":
            close = record
for kind, count in sorted(kinds.items()):
    print(f"{kind}: {count}")
print("records at the root path:", dict(at_root) or "none")
print("trace-close:", json.dumps({k: close[k] for k in ("records_lost", "truncated")}))
```

```bash theme={null}
VEXLO_SPOOL_DIR=spool-overflow python overflow.py
```

```text theme={null}
step-attempt: 255
step-open: 1
trace-close: 1
trace-open: 1
records at the root path: {'step-open': 1}
trace-close: {"records_lost": 28085, "truncated": true}
```

So loss does not always arrive as a whole missing trace. Two bounds acted on this one and the
close record reports them separately. `truncated: true` is the
[10,000-step cap](https://docs.vexlo.ai/sdk/mark-functions.md#the-10000-step-cap): the run made 10,201 marked entries, so
201 of them got no path and no record. `records_lost` is the eviction: of the 10,000 entries that
did get a path, 255 completions reached disk. No `value` record survived at all, and the only
record at the root path is the `step-open` the close trigger wrote, so the root's completion never
arrived and the server derives `completeness: running`, then `abandoned` once nothing has arrived
for 15 minutes. Read `records_lost` and `truncated` before reading a trace as complete. Every drop
is counted, and when the loss happens before the trace's `trace-close` record is admitted,
the count rides out on that record as `records_lost`, which the server reports in the trace's
coverage block. A local write that fails (a full disk, a permissions error, a removed directory)
is counted the same way and never retried.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.