2026-07-18 SigNoz Alert Fires and LiteLLM Empty-Completion Deep-Dive
What I set out to do
Check whether any SigNoz alerts had fired recently, then investigate the ones that did.
What I actually did
Reviewed the alert rules: 5 configured, all currently inactive. Two had fired in the last 7 days.
ClickHouse replication queue not draining (fired Jul 15 14:35–15:50 UTC, ~75m). Investigated: peak queue size was 1 (a single stuck replication entry), LoadAverage1 stayed 0.5–2.3 the whole window (no CPU pin, actually higher after the queue drained), and it self-recovered without intervention. Benign transient, not the catastrophic GET_PART-retry-forever incident that needs a manual replica rebuild. No action needed. See 2026-07-05 SigNoz ClickHouse Replication Queue Root Cause and Graceful-Stop Fix and 2026-06-04 SigNoz ClickHouse Replication Queue CPU Burn for the catastrophic sibling.
LiteLLM empty completions (fired Jul 17 07:24 UTC, openrouter/deepseek/deepseek-v4-pro). Reproduced the exact empty span. Root client was SillyTavern (root ingress span). The prompt ended on a complete assistant turn (a Continue); output was finish_reason=stop, 0 output tokens.
I went down two wrong paths before the evidence settled it:
- First claimed
continue_prefillregressed — false; it’s beenfalsethroughout (verified against the Jul-13 backup, config byte-identical to the working state). - Then, after direct A/B tests on litellm (
localhost:4000), claimed it was purely a reasoning-model artifact — also wrong. A benign complete-boundary Continue produced 4213 chars, so assistant-last isn’t deterministically empty.
The billing discriminator (from the 2026-07-13 SillyTavern Continue Fixed by Turning Prefill Off work / ADR 0020) settled it: the fire billed input 0. Input-billed empty = the documented Continue-empty type — an assistant-last prefill was sent despite continue_prefill=false, so the Jul-13 nudge-path fix had regressed. Config unchanged; SillyTavern auto-updated to 1.18.0 (image created Jul 16 22:57 UTC via watchtower) ~8.5h before the fire = likely trigger (unconfirmed — couldn’t read the 1.18.0 changelog, and the failure is intermittent ~4/6).
What was striking
gen_ai.usage.output_tokenscounts reasoning tokens too, not content-only. Amax_tokens=128request that spent all 128 on reasoning recordedoutput_tokens=128, not 0 — so reasoning-truncation empties never trip the alert. The alert only catches true zero-completion-token responses, which is exactly the fire.deepseek-v4-prois a reasoning model: a smallmax_tokensyields reasoning-only, empty content,finish_reason=length— and OpenRouter bills the reasoning (~$0.00011 on the test) even with no visible output. A separate, non-alerting cost concern. ST sendsmax_tokens=20000, so ST itself is safe from that one.- The alert annotation’s
request_shapetriage (fresh_generation/continue_prefill) is dead for this model — the attribute isn’t present on the spans. Discriminate by billing instead. - The working trick to read large span attributes (message bodies, cost, tokens) is groupBy on the attribute — the group value comes back. (Corrected Jul-19: raw projection is not broken; see Update below.)
- Two turns of confident-but-wrong diagnosis, each corrected by insisting on a test / checking the billing. “Test it first” earned its keep.
Update 2026-07-19 — landed the declarative changes
Followed through on tomorrow-item 2 plus the alert-management gap:
- Rewrote the
litellm_empty_completionsalert annotation with the billing discriminator (zero-billed = our-side degenerate trailing-system empty per ADR 0018; input-billed = model ran and returned empty, then split assistant-last Continue per ADR 0020 vs a genuine decline). Notes that reasoning models never reachoutput_tokens=0via reasoning-only truncation. - Codified the last hand-created alert into nix/terraform. “Claude raw body filename parse drift” (
019dbfe8…) was the only rule lackingmanagedBy:terraform. Added aclaudeRawBodyParseAlertsgroup tonix/home-manager/modules/signoz.nix(LOGS_BASED, fires onservice.name='claude-code' AND body_kind='unparsed' > 0over 5m; description points at theregex_parserinpatch-otel-config.py). Deleted the unmanaged copy,hm switchrecreated all 5 as managed (5 added, 1 changed, 4 destroyed). All alerts inactive during the recreate = safe window. - Committed
b265e2d; clean-tree re-hm switchwas a no-op (0 added, 0 changed, 0 destroyed) confirming noalertsHashdrift.
Meta-lesson from the investigation got its own standalone memory note, reference_signoz_mcp_query_mechanics: the “raw projection returns null” belief was wrong — a zero-match raw query returns rows: null (not []) with nonzero rowsScanned; my name='raw_gen_ai_request' filter was a contradiction because the gen_ai.* attrs live on the litellm_request span. groupBy is the robust read; raw works fine when you don’t over-filter.
Every SigNoz alert is now fully declarative. Remaining open items: the ST 1.18.0 regression link is still unconfirmed (tomorrow-items 1 and 3 below stand).
Top 3 tomorrow
- If the LiteLLM empty recurs, pin SillyTavern off
:latestand exclude it from watchtower, or re-tune prompt post-processing for 1.18.0. Update the alert annotation to add a billing-discriminator branch— done Jul-19 (see Update).- Confirm (or rule out) that ST 1.18.0 changed the Continue/prompt-post-processing folding, since that’s the unconfirmed link in the regression.
Related
- Memory notes:
project_sillytavern_continue_prefill(dated addendum),reference_signoz_mcp_query_mechanics,reference_signoz_litellm_gen_ai_bodies - 2026-07-13 SillyTavern Continue Fixed by Turning Prefill Off
- 2026-06-17 DeepSeek Empty Replies via OpenRouter
- 2026-07-04 SigNoz Alert Notification Gap and ntfy Decision