@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
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 isassign_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
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
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.
step-attempt records. One line each, in the order they shipped,
as path, attempt, status, name, and error_class where there is one:
in_zone call and pick, pretty-printed from the segment file):
- A step’s name is
module.qualnameof the function object:delivery.orders.OrderBook.fetch, dots and class name included.parent_nameanderror_classuse 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; afunctools.partialis named after the function it wraps, and its bound arguments appear ininput. - A step’s identity is its path.
""is the root;0002is the root’s third child;00020001is 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 inerror_class, and its trace still closes with the rootok, becauseassign_dashercaught the exception. The trace’sroot_statusisok; the rejection lives in thepickstep and in the root’s output. selfis dropped from a method’s captured input.in_zone(self, zone)records{"zone":"SF-94110"}, not theDasherSelectioninstance. Defaulted parameters are filled in:candidates(self, zone, limit=MAX_RANKED)records{"limit":5,"zone":"SF-94110"}.
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 ownstep-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:
- It is the same function.
- The previous attempt raised.
- No other child has run in between.
- The input bytes are identical, and not truncated. Waived by
retries=True. - 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
0000:
continues_retry: false (nothing precedes it); attempt 1 is
continues_retry: true. The two reserve_dasher records, pretty-printed:
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’strace-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 incrementsinternal_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 whateverstrict 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
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, soinspect.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
output is not_captured.
The sequence it yields is unbounded, and its return value is part of that same contract.
generator.py
duration_usfor 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 forloop that leaves a marked async generator unfinished, through abreak, areturn, or an exception out of the loop body, records the step but never its outcome, and countsdrives_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 astep-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 aforloop’s generator inside the handler. - Arguments are bound at the first
awaitornext(), not at the call. ATypeErrorfrom 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 ownRuntimeWarning: coroutine ... was never awaitedis 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 theasync defitself.
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.