Conversation
|
|
…ssions flush_and_shutdown_langfuse_client() joined the flush thread with a hard t.join(5.0) inside a daemon thread. Sessions with 200+ transcript rows produce OTLP payloads that take longer than 5s to upload, so the entire session's events were dropped while the hook still exited 0 and logged "Processed N turns". The cap is now configurable via CC_LANGFUSE_FLUSH_TIMEOUT and defaults to 120s. Verified against self-hosted Langfuse v4 (events_only mode): a 254-event session was lost entirely with the 5s cap; with the raised cap all events ingest.
eee22f2 to
bf971f8
Compare
Follow-up to the previous commit on this branch, which made the flush cap configurable but left four holes. Parsing moved out of the function's blanket `except Exception`. A malformed CC_LANGFUSE_FLUSH_TIMEOUT made float() raise inside that try, so the join was skipped entirely and the session was dropped: the exact failure the cap exists to bound, reached by trying to configure it. Unusable values (non-numeric, non-positive, NaN, inf, absurdly large) now fall back to the default and log, matching how CC_LANGFUSE_STATE_DIR handles a bad value. The variable is read through _opt(), like every other option, so a value set in the plugin wizard reaches the hook. It is now declared in plugin.json's userConfig and documented in the README's option table. Giving up is no longer silent: the hook logs when the flush thread is still alive after the cap, and the troubleshooting table gains that line. Silence is what made the original bug cost a whole session before anyone noticed. SessionEnd needed more than a longer join. Claude Code allows its SessionEnd hooks the longest `timeout` any of them declares, floored at 1.5s and capped at 60s (getSessionEndHookTimeoutMs, verified in 2.1.263). Declaring no timeout meant a 1.5s window, so almost nothing shipped at session end no matter what the join said; hooks.json now declares 60 on that entry to claim the ceiling. Stop has no such cap and keeps the 600s command default, so it stays where large sessions actually finish flushing. Also: the comment above the function still promised a 5s cap, and the stale default is gone from it. Covered by tests/unit/test_flush_timeout.py, including a regression test that a malformed value still waits for the flush.
|
Pushed 1. Configuring the timeout could break the wait entirely. The parse sat inside the function's blanket 2. It bypassed 3. Math.max(1500, Math.min(maxTimeoutDeclaredByAnySessionEndHook * 1000, 60000))and the 4. Giving up was silent. The hook now logs when the flush thread is still alive after the cap, and the troubleshooting table gains that line. Silence is the whole reason this cost a session before anyone noticed; the only symptom was the existing Also dropped the stale Tests in One open question for you: the default now sits on the correctness side of a trade the 5s cap made the other way — an unreachable Langfuse can delay the end of a turn by up to the cap. |
Problem
flush_and_shutdown_langfuse_client()hands the turn's events to the SDK on a daemon thread and waits witht.join(5.0). A session with a few hundred transcript rows produces an OTLP payload that takes far longer than 5s to upload, so the join returned, the process exited, and every event of that session was dropped — while the hook exited 0 and loggedProcessed N turns. Nothing in the log said anything had been abandoned.Reproduced on self-hosted Langfuse v4 (
events_onlymode): a 254-event session was lost entirely at the 5s cap and ingests completely at a raised one.Fix
The wait is configurable via
CC_LANGFUSE_FLUSH_TIMEOUT, default 120s, read through_opt()like every other option so a value set in the plugin wizard also reaches the hook. Declared inplugin.json'suserConfigand in the README option table.Unusable values fall back and log instead of breaking the wait. Parsing happens before the function's blanket
except Exception— inside it, a malformed value madefloat()raise, the join was skipped entirely, and the session was dropped: the exact failure the cap exists to bound, reached by trying to configure it. Non-numeric, non-positive,NaN,infand absurdly large values all fall back to the default and log, the wayCC_LANGFUSE_STATE_DIRalready handles a bad value.Giving up is no longer silent. When the flush thread is still alive after the cap, the hook logs it and the README troubleshooting table gains that line. Silence is why the original bug cost whole sessions before anyone noticed — the only visible symptom was the pre-existing
Processed N turns … but nothing in Langfuserow.SessionEndneeded more than a longer join. Claude Code gives itsSessionEndhooks the longesttimeoutany of them declares, floored at 1.5s and capped at 60s (getSessionEndHookTimeoutMs, read out of the 2.1.263 bundle:Math.max(1500, Math.min(maxDeclaredTimeout * 1000, 60000)), and the hooks run underAbortSignal.timeoutof that value). Declaring no timeout meant a 1.5s window, so almost nothing shipped at session end regardless of the join.hooks/hooks.jsonnow declares"timeout": 60on theSessionEndentry to claim the whole ceiling.Stopis deliberately left alone — it has no such cap and keeps the 600s command default, so it is where large sessions actually finish flushing, and declaring a timeout there would only lower it.The stale
# Cap flush+shutdown at 5s …comment is gone.Tests
tests/unit/test_flush_timeout.py— default, explicit value,CLAUDE_PLUGIN_OPTION_fallback, each unusable value, that the resolved cap is the one passed tojoin(), that a still-running flush is reported, and a regression test that a malformed value still waits for the flush. Full suite: 238 passed.claude plugin validatepasses on the updatedplugin.json.Tradeoff, explicitly
The old 5s cap traded correctness for responsiveness: an unreachable Langfuse could never delay a turn by more than 5s, at the cost of losing large sessions outright. The default now sits on the other side of that trade. Anyone who prefers the old behaviour sets
CC_LANGFUSE_FLUSH_TIMEOUT=5. Happy to lower the default if you would rather ship the conservative one.Side note for anyone running a patched fork of this plugin: Claude Code 2.1.263 (2026-09-06) added strict plugin-manifest validation — a
userConfigentry with"default": nullon a typed field now fails validation and the entire plugin is silently skipped, all hooks dead. Upstream's manifest is clean; forks that added fields with null defaults need to drop them. Found via~/.claude/debug/<session>.txt.