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

# Mark functions

> The two markers, @vexlo.trace and @vexlo.step, what each records, how retries group into attempts, and what the SDK never does to your function.

The SDK has two markers and nothing else that touches your code: `@vexlo.trace` on a function
that starts a request, `@vexlo.step` on the functions beneath it whose values matter. A marked
function keeps its arguments, return value, exceptions, name, and docstring. This page uses the
demo service's own code and one run of it, plus five short scripts for what the demo does not
exercise.

## The two markers

`@vexlo.trace` opens a trace when the function is entered with no trace active. Entered while
a trace is already open, it behaves exactly like `@vexlo.step`, so a function that is sometimes
an entry point and sometimes an inner call is marked once, with `trace`.

`@vexlo.step` records one step in the current trace. Entered with no trace active, it runs the
function and records nothing. This script marks a step and calls it without any trace:

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

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


@vexlo.step
def find_order(order_id):
    return {"order_id": order_id}


print(find_order("o-5001"))
print("steps_outside_trace:", vexlo.diagnostics().steps_outside_trace)
vexlo.flush()
```

```bash theme={"system"}
VEXLO_SPOOL_DIR=spool-outside python step_outside_trace.py
echo "spool-outside exists: $(test -e spool-outside && echo true || echo false)"
```

```text theme={"system"}
{'order_id': 'o-5001'}
steps_outside_trace: 1
spool-outside exists: false
```

The function returned normally and the run created no spool directory at all: no record, no
segment file. A library that marks its internals with `step` is silent in a process that never
opens a trace. The one place that call shows up is `steps_outside_trace`, one of the counters
[`vexlo.diagnostics()`](https://docs.vexlo.ai/sdk/configuration#reading-what-the-sdk-absorbed) reads.

Both markers accept a bare form and a call form, and `retries` is the only keyword either
takes:

```python theme={"system"}
@vexlo.trace
def assign_dasher(order_id): ...

@vexlo.step(retries=True)
def reserve_dasher(dasher_id): ...
```

## What the demo marks

The demo's entry point is `assign_dasher`. It is a service boundary: a domain error raised
anywhere beneath it becomes a rejected response, so the caller sees an outcome rather than an
exception.

```python vexlo-demo/src/delivery/api.py theme={"system"}
@vexlo.trace
def assign_dasher(order_id: str) -> AssignResponse:
    """Assign a dasher to the order, or explain why none could be.

    Every dasher in the zone is checked for eligibility and every eligible one is scored, so
    the shape of the call tree follows how busy the zone is.

    This is a service boundary: it turns any ``DeliveryError`` raised beneath it into a rejected
    response, so a caller sees an outcome rather than an exception. It does not catch anything
    wider than ``DeliveryError`` — a programming error still surfaces as one.
    """
    try:
        order = ORDER_BOOK.fetch(order_id)
        DISPATCH.check_serviceable(order)
        dasher = SELECTION.pick(order.zone)
        plan = DISPATCH.plan(order, dasher)
    except DeliveryError as err:
        return AssignResponse(Outcome.REJECTED, order_id, plan=None, reason=str(err))

    return AssignResponse(Outcome.SUCCESS, order_id, plan=plan, reason=None)
```

Beneath it, methods on frozen dataclasses are marked with `step`. `pick` raises when no dasher
qualifies; `fetch` raises when the order does not exist.

```python vexlo-demo/src/delivery/assignment.py theme={"system"}
    @vexlo.step
    def pick(self, zone: str) -> Dasher:
        """Return the dasher who takes the order.

        Raises ``NoEligibleDasherError`` when nobody qualifies, with a reason that separates an
        unworked zone from one whose dashers were all rated too low.
        """
        eligible = self.candidates(zone)
        if eligible:
            return eligible[0]

        # The reason needs the unfiltered count, which the filtered list no longer carries.
        working = self.in_zone(zone)
        if not working:
            raise NoEligibleDasherError(zone, "no dasher is working this zone")
        raise NoEligibleDasherError(
            zone, f"none of {len(working)} dashers met the {MINIMUM_RATING} rating floor"
        )
```

```python vexlo-demo/src/delivery/orders.py theme={"system"}
    @vexlo.step
    def fetch(self, order_id: str) -> Order:
        """Return the order with this identifier, or raise ``OrderNotFoundError``."""
        order = self.find(order_id)
        if order is None:
            raise OrderNotFoundError(order_id)
        return order
