Skip to main content
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:
step_outside_trace.py
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() reads. Both markers accept a bare form and a call form, and retries is the only keyword either takes:

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.
vexlo-demo/src/delivery/api.py
Beneath it, methods on frozen dataclasses are marked with step. pick raises when no dasher qualifies; fetch raises when the order does not exist.
vexlo-demo/src/delivery/assignment.py
vexlo-demo/src/delivery/orders.py
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 builds. Every other run on this page is a script printed above its command.
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:
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"}.
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 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.
retries.py
The two calls produce two attempts at path 0000:
Attempt 0 is continues_retry: false (nothing precedes it); attempt 1 is continues_retry: true. The two reserve_dasher records, pretty-printed:
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() 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.
unsupported.py
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 shapes 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:
async_root.py
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.
generator.py
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: 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() carries the count for each of those cases, so a step missing from a graph is never silent. Next: tags relate a trace to the request it served and to traces on other services.