Problem/Motivation
LangFuseClient::syncTraces() refuses to sync under CLI:
public function syncTraces(bool $force = FALSE): void { if (empty($this->pendingTraceIds) || (!$force && !$this->shouldSyncNow())) { return; } // ... } protected function shouldSyncNow(): bool { // Check if the request is ending or other conditions that would // indicate it's appropriate to sync traces now. return php_sapi_name() !== 'cli'; }
So any trace created from Drush, from cron, or from a queue worker is
silently dropped unless the caller happens to pass
$force = TRUE. There is no log line, no warning, and no exception —
the method simply returns and the accumulated traces are discarded when the
process ends.
This matters more than it might appear, because a large share of AI work in
Drupal runs exactly there: queued indexing, scheduled agent runs, batch
content operations, and anything driven by cron. Those are the operations whose
cost and latency people most want observability on, and they are the ones that
produce nothing.
The failure mode is the worst kind: it looks like it worked. The trace object
is created successfully, code that inspects it behaves normally, and no error
surfaces anywhere.
Steps to reproduce
- Configure the module against a reachable Langfuse instance.
- From Drush, create a trace via
langfuse.client, add an
observation, and callsyncTraces()withoutforce. - Nothing is sent. The trace never appears in Langfuse, and Drupal logs
contain nothing about it. - Call
syncTraces(TRUE)instead and the same trace lands
correctly.
Proposed resolution
The intent behind shouldSyncNow() looks like "only flush when the
request is ending" — the docblock says as much. Keying that on
php_sapi_name() is the wrong test: under CLI there is no kernel
terminate event to defer to, so "not now" effectively means "never".
Options, roughly in order of preference:
- Flush on
drush_shutdown/register_shutdown_function
under CLI, so CLI gets an end-of-process flush equivalent to the
kernel-terminate flush that web requests get. - Or drop the SAPI check entirely and let the existing sync subscriber own
the timing, with an explicit flush at process end.
Whichever is chosen: a skipped sync must be logged. Silently
discarding collected telemetry is not an acceptable outcome in any branch of
this method.
Remaining tasks
- Decide between the shutdown-hook and subscriber-owned approaches.
- Kernel test asserting traces created under CLI are actually flushed.
- Add a log line for any path that discards pending traces.
- Manual verification with a real Drush-triggered AI operation.
User interface changes
None.
API changes
None expected. syncTraces(bool $force) keeps its signature; the
$force parameter becomes a genuine override rather than the only
way to make CLI work at all.
Data model changes
None.
Release notes snippet
Traces created from Drush, cron and queue workers are now sent to Langfuse.
Previously they were collected and then silently discarded at the end of the
process.
Issue fork langfuse-3616634
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
nikro commentedRan a test with Claude:
Confirming this empirically, because the natural first objection is that Langfuse ingests asynchronously and the trace simply had not propagated yet. It is not propagation.
1. The method returns before sending anything. Inspecting the client under Drush via reflection:
pendingTraceIdsis only cleared after a successful send. It still holds the trace aftersyncTraces()returns, which means execution stopped at the guard. No HTTP request is issued at all, so there is nothing in flight to propagate.2. Timed against a control. Two traces were created from the same Drush process — one synced normally, one with
syncTraces(TRUE)— then both polled againstGET /api/public/traces/{id}every 8 seconds:The forced control was already retrievable at
t=0— ingestion on this instance is effectively instant. The unforced trace never appeared across 24 polls spanning 200 seconds, long after the Drush process had exited.3. The log actively reports success. The only entry written for the discarded run was:
That message is itself misleading for a second reason:
syncTraces()logscount($this->pendingTraceIds)after emptying the array, so it reports0on every run including successful ones. Between the two, the logs cannot distinguish "synced nothing because CLI" from "synced successfully" — which is why this went unnoticed.That count bug is already fixed on the #3594090 branch and is not part of this issue; noting it here only so the two are not conflated while debugging.
Environment: Drupal 11.4.5,
dropsolid/langfuse-php-sdkv1.2.0,Langfuse v3.225.2.
Comment #3
nikro commentedPushed a fix.
shouldSyncNow()returnedphp_sapi_name() !== 'cli', so traces created by Drush, cron and queue workers were collected and then dropped when the process ended, with no error and nothing in the log. The guard turned out to be redundant: syncing only runs from the kernel terminate subscriber, and Drush terminates the kernel too, so the request is already ending in both cases. It now returns TRUE, and the branch that refuses to sync logs a warning rather than discarding silently.How to review it
drush php:eval '$t = \Drupal::service("langfuse.client")->createTrace("cli check"); print $t->getId();'1.xthat trace never appears in LangFuse. On the branch it lands within a few seconds.tests/src/Unit/CliSyncTest.php. PHPUnit runs under CLI, so it asserts the previously broken case directly.LangFuseClient::syncTraces()andshouldSyncNow(), plus the new test.One thing not fixed here, so it does not look like an oversight: the success message logs
count($this->pendingTraceIds)after emptying the array, so it always reads "Successfully synced 0 LangFuse traces". That is fixed on the #3594090 branch.Comment #4
nikro commentedComment #7
abhisekmazumdarReviewed. The fix is correct:
shouldSyncNow()no longer blocks CLI, so Drush/cron/queue traces sync instead of getting silently dropped. Diff is exactly the two hunks it should be, plus a matching test.The failing
phpstanjob is unrelated, those same 4 errors already fail on 1.x today, in files this MR doesn't touch.Pushed a tiny follow-up commit: two comment/docblock wording fixes, no logic changes.
RTBC from my side.
Comment #8
nikro commentedPushed a one-line follow-up (
770fee0) to get thephpcsjob green. The docblock reword in8eb1364left the long description starting with a lowercaseshouldSyncNow(), which PHPCS rejects as Doc comment long description must start with a capital letter. The wording was an improvement so I kept it as-is and only changed the opening to "The shouldSyncNow() method ...", rewrapping the three lines. No code or test behaviour touched.Comment #9
nikro commented