```

Order `o-5003` sits in a zone nobody is working, so `pick` raises and `assign_dasher` rejects.
The command below needs the demo service installed beside the SDK, which is one more
`python -m pip install /path/to/vexlo-demo` in the environment
[the install page](https://docs.vexlo.ai/sdk/install.md) builds. Every other run on this page is a script printed above
its command.

```bash theme={"system"}
VEXLO_SPOOL_DIR=spool-demo python -m delivery assign o-5003
```

```text theme={"system"}
{"command": "assign", "order_id": "o-5003", "dasher_id": null, "fee_cents": null, "eta_minutes": null, "outcome": "rejected", "reason": "dasher selection failed in zone 'SF-94110': no dasher is working this zone"}
```

The segment file holds ten `step-attempt` records. One line each, in the order they shipped,
as path, attempt, status, name, and `error_class` where there is one:

```text theme={"system"}
00000000     0  ok     delivery.orders.OrderBook.find
0000         0  ok     delivery.orders.OrderBook.fetch
00010000     0  ok     delivery.dispatch.Dispatch.serviced_zones
0001         0  ok     delivery.dispatch.Dispatch.check_serviceable
000200000000 0  ok     delivery.assignment.DasherSelection.in_zone
000200000001 0  ok     delivery.assignment.DasherSelection.rank
00020000     0  ok     delivery.assignment.DasherSelection.candidates
00020001     0  ok     delivery.assignment.DasherSelection.in_zone
0002         0  error  delivery.assignment.DasherSelection.pick  delivery.errors.NoEligibleDasherError
             0  ok     delivery.api.assign_dasher
```

Four facts about marked functions are visible in that listing and in the two records below
it (the second `in_zone` call and `pick`, pretty-printed from the segment file):

* **A step's name is `module.qualname`** of the function object: `delivery.orders.OrderBook.fetch`,
  dots and class name included. `parent_name` and `error_class` use the same spelling, which is
  the key the function index is built on. A callable with no `__qualname__`, such as an instance
  whose class defines `__call__`, is named after its type instead; a `functools.partial` is named
  after the function it wraps, and its bound arguments appear in `input`.
* **A step's identity is its path.** `""` is the root; `0002` is the root's third child;
  `00020001` is that child's second child. A child's record ships when the child returns, so
  it always precedes its parent's record.
* **A step that raises is `status: "error"`** with the exception's class in `error_class`, and
  its trace still closes with the root `ok`, because `assign_dasher` caught the exception. The
  trace's `root_status` is `ok`; the rejection lives in the `pick` step and in the root's
  output.
* **`self` is dropped from a method's captured input.** `in_zone(self, zone)` records
  `{"zone":"SF-94110"}`, not the `DasherSelection` instance. Defaulted parameters are filled
  in: `candidates(self, zone, limit=MAX_RANKED)` records `{"limit":5,"zone":"SF-94110"}`.

```json theme={"system"}
... 243 lines omitted
{
  "kind": "step-attempt",
  "trace_id": "c8fd92a9952a7cd092c8776b8bfbd935",
  "path": "00020001",
  "name": "delivery.assignment.DasherSelection.in_zone",
  "attempt": 0,
  "started_us": 399,
  "duration_us": 6,
  "status": "ok",
  "declared_retryable": false,
  "continues_retry": false,
  "slots": {
    "input": {
      "state": "pending",
      "content_id": "f2495f3a70a233780b4f1802043f038f51ff26c666905fbd3bbe64a31818b4d9",
      "bytes_count": 19,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"zone\":\"SF-94110\"}"
    },
    "output": {
      "state": "pending",
      "content_id": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945",
      "bytes_count": 2,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "[]"
    }
  },
  "parent_name": "delivery.assignment.DasherSelection.pick"
}
{
  "kind": "step-attempt",
  "trace_id": "c8fd92a9952a7cd092c8776b8bfbd935",
  "path": "0002",
  "name": "delivery.assignment.DasherSelection.pick",
  "attempt": 0,
  "started_us": 271,
  "duration_us": 152,
  "status": "error",
  "declared_retryable": false,
  "continues_retry": false,
  "slots": {
    "input": {
      "state": "stored",
      "content_id": "f2495f3a70a233780b4f1802043f038f51ff26c666905fbd3bbe64a31818b4d9",
      "bytes_count": 19,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"zone\":\"SF-94110\"}"
    },
    "error": {
      "state": "stored",
      "content_id": "18b52ae78690499668eec99a924f4eb149deb8d1d2856a1a923fa0de966cf3e2",
      "bytes_count": 195,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"message\":\"dasher selection failed in zone 'SF-94110': no dashe"
    }
  },
  "parent_name": "delivery.api.assign_dasher",
  "error_class": "delivery.errors.NoEligibleDasherError"
}
... 146 lines omitted
```

The `in_zone` step returned `()`, an empty tuple, captured as `[]`: the answer to "why was
this list empty" is on the step that produced it. [What gets captured](https://docs.vexlo.ai/sdk/what-gets-captured.md)
covers the slots, their states, and how values are rendered.

## Retries are attempts, not siblings

A function that is called again after it raised is one step with several attempts, indexed
from 0, each attempt its own `step-attempt` record at the same path. The SDK decides this at
entry, by looking at the parent's most recently completed child. Five conditions have to hold:

1. It is the same function.
2. The previous attempt raised.
3. No other child has run in between.
4. The input bytes are identical, and not truncated. **Waived by `retries=True`.**
5. The new call raises the same exception class. **Waived by `retries=True`.**

`retries=True` declares the function retryable and waives the last two, so a host is free to
retry with different arguments, or to fail a second time with a different exception, and the
attempts still group. The first three always apply. Every attempt of a declared step carries
`declared_retryable: true`.

Condition 4 is the captured input, so `init(capture_values=False)` leaves an undeclared retry
ungrouped: with no input captured there is nothing to compare. A declared step still groups.

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

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

calls = 0


@vexlo.step(retries=True)
def reserve_dasher(dasher_id):
    global calls
    calls += 1
    if calls == 1:
        raise TimeoutError("dispatch upstream timed out")
    return {"dasher_id": dasher_id, "reserved": True}


@vexlo.trace
def assign_dasher(order_id):
    for _ in range(2):
        try:
            return reserve_dasher("d-01")
        except TimeoutError:
            continue
    return None


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

```text theme={"system"}
{'dasher_id': 'd-01', 'reserved': True}
```

The two calls produce two attempts at path `0000`:

```text theme={"system"}
0000         0  error  __main__.reserve_dasher  builtins.TimeoutError
0000         1  ok     __main__.reserve_dasher
             0  ok     __main__.assign_dasher
