Skip to content

fix(api): /metrics/facet 500 on null-kind rollups; log every 5xx; label by node (JEF-494)#123

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-494-apimetricsfacet-500s-on-certain-metrics-breaks-the-metric
Jul 23, 2026
Merged

fix(api): /metrics/facet 500 on null-kind rollups; log every 5xx; label by node (JEF-494)#123
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-494-apimetricsfacet-500s-on-certain-metrics-breaks-the-metric

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Closes JEF-494.

The 500 (root cause)

GET /api/metrics/facet?name=<m> 500'd for k8s.container.restarts and system.cpu.load_average.5m. The metric-meta query decoded metric_series_rollups.kind as a non-nullable Rust String, but that column is nullable (unlike metrics.kind). For a metric whose most-recent meta row is a rollup with kind IS NULL, sqlx failed with error occurred while decoding column 0: unexpected nullinternal() → 500. Fast, and with no Postgres error, exactly matching the reported symptom. It's the only non-Option decode in the whole handler; non-finite floats already serialize to null (ruled out NaN/Infinity), and jsonb re-serialization can't fail — so the meta kind decode was the sole possible failure.

Fix: decode kind as Option<String> (a null kind just means "unknown"), matching how FacetResponse.kind already models it. The SQL is unchanged and stays parameterized.

Every 5xx is now logged

internal() returned (500, msg) but never logged — which is why this 500 produced zero server-side error logs on an observability tool. It now emits tracing::error! for every internal error; each handler's #[tracing::instrument] span carries the route.

Node-vs-agent series labels (UI-only)

facetLabels took the first ≤3 varying keys in discovery order, which could put a collector/agent key ahead of the node. It now prefers node/host/container identity keys (k8s.node.name, host.name, k8s.container.name, …) so node/host metrics are labeled by the node/container they describe. The node identity is present in these metrics' attrs (k8s.node.name/k8s.container.name), so this is a UI-only fix — no ../cluster collector-config follow-up needed.

Tests

  • facet_gauge_with_null_kind_rollup_returns_200 (smoke) — seeds the exact failing shape (null-kind gauge rollup, no raw row); fails with 500 before the fix, 200 after.
  • internal_logs_the_error_and_returns_500 (unit) — captures the tracing output and asserts internal() both returns 500 and logs the error at ERROR.
  • metricLabels.test.ts — node key leads ahead of a discovered-first agent key; uid/id keys stay suppressed.

Checks

  • Backend: cargo fmt, cargo check, cargo clippy --all-targets (clean), cargo test --locked — 54 passed.
  • UI: npm run build, npm run lint, npm test — 18 passed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo

…el by node (JEF-494)

The meta query in `metric_facet` decoded `metric_series_rollups.kind` as a
non-nullable `String`, but that column is nullable. For metrics whose latest
meta row is a rollup with `kind IS NULL` (e.g. k8s.container.restarts,
system.cpu.load_average.5m), sqlx failed with "unexpected null" → 500, fast,
with no Postgres error — breaking the /metrics/:name chart page. Decode `kind`
as `Option<String>` (a null kind just means "unknown"), matching how the
response already models it.

`internal()` mapped errors to 500 but never logged, so the decode failure was
invisible in watcher's own logs. It now logs every internal error at ERROR; the
active per-handler tracing span carries the route.

facetLabels now prefers node/host/container identity keys so node/host metrics
are labeled by the node/container they describe rather than by whichever varying
attribute (e.g. the reporting collector agent) happened to be discovered first.

Tests: regression that a null-kind gauge rollup facets with 200; a unit test
that `internal()` both returns 500 and logs the error; UI tests for the label
ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JbrmfzHsTMMzPaSrUkZgWo
@thejefflarson
thejefflarson merged commit c6b3068 into main Jul 23, 2026
4 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-494-apimetricsfacet-500s-on-certain-metrics-breaks-the-metric branch July 23, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant