> ## 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.

# What gets captured

> Which slots an attempt carries, how each Python type is rendered, the bounds on a value, redaction, the error slot, and value sampling.

Every attempt of a marked function captures its arguments, and then either its return value or
the exception it raised. This page states exactly how a Python value becomes bytes on the wire,
where the SDK stops, which names it redacts, and when it keeps the bytes at all.

## The slots

A `step-attempt` record's `slots` object holds a handle per captured value:

| Slot | Present on | What it holds |
| - | - | - |
| `input` | every attempt | The bound arguments as one object, parameter name to value. Defaults are filled in; `self` is dropped from a method. |
| `output` | an attempt that returned | The return value. |
| `error` | an attempt that raised | `{"message": str(exc), "stack": [...]}`; see [the error slot](#the-error-slot). |
| `logs` | every attempt, when `capture_logs=True` | The log records made while this attempt was the innermost marked call, as an array of entries. See [log capture](https://docs.vexlo.ai/sdk/configuration.md#log-capture), which owns this slot. |

An attempt carries `output` or `error`, never both, so it holds at most three slots, or two with
`capture_logs` off.

A handle carries no bytes. When bytes were held it carries the slot's `state`, the `content_id`
(SHA-256 of the bytes), `bytes_count`, `truncated`, `redacted`, `encoding`, and `preview`, the
first 64 Unicode code points of the rendered value. `redacted` is `true` when the SDK redacted or
withheld anything inside that slot. That flag belongs to the spool record and stops there: ingest
drops it and no read route returns it, so nothing a reader fetches reports it. What a reader sees
of an in-place redaction is the `{"$redacted":true}` marker in the value bytes, and in `preview`
when the marker falls inside those first 64 code points. The bytes travel in `value` records, one
per distinct `content_id` per trace, shipped when the root returns.

Three kinds of handle carry less than that. A slot with `state: "not_captured"` carries nothing
else, which is what [`capture_values=False`](#withholding-a-value) and a marked generator's
`output` ship. A `logs` slot whose first entry alone did not fit carries `state: "too_large"` and
nothing else. A `logs` slot on an attempt that logged nothing has no `content_id` and no
`preview`. So read `state` first and the rest only where the state says there are bytes.

`encoding` is `json` for every value except a bare top-level `str` (`text`) and a bare
top-level `bytes` (`base64`).

## How a value is rendered

The SDK dispatches on the exact type of a value, `type(x)`, against a fixed table. It never
calls `isinstance`. For a type the table does not list it reads no attribute and calls neither
`repr()` nor `str()` nor iteration: the value is rendered from its type object alone. A type the
table does list is rendered the way the table says, which for an `Enum` member means reading
`.value` and for a `Decimal` or a `UUID` means calling `str()`.

| Exact type | Rendered as |
| - | - |
| `dict` | Object, members sorted by key (RFC 8785 canonical JSON). A non-string key is rendered as text; two keys that render alike are disambiguated. |
| `list`, `tuple` | Array |
| `set` | Array, elements sorted by their rendered text |
| `str` | String, cut at 2,048 characters |
| `bytes` | Base64 string; nested inside a container, the input is cut at 2,048 bytes |
| `bool`, `int`, `float`, `None` | JSON scalars. A `float` that is NaN or infinite has no JSON form and is rendered as the `float` type marker. |
| `Decimal`, `UUID` | `str()` of the value, as a string |
| `datetime`, `date` | `.isoformat()`, as a string |
| any `Enum` member | Its `.value`, rendered by this same table |
| anything else | `{"$type": "module.Qualname"}`, from the type object alone |

Exact means exact. A `frozenset` is not a `set`, so the demo's `Dispatch.serviced_zones`, which
returns a `frozenset`, records `{"$type": "builtins.frozenset"}`. A subclass of `dict` is not a
`dict` and records its type marker, because a subclass can override iteration.

## Adapters

Without help, a dataclass, a namedtuple, or a Pydantic model would also arrive as a bare type
marker. Adapters, on by default, convert those three shapes into a dict through exactly one
conversion, then render the dict under every bound below with `"captured_via": "adapter"`
merged in, so a reader can tell that host code ran:

| Shape, detected on the type object | Conversion |
| - | - |
| a dataclass | one level of `{field: getattr(instance, field)}` over its declared fields |
| a type with `_asdict()` (a namedtuple) | `instance._asdict()` |
| a type with `model_dump()` | `instance.model_dump()` |

A conversion that raises, or returns anything whose exact type is not `dict` (a `dict` subclass
included, by the same rule as the walk itself), increments `adapter_errors`, one of the counters
[`vexlo.diagnostics()`](https://docs.vexlo.ai/sdk/configuration#reading-what-the-sdk-absorbed) reads, and the value
falls back to its type marker. `init(adapters=False)` or `VEXLO_ADAPTERS=0` turns adapters off
for the process; there is no per-type setting.

This script passes one value of most rows through a step:

```python values.py theme={"system"}
import dataclasses
import datetime
import decimal
import enum
import uuid

import vexlo

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


class Stage(enum.Enum):
    PICKED_UP = "picked_up"


@dataclasses.dataclass(frozen=True)
class Order:
    order_id: str
    subtotal: decimal.Decimal
    stage: Stage


class Connection:
    """A type outside the dispatch table: captured by name only."""


@vexlo.step
def plan(order, conn, credentials):
    return {
        "order_id": order.order_id,
        "placed_at": datetime.datetime(2026, 9, 14, 8, 30, tzinfo=datetime.timezone.utc),
        "dasher_ids": ("d-01", "d-02"),
        "zones": {"downtown"},
        "plan_id": uuid.UUID("12345678-1234-5678-1234-567812345678"),
        "fee": 6.39,
    }


@vexlo.trace
def assign_dasher(order_id):
    order = Order(order_id, decimal.Decimal("10.83"), Stage.PICKED_UP)
    return plan(order, Connection(), {"user": "dispatch", "api_key": "sk-live-1234"})


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

```bash theme={"system"}
VEXLO_SPOOL_DIR=spool-values python values.py
```

The `value` field of the three `value` records, one per line, in the order they shipped: the root's
input, then the returned dict (the same bytes for `plan`'s output and the root's output, so one
record), then `plan`'s input:

```text theme={"system"}
{"order_id":"o-5001"}
{"dasher_ids":["d-01","d-02"],"fee":6.39,"order_id":"o-5001","placed_at":"2026-09-14T08:30:00+00:00","plan_id":"12345678-1234-5678-1234-567812345678","zones":["downtown"]}
{"conn":{"$type": "__main__.Connection"},"credentials":{"api_key":{"$redacted":true},"user":"dispatch"},"order":{"captured_via":"adapter","order_id":"o-5001","stage":"picked_up","subtotal":"10.83"}}
```

In `plan`'s input: the `Order` dataclass went through the adapter, its `Decimal` became the
string `"10.83"` and its `Enum` member became `"picked_up"`; the `Connection` instance became a
type marker; and `api_key` inside the `credentials` dict was redacted in place. In the output:
the tuple and the set became arrays, the `datetime` and `UUID` became strings, and the members
are in key order, not the order the dict was built in.

## Where capture runs your code

Adapters are on by default, so the default configuration runs one method of yours per
unrecognised type. That matters for a value whose attributes are lazy: a dataclass-based ORM row
with an unloaded relationship emits its query inside the adapter's field reads, in the request
that was being captured. With `adapters=False` the walk over your values runs no code of yours
at all, and such a value renders as a bare type marker.

Two paths run your code whatever `adapters` is set to, both guarded, both counted on a raise:

* An exception's message is `str(exc)`, rendered by the exception's own `__str__` on every
  failed attempt. [`capture_error_message=False`](#withholding-a-value) is the one way to stop
  that call being made.
* With `capture_logs=True`, a captured log record is rendered with `record.getMessage()`, which
  formats whatever arguments were logged.

So `adapters=False` is the setting that keeps capture out of your attributes. It is not a
setting that keeps capture out of your code entirely.

## Bounds

Bounds are structural and temporal. Structural caps alone do not terminate a walk over an
aliased or self-referential structure, so a node budget and a wall-clock deadline sit beside
them.

| Bound | Value | At the bound |
| - | - | - |
| Depth | 6 | A value nested deeper than six containers is rendered as `null` |
| Items per container | 64 | Further items are dropped |
| Characters per string; bytes per nested `bytes` | 2,048 | Cut |
| Nodes per value | 1,024 | The rest of the value is rendered as `null` |
| Wall-clock deadline, checked every 64 nodes | 500 microseconds | The rest of the value is rendered as `null` |
| Bytes per slot | 16 KiB | The rendered text is cut at 16 KiB, never mid-character |
| Frames per captured stack | 64 | Further frames are dropped and the handle is `truncated` |

Any cut sets `truncated: true` on the handle and on the `value` record. `bytes_count` records
the size before the cut only when the 16 KiB slot cap made it; a cut by one of the structural
bounds cannot know the size of a walk it stopped, so `bytes_count` is then the emitted length.
This script hits both: `render_label` returns one 20,000-character string, `render_manifest`
returns 64 strings of 2,000 characters.

```python truncation.py theme={"system"}
import vexlo

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


@vexlo.step
def render_label(order_id):
    return {"order_id": order_id, "label": "x" * 20_000}


@vexlo.step
def render_manifest(order_id):
    return {f"page_{index:02d}": "x" * 2_000 for index in range(64)}


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


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

The two `output` handles, from the two steps' `step-attempt` records:

```json theme={"system"}
... 21 lines omitted
    "output": {
      "state": "pending",
      "content_id": "4668afda9f27ac05a33492bd5039b268c6d01eabef02bfae45a9e77664aacb38",
      "bytes_count": 2080,
      "truncated": true,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"label\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  },
... 23 lines omitted
    "output": {
      "state": "pending",
      "content_id": "dcfc45ae521b0b0c6a1a25e711753bac8cf10351e7977cc710fac78de5a4f604",
      "bytes_count": 128833,
      "truncated": true,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"page_00\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  },
... 34 lines omitted
```

`render_label`'s string was cut to 2,048 characters by the string bound, so its `bytes_count`
of 2,080 is the emitted length. `render_manifest`'s 64 strings each fit the string bound, the
rendered object came to 128,833 bytes, and the slot cap cut it to 16,384: `bytes_count` keeps
the pre-cut size.

The frame bound is the one a reader meets without a large value in sight. A stack deeper than
64 frames keeps its first 64 and sets `truncated` on a handle whose message is short and whose
payload is nowhere near 16 KiB. Deep recursion is the ordinary way to reach it. This script
raises from 100 frames of it, then reads its own segment file back:

```python deep.py theme={"system"}
import json
import os
import pathlib

import vexlo

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


def recurse(depth):
    if depth == 0:
        raise RuntimeError("bottom")
    return recurse(depth - 1)


@vexlo.step
def walk_zones():
    return recurse(100)


@vexlo.trace
def assign_dasher():
    try:
        return walk_zones()
    except RuntimeError as err:
        return {"outcome": "rejected", "reason": str(err)}


assign_dasher()
vexlo.flush()

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)
        if record["kind"] == "step-attempt" and record["name"].endswith("walk_zones"):
            handle = record["slots"]["error"]
            print("error handle: truncated", handle["truncated"],
                  "bytes_count", handle["bytes_count"])
        if record["kind"] == "value" and record["value"].startswith('{"message":"bottom"'):
            print("stack frames:", len(json.loads(record["value"])["stack"]))
```

```bash theme={"system"}
VEXLO_SPOOL_DIR=spool-deep python deep.py
```

```text theme={"system"}
error handle: truncated True bytes_count 1598
stack frames: 64
```

## Redaction

Redaction matches a name, never a value. A dict member whose key matches a redaction rule is
replaced in place with `{"$redacted":true}`. The real value is never rendered, never touched,
never type-checked. The match runs at any depth on every `str` dict key, and the top level of
the `input` slot is a dict of parameter names wherever a signature can be read, so a parameter
called `password` is redacted too. The slot keeps its state and its other members, and its
handle carries `redacted: true` on the spool record.

A redacted slot is never reported with the handle state `redacted`. The wire format defines that
state and ingest accepts it, but this SDK writes only `stored`, `pending`, `sampled_out`,
`too_large`, and `not_captured`, so in-place redaction leaves the slot's own state alone.
`redacted` is absent from a handle in the states that hold no bytes (`not_captured`,
`too_large`), where there is nothing for it to be a fact about, and from every handle a reader
fetches through the API. An absent `redacted` therefore means "not reported", never "nothing was
withheld".

The default set has 12 names, which `vexlo.DEFAULT_REDACT_KEYS` holds: `access_token`,
`api_key`, `apikey`, `authorization`, `credit_card`, `passwd`, `password`, `private_key`,
`refresh_token`, `secret`, `ssn`, and `token`.

`init(redact_keys=[...])` replaces the set; it does not extend it. A process that passes
`redact_keys=["session_cookie"]` no longer redacts `password`. Extend it by naming the default
list: `redact_keys=[*vexlo.DEFAULT_REDACT_KEYS, "email"]`.

Each name matches as you wrote it, and also matches its lower-, upper- and capitalized
spellings, so `authorization` covers `Authorization` and `AUTHORIZATION`. No other convention is
covered: `api_key` does not match `Api_Key`, `apiKey`, `Api-Key`, or `X-Api-Key`. Name the
spellings your own code uses. The match is of a whole name, never a substring, so
`user_password` is not `password`.

One rule object is built per `init()` and enforced in four places. Three are places a record
gains your data under a name: the walk's dict keys, the `tag_keys` harvest, and `vexlo.tag()`. A
`tag()` pair whose key matches is dropped rather than shipped, and counted in `redacted_tags`;
see [tags](https://docs.vexlo.ai/sdk/tags.md#bounds). The fourth is `init()` itself, which raises rather than accept a
name declared in both `tag_keys` and `redact_keys`, since one setting asks for the name to be
indexed and the other for its value never to leave the process.

A rule matches a `str` dict key. Three things a record can carry are not one:

* An exception's message, text the host already rendered.
  `ValueError(f"invalid token {token}")` from a marked function ships that token whatever
  `redact_keys` holds, and an error attempt keeps its values at every sample rate.
* A captured log message, text for the same reason. `log.info("auth %s", token)` ships as
  written, which is why `capture_logs` defaults to off.
* A positional argument to a callable whose signature cannot be read, which happens on a C
  builtin and on a wrapper that hides one. The `input` slot then falls back to `{"args": [...],
  "kwargs": {...}}`, so a positional value sits in a list with no name beside it. Keyword
  arguments in that same call keep their names and are matched normally.

None of the three is covered by a filter that would only pretend to reach it. The message has
`capture_error_message` and the log entries have `capture_logs`. The unnamed positional argument
has no switch of its own: the one that reaches it is `capture_values`, which is wider than the
case, since [it withholds](#withholding-a-value) `input`, `output` and `error` whole and runs no
walk at all.

## Withholding a value

Redaction replaces a value whose name matches. The two settings here stop a value being captured
at all, whatever it is named. Both are
`init()` arguments with an environment form, and both are
[lowerable by the relay daemon's policy file](https://docs.vexlo.ai/sdk/configuration.md#the-capture-policy-file), so an
operator who finds credentials in a service's exception messages turns them off without a
deploy.

One script covers both. It takes the setting to turn off as its argument, and raises from a
marked `charge` whose message carries the card number:

```python withhold.py theme={"system"}
import sys

import vexlo

vexlo.init(service="delivery-service", **{sys.argv[1]: False})


@vexlo.step
def charge(order_id, card_number):
    raise ValueError(f"card {card_number} declined for {order_id}")


@vexlo.trace
def assign_dasher(order_id):
    try:
        return charge(order_id, "4111111111111111")
    except ValueError as err:
        return {"outcome": "rejected", "reason": str(err)}


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

`capture_error_message=False` withholds a failed attempt's message. `str(exc)` is never called,
so the text never enters the process. The class and the stack still ship, so a trace still says
which step failed and with what. The host's own `except` block still sees the real message,
because this governs what is captured, not what the exception is:

```bash theme={"system"}
VEXLO_SPOOL_DIR=spool-no-message python withhold.py capture_error_message
```

```text theme={"system"}
{'outcome': 'rejected', 'reason': 'card 4111111111111111 declined for o-5001'}
```

```json theme={"system"}
... 21 lines omitted
    "error": {
      "state": "stored",
      "content_id": "f6fddf01f74468bcd1199db59fe52ac198fd4ade1c2300fb62a37c770ccc69de",
      "bytes_count": 120,
      "truncated": false,
      "redacted": true,
      "encoding": "json",
      "preview": "{\"message\":{\"$redacted\":true},\"stack\":[\"_markers.py:2839 in _mak"
    }
  },
  "parent_name": "__main__.assign_dasher",
  "error_class": "builtins.ValueError"
}
```

`capture_values=False` is the limiting case: `input`, `output`, and `error` all ship
`not_captured`, no walk runs, and no value byte of a slot leaves the process. The same script,
the same failure:

```bash theme={"system"}
VEXLO_SPOOL_DIR=spool-no-values python withhold.py capture_values
```

```text theme={"system"}
{'outcome': 'rejected', 'reason': 'card 4111111111111111 declined for o-5001'}
```

```json theme={"system"}
... 11 lines omitted
  "slots": {
    "input": {
      "state": "not_captured"
    },
    "error": {
      "state": "not_captured"
    }
  },
  "parent_name": "__main__.assign_dasher",
  "error_class": "builtins.ValueError"
}
```

A withheld message is written to the spool record the same way a redacted value is: the handle
carries `redacted: true` and the message reads `{"$redacted":true}`, as the
`capture_error_message=False` record above shows. The `capture_values=False` record carries
neither, because a `not_captured` slot holds no bytes for either to describe. Shape
survives either setting: every step, attempt, status, timing, and path is still recorded.
Two things do not. `capture_values=False` covers no log text, so `capture_logs`, if it is on,
still ships a captured log message as written. And grouping two identical consecutive calls as
retries of one step works off the captured input, so with nothing captured the attempts do not
group; `@vexlo.step(retries=True)` still groups them. Declared tags are harvested either way, so
traces still correlate across services.

## The error slot

An attempt that raises records `input` and `error`, both `stored` whatever the trace's
sampling rate, and never `output`. The `error` value is an object with the exception's message
and its stack, one string per frame as `file:line in function`. The file is a basename, so a
record never carries the directory a process was checked out in, and the function is its
qualified name. A frame carries no source line: fetching one means reading that frame's file off
disk, inside the request that just failed.

```python error_slot.py theme={"system"}
import vexlo

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


class OrderNotFoundError(LookupError):
    pass


@vexlo.step
def fetch_order(order_id):
    raise OrderNotFoundError(f"no order {order_id!r}")


@vexlo.trace
def assign_dasher(order_id):
    try:
        return fetch_order(order_id)
    except OrderNotFoundError as err:
        return {"outcome": "rejected", "reason": str(err)}


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

```bash theme={"system"}
VEXLO_SPOOL_DIR=spool-error python error_slot.py
```

```text theme={"system"}
{'outcome': 'rejected', 'reason': "no order 'o-9999'"}
```

The `fetch_order` record and the `error` value behind its handle:

```json theme={"system"}
{
  "kind": "step-attempt",
  "trace_id": "26376ac2d310dc019ad3560b935498ff",
  "path": "0000",
  "name": "__main__.fetch_order",
  "attempt": 0,
  "started_us": 59,
  "duration_us": 15,
  "status": "error",
  "declared_retryable": false,
  "continues_retry": false,
  "slots": {
    "input": {
      "state": "stored",
      "content_id": "148886f2753aed0213513d0671e89605f4fd85b6cfbfd246e30477f5b23914de",
      "bytes_count": 21,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"order_id\":\"o-9999\"}"
    },
    "error": {
      "state": "stored",
      "content_id": "fc0c1f09d372e858ae20aa3f6f8699ce5d0ae1f8938ca75a1361635fa519ea68",
      "bytes_count": 128,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"message\":\"no order 'o-9999'\",\"stack\":[\"_markers.py:2839 in _ma"
    }
  },
  "parent_name": "__main__.assign_dasher",
  "error_class": "__main__.OrderNotFoundError"
}
```

```text theme={"system"}
{"message":"no order 'o-9999'","stack":["_markers.py:2839 in _make_wrapper.<locals>.wrapper","error_slot.py:12 in fetch_order"]}
```

The first frame is the SDK's own wrapper, where the exception was caught; the frames below it
are the function's. The exception's class travels separately, as `error_class` on the record.

## Value sampling

`value_sample_rate`, from `init()` or `VEXLO_VALUE_SAMPLE_RATE`, is a number from `0.0` to
`1.0`, default `1.0`. It decides whether the values of a trace that closes `ok` are kept. It
never touches shape: every step, attempt, status, timing, and handle is recorded at any rate.
It never touches an error: an attempt with `status: "error"` keeps its values at any depth and
any rate. The decision is made once per trace, deterministically from the `trace_id`, at the
moment the root returns, and a slot's `state` reports the outcome:

| State | When |
| - | - |
| `stored` | An error attempt at any depth; or the root's own `ok` attempt in a trace the rate kept. A `value` record ships. |
| `pending` | An `ok` attempt below the root. Its outcome is unknown until the root returns. If the trace is kept, a `value` record ships then; if not, nothing ships. |
| `sampled_out` | The root's `ok` attempt in a trace the rate excluded. No `value` record ships. |

`sampled_out` appears only on the root, the one attempt whose completion coincides with knowing
the trace's fate. An excluded `pending` slot is never revised: it keeps that state and never
gets a `value` record.

Two further states reach the wire. `not_captured` is what
[withholding](#withholding-a-value) ships, and what a marked generator's `output` ships.
`too_large` appears on a `logs` slot whose first entry alone did not fit the 16 KiB cap. Those
five are what this version of the SDK writes; the wire format defines two more, `redacted` and
`dropped`, which it never writes, and the server adds `expired` and `missing` at read time.

The rate is a volume control, not a privacy control. It changes only whether captured values are
kept, never whether capture runs, so `0.0` costs the same per call as `1.0`, a trace that ends
in an error keeps its values regardless, and an excluded slot still carries its `content_id` and
its 64-character `preview`. [Withholding](#withholding-a-value) is the control for keeping
values inside the process.

The same script under `value_sample_rate=0.0`, once with an order that succeeds and once with
one that makes the inner step raise:

```python sampling.py theme={"system"}
import sys

import vexlo

vexlo.init(service="delivery-service", value_sample_rate=0.0)


@vexlo.step
def find_order(order_id):
    if order_id == "o-9999":
        raise LookupError(f"no order {order_id!r}")
    return {"order_id": order_id}


@vexlo.trace
def assign_dasher(order_id):
    try:
        return find_order(order_id)
    except LookupError as err:
        return {"outcome": "rejected", "reason": str(err)}


print(assign_dasher(sys.argv[1]))
vexlo.flush()
```

Each listing below is the segment file's records filtered to their `kind`, `name`, `status`,
and slot `state` lines.

> [tab] o-5001: both attempts ok
>
> ```bash theme={"system"}
> VEXLO_SPOOL_DIR=spool-sampling-ok python sampling.py o-5001
> ```
>
> Four records and no `value` record. `find_order`'s slots are `pending`; the root's are
> `sampled_out`:
>
> ```text theme={"system"}
>   "kind": "trace-open",
>   "kind": "step-attempt",
>   "name": "__main__.find_order",
>   "status": "ok",
>       "state": "pending",
>       "state": "pending",
>   "kind": "step-attempt",
>   "name": "__main__.assign_dasher",
>   "status": "ok",
>       "state": "sampled_out",
>       "state": "sampled_out",
>   "kind": "trace-close",
> ```

> [tab] o-9999: the inner step raises
>
> ```bash theme={"system"}
> VEXLO_SPOOL_DIR=spool-sampling-error python sampling.py o-9999
> ```
>
> `find_order` raised, so its `input` and `error` are `stored` and two `value` records
> shipped for them. The root caught the error and returned `ok`, so its own slots are still
> `sampled_out`:
>
> ```text theme={"system"}
>   "kind": "trace-open",
>   "kind": "step-attempt",
>   "name": "__main__.find_order",
>   "status": "error",
>       "state": "stored",
>       "state": "stored",
>   "kind": "step-attempt",
>   "name": "__main__.assign_dasher",
>   "status": "ok",
>       "state": "sampled_out",
>       "state": "sampled_out",
>   "kind": "value",
>   "kind": "value",
>   "kind": "trace-close",
> ```

The capture policy the relay daemon writes for a service lowers this rate at runtime, never
raises it; see [the configuration reference](https://docs.vexlo.ai/sdk/configuration.md#the-capture-policy-file).

Next: [context across threads](https://docs.vexlo.ai/sdk/context-across-threads.md), for steps that run on a thread
pool.


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