Problem/Motivation
The "current trace" pointer is stored in the global State service under a
single fixed key, langfuse_current_trace_id (written by
LangFuseClient::setCurrentTrace(), read by
getCurrentTrace()). State is shared across the entire site, not scoped to a single request
or user.
LangFuseTraceManager::ensureTrace() reuses whatever trace
getCurrentTrace() returns, and the pointer is cleared at the
end of a request by LangfuseSyncSubscriber on
KernelEvents::TERMINATE. Two or more requests
can resolve to the same trace, so one user's prompts and
responses could be appended to another user's trace and sent to LangFuse, mixing
conversations.
Proposed resolution
Scope the active/current trace to the lifetime of a single request instead
of storing it in the global state service.
Remaining tasks
- Implement request-scoped trace storage.
- Review / RTBC.
Scoping update, 2026-08-14
Worth checking before this lands
- Whether a true race is needed at all. The pointer is cleared on
KernelEvents::TERMINATE, so a request that fatals or throws during sync leaves it set for the next request to pick up. - Anything running under the CLI SAPI may leave it behind entirely.
syncTraces()returns early there (#3616634) andclearCurrentTrace()sits after that return, sodrush cron, queue workers and other Drush-invoked code can create a trace and never clear the pointer. Web-triggered cron is unaffected, since it runs under the web SAPI.
langfuse_active_traces has the same problem
It is scoped as badly as langfuse_current_trace_id, so whatever ships here should remove it too. It also serialises entire SDK Trace objects into the database, which is worth dropping on its own account: live objects in State have no TTL, grow unbounded, and are read back on every request that touches a trace.
Sites carrying stale values will need an update hook or a release note.
This cannot merge on its own
The pointer does two jobs. It is the current-trace pointer, and it is also the only thing holding a Deep Chat turn together across the AJAX requests that make it up. Scoping it to the request drops the second job, so every turn becomes its own disconnected trace.
The replacement is in #3561460: a thread-keyed pointer scoped to thread_id instead of a global one. The two need to land in the same merge.
Verification
Each of these checked for two things, that traces still arrive and that one turn still yields one trace:
- Plain Drupal chat, with an assistant and without.
- AI Search Block, with an assistant and without.
- Deep Chat, with an assistant and without, including agent delegation.
- Two concurrent users.
- The existing test suite against the branch.
- Current released
ai,ai_agents,ai_search.
API changes
setCurrentTrace(), getCurrentTrace() and clearCurrentTrace() keep their signatures but narrow from global to per-request scope. LangFuseClient::__construct() loses its CacheBackendInterface argument.
Release notes snippet
The current trace is no longer stored in global state. Traces are scoped to the request that created them, fixing a bug where concurrent users could have their AI observations recorded against each other's traces.
Issue fork langfuse-3594090
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mr.baileysComment #4
nikro commentedPicking this up - btw this one might be tricky, I had buried a task related to this, I'll explain:
Deepchat interface - when used with Assistants / Agents, and those using delegation and/or toolcalls - sometimes interrupts each "next step" as a separate AJAX request - and normally this resulted in fragmented traces (i.e. each ajax request = 1 trace) - which made things unusable from tracing perspective -> you couldn't get the full "hierarchical chain" of step 1 -> tool-call 1 -> step 2 -> etc.. but you'd get each step as a separate trace..
If I recall correctly - I think that state-based approach was me trying to fix that (but it wasn't 100% successful as in some cases traces would still end-up broken-up).
I'll test before/after with both ai_search_block and deepchat to see how differently it behaves (and maybe now with latest Opus might figure out an elegant solution).
Comment #5
nikro commentedFlagging a coupling rather than reviewing the code. I have read the branch as a diff, not checked it out or tested it, so treat none of this as a verdict on the implementation.
The
Statekey being removed is doing two jobs. It is the current-trace pointer, which is the bug reported here, and it is also what keeps a single Deep Chat turn inside a single trace, since one turn is split across several AJAX requests. Scoping it to the request fixes the leak and drops the stitching at the same time, which is #3561460. Worth weighing that the trace splitting is something we hit regularly, while the leak is derived from reading the code and has not actually been observed on a running site.Moving to Needs work for that reason, not for anything in the diff. The suggestion is to keep this branch as the base, add the thread-keyed pointer from #3561460 on top, and verify the combined change across plain chat, AI Search Block and Deep Chat, with and without an assistant, checking that one turn still yields one trace. Happy to do that second half here rather than opening a competing MR.
Comment #6
nikro commentedComment #7
nikro commentedComment #8
nikro commentedComment #9
nikro commentedComment #10
nikro commentedComment #11
nikro commentedPushed seven commits on top of mr.baileys' two, which are rebased onto current
1.xwith authorship unchanged. The branch now also closes #3561460, since the two cannot be separated: removing the global pointer takes conversation stitching with it, so the replacement had to land in the same merge.One commit deserves a closer look than the rest.
8a65cfcscopes conversation to trace mappings to their owner. Thread IDs areassistant_thread_{uid}_{i}, so they are predictable rather than secret, andDeepChatApiaccepts whateverthread_ida client posts without checking who it belongs to. Before this commit, posting another user's thread ID recorded your observations against their trace. The AI module is unaffected because its own history lives in aPrivateTempStoreFactory; our store did not have that protection.The stitching works by reusing the trace ID across the requests that make up one turn, since LangFuse upserts on trace ID. That needs
dropsolid/langfuse-php-sdk1.2.1, which adds an optional$idto theTraceconstructor and is a backport of behaviour 1.3 already has. The module's~1.2.0constraint covers it, so no dependency change is required. A turn is bounded by the request carrying a real user message, so separate turns still get separate traces rather than a whole conversation collapsing into one.Verified against a local/live LangFuse instance: two turns produce two traces, continuation polls join the turn already in progress, one user cannot continue another's, an expired mapping starts a new trace rather than failing, and two staggered concurrent users are both recorded. That last one used to lose the second user's data entirely, which is worth stating because the symptom was silent: the request succeeded, the answer was correct, and only the observability vanished. 21 tests, 152 assertions, phpcs clean, phpstan unchanged.
Still open and not covered here: a turn that fans out to an agent across several polls. The single-request agent cascade is confirmed working, with tool and retrieval spans nested correctly, but the multi-request version has not been exercised.
Comment #13
abhisekmazumdarFixed the cspell failure
Also verified the trace-continuation logic end to end. It needs
dropsolid/langfuse-php-sdk1.2.1: 1.2.0 silently drops the trace ID passed tocreateTrace(), soThreadTraceStore's stored ID never actually reaches the SDK. Bumped to 1.2.1 locally and reran the suite: all Unit/Kernel/Functional tests pass, including the two that fail against 1.2.0 (testContinuationRequestsShareOneTrace,testNewTurnStartsNewTrace).Pipeline is still red on
phpstan, but all 4 errors are pre-existing "should return static(...)" false positives in files this MR doesn't touch (langfuse_example's two forms,langfuse_feedback's controller,LangFuseSettingsForm).other then that Its RTBC.