Skip to main content
The SDK knows which trace a marked function belongs to through context: the current trace id and the stack of open steps, held in 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:
The two calls made inside 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:
Everything that depends on context works inside the block: nested steps get paths beneath the carried step, attempt grouping sees the carried parent, and 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: its step-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
The segment file holds two step records. The root completed, so it has a 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:
That open record carries the step’s identity and its start, and nothing about how it ended:
The trace-close reports no loss, because the drop happened after it was built and nothing later revises it:
So a reader sees a step that started and never finished. What the trace does not say is whether that step is still running or was abandoned at the close. Keep the fan-out inside the root, as 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
Marking the 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.