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

# Context across threads

> Where the current trace lives, why a thread pool loses it, and how carrier() and attach() carry it across.

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](https://docs.vexlo.ai/sdk/mark-functions.md). 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()`](https://docs.vexlo.ai/sdk/configuration#reading-what-the-sdk-absorbed) 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:

```python threads.py theme={null}
from concurrent.futures import ThreadPoolExecutor

import vexlo

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


@vexlo.step
def score(dasher_id):
    return {"dasher_id": dasher_id, "score": 470.0}


@vexlo.trace
def rank(dasher_ids):
    snapshot = vexlo.carrier()

    def attached(dasher_id):
        with vexlo.attach(snapshot):
            return score(dasher_id)

    with ThreadPoolExecutor(max_workers=2) as pool:
        with_context = list(pool.map(attached, dasher_ids))
        without_context = list(pool.map(score, dasher_ids))
    return with_context == without_context


print(rank(["d-01", "d-02"]))
vexlo.flush()
```

```bash theme={null}
VEXLO_SPOOL_DIR=spool-threads python threads.py
```

```text theme={null}
True
```

`score` ran four times on pool threads and returned the same values every time. The segment
file holds three `step-attempt` records, not five:

```text theme={null}
0000         0  ok     __main__.score
0001         0  ok     __main__.score
             0  ok     __main__.rank
```

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:

```json theme={null}
{
  "kind": "step-attempt",
  "trace_id": "f5e0147dbc5efb65d3359745060b71ee",
  "path": "0000",
  "name": "__main__.score",
  "attempt": 0,
  "started_us": 109,
  "duration_us": 12,
  "status": "ok",
  "declared_retryable": false,
  "continues_retry": false,
  "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": "fddf12748febf866e34af902c14df7b7b66327ced28a0ef565c24a83b929b173",
      "bytes_count": 32,
      "truncated": false,
      "redacted": false,
      "encoding": "json",
      "preview": "{\"dasher_id\":\"d-01\",\"score\":470}"
    }
  },
  "parent_name": "__main__.rank"
}
```

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()`](https://docs.vexlo.ai/sdk/configuration#reading-what-the-sdk-absorbed) reads.

This root starts a thread whose step sleeps for 0.3 seconds, then returns without waiting for
it:

```python late_thread.py theme={null}
import threading
import time

import vexlo

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

running = []


@vexlo.step
def score_later(dasher_id):
    time.sleep(0.3)
    return {"dasher_id": dasher_id, "score": 470.0}


@vexlo.trace
def dispatch_scoring(dasher_id):
    snapshot = vexlo.carrier()

    def scored():
        with vexlo.attach(snapshot):
            score_later(dasher_id)

    thread = threading.Thread(target=scored)
    thread.start()
    running.append(thread)
    return {"dispatched": True}


print(dispatch_scoring("d-01"))
running[0].join()
print("attempts_outlived_trace:", vexlo.diagnostics().attempts_outlived_trace)
vexlo.flush()
```

```bash theme={null}
VEXLO_SPOOL_DIR=spool-late python late_thread.py
```

```text theme={null}
{'dispatched': True}
attempts_outlived_trace: 1
```

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:

```text theme={null}
             0  ok     __main__.dispatch_scoring
0000         0  open   __main__.score_later
```

That open record carries the step's identity and its start, and nothing about how it ended:

```json theme={null}
{
  "kind": "step-open",
  "trace_id": "183de4014623e990800031293d95ec14",
  "path": "0000",
  "name": "__main__.score_later",
  "attempt": 0,
  "started_us": 90,
  "declared_retryable": false,
  "parent_name": "__main__.dispatch_scoring"
}
```

The `trace-close` reports no loss, because the drop happened after it was built and nothing
later revises it:

```json theme={null}
{
  "kind": "trace-close",
  "trace_id": "183de4014623e990800031293d95ec14",
  "records_lost": 0,
  "truncated": false
}
```

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:

```python sync_root_async_inner.py theme={null}
import asyncio

import vexlo

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


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


async def lookup(order_id):
    await asyncio.sleep(0)
    return find_order(order_id)


@vexlo.trace
def assign_dasher(order_id):
    return asyncio.run(lookup(order_id))


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

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

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

Marking the `async def` itself works too, and bounds its attempt by the coroutine's own run:
[the mark functions page](https://docs.vexlo.ai/sdk/mark-functions.md#coroutines-generators-and-async-generators) shows
that.

Next: the [configuration reference](https://docs.vexlo.ai/sdk/configuration.md).


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