contextvars. Context follows synchronous calls and
survives an await (a coroutine gets a copy of its caller’s context, and concurrent asyncio
tasks are isolated from each other). It does not cross a ThreadPoolExecutor, a bare
Thread, or a subprocess: none of those copy the calling context, so code running on one sees
no trace at all.
A step entered with no trace in scope is not attached to a guess. It runs and records nothing,
exactly as on the mark functions page. Nothing in the trace marks the
gap: that step has no path, no record, and no placeholder, so the symptom is a fan-out missing
from the trace rather than anything the trace itself says. The count is in steps_outside_trace,
which vexlo.diagnostics() reads.
carrier() and attach()
vexlo.carrier() snapshots the current trace id and step stack. vexlo.attach(snapshot) is
a context manager that makes that snapshot the ambient context for the duration of a block, and
restores whatever was there before when the block ends. Take the snapshot on the thread that
holds the trace, and open the block on the thread that does the work:
threads.py
score ran four times on pool threads and returned the same values every time. The segment
file holds three step-attempt records, not five:
attach() were recorded as children of rank, at paths 0000 and
0001, with parent_name: "__main__.rank". The two calls submitted without it ran with no
trace in scope and were silent. The first recorded call, pretty-printed:
vexlo.tag() lands on the carried
trace.
Finish the work before the root returns
The trace closes when the root function returns. A step still running on another thread at that moment never records its outcome: itsstep-attempt never ships, and neither do its values. What
does ship is its identity. The SDK holds a step-open record for every attempt from the moment
it is entered, and writing the trace’s trace-close is one of the three triggers that writes
one, so the attempt arrives as a step with a start and no end. The drop of the outcome is counted
as attempts_outlived_trace, which
vexlo.diagnostics() reads.
This root starts a thread whose step sleeps for 0.3 seconds, then returns without waiting for
it:
late_thread.py
step-attempt.
score_later has only a step-open, which the outline below prints with a status of open
because the kind carries no status field:
trace-close reports no loss, because the drop happened after it was built and nothing
later revises it:
rank does above by leaving the with ThreadPoolExecutor block before returning, and every
carried step records its outcome with the rest of the trace. The same holds for a
fire-and-forget asyncio task.
Coroutines
carrier() and attach() are for threads. A coroutine run from inside a marked synchronous
function sees the trace without them, because an asyncio task starts with a copy of the context
it was created in. Here the root runs a coroutine with asyncio.run, and the step called from
the coroutine is recorded as the root’s child:
sync_root_async_inner.py
async def itself works too, and bounds its attempt by the coroutine’s own run:
the mark functions page shows
that.
Next: the configuration reference.