Repository navigation
fix(livekit): flush traceAI's spans when a LiveKit job shuts down (TH-8730) - #282
Conversation
…-8730) LiveKit voice traces reached Future AGI without their root agent_session span. traceAI exports through a batch processor that sends every 5 s; on Linux a LiveKit job runs in a forkserver process that ends with os._exit(), which skips atexit, and LiveKit flushes only its own processors at job end. So the spans that end last, the root among them, were dropped. enable_* now adds a shutdown callback to the job it runs in that flushes the tracer provider. The flush runs in a daemon thread and job shutdown waits for it at most 10 s, because OpenTelemetry's batch force_flush ignores its timeout and LiveKit joins the loop's default executor before the process exits. A new flush_on_job_shutdown() covers set-up done outside a job; enable_* warns when it runs outside one. On any livekit-agents 1.x the hook either registers or is skipped, never raising into the entrypoint. Tests cover each behaviour, including a real job process started with forkserver in which agent_session must reach the exporter. Signed-off-by: khushalsonawat <[email protected]>
khushalsonawat
left a comment
There was a problem hiding this comment.
Three Mediums: the 10 s flush cap equals LiveKit's 10 s exit budget, the pre-1.5.17 get_job_context fix isn't pinned by a test, and the README misses the parentless job spans the flush now sends. The rest holds: no deadlock or blocked loop when the exporter is slow, one callback per job, no raise on any 1.x, and the tests catch a removed, synchronous or bare-method flush.
…TH-8730) Review follow-up on the job-shutdown flush. - The job-shutdown flush now waits at most 3 s, well inside LiveKit's 10 s exit budget, instead of 10 s. - A second flush runs when the job process exits (a multiprocessing finalizer, which forkserver children run before os._exit). It sends the spans LiveKit ends after the shutdown callbacks, such as job_shutdown, and covers set-up done outside a job. Also capped at 3 s. - The once-per-job guard is now per job and per provider, so a second provider in the same job gets its own flush. - Outside a job, a debug line instead of a warning; the comment on get_job_context now says required= arrived in livekit-agents 1.5.3. - Tests for the pre-1.5.3 API during a job, a second provider, the exit flush (registered once, flushes every provider, bounded), and the real-process test now also requires job_shutdown. - README and CHANGELOG describe both flushes and their limits. Signed-off-by: khushalsonawat <[email protected]>
khushalsonawat
left a comment
There was a problem hiding this comment.
Mediums 1 and 2 hold, and I've resolved them. Medium 3 is half closed. On Linux the new exit flush is never registered when tracing is set up at process start, and the README's "job's root span" only holds for setup before the job. The rest holds: the 3 s caps fit LiveKit's budget, nothing is sent twice, and the exit flush is harmless in the main process and in non-LiveKit apps.
…H-8730) On Linux a LiveKit job process is a forkserver child. A module it imports while unpickling its target runs before multiprocessing clears the child's exit finalizers, so tracing set up at the top of an agent module lost its exit flush, and the pid check stopped it being registered again: no spans were sent at all. - Keep the finalizer and make it again when it is gone or belongs to another process; an after-fork hook re-arms it in fork and forkserver children right after the clear. - Register under a lock, so jobs enabling at once in one process (LiveKit's thread executor) register one finalizer. - Flush each provider in its own thread under one deadline, so a hung provider does not hold up the others. - A child keeps the providers it inherited: they are the copies its spans go to. - README: the exit flush sends job_entrypoint only when tracing is set up before the job starts. - Tests: a real forkserver job process with tracing set up at import of a module only the child loads, plus the hook, the lock, the pid change and the per-provider flush. Signed-off-by: khushalsonawat <[email protected]>
Conflict in python/frameworks/livekit/tests/test_integration.py imports only: main (TH-8730, #282) dropped `import signal`; this branch's version-based livekit test needs `importlib.metadata` and `re`. Kept those two, dropped `signal` (unused after the merge). Checked on the merged tree (Python 3.11, CI's pytest pins): livekit 82 passed with livekit-agents 1.8.8; strands 102 passed; fi_instrumentation 205 passed, 3 failed - the same 3 timeout-introspection tests fail on main's suite with OTel 1.45 (CI locks 1.34.1). Co-Authored-By: Claude Opus 5.5 <[email protected]>
What does this PR do?
traceai-livekit now sends the spans it is still holding when a LiveKit job shuts down, so voice traces arrive with their root
agent_sessionspan.enable_http_attribute_mapping()/enable_grpc_attribute_mapping()add a shutdown callback to the job they run in that flushes the tracer provider. Agents need no code change: the examples and the README already callenable_*in the entrypoint. They also register a second flush that runs when the job process exits, which sends what LiveKit ends after the shutdown callbacks (its ownjob_shutdownand job spans) and covers tracing set up outside a job. A newflush_on_job_shutdown()lets set-up done outside a job (for example at process start) also flush as each call ends.Why?
Linear: TH-8730. Every voice trace from the phase 1 e2e run on the dev box (livekit-agents 1.8.3) was missing exactly its
agent_sessionspan, so the Traces tab showed "Root span not stored yet".The cause, reproduced on my laptop:
forkserverprocess, which exits withos._exit()and skipsatexit.So the spans that end last, the root among them, stayed queued and were dropped. Running a fake job through LiveKit's own job-process executor:
agent_sessionexported?forkserver, before this changespawn(the macOS default)forkserver, with an async shutdown flushctx.add_shutdown_callback(provider.force_flush)How it works
flush_on_job_shutdown(provider=None)finds the job withget_job_context(), called with no arguments becauserequired=only exists from livekit-agents 1.5.3. It adds one async shutdown callback per job and provider.multiprocessingfinalizer at exit priority 10. LiveKit's forkserver children run these finalizers beforeos._exit(), so it flushes every registered provider just before the process stops. Outside a job this is all that's registered, with a debug line.except, so on any livekit-agents 1.x it registers or is skipped and never raises into the entrypoint.provider.force_flush()in a daemon thread and wait for it at most 3 s, well inside LiveKit's 10 s exit budget:force_flushignores its timeout, and LiveKit joins the loop's default executor before the process exits, soasyncio.to_threadwould let a hung collector hold the job open.How was it tested?
pytest): newtests/test_shutdown_flush.py(24 cases), plustests/_job_process_entrypoint.pyandtests/_job_process_setup_at_import.py. The shared fixtures moved fromtest_integration.pytotests/conftest.py.forkserverand checks bothagent_sessionand LiveKit'sjob_shutdownreach traceAI's exporter before the process exits.agent_session,job_shutdownandjob_entrypointarrive. On the previous commit that set-up sent nothing at all.shutdown_process_timeout(read fromServerOptions), no warning outside a job, and older LiveKit job APIs, including the pre-1.5.3 one during a job.tests/test_integration.py tests/test_mapping.py tests/test_shutdown_flush.pyin one run, in CI's order: 82 passed (livekit-agents 1.8.6 from the lock, Python 3.11).ruff checkpasses on the touched files.enable_*no longer calling it;get_job_context(required=False);exceptnarrowed.Checklist
dev(the PR goes intodev;mainis for releases)feat:,fix:,docs:,chore:…)docs/VOCABULARY.md(N/A: not in this repo)Notes for reviewers
Known limits (also in the README):
agent_session; the exit flush still sends it.AgentSessionstill closing during the shutdown callbacks is sent by the exit flush, not the job-shutdown one.Changed after the last review (commit 7316d78, for review 5600437004 on cf0d4e4): no reviewer has seen these yet.
agent_session,job_shutdownandjob_entrypoint.job_entrypointis sent only when tracing is set up before the job starts. With set-up inside the entrypoint, the job trace is rooted atjob_shutdown.One cold review (Opus) found nothing above Low. I fixed the two Lows it raised: an order-dependent test, and a silent skip when a thread can't start. All 24 planted bugs above are caught.
Follow-ups (Lows from the round 3 review, left for a later PR):
anychanged toall.os.register_at_forkwould be cleaner, and would also cover plainos.forkchildren.atexit, so Future AGI's provider shutdown there can wait past 3 s. This was already the case before this PR.The last check is a rerun of the voice agent on the dev box with this branch installed, to confirm the root span is stored and the Traces tab opens the trace.
🤖 Generated with Claude Code