Skip to content

Attribution across threads

Route, trace, session and tags are ambient. You set them once around a block of work and every wrapped call inside picks them up, without threading a context object through your function signatures.

That convenience is built on a per-language mechanism, and each mechanism has a boundary it does not cross. Cross it and the calls still get captured, they just arrive with no route, no trace and no session, which is the same as losing them for analysis.

LanguageMechanismWhere it stops
PythoncontextvarsA new thread. A thread pool. A process
TypeScriptAsyncLocalStorageA worker thread. A process. A queue

A contextvars.ContextVar is not global state. Each thread has its own current Context, and a newly created thread starts with an empty one rather than a copy of whatever the thread that created it was holding.

ThreadPoolExecutor.submit() does not copy the caller’s context either. It puts your callable on a queue, and some worker thread eventually runs it in that worker’s own context. So this looks right and is not:

Wrong: the route does not survive submit()
with metergraph.route("batch-classify"):
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(classify, tickets))

Every call classify makes is captured, and every one of them has no route, no trace and no tags.

Pass your executor through metergraph.wrap_executor() once. It patches submit() so each submission snapshots the caller’s context at submit time and runs the work inside a copy of it.

app/batch.py
import metergraph
from concurrent.futures import ThreadPoolExecutor
pool = ThreadPoolExecutor(max_workers=8)
pool = metergraph.wrap_executor(pool)
with metergraph.route("batch-classify"):
with metergraph.trace("nightly-classification"):
results = list(pool.map(classify, tickets))

Three things worth knowing:

  • The snapshot happens at submit(), not at execution. Whatever route, trace, session and tags were active when you submitted are what the work runs with, however much later the pool gets to it. That is what you want: the submitting code is the code that knows what the work is for.
  • map() is covered too, even though only submit() is patched, because the standard library’s map() calls submit() for each item.
  • It is idempotent. Wrapping an already-wrapped executor returns it unchanged, so wrapping in a helper that may be called twice is safe.

wrap_executor() returns the same executor object. You can wrap in place and carry on using the variable you already have.

If you start a threading.Thread directly rather than through an executor, copy the context yourself. This is plain standard library, with no Metergraph API involved:

app/worker.py
import contextvars, threading
with metergraph.route("ticket-classifier"):
ctx = contextvars.copy_context()
threading.Thread(target=ctx.run, args=(classify, ticket)).start()

route(), trace(), withContext(), withSession(), withTags() and track() all run your callback inside an AsyncLocalStorage store. The store follows the async work created inside that callback: awaited promises, promise chains, setTimeout and setInterval callbacks, event handlers registered inside the scope. It is restored when the callback returns.

src/batch.ts
await mg.route("batch-classify", async () => {
await Promise.all(tickets.map((ticket) => classify(ticket)));
});

Concurrency inside one process is the easy case in Node, because AsyncLocalStorage is exactly the tool for it. Ten concurrent requests each get their own store and do not see each other’s session or tags.

setSession() and setTags() mutate the current store. Called with no active scope there is nothing to mutate, so they warn once and do nothing.

// Does nothing. Warns once.
mg.setSession(runId);
// Works.
await mg.withSession(runId, async () => { await runJob(); });

Python behaves the same way: metergraph.set_session() and metergraph.set_tags() require an active context(), session(), tags(), route() or trace() scope, and warn once outside one.

Use the scoped forms. They are correct under concurrency and the setters are not.

No language mechanism crosses a process. A job queue, a subprocess, an HTTP call to another service: none of them carry ambient context. Mint the trace ID yourself, put it in the message, and open the same trace on the other side.

producer.py
import secrets
import metergraph
trace_id = secrets.token_hex(16)
with metergraph.trace("checkout", trace_id=trace_id):
summarize_cart()
enqueue({"job": "price", "trace_id": trace_id})
consumer.py
with metergraph.trace("checkout", trace_id=message["trace_id"]):
price(message)

Both sides pass the same trace ID, so the calls land in one trace even though they happened in different processes at different times. Generate it the way the SDK does, as 16 random bytes in lowercase hexadecimal.

parent_span_id (parentSpanId) is the other half: it attaches the downstream work beneath a specific span rather than beside it.

Opening a trace inside an existing one does not start a new trace. It reuses the active trace ID and keeps the outer trace’s name, unless you pass a different trace_id explicitly, in which case that one wins. So a nested helper that opens its own trace joins the caller’s workflow instead of splitting it in two, which is almost always what you want.

Python registers a fork handler at init(). A forked child gets a clean writer and its own background thread. Rows the parent had queued stay with the parent, so each worker delivers its own. Ambient context does not survive the fork either: set it inside the child, per unit of work.

See Serverless and short-lived jobs for making sure each worker actually delivers before it exits.