fix: zero-fill missing status codes to stop missing 5xx alert swings - #48
Conversation
Cloudflare's Adaptive Groups API only returns a (zone, status) row for a minute when that status had at least one request. Engine.Ingest only re-emits a counter when an Observation actually arrives, so a status code that's gone quiet for a minute just stops getting pushed - the series ages past VictoriaMetrics' staleness window and drops out of any sum()/rate()/delta() over the zone's total, producing a phantom drop that "recovers" the instant the status code reappears. This is what's been driving the CloudflareZone5xxZscoreWarn noise. A minute row that comes back at all is authoritative for what it lists: absence of a known status inside it is Cloudflare's own way of saying zero, not an ambiguous gap. flattenHTTPAdaptiveGroups now tracks, per zone, every status code ever seen and zero-fills any that a given minute's response doesn't mention. This can only ever fire for a (zone, minute) a fetch actually returned data for - if a chunk fetch fails outright, this never runs for that zone, and its keys fall through to real staleness exactly as today, so CloudflareZoneRequestsMetricMissing still catches a genuine sustained outage at its normal speed. ⚡ Built with Claude Code
Every real Fetch call spans the full Lookback/BackfillChunk window (10 minutes) at once, not a single bucket. Adds coverage for a status seen only in the newest minute of a batch still correctly zero-filling into the earlier minutes of that same batch. ⚡ Built with Claude Code
httpRequests1mGroups has the same gap shape as Adaptive Groups: a status code missing from an otherwise-present minute means zero requests, not unknown, and the exporter previously let those series go stale. Confirmed with real data this defect exists on the non-v2 path too (e.g. supabase.co status 507: 174 gaps over 3 days, max 51min). Extracted zoneStatusSet/zeroFillMissingStatuses so both flatten paths share the same zero-fill logic, tracked in separate per-path registries (knownStatusesV2 / knownStatuses1m) since the two datasets are fetched and timed independently.
davidbirdsong
left a comment
There was a problem hiding this comment.
Note for history: what Engine.seed does, and why it's not the same thing as the new known-status maps
This PR adds knownStatusesV2/knownStatuses1m on cfetch.Fetcher. They're populated only from statuses this process has itself observed via Fetch(), live or backfill. On restart, both maps reset to empty and rebuild from scratch as new responses arrive.
It's worth being explicit about how this relates to Engine.seed() (converge/engine.go), since the two are easy to conflate: seed() queries VictoriaMetrics at startup with selector {__name__=~"cloudflare_zone_.*"}, which happens to match both status metric names (cloudflare_zone_requests_status, cloudflare_zone_requests_status_v2). So the query VM seed already runs does return, as a side effect, every (zone, status) pair VM has recently recorded. It would be reasonable to look at that and assume it already solves cold-start for the known-status maps too. It doesn't: seed() only reads sample.Value from each result to call e.SeedChainBase(sample.Key, sample.Value), restoring counter continuity so a restarted process doesn't reset Prometheus counters to zero. It never inspects the zone/status labels on those same samples, and nothing about that result reaches cfetch.Fetcher.
The two mechanisms solve different problems:
Engine.seed/ChainSeeder: counter continuity across restarts. Lives inconverge, generic across anyFetcher/Sinkpair.knownStatusesV2/knownStatuses1m: preventing a quiet status code from aging out of VM's staleness window. Lives incfetch, specific to this oneFetcherimplementation.
Because converge.Run only knows Fetcher by its single method interface, there's currently no path for VM seeded data to reach cfetch's internal maps, and reusing seed()'s query for that purpose would mean coupling the generic converge package to cfetch specific state, plus trusting that VM's pre-existing history is itself free of the exact gap pattern this PR fixes. That's a separate, smaller piece of work if we ever want to close the narrow post-restart window before a status is first re-observed. It's out of scope here: this PR closes the open-ended, never self-healing version of the staleness bug; the cold-start window it leaves behind is bounded and resolves itself the next time each status reappears.
…e zero-fill to one pass - flattenHTTPAdaptiveGroups/flattenHTTP1mGroups use f.enabled directly instead of taking it as a duplicate param (Adam's review comment). - Collapsed the two-pass emit (real values, then a second pass for zero-fill) into one shared flattenStatusCounts helper, replacing zeroFillMissingStatuses. - flattenHTTP1mGroups now skips status bookkeeping entirely when that metric is disabled, matching flattenHTTPAdaptiveGroups's existing short-circuit. - Documented why knownStatusesV2/knownStatuses1m need no locking (Adam's review comment): Fetch only ever runs on one goroutine.
Summary
Rare status codes (507, 555, etc) go stale and drop out of zone sums when quiet for a few minutes, causing phantom drop/recovery swings that trip
CloudflareZone5xxZscoreWarn.This fixes this by zero-filling known but absent statuses per minute instead of leaving them silent. It covers both v2 (Adaptive Groups) and non-v2 (1m Groups) status metrics
See https://supabase.slack.com/archives/C0B4U36LJEB/p1786699723052219
Claude details
Root cause
Cloudflare's Adaptive Groups API only returns a
(zone, status)row in a minute when that status had at least one request.Engine.Ingestonly re-emits a counter when anObservationfor that exact(key, bucket)arrives, so a rare 5xx code (501, 507, 520, 523, 525, 555 on supabase.co; 504, 530 on snapcloud.dev) that goes quiet for a few minutes just stops getting pushed. Once the gap outlives VictoriaMetrics' staleness window, the series drops out ofsum by (zone) (...)entirely, making the zone's total 5xx count appear to fall - then jump back the instant the code reappears. That swing is what tripscloudflare:zone_5xx_zscore.Verified against the three real alerts (08:45, 09:15, 09:25 UTC, 2026-08-14):
supabase.co: status555gap 09:14:30-09:32 UTC (17.5min),507gap 09:24-09:36 UTC (12min), plus shorter gaps on501/520/525snapcloud.dev: status504gaps of 12-14min,530appears for a single 30s sample then vanisheshttpRequests1mGroups(non-v2) has the identical shape: it also only lists a status in its response when that status had at least one request that minute. Confirmed directly against 3 days of supabase.co data: status507had 174 gaps (max 51min),523had 114 gaps (max 119min),526's max gap ran 1093min,599's max gap ran over 50 hours.Fix
A superseded earlier version of this fix (#47, closed) used a timer: re-push every known key's last value every tick unless it's been idle past a cap, to bound (but not eliminate) the risk of masking a genuine sustained outage. That was solving with a guess something Cloudflare already answers directly: a minute row that comes back at all is a complete, authoritative list of every status that occurred - a known status missing from it is a confirmed zero, not an ambiguous gap.
Both
flattenHTTPAdaptiveGroups(v2) andflattenHTTP1mGroups(non-v2) now track, per zone, every status code ever seen on their respective path and zero-fill any that a given minute's response doesn't mention, feeding a realValue: 0Observation through the existing (already-tested) counter-chain/tracker pipeline - no new Engine API surface. The zero-fill logic itself (zoneStatusSet,zeroFillMissingStatuses) is shared between both paths; each keeps its own known-status registry (knownStatusesV2/knownStatuses1m) since the two datasets are fetched and timed independently.Because zero-fill only ever fires for a
(zone, minute)a fetch actually, successfully returned data for, it can't mask a real outage the way the timer could: if a chunk fetch fails outright, this never runs for that zone, and its keys fall through to real staleness exactly as today -CloudflareZoneRequestsMetricMissingstill catches a genuine sustained failure at its normal ~10 minute speed, no grace period needed.Testing
cfetch/zerofill_test.go(v2) andcfetch/zerofill_1m_test.go(non-v2) - TDD throughout: tests written first (confirmed failing to compile without the relevant field/method), then implemented. Covers: zero-fill of a previously-seen-now-absent status, no zero-fill before a status has ever been seen, zero-fill knowledge isolated per zone, the existingenabledmetric filter still works, and (non-v2 only) every other metricflattenHTTP1mGroupsemits is unaffected by the refactor. Full suite (go test ./...),go vet ./..., andgofmtall clean.Scope
Left
cfgql.FetchZones's silent per-chunk error swallowing alone (real defect - no error propagation, no metric - but this fix's safety doesn't depend on it, since zero-fill inherently can't fire off a failed fetch).⚡ Built with Claude Code