Skip to content

feat(health): expose failure-log via /api/health?history=1#3641

Merged
koala73 merged 2 commits into
mainfrom
feat/health-history-endpoint
May 10, 2026
Merged

feat(health): expose failure-log via /api/health?history=1#3641
koala73 merged 2 commits into
mainfrom
feat/health-history-endpoint

Conversation

@koala73

@koala73 koala73 commented May 10, 2026

Copy link
Copy Markdown
Owner

Summary

Surfaces the health:last-failure + health:failure-log Redis 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-pulling health:failure-log via curl + Upstash token. This PR adds the missing read surface.

Behavior

GET /api/health?history=1
{
  "lastFailure": { "at": "...", "status": "WARNING", "critCount": 0, "warnCount": 1, "problems": ["correlationCards:STALE_SEED(19min)"] },
  "failureLog": [
    { "at": "2026-05-10T04:04:29Z", "status": "WARNING", "problems": ["correlationCards:STALE_SEED(19min)"] },
    { "at": "2026-05-10T02:21:49Z", "status": "WARNING", "problems": ["correlationCards:STALE_SEED(17min)"] },
    ...
  ],
  "checkedAt": "2026-05-10T04:30:00Z"
}
GET /api/health             ← unchanged (full classification probe)
GET /api/health?compact=1   ← unchanged (compact problem-list shape)

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 the history query 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), redisPipeline returns 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
    • Returns history shape on ?history=1, no exceptions when Redis unconfigured
    • Default /api/health?compact=1 does NOT include history fields (no leak into the classification response)
    • Non-1 values for ?history (0, true, yes, 01) all fall through to the classification path
  • node --test tests/health-content-age.test.mjs — 18/18 pass (existing classifier tests unchanged)
  • node --check api/health.js — syntax OK
  • npx esbuild --bundle --format=esm --platform=browser — bundles cleanly for the edge runtime
  • Post-merge: curl https://api.worldmonitor.app/api/health?history=1 returns the live failure log

Companion

Pairs with #3640 (correlationCards budget bump). Together they (a) reduce false-positive flips and (b) make residual flips easier to diagnose.

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.
@vercel

vercel Bot commented May 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 10, 2026 4:37am

Request Review

@greptile-apps

greptile-apps Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds an early-return path on GET /api/health?history=1 that reads two Redis keys (health:last-failure, health:failure-log) and returns the incident history without executing the full bootstrap-key freshness probe. The motivation is diagnosing UptimeRobot flips without needing direct Upstash credentials.

  • New handler branch (api/health.js): Strict === '1' match on ?history exits before the expensive STRLEN/GET pipeline; gracefully degrades to { lastFailure: null, failureLog: [] } when Redis is unreachable.
  • New test file (tests/health-history-endpoint.test.mjs): Three tests verify the history shape, graceful degradation on no-Redis, and that non-"1" values don't trigger the early-return.

Confidence Score: 4/5

Safe 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

Filename Overview
api/health.js Adds a 38-line early-return block for ?history=1; logic is correct with proper optional chaining, graceful null fallback, and reuse of existing headers; minor: the outer typeof lastFailureRaw === 'string' guard is redundant with parseJson's own type check, and new Date(Date.now()) can be written as new Date().
tests/health-history-endpoint.test.mjs Three focused tests covering the happy path (no-Redis degradation), the non-trigger case, and the discriminator logic; uses the ESM cache-busting query-param trick (?second-import, ?third-import) which is valid but non-obvious.

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
Loading

Reviews (1): Last reviewed commit: "feat(health): expose failure-log via /ap..." | Re-trigger Greptile

Comment thread api/health.js Outdated
Comment on lines +748 to +751
const lastFailureRaw = results?.[0]?.result;
const failureLogRaw = results?.[1]?.result;
const body = {
lastFailure: typeof lastFailureRaw === 'string' ? parseJson(lastFailureRaw) : null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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),

Comment thread api/health.js Outdated
failureLog: Array.isArray(failureLogRaw)
? failureLogRaw.map(parseJson).filter((e) => e !== null)
: [],
checkedAt: new Date(Date.now()).toISOString(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 new Date(Date.now()) constructs a Date from the current timestamp, which is identical to new Date() with no arguments. The Date.now() call is redundant here.

Suggested change
checkedAt: new Date(Date.now()).toISOString(),
checkedAt: new Date().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.
@koala73

koala73 commented May 10, 2026

Copy link
Copy Markdown
Owner Author

Both P2 nits addressed in a17295f7a:

P2 — redundant typeof === 'string' guard: collapsed to a direct parseJson(lastFailureRaw) call. The helper already returns null for any non-string input.

P2 — redundant Date.now() wrapper: changed new Date(Date.now())new Date().

3/3 tests still pass. Ready for merge.

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