```

Attempt 0 is `continues_retry: false` (nothing precedes it); attempt 1 is
`continues_retry: true`. The two `reserve_dasher` records, pretty-printed:

```json theme={"system"}
{
  "kind": "step-attempt",
  "trace_id": "8a0a8cd51e081ed131d53909ae29dc2e",
  "path": "0000",
  "name": "__main__.reserve_dasher",
  "attempt": 0,
  "started_us": 54,
  "duration_us": 13,
  "status": "error",
  "declared_retryable": true,
  "continues_retry": false,
  "slots": {
    "input": {
      "state": "stored",
      "content_id": "285006004528538ab8d527b3f61a5f96f2b76a0de9a84a2e45e40bcf2f432305",
      "bytes_count": 20,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"dasher_id\":\"d-01\"}"
    },
    "error": {
      "state": "stored",
      "content_id": "866b31afa93c2277a44201f404e5d20ae652028e56b155d3fdf4bdf35cb5fe46",
      "bytes_count": 138,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"message\":\"dispatch upstream timed out\",\"stack\":[\"_markers.py:2"
    }
  },
  "parent_name": "__main__.assign_dasher",
  "error_class": "builtins.TimeoutError"
}
{
  "kind": "step-attempt",
  "trace_id": "8a0a8cd51e081ed131d53909ae29dc2e",
  "path": "0000",
  "name": "__main__.reserve_dasher",
  "attempt": 1,
  "started_us": 159,
  "duration_us": 8,
  "status": "ok",
  "declared_retryable": true,
  "continues_retry": true,
  "slots": {
    "input": {
      "state": "pending",
      "content_id": "285006004528538ab8d527b3f61a5f96f2b76a0de9a84a2e45e40bcf2f432305",
      "bytes_count": 20,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"dasher_id\":\"d-01\"}"
    },
    "output": {
      "state": "pending",
      "content_id": "947b5ba0abc7ecc396f4ca783c1eea4ab64086e7e10add11aa9f348ff6914a49",
      "bytes_count": 36,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"dasher_id\":\"d-01\",\"reserved\":true}"
    }
  },
  "parent_name": "__main__.assign_dasher"
}
```

The server derives `attempt_grouping` from these two flags: `declared` when any attempt has
`declared_retryable: true`, `observed_retry` when one has `continues_retry: true` without a
declaration, `single` otherwise.

## The 10,000-step cap

A trace records at most 10,000 marked entries, the root included. Past the cap a marked
function still runs, but gets no path and no record, and the trace's `trace-close` record
carries `truncated: true`. Retry attempts count as entries.

## The never-raise boundary

A marker never raises into your code, with one deliberate exception named below. The boundary
covers every path a marker touches: decorating the function at import, entering it, recording its
result, and the drain at process exit. If anything inside the SDK fails on one of them, the
fault is counted, the function runs as if it had never been marked, and its return value comes
back untouched. An exception your function raises is re-raised bare: never wrapped, chained, or
annotated, and never replaced by an SDK fault, even one that happens while the error is being
recorded.

A swallowed fault is not silent. It increments `internal_errors`, which
[`vexlo.diagnostics()`](https://docs.vexlo.ai/sdk/configuration#reading-what-the-sdk-absorbed) reads, and it emits
one `WARNING` record through the root logger's own handlers, naming the place it was caught and
the exception's type and nothing else. No captured value and no traceback go into that line,
since rendering one reads your source files off disk. The record is rate-limited to one a minute
per place, and the call that pays for those handlers is the request thread's. Setting the
`vexlo` logger to `ERROR` silences the line; the counters carry the volume either way.

`init(strict=True)` is for a test suite that wants to know the SDK is misbehaving: it re-raises
the SDK's own faults from a marker's entry, from a marker's success path, from `tag()`, and from
`flush()`. It never changes what happens on the error path, where your exception is always the
one that propagates, and it never changes the drain at exit. `init()` sits outside the boundary
and raises `ConfigurationError` on bad configuration whatever `strict` is set to.

## What a marker refuses to wrap

Decoration is the one moment a marker raises, and it does so whatever `strict` is set to. A
marker applied to a `classmethod`, `property`, `cached_property`, or `partialmethod` object, or
to something that is not callable, raises `UnsupportedCallable`, a subclass of
`vexlo.VexloError`, where the decorator runs. That is usually your import.

```python unsupported.py theme={"system"}
import functools

