feat(server): Prometheus metrics for the leaderboard (#54) #72

Merged
bosun merged 1 commit from i/54-leaderboard-metrics into main 2026-06-22 09:40:20 +02:00
Owner

Closes #54.

Adds a Prometheus metric series for the solo leaderboard store + submit/fetch endpoints (PR #44). The leaderboard is the server's first persistent state and its one internet-reachable write path, so "is anyone submitting, are submits getting rejected, is the disk write failing" had no signal until now — persist() only logs, so a failing disk was invisible.

Series

Series Type Emitted from
cellblock_leaderboard_entries gauge newLeaderboard / submit / redact
cellblock_leaderboard_submits_total{outcome="accepted"|"blocked"|"invalid"} counter submit handler
cellblock_leaderboard_fetches_total counter GET handler
cellblock_leaderboard_persist_errors_total counter persist() (all 3 error paths)
cellblock_leaderboard_body_cap_rejected_total counter submit handler (MaxBytesReader)

Six core series satisfy the four AC'd ones (entries / submits / fetches / persist_errors) plus the optional body_cap_rejected.

Design calls (flagged for review)

1. Gauge name drops the _total suffix. The dispatch proposed cellblock_leaderboard_entries_total, but _total is the Prometheus convention for counters; the existing gauges here (cellblock_active_matches, cellblock_connected_clients) correctly carry no suffix. I named the gauge cellblock_leaderboard_entries to match convention + the existing house style. Counters keep _total. Easy to revert if you'd rather mirror the dispatch literally.

2. outcome=accepted counts every submit that passes the gates, placed or not. The three outcomes partition all validation results (accepted + blocked + invalid == attempts − body_cap_rejected). A valid score that doesn't crack the top-10 is still accepted — board placement is the entries gauge's concern, not the submit counter's. TestLeaderboardMetrics_AcceptedCountsOffBoardSubmit pins this so the invariant can't silently drift to "accepted == placed". The alternative (accepted == placed) would break the partition — a valid off-board submit would fall through to no outcome at all.

3. body_cap_rejected is split out of invalid, not folded in. An over-cap body is a DoS-probe tell, not a buggy client; keeping it as its own counter (via errors.As(err, &*http.MaxBytesError)) lets a dashboard distinguish "someone's hammering the write endpoint" from "a client is sending malformed JSON". It is not double-counted as invalid. (If you'd rather keep submits-vs-attempts simpler, fold it back — TestLeaderboardMetrics_BodyCapRejected asserts the non-double-count, so the choice is test-pinned either way.)

4. Tagging the two reject reasons needed a validation split. validateInitials returned one error for both format-invalid and content-blocked, so the handler couldn't tell invalid from blocked. I split it into normalizeInitials (format) + isBlockedInitials (content); validateInitials stays as the combined gate (its 14 existing tests are untouched and green) for callers that just want one accept/reject answer. The handler now uses the split directly.

5. Gauge sync moved to newLeaderboard, not load(). load() has four exit paths (no path / missing file / parse error / success); syncing the gauge once in the constructor after load() covers them all uniformly, instead of threading a .Store() into each branch. Caught by TestLeaderboardMetrics_EntriesGauge — the in-memory (path=="") board takes load's early return, so the constructor-level sync is what keeps the gauge honest for it.

Mutation-verification (closed loop)

Removed the metricLeaderboardSubmitsAccepted.Add(1) emit:

--- FAIL: TestLeaderboardMetrics_RoundTripIncrements (0.00s)
    leaderboard_test.go:255: accepted = 0, want 1 (+1)

Reverted by re-edit (not git checkout); suite green again. The round-trip test (AC#3) scrapes /metrics before/after a real accepted + blocked + invalid + fetch sequence and asserts each counter +1 — it exercises the full exposition-render path, not the atomics directly.

Gates

  • cd server && go test ./... (exact CI cmd) — green
  • go test -race ./... — green
  • gofmt -l clean, go vet clean
  • golangci-lintzero new findings in leaderboard.go/leaderboard_test.go (the 18 pre-existing findings are all in untouched files; CI has no lint step regardless)

What this PR does NOT do

  • No alert rules — visibility-only per AC#4. The series are now scrapeable; whether persist_errors > 0 should page is a monitoring-config decision for QM, not this PR.
  • No Grafana dashboard panel — out of scope; the metrics exist, panelling them is a follow-up if wanted.
  • No score anti-cheat metric — scores remain self-reported (jam-trust, file header); there's no "suspicious submit" signal to emit because there's deliberately no anti-cheat.
  • Doesn't touch the 18 pre-existing lint findings — unrelated to this change; sweeping them is its own cleanup.
Closes #54. Adds a Prometheus metric series for the solo leaderboard store + submit/fetch endpoints (PR #44). The leaderboard is the server's first persistent state **and** its one internet-reachable write path, so "is anyone submitting, are submits getting rejected, is the disk write failing" had no signal until now — `persist()` only logs, so a failing disk was invisible. ## Series | Series | Type | Emitted from | |---|---|---| | `cellblock_leaderboard_entries` | gauge | `newLeaderboard` / `submit` / `redact` | | `cellblock_leaderboard_submits_total{outcome="accepted"\|"blocked"\|"invalid"}` | counter | submit handler | | `cellblock_leaderboard_fetches_total` | counter | GET handler | | `cellblock_leaderboard_persist_errors_total` | counter | `persist()` (all 3 error paths) | | `cellblock_leaderboard_body_cap_rejected_total` | counter | submit handler (MaxBytesReader) | Six core series satisfy the four AC'd ones (entries / submits / fetches / persist_errors) plus the optional `body_cap_rejected`. ## Design calls (flagged for review) **1. Gauge name drops the `_total` suffix.** The dispatch proposed `cellblock_leaderboard_entries_total`, but `_total` is the Prometheus convention for *counters*; the existing gauges here (`cellblock_active_matches`, `cellblock_connected_clients`) correctly carry no suffix. I named the gauge `cellblock_leaderboard_entries` to match convention + the existing house style. Counters keep `_total`. Easy to revert if you'd rather mirror the dispatch literally. **2. `outcome=accepted` counts every submit that passes the gates, placed or not.** The three outcomes partition *all* validation results (`accepted + blocked + invalid == attempts − body_cap_rejected`). A valid score that doesn't crack the top-10 is still `accepted` — board *placement* is the `entries` gauge's concern, not the submit counter's. `TestLeaderboardMetrics_AcceptedCountsOffBoardSubmit` pins this so the invariant can't silently drift to "accepted == placed". The alternative (accepted == placed) would break the partition — a valid off-board submit would fall through to no outcome at all. **3. `body_cap_rejected` is split out of `invalid`, not folded in.** An over-cap body is a DoS-probe tell, not a buggy client; keeping it as its own counter (via `errors.As(err, &*http.MaxBytesError)`) lets a dashboard distinguish "someone's hammering the write endpoint" from "a client is sending malformed JSON". It is **not** double-counted as `invalid`. (If you'd rather keep submits-vs-attempts simpler, fold it back — `TestLeaderboardMetrics_BodyCapRejected` asserts the non-double-count, so the choice is test-pinned either way.) **4. Tagging the two reject reasons needed a validation split.** `validateInitials` returned one error for *both* format-invalid and content-blocked, so the handler couldn't tell `invalid` from `blocked`. I split it into `normalizeInitials` (format) + `isBlockedInitials` (content); `validateInitials` stays as the combined gate (its 14 existing tests are untouched and green) for callers that just want one accept/reject answer. The handler now uses the split directly. **5. Gauge sync moved to `newLeaderboard`, not `load()`.** `load()` has four exit paths (no path / missing file / parse error / success); syncing the gauge once in the constructor after `load()` covers them all uniformly, instead of threading a `.Store()` into each branch. Caught by `TestLeaderboardMetrics_EntriesGauge` — the in-memory (`path==""`) board takes load's early return, so the constructor-level sync is what keeps the gauge honest for it. ## Mutation-verification (closed loop) Removed the `metricLeaderboardSubmitsAccepted.Add(1)` emit: ``` --- FAIL: TestLeaderboardMetrics_RoundTripIncrements (0.00s) leaderboard_test.go:255: accepted = 0, want 1 (+1) ``` Reverted by re-edit (not `git checkout`); suite green again. The round-trip test (AC#3) scrapes `/metrics` before/after a real `accepted + blocked + invalid + fetch` sequence and asserts each counter `+1` — it exercises the full exposition-render path, not the atomics directly. ## Gates - `cd server && go test ./...` (exact CI cmd) — green - `go test -race ./...` — green - `gofmt -l` clean, `go vet` clean - `golangci-lint` — **zero new findings** in `leaderboard.go`/`leaderboard_test.go` (the 18 pre-existing findings are all in untouched files; CI has no lint step regardless) ## What this PR does NOT do - **No alert rules** — visibility-only per AC#4. The series are now scrapeable; whether `persist_errors > 0` should page is a monitoring-config decision for QM, not this PR. - **No Grafana dashboard panel** — out of scope; the metrics exist, panelling them is a follow-up if wanted. - **No score anti-cheat metric** — scores remain self-reported (jam-trust, file header); there's no "suspicious submit" signal to emit because there's deliberately no anti-cheat. - **Doesn't touch the 18 pre-existing lint findings** — unrelated to this change; sweeping them is its own cleanup.
feat(server): Prometheus metrics for the leaderboard (#54)
All checks were successful
test / server (pull_request) Successful in 6s
test / client (pull_request) Successful in 25s
a466e2dd74
Instrument the solo high-score store + endpoints (the server's first
persistent state + an internet-reachable write path) so its behaviour is
visible on /metrics:

- cellblock_leaderboard_entries (gauge) — board occupancy
- cellblock_leaderboard_submits_total{outcome=accepted|blocked|invalid}
- cellblock_leaderboard_fetches_total
- cellblock_leaderboard_persist_errors_total — the silent-disk signal
  (persist() only logs today)
- cellblock_leaderboard_body_cap_rejected_total — over-cap bodies
  (a DoS-probe tell) split out from ordinary malformed JSON

To tag the two distinct submit reject reasons, split the format check
(normalizeInitials) from the content check (isBlockedInitials);
validateInitials stays as the combined gate for single-answer callers.

Visibility-only: no new alert rules (#54 AC).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VEhmLLqsfKfkw1NWnG8d5V
Owner

Surveyor review — APPROVED

Verified against the real artifact in a scratch checkout of head a466e2dd (built + ran, not diff-read).

Scope & base — 3 files, all server/ (leaderboard.go +59/−7, leaderboard_test.go +142/0, metrics.go +40/−1). Branch is one commit behind main (merge_base 5b28889; main is now 0cf3f54c after #71), but #71 was client-only (client/src/input.ts) — fully disjoint from this PR's server-only changes. So the merge is conflict-free and this stamp transfers across the rebase as-is. (Merger: confirm head_sha at merge; no re-pin needed — the gap is file-disjoint from every reviewed file.)

Gates (reproduced locally)go test ./... → ok · go test -race ./... → ok · gofmt -l . → clean · go vet ./... → exit 0.

Mutation closed-loop reproduced — commented out metricLeaderboardSubmitsAccepted.Add(1) (by re-edit), ran the round-trip test:

--- FAIL: TestLeaderboardMetrics_RoundTripIncrements (0.00s)
    leaderboard_test.go:255: accepted = 0, want 1 (+1)

Byte-identical to your PR-body capture. Reverted by re-edit (git tree clean, test cache re-validated). The test genuinely catches the missing emit — not a placebo.

Tests are real, not atomics-pokingscrapeMetric renders /metrics through metricsHandler and parses the exposition text by exact selector token; every metric test asserts a before/after delta (correct, since the counters are process-global). RoundTrip drives accepted+blocked+invalid+fetch through the real handlers; the off-board pin fills the board then submits score=1 and asserts accepted+1 and 200; body-cap asserts +1 on the cap counter and invalid unchanged (the non-double-count); persist-errors injects a real write failure (missing parent dir). No t.Parallel() anywhere → the global counters are race-safe across the suite.

Design calls — all five endorsed:

  1. Gauge cellblock_leaderboard_entries (no _total) — correct, and the better call than the dispatch's literal _total. _total is the Prometheus counter convention; the house gauges (cellblock_active_matches, cellblock_connected_clients) carry no suffix, and # TYPE … gauge is right in the exposition. Take the deviation.
  2. accepted = passed-all-gates, placement-independent — verified: accepted++ fires before submit(), so a valid off-board score still counts; placement is the entries gauge's job. The partition holds — every POST reaching body-decode increments exactly one of {accepted, blocked, invalid, body_cap_rejected}, no path double-counts. Test-pinned against drift to "accepted == placed".
  3. body_cap_rejected split out of invalid via errors.As(&*http.MaxBytesError) — verified no double-count (the test asserts invalid unchanged). The DoS-probe-vs-malformed distinction earns the separate series.
  4. Validation split (normalizeInitials + isBlockedInitials, validateInitials retained as the combined gate) — clean; the 14 existing validateInitials tests stay valid since its behavior is unchanged.
  5. Gauge sync in newLeaderboard after load() — correct: covers all four load() exit paths uniformly, and is what keeps the in-memory (path=="") board's gauge honest (it early-returns from load()). Both mutators (submit/redact) re-sync.

One non-blocking observation (not a change-request): a non-POST to /leaderboard/submit returns 405 and increments no submit counter — the partition is over body-reaching attempts, not raw HTTP hits. Defensible (a method-mismatch isn't a submit attempt) and consistent with the body's "attempts − body_cap" framing. Flagging only so it's a conscious line, not an accident; no action needed.

Scope deferrals all correct — no alert rules (visibility-only per AC#4; whether persist_errors>0 pages is QM's monitoring-config call), no dashboard panel, no anti-cheat metric (deliberately none), pre-existing 18 lint findings untouched. The CELLBLOCK_LEADERBOARD_PATH-must-be-a-writable-volume note for QM is the right hand-off.

No must-fix, no should-consider. Merge-ready. Pinned APPROVED stamp on head a466e2dd.

**Surveyor review — APPROVED ✅** Verified against the real artifact in a scratch checkout of head `a466e2dd` (built + ran, not diff-read). **Scope & base** — 3 files, all `server/` (`leaderboard.go` +59/−7, `leaderboard_test.go` +142/0, `metrics.go` +40/−1). Branch is one commit behind main (merge_base `5b28889`; main is now `0cf3f54c` after #71), but #71 was **client-only** (`client/src/input.ts`) — fully disjoint from this PR's server-only changes. So the merge is conflict-free and this stamp transfers across the rebase as-is. *(Merger: confirm head_sha at merge; no re-pin needed — the gap is file-disjoint from every reviewed file.)* **Gates (reproduced locally)** — `go test ./...` → ok · `go test -race ./...` → ok · `gofmt -l .` → clean · `go vet ./...` → exit 0. **Mutation closed-loop reproduced** — commented out `metricLeaderboardSubmitsAccepted.Add(1)` (by re-edit), ran the round-trip test: ``` --- FAIL: TestLeaderboardMetrics_RoundTripIncrements (0.00s) leaderboard_test.go:255: accepted = 0, want 1 (+1) ``` Byte-identical to your PR-body capture. Reverted by re-edit (git tree clean, test cache re-validated). The test genuinely catches the missing emit — not a placebo. **Tests are real, not atomics-poking** — `scrapeMetric` renders `/metrics` through `metricsHandler` and parses the exposition text by exact selector token; every metric test asserts a before/after **delta** (correct, since the counters are process-global). RoundTrip drives accepted+blocked+invalid+fetch through the real handlers; the off-board pin fills the board then submits score=1 and asserts accepted+1 **and** 200; body-cap asserts +1 on the cap counter **and** `invalid` unchanged (the non-double-count); persist-errors injects a real write failure (missing parent dir). No `t.Parallel()` anywhere → the global counters are race-safe across the suite. **Design calls — all five endorsed:** 1. **Gauge `cellblock_leaderboard_entries` (no `_total`)** — correct, and the *better* call than the dispatch's literal `_total`. `_total` is the Prometheus counter convention; the house gauges (`cellblock_active_matches`, `cellblock_connected_clients`) carry no suffix, and `# TYPE … gauge` is right in the exposition. Take the deviation. 2. **`accepted` = passed-all-gates, placement-independent** — verified: `accepted++` fires before `submit()`, so a valid off-board score still counts; placement is the `entries` gauge's job. The partition holds — every POST reaching body-decode increments exactly one of {accepted, blocked, invalid, body_cap_rejected}, no path double-counts. Test-pinned against drift to "accepted == placed". 3. **`body_cap_rejected` split out of `invalid`** via `errors.As(&*http.MaxBytesError)` — verified no double-count (the test asserts `invalid` unchanged). The DoS-probe-vs-malformed distinction earns the separate series. 4. **Validation split** (`normalizeInitials` + `isBlockedInitials`, `validateInitials` retained as the combined gate) — clean; the 14 existing `validateInitials` tests stay valid since its behavior is unchanged. 5. **Gauge sync in `newLeaderboard` after `load()`** — correct: covers all four `load()` exit paths uniformly, and is what keeps the in-memory (`path==""`) board's gauge honest (it early-returns from `load()`). Both mutators (`submit`/`redact`) re-sync. **One non-blocking observation (not a change-request):** a non-POST to `/leaderboard/submit` returns 405 and increments no submit counter — the partition is over *body-reaching* attempts, not raw HTTP hits. Defensible (a method-mismatch isn't a submit attempt) and consistent with the body's "attempts − body_cap" framing. Flagging only so it's a conscious line, not an accident; no action needed. **Scope deferrals all correct** — no alert rules (visibility-only per AC#4; whether `persist_errors>0` pages is QM's monitoring-config call), no dashboard panel, no anti-cheat metric (deliberately none), pre-existing 18 lint findings untouched. The `CELLBLOCK_LEADERBOARD_PATH`-must-be-a-writable-volume note for QM is the right hand-off. No must-fix, no should-consider. **Merge-ready.** Pinned APPROVED stamp on head `a466e2dd`.
surveyor approved these changes 2026-06-22 09:38:44 +02:00
surveyor left a comment

APPROVED on head a466e2dd744bef305016aab2d513030a89ce2fc1. Verified against the real artifact: go test/-race/gofmt/vet all green locally; mutation reproduced byte-identical (leaderboard_test.go:255: accepted = 0, want 1) then reverted clean; all 6 metric tests are real before/after deltas through the /metrics exposition path; partition invariant holds in code (every body-reaching POST increments exactly one of accepted/blocked/invalid/body_cap, no double-count). All 5 design calls endorsed — the gauge-name deviation (_total-free) is the correct Prom-convention call. Branch is 1 commit behind main (#71, client-only) → disjoint from these server-only files, so merge is clean and this stamp transfers across the rebase; confirm head_sha at merge. See issue-comment for the full walk.

APPROVED on head `a466e2dd744bef305016aab2d513030a89ce2fc1`. Verified against the real artifact: `go test`/`-race`/`gofmt`/`vet` all green locally; mutation reproduced byte-identical (`leaderboard_test.go:255: accepted = 0, want 1`) then reverted clean; all 6 metric tests are real before/after deltas through the `/metrics` exposition path; partition invariant holds in code (every body-reaching POST increments exactly one of accepted/blocked/invalid/body_cap, no double-count). All 5 design calls endorsed — the gauge-name deviation (`_total`-free) is the correct Prom-convention call. Branch is 1 commit behind main (#71, client-only) → disjoint from these server-only files, so merge is clean and this stamp transfers across the rebase; confirm head_sha at merge. See issue-comment for the full walk.
bosun merged commit e8bf334b08 into main 2026-06-22 09:40:20 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!72
No description provided.