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.
| Language | Mechanism | Where it stops |
|---|---|---|
| Python | contextvars | A new thread. A thread pool. A process |
| TypeScript | AsyncLocalStorage | A worker thread. A process. A queue |
Why a bare thread pool loses the context
Section titled “Why a bare thread pool loses the context”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:
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.
wrap_executor() in Python
Section titled “wrap_executor() in Python”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.
import metergraphfrom 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 onlysubmit()is patched, because the standard library’smap()callssubmit()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.
Threads you start yourself
Section titled “Threads you start yourself”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:
import contextvars, threading
with metergraph.route("ticket-classifier"): ctx = contextvars.copy_context() threading.Thread(target=ctx.run, args=(classify, ticket)).start()AsyncLocalStorage in TypeScript
Section titled “AsyncLocalStorage in TypeScript”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.
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 need a scope
Section titled “setSession and setTags need a scope”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.
Across processes and queues
Section titled “Across processes and queues”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.
import secretsimport metergraph
trace_id = secrets.token_hex(16)
with metergraph.trace("checkout", trace_id=trace_id): summarize_cart() enqueue({"job": "price", "trace_id": trace_id})with metergraph.trace("checkout", trace_id=message["trace_id"]): price(message)import { randomBytes } from "node:crypto";import * as mg from "metergraph";
const traceId = randomBytes(16).toString("hex");
await mg.trace("checkout", async () => { await summarizeCart(); await enqueue({ job: "price", traceId });}, { traceId });await mg.trace("checkout", () => price(message), { traceId: message.traceId,});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.
Nesting reuses the current trace
Section titled “Nesting reuses the current trace”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.
Forked workers
Section titled “Forked workers”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.
- Name a route for what routes and traces are for
- Identity model for sessions, tags and units
- Python SDK and TypeScript SDK for the exact signatures