langfuse / langfuse/codex-observability-plugin
Dedup sidecar is committed before batched trace export is flushed, which can suppress retries after export failure
@milanagm is already working on this.
Since Sep 14, 2026.
- Dominant language
- TypeScript
- Stars
- 30
- Forks
- 31
- Avg merge
- 3d 6h
- Merged PRs (30d)
- 13
Description
Summary
Completed Codex turn IDs are currently written to the dedup sidecar before the plugin has successfully flushed its batched spans to Langfuse.
This creates a data-loss path:
convertRollout()emits the spans for a completed turn.- The turn ID is immediately appended to
<rollout>.langfuse. runHook()later callsinstrumentation.shutdown().shutdown()performsspanProcessor.forceFlush().- If the batch export fails or times out, the sidecar already records the turn as uploaded.
- A later Stop hook loads that sidecar and skips the turn, so the failed export is not retried.
A transient export failure can therefore become a permanent missing trace.
Current ordering
In plugins/tracing/src/trace.ts, a completed turn is marked immediately after the observation tree is created:
if (turn.completed && turn.turnId) {
uploaded.add(turn.turnId);
await markTurnUploaded(rolloutFile, turn.turnId);
}
However, the plugin uses batched exporting in plugins/tracing/src/instrumentation.ts:
exportMode: "batched",
and the actual awaited flush happens later:
shutdown: async () => {
await spanProcessor.forceFlush();
await provider.shutdown();
setLangfuseTracerProvider(null);
}
runHook() calls convertRollout() first and instrumentation.shutdown() afterward.
This means the persistent dedup receipt is committed before the awaited delivery boundary.
Expected behavior
A completed turn should only be recorded in the dedup sidecar after its batched spans have successfully passed the awaited flush/export boundary.
If the export fails, the sidecar should remain uncommitted for that turn so the next Stop hook can retry it.
This also matches the existing failure trade-off in markTurnUploaded(): failure to persist the sidecar may cause a duplicate later, which is safer than silently losing the trace.
Suggested direction
One possible approach:
- Let
convertRollout()return the completed top-level turn IDs that were emitted, without persisting them yet. - Explicitly
forceFlush()the instrumentation. - Only after a successful flush, append those turn IDs to the sidecar.
- Then shut down the provider.
This keeps conversion, delivery confirmation, and dedup receipt commitment in the correct order.
Suggested regression test
This can be tested without real Langfuse credentials or network access because the existing instrumentation tests already mock LangfuseSpanProcessor.
A regression test could:
- Process a completed fixture rollout.
- Make the mocked
forceFlush()fail. - Assert that the turn ID is not written to the
.langfusesidecar. - Run the same rollout again with a successful flush.
- Assert that the turn is emitted again and the sidecar is then committed.
- Run it a third time.
- Assert that the committed sidecar now prevents another export.
It would also be useful to cover the default fail_on_error=false path, since the hook intentionally fails open but should not convert an export failure into a permanent dedup receipt.
Impact
This matters most for short-lived Stop-hook execution, where the final forceFlush() is the point at which buffered spans are actually handed off before process exit.
The current ordering can make temporary Langfuse/network/export failures unrecoverable even though later Stop hooks would otherwise provide a natural retry opportunity.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.