1. Initialize in every process
initialize() is per process. A queue worker, forked child, or subprocess never inherits the parent’s initialization: its LLM calls produce nothing, and — the part that hides the problem — flush() in that process silently no-ops instead of erroring. Call initialize() at each worker process’s startup.
For prefork servers (gunicorn with --preload, multiprocessing with the fork start method), initialize after the fork, in the worker’s own startup hook: the span exporter runs on a background thread, and a thread started before fork() does not survive into the child.
2. Pass the session id as data, and re-open the session
session() attaches its context via the OpenTelemetry context (contextvars-backed in Python, async-context-backed in Node). That context follows await within the same task, but it never crosses a thread, a process, or a queue hop: an LLM call made inside a Celery task, Temporal activity, or ThreadPoolExecutor job arrives session-less even when the job was enqueued from inside session().
The fix is one line on each side. A session id is just a string — carry it in the job payload (or the activity’s arguments), and re-open the session inside the worker:
contextvars.copy_context().run(...) also carries the context over in Python, but re-opening the session is simpler and works everywhere.
If a workload genuinely has no id to carry (a third-party agent you can’t modify), the ingest header x-belvedir-session-from: trace promotes each trace’s id to a session id — but that makes one session per trace and fragments multi-step work, so pass the real id whenever you control the code.
3. Flush before the job ends
Spans export in batches on a background thread. Long-lived workers shouldflush() at the end of each job — it’s cheap, and it means a worker killed between jobs loses nothing. Short-lived workers must flush before exit, or the batch dies with the process.
flush() is synchronous: calling it immediately before os._exit() (or a hard exit in a signal handler) is safe. The hazard is exiting without it — os._exit and SIGKILL skip atexit hooks and kill the exporter thread, so an unflushed batch is silently lost.
Reporting outcomes from workers
Workers are the natural place to report ground truth — the job is where you learn whether the work succeeded. Callreport_outcome(session_id, ...) after flush(), since outcomes only attach to sessions the platform has already ingested. See POST /api/v1/outcomes.
Next steps
- Custom agent loops — instrumenting the loop the worker runs.
- Common issues — the symptoms, if worker spans are still missing.