import vexlo


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


class OrderBook:
    @property
    def size(self):
        return 0


attempt("@vexlo.step above @property", OrderBook.__dict__["size"])
attempt("@vexlo.step above @classmethod", classmethod(lambda cls: None))
attempt("@vexlo.step above @functools.cached_property",
        functools.cached_property(lambda self: None))
attempt("@vexlo.step on a value that is not callable", 3)
```

```bash theme={"system"}
python unsupported.py
```

```text theme={"system"}
@vexlo.step above @property
  UnsupportedCallable: @vexlo.trace/@vexlo.step cannot wrap a property object: it re-binds on every attribute access, and a marker's wrapper does not. Apply the marker underneath it -- @property above @vexlo.step, not below.
@vexlo.step above @classmethod
  UnsupportedCallable: @vexlo.trace/@vexlo.step cannot wrap a classmethod object: it re-binds on every attribute access, and a marker's wrapper does not. Apply the marker underneath it -- @classmethod above @vexlo.step, not below.
@vexlo.step above @functools.cached_property
  UnsupportedCallable: @vexlo.trace/@vexlo.step cannot wrap a cached_property object: it re-binds on every attribute access, and a marker's wrapper does not. Apply the marker underneath it -- @cached_property above @vexlo.step, not below.
@vexlo.step on a value that is not callable
  UnsupportedCallable: @vexlo.trace/@vexlo.step cannot wrap builtins.int: it is not callable, so the wrapper would raise on every call.
