feat(health): expose failure-log via /api/health?history=1#3641
Conversation
Adds an early-return query-param path that surfaces the existing
`health:last-failure` + `health:failure-log` Redis state without
running the full freshness probe.
Background: every non-OK /api/health response writes a snapshot
`{at, status, critCount, warnCount, problems}` to Redis:
health:last-failure — most recent unhealthy snapshot (24h TTL)
health:failure-log — last 50 deduped incidents (7-day TTL)
Pre-fix this data was operator-only — diagnosing UptimeRobot flips
required Upstash credentials. WM 2026-05-10 hit this directly: a
night of correlationCards STALE_SEED flaps could only be diagnosed by
hand-pulling the failure-log via curl + token. The history endpoint
now answers "what's been bringing health down?" from a browser.
Behavior:
GET /api/health?history=1
→ { lastFailure, failureLog, checkedAt }
Reads only 2 Redis keys; skips the full bootstrap-keys probe.
GET /api/health (default)
→ unchanged: full classification probe + summary + problems.
The history path uses a strict `=== '1'` match on the query param.
Any other value (including 0, true, yes) falls through to the
classification path. Tests cover all three cases.
Graceful degradation: when Upstash is unreachable, redisPipeline
returns null and the handler returns
`{ lastFailure: null, failureLog: [] }` — never throws, never 5xx.
3/3 new tests pass; 18/18 existing health tests unchanged.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds an early-return path on
Confidence Score: 4/5Safe to merge — the new branch is well-isolated, never throws, and reuses the existing response headers without touching the full probe path. The early-return logic is correct end-to-end: optional chaining handles a null pipeline result, parseJson catches malformed JSON, and the LRANGE range covers the full bounded list. The only findings are cosmetic redundancies that carry no runtime risk. No files require special attention; both changed files are straightforward additions with no interaction with the existing classification path. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[GET /api/health] --> B{OPTIONS request?}
B -- Yes --> C[204 No Content]
B -- No --> D{history param equals 1?}
D -- Yes --> E[redisPipeline: GET health:last-failure\nLRANGE health:failure-log 0 -1\ntimeout 4s]
E --> F{results null?}
F -- Yes --> G[lastFailure null\nfailureLog empty array]
F -- No --> H[parseJson on each result\nfilter nulls from log array]
H --> I[lastFailure + failureLog + checkedAt]
G --> J[200 JSON early return]
I --> J
D -- No --> K[Full freshness probe\nSTRLEN all bootstrap keys\nGET all seed-meta keys\ntimeout 8s]
K --> L{Redis reachable?}
L -- No --> M[REDIS_DOWN 200 response]
L -- Yes --> N[Classify all keys\ncompute overall status]
N --> O{overall not HEALTHY?}
O -- Yes --> P[Write health:last-failure\nAppend to health:failure-log in Redis]
P --> Q[Return classification JSON]
O -- No --> Q
Reviews (1): Last reviewed commit: "feat(health): expose failure-log via /ap..." | Re-trigger Greptile |
| const lastFailureRaw = results?.[0]?.result; | ||
| const failureLogRaw = results?.[1]?.result; | ||
| const body = { | ||
| lastFailure: typeof lastFailureRaw === 'string' ? parseJson(lastFailureRaw) : null, |
There was a problem hiding this comment.
The outer
typeof lastFailureRaw === 'string' guard is redundant — parseJson already returns null for any non-string input via its own typeof raw !== 'string' check. Both branches produce identical results, so the ternary can be collapsed to a direct parseJson call.
| const lastFailureRaw = results?.[0]?.result; | |
| const failureLogRaw = results?.[1]?.result; | |
| const body = { | |
| lastFailure: typeof lastFailureRaw === 'string' ? parseJson(lastFailureRaw) : null, | |
| const lastFailureRaw = results?.[0]?.result; | |
| const failureLogRaw = results?.[1]?.result; | |
| const body = { | |
| lastFailure: parseJson(lastFailureRaw), |
| failureLog: Array.isArray(failureLogRaw) | ||
| ? failureLogRaw.map(parseJson).filter((e) => e !== null) | ||
| : [], | ||
| checkedAt: new Date(Date.now()).toISOString(), |
Greptile PR #3641 review: P2 — `typeof lastFailureRaw === 'string'` ternary is redundant because the parseJson helper already returns null for any non-string input via its own `typeof raw !== 'string'` check. Both branches produced identical results — collapsed to a direct parseJson call. P2 — `new Date(Date.now())` is identical to `new Date()` with no args. Dropped the redundant Date.now() wrapper. Tests still 3/3.
|
Both P2 nits addressed in P2 — redundant P2 — redundant 3/3 tests still pass. Ready for merge. |
Summary
Surfaces the
health:last-failure+health:failure-logRedis keys via a query-param path on/api/health, so diagnosing UptimeRobot flips no longer requires Upstash credentials.Why
The /api/health classifier already writes incident snapshots to Redis on every non-OK probe:
health:last-failure— most recent unhealthy snapshot (24h TTL)health:failure-log— last 50 deduped incidents (7-day TTL)But there was no read endpoint. WM 2026-05-10 had a night of UptimeRobot DOWN/UP flips on
correlationCards; the only way to diagnose was hand-pullinghealth:failure-logvia curl + Upstash token. This PR adds the missing read surface.Behavior
The new path is early-return: it reads two Redis keys and returns. Skips the full bootstrap-keys probe (which is expensive). Cheap to call from a browser tab.
Discriminator
Strict
=== '1'match on thehistoryquery param. Any other value (0,true,yes,01) falls through to the classification path. Tests cover all three cases.Graceful degradation
When Upstash is unreachable (creds missing, network failure, timeout),
redisPipelinereturns null and the handler responds with{ lastFailure: null, failureLog: [] }. Never throws, never 5xx.Test plan
node --test tests/health-history-endpoint.test.mjs— 3/3 pass?history=1, no exceptions when Redis unconfigured/api/health?compact=1does NOT include history fields (no leak into the classification response)1values for?history(0,true,yes,01) all fall through to the classification pathnode --test tests/health-content-age.test.mjs— 18/18 pass (existing classifier tests unchanged)node --check api/health.js— syntax OKnpx esbuild --bundle --format=esm --platform=browser— bundles cleanly for the edge runtimecurl https://api.worldmonitor.app/api/health?history=1returns the live failure logCompanion
Pairs with #3640 (correlationCards budget bump). Together they (a) reduce false-positive flips and (b) make residual flips easier to diagnose.