feat(server): Prometheus metrics for the leaderboard (#54) #72
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/54-leaderboard-metrics"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
cellblock_leaderboard_entriesnewLeaderboard/submit/redactcellblock_leaderboard_submits_total{outcome="accepted"|"blocked"|"invalid"}cellblock_leaderboard_fetches_totalcellblock_leaderboard_persist_errors_totalpersist()(all 3 error paths)cellblock_leaderboard_body_cap_rejected_totalSix 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
_totalsuffix. The dispatch proposedcellblock_leaderboard_entries_total, but_totalis the Prometheus convention for counters; the existing gauges here (cellblock_active_matches,cellblock_connected_clients) correctly carry no suffix. I named the gaugecellblock_leaderboard_entriesto match convention + the existing house style. Counters keep_total. Easy to revert if you'd rather mirror the dispatch literally.2.
outcome=acceptedcounts 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 stillaccepted— board placement is theentriesgauge's concern, not the submit counter's.TestLeaderboardMetrics_AcceptedCountsOffBoardSubmitpins 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_rejectedis split out ofinvalid, not folded in. An over-cap body is a DoS-probe tell, not a buggy client; keeping it as its own counter (viaerrors.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 asinvalid. (If you'd rather keep submits-vs-attempts simpler, fold it back —TestLeaderboardMetrics_BodyCapRejectedasserts the non-double-count, so the choice is test-pinned either way.)4. Tagging the two reject reasons needed a validation split.
validateInitialsreturned one error for both format-invalid and content-blocked, so the handler couldn't tellinvalidfromblocked. I split it intonormalizeInitials(format) +isBlockedInitials(content);validateInitialsstays 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, notload().load()has four exit paths (no path / missing file / parse error / success); syncing the gauge once in the constructor afterload()covers them all uniformly, instead of threading a.Store()into each branch. Caught byTestLeaderboardMetrics_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:Reverted by re-edit (not
git checkout); suite green again. The round-trip test (AC#3) scrapes/metricsbefore/after a realaccepted + blocked + invalid + fetchsequence and asserts each counter+1— it exercises the full exposition-render path, not the atomics directly.Gates
cd server && go test ./...(exact CI cmd) — greengo test -race ./...— greengofmt -lclean,go vetcleangolangci-lint— zero new findings inleaderboard.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
persist_errors > 0should page is a monitoring-config decision for QM, not this PR.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_01VEhmLLqsfKfkw1NWnG8d5VSurveyor 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_base5b28889; main is now0cf3f54cafter #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: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 —
scrapeMetricrenders/metricsthroughmetricsHandlerand 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 andinvalidunchanged (the non-double-count); persist-errors injects a real write failure (missing parent dir). Not.Parallel()anywhere → the global counters are race-safe across the suite.Design calls — all five endorsed:
cellblock_leaderboard_entries(no_total) — correct, and the better call than the dispatch's literal_total._totalis the Prometheus counter convention; the house gauges (cellblock_active_matches,cellblock_connected_clients) carry no suffix, and# TYPE … gaugeis right in the exposition. Take the deviation.accepted= passed-all-gates, placement-independent — verified:accepted++fires beforesubmit(), so a valid off-board score still counts; placement is theentriesgauge'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".body_cap_rejectedsplit out ofinvalidviaerrors.As(&*http.MaxBytesError)— verified no double-count (the test assertsinvalidunchanged). The DoS-probe-vs-malformed distinction earns the separate series.normalizeInitials+isBlockedInitials,validateInitialsretained as the combined gate) — clean; the 14 existingvalidateInitialstests stay valid since its behavior is unchanged.newLeaderboardafterload()— correct: covers all fourload()exit paths uniformly, and is what keeps the in-memory (path=="") board's gauge honest (it early-returns fromload()). Both mutators (submit/redact) re-sync.One non-blocking observation (not a change-request): a non-POST to
/leaderboard/submitreturns 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>0pages is QM's monitoring-config call), no dashboard panel, no anti-cheat metric (deliberately none), pre-existing 18 lint findings untouched. TheCELLBLOCK_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.APPROVED on head
a466e2dd744bef305016aab2d513030a89ce2fc1. Verified against the real artifact:go test/-race/gofmt/vetall 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/metricsexposition 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.