```

Each of the four is already broken in your own call: a `classmethod` object raises `TypeError`
every time it is called, and a marked `property` hands back the wrapper instead of the
property's value. So no import that works today stops working. Put the marker under the
descriptor, which the message says in each case.

Three cases are not refused. `@vexlo.step` above `@staticmethod` works. Marking an
already-marked function works, and records one redundant nested step, which is what the
decorator stack says happened. Marking a class hands the class back untouched, counts
`markers_declined`, and logs a line naming it: no wrapper can stand in for a type without
breaking `isinstance` and subclassing.

## Coroutines, generators, and async generators

All four callable shapes are markable. The marker reads the decorated callable's shape once, at
decoration, and installs the matching wrapper, so `inspect.iscoroutinefunction` and its siblings
still answer `True` and a framework dispatches a marked handler exactly as it dispatches an
unmarked one.

A deferred shape's attempt is bounded by its whole drive, not by the call that created it. A
coroutine's attempt runs from its first resumption to its termination. A generator's or async
generator's spans the whole drive, so the step is current while the body runs and absent while
the consumer runs. This `async def` root records a real trace, and the synchronous step it calls
is its child:

```python async_root.py theme={"system"}
import asyncio

import vexlo

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


@vexlo.step
def find_order(order_id):
    return {"order_id": order_id}


@vexlo.trace
async def assign_dasher(order_id):
    await asyncio.sleep(0)
    return find_order(order_id)


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

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

```text theme={"system"}
{'order_id': 'o-5001'}
```

```text theme={"system"}
0000         0  ok     __main__.find_order
             0  ok     __main__.assign_dasher
```

A marked generator records the same way, with one difference: its `output` is `not_captured`.
The sequence it yields is unbounded, and its `return` value is part of that same contract.

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

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


@vexlo.step
def candidates(zone):
    for dasher_id in ("d-01", "d-02"):
        yield {"dasher_id": dasher_id, "zone": zone}


@vexlo.trace
def assign_dasher(order_id):
    found = [candidate["dasher_id"] for candidate in candidates("downtown")]
    return {"order_id": order_id, "candidates": found}


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

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

```text theme={"system"}
{'order_id': 'o-5001', 'candidates': ['d-01', 'd-02']}
```

```json theme={"system"}
... 11 lines omitted
  "slots": {
    "input": {
      "state": "pending",
      "content_id": "3455ac290480283e67a5640ca3711afbf82be4c3c37cf4d503215315ebc474aa",
      "bytes_count": 19,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"zone\":\"downtown\"}"
    },
    "output": {
      "state": "not_captured"
    }
  },
  "parent_name": "__main__.assign_dasher"
}
```

Four more consequences to know before marking one of the three deferred shapes:

* `duration_us` for a generator is a span that includes the consumer's own time between
  resumptions. A generator drained slowly across a request reports close to that request's
  duration and ranks high when a trace's steps are sorted by duration.
* An `async for` loop that leaves a marked **async** generator unfinished, through a `break`, a
  `return`, or an exception out of the loop body, records the step but never its outcome, and
  counts `drives_lost_to_trace_end`. An async generator is finalised by the event loop after the
  handler's own coroutine has completed and the trace has closed, so the drive is still open at
  that close and ships as a
  [`step-open`](https://docs.vexlo.ai/sdk/initialise#the-fifth-record-kind): the step is in the graph, named and in
  its right place, with no duration, no status, and no values. `async with
  contextlib.aclosing(...)` around the loop, or draining the generator, records it in full. The
  synchronous equivalent is unaffected: refcounting closes a `for` loop's generator inside the
  handler.
* Arguments are bound at the first `await` or `next()`, not at the call. A `TypeError` from
  passing the wrong ones surfaces there rather than at the call site, and for a coroutine
  constructed and never awaited, nowhere at all: Python's own `RuntimeWarning: coroutine ... was
  never awaited` is then the only signal.
* A synchronously marked function that returns a coroutine has its record dropped and counts
  `attempts_dropped_hidden_coroutine`, because that attempt timed the coroutine's construction.
  The trace's own root is the exception and still ships: a root with no record reads as a
  request that never finished. Both cases are logged with the function named, which is what
  moves the mark onto the `async def` itself.

[`vexlo.diagnostics()`](https://docs.vexlo.ai/sdk/configuration#reading-what-the-sdk-absorbed) carries the count for
each of those cases, so a step missing from a graph is never silent.

Next: [tags](https://docs.vexlo.ai/sdk/tags.md) relate a trace to the request it served and to traces on other
services.


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