Files
noitu/plans/reports/fullstack-developer-260921-0027-server-ops-observability.md
tiennm99 8223f40b16 feat(server): counters, readiness, drain mode and a version stamp
expvar counters for connections, rooms, games, submissions by rejection
reason, eliminations, chat, joins and bot moves, served on a separate
debug address so they never sit on the public mux, plus one structured
word_rejected log line per refused word carrying the normalized word and
its link. GET /readyz flips to 503 while draining; SIGTERM stops new
rooms, waits up to NOITU_DRAIN_TIMEOUT for live games, then shuts down.
GET /version and the startup log carry the build's git describe.

Fuzz targets for the frame decoder, the text sanitizer and Vietnamese
normalization; the last one found that composing before lowercasing
could leave a non-NFC result, now recomposed after lowering.

CI runs on dev as well as main, gates gofmt and golangci-lint, tracks the
buf major instead of an exact pin, and dependabot watches every
ecosystem. The lint findings that had been hidden by the default
per-issue cap are fixed.
2026-09-21 01:17:52 +07:00

169 lines
11 KiB
Markdown

# Server ops/observability batch
Scope: server/, .github/, Dockerfile, Makefile, README.md, docs/deployment.md. Did not
touch proto/, web/, or generated code.
## What changed
### 1. Observability (D2)
- `server/internal/wsapi/metrics.go` (new): `metricSet` struct of `expvar.Int`/`expvar.Map`,
one package var `metrics`. Counters: connections open/total; rooms live/total (bot/pvp);
games started/finished (bot/pvp); words submitted/accepted/rejected (by
`game.RejectReason.String()`); eliminations (by wire `GameEndReason` string); chat lines;
joins refused (`too_many_attempts`/`room_not_found`/`room_full` — reused wire error codes
as keys, one vocabulary instead of two); bot moves (by `bot.Difficulty.String()`); resumes
attempted/succeeded.
- Wired at call sites: `server.go` (handleWS: connections), `hub.go` (newRegisteredRoom:
rooms live/total, draining check), `room.go` (beginGame: games started; broadcastGameOver:
games finished; handleSubmit: words + new `recordRejection` helper; handleBotMove: bot
moves; handleChat: chat lines; handleJoin: room_full; broadcastElimination: eliminations;
handleResume: resumes succeeded), `session.go` (resumeFrom: resumes attempted; JoinRoom
dispatch: rate-limited/not-found refusals).
- `GET /debug/vars` only ever mounted on a **separate** `http.Server` in
`cmd/noitu-server/main.go` (`newDebugServer`), started only when `NOITU_DEBUG_ADDR` is set.
Never touches the public mux — verified by `TestDebugVarsNotOnPublicMux`.
- `word_rejected` Info log line in `room.recordRejection` (room.go): `reason`, `word`
(normalized via `vietnamese.Normalize` after `sanitizeText`, capped at `maxWordRunes`=64 —
never the raw typed text), `link` (`engine.Current()`), `mode`, `room`. No chat text or
nicknames logged anywhere.
- Documented in `docs/deployment.md` under new "## Observability" section: what
`NOITU_DEBUG_ADDR` does, every counter name, and exactly what the log line contains.
### 2. Ops hygiene (D4 + D1a)
- `GET /readyz` (server.go): 200 while accepting, 503 while draining (`hub.isDraining()`).
`GET /healthz` unchanged.
- Drain mode: `hub.draining` (atomic.Bool) refuses new rooms with `errDraining` →
`server_restarting` (session.go `roomCreateError`, reusing the existing i18n key).
`hub.liveGames` (atomic.Int64) counts rooms with a game **running**, not lobbies;
`room.liveCounted` (atomic.Bool) + a `CompareAndSwap` in `room.run()`'s teardown guarantees
exactly one decrement per increment even if a room is cancelled mid-game rather than
finishing normally. `main.go`'s `run()`: on ctx.Done, logs room/live-game count, calls
`api.StartDraining()`, calls `waitForGamesToFinish(api, cfg.drainTimeout)` (polls
`LiveGameCount()` every 200ms up to `NOITU_DRAIN_TIMEOUT`), logs again, then does the
existing `api.Shutdown()` / `srv.Shutdown()`.
- Version stamp: `main.version` (default `"dev"`), set via `-X main.version=...` from
`git describe --tags --always --dirty`. `Makefile`'s `server` target computes it directly;
a new `image` target passes it to `docker build --build-arg VERSION=...` (`.dockerignore`
deliberately excludes `.git`, so the Dockerfile can't run `git describe` itself — this is
the standard, documented reason). `wsapi.Config.Version` → `Server.version` → printed in
the `listening` log line and served as plain text by `GET /version`.
- `README.md` and `docs/deployment.md`: added `NOITU_DEBUG_ADDR`, `NOITU_DRAIN_TIMEOUT` to
the env tables; new "Draining on deploy" section; "Version" subsection under "The image";
`image` make target in both the targets table and "Without make" raw equivalents;
`/readyz` and `/version` added to the endpoints list.
### 3. Fuzz targets (F3)
- `server/internal/wsapi/codec_test.go`: `FuzzDecode`, seeded from `wire_test.go`'s
`clientVariants()` plus malformed/short/wrong-type frames. Asserts non-binary always
`ErrNotBinary`, otherwise either a real error or a real message — never both nil.
- `server/internal/wsapi/nickname_test.go` (new): `FuzzSanitizeText`, seeded from the
existing `TestSanitizeNickname` table plus the stacking-mark/bidi/zero-width regression
cases. Asserts: no control/format char in the output, no non-printable rune, rune count
≤ `maxNicknameRunes`, no run of combining marks over `maxNicknameMarks`.
- `server/internal/vietnamese/normalize_test.go`: `FuzzNormalize`, seeded from the existing
table tests. Asserts NFC, lowercase, single-spaced, and word/syllables agree with each
other (or `ErrEmpty` with empty output).
**Found and fixed a real bug**: `Normalize` composed (NFC) then lowercased, but lowering a
base rune can enable a composition that only exists for the lowercase form (e.g. `Y` + ring
above has no precomposed codepoint; `y` + ring above does, U+1E99) — so the result could be
non-NFC despite the doc's promise. Fixed by re-composing after lowering
(`normalize.go`). Vietnamese text never hits this (no case-asymmetric diacritics in the
language), so no dictionary data is invalidated by the fix. Regression seed corpus saved at
`server/internal/vietnamese/testdata/fuzz/FuzzNormalize/d3ebd1ce3935d20e`.
- All three run their seed corpus under plain `go test` (fast, no `-fuzz` needed); verified
each with `-fuzz -fuzztime 15-20s` locally, clean after the Normalize fix.
### 4. CI hygiene (F4 + reviewer findings)
- `.github/workflows/ci.yml`: `push` trigger now `[main, dev]`. Go job gained a `gofmt -l`
format-check step and a `golangci-lint` step (`golangci/golangci-lint-action@v9`, moving
major tag, default config — no `.golangci.yml` added, no linters disabled).
- `.github/workflows/proto.yml`: `push` trigger now `[main, dev]`; `bufbuild/buf-setup-action`
`version:` changed from the pinned `1.69.0` to `latest`; `buf breaking` still compares
against `.git#ref=origin/main`, untouched.
- `.github/dependabot.yml` (new): `gomod` (`/server`), `npm` (`/web`), `github-actions` (`/`),
`docker` (`/`); weekly; each grouped for minor+patch.
- Fixed **23** pre-existing `errcheck` findings and **1** `staticcheck` finding (Unicode
format chars in a string literal) across `server/` so the tree passes `golangci-lint`
default linters clean — these were latent (hidden by golangci-lint's default
`max-same-issues: 3`, confirmed with `--max-same-issues=0`), not something I introduced;
fixed rather than suppressed per the instruction not to add lint noise. Files touched:
`cmd/build-dictionary/{dump.go,main.go,main_test.go}`, `internal/dictionary/{store.go,
store_test.go}`, `internal/wsapi/{multiplayer_test.go,limits_test.go}`. All are
`defer x.Close()`/`Rollback()`/`os.Remove()` wrapped as `defer func() { _ = x.Close() }()`,
or a raw Unicode literal replaced with its `\uXXXX` escape.
- `make proto-check` unaffected (didn't touch `proto/`, `buf.yaml`, `buf.gen.yaml`, or the
builder's `DICT_URL` constant).
### 5. Makefile / README
- `Makefile`: `VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo
dev)`; `server` target now passes `-ldflags "-X main.version=$(VERSION)"`; new `image`
target; help text and `.PHONY` updated for both. (`web-lint` target/step already present
from a concurrent change — left untouched, not mine.)
- `README.md`: env table, endpoints list, Make targets table, "Without make" raw equivalents
(server ldflags line, new `image` raw docker command), Deployment section's docker example
now points at `make image`.
## Verification
```
cd server && go vet ./... && gofmt -l . && golangci-lint run ./... && go test ./... -race -count=1
```
Tail of the actual run:
```
0 issues.
ok github.com/tiennm99dev/noitu/server/cmd/build-dictionary 2.803s
? github.com/tiennm99dev/noitu/server/cmd/noitu-server [no test files]
? github.com/tiennm99dev/noitu/server/gen/noitu/v1 [no test files]
ok github.com/tiennm99dev/noitu/server/internal/bot 1.609s
ok github.com/tiennm99dev/noitu/server/internal/dictionary 4.074s
ok github.com/tiennm99dev/noitu/server/internal/game 1.055s
ok github.com/tiennm99dev/noitu/server/internal/vietnamese 1.030s
ok github.com/tiennm99dev/noitu/server/internal/wsapi 28.489s
```
Also: built the real binary via `make server` (embeds `442ced3-dirty`, confirmed via
`/version`); ran it against the fixture dictionary with `NOITU_DEBUG_ADDR` set and curled
`/healthz` (200), `/readyz` (200, then 503 conceptually verified via the unit test),
`/version` (`442ced3-dirty`), `/debug/vars` on the public port (404) and on the debug port
(200, all `noitu_*` counters present at zero); sent `SIGTERM` and confirmed the `draining` /
`shutting down` log lines with room/live-game counts. Cleaned up the binary, fixture db, and
verified no leftover process. Also built the Dockerfile's `build` stage in isolation
(`docker build --target build --build-arg VERSION=...`, avoids the `web` stage's `npm`
entirely per the constraint) to confirm the new `-ldflags` line is valid; removed the test
image afterward.
New/changed tests, all in the behaviour list asked for:
- `TestReadyzFlipsOnDrain`, `TestDrainRefusesNewRooms`, `TestLiveGameCountTracksGamesNotLobbies`
(drain_test.go)
- `TestVersionEndpoint`, `TestVersionEndpointDefaultsWhenUnset`, `TestDebugVarsNotOnPublicMux`
(drain_test.go)
- `TestMetricsCountWordSubmissions` (metrics_test.go)
- `FuzzDecode`, `FuzzSanitizeText`, `FuzzNormalize`
Two existing tests (`TestTheFirstTurnIsDrawn`, `TestABotGameOpensWithTheHuman`) hand-build a
`room{}` and call `beginGame()` directly without going through the hub; `beginGame` now
touches `r.hub.gameStarted()`, so both were given `hub: &hub{}` — a zero-value hub is fine
since only its atomic fields are touched.
## Deferred / not done
- Did not add per-room `game_in_progress` join refusal to the `joinsRefused` metric — spec
named exactly three buckets (rate limit/not found/full); adding a fourth wasn't asked for.
- Did not touch `ci.yml`'s image-build steps to pass `VERSION` — the deliverable named the
Makefile and Dockerfile specifically; the Dockerfile's `ARG VERSION=dev` default keeps
those builds working unchanged.
- Observed `TestStandingsReachEverySeat` fail once under full-suite load (`context deadline
exceeded` reading a socket) but pass 5/5 in isolation and clean on every subsequent full
run; looks like a pre-existing environment flake under CPU contention, not something my
change touches (it doesn't call any of the changed code paths). Did not chase further since
the suite has been green on every full re-run since.
## Unresolved questions
- None blocking. One judgment call worth flagging: `NOITU_DRAIN_TIMEOUT` accepts `0` as an
explicit valid value (meaning "don't wait"), unlike every other duration env var where `0`/
invalid falls back to the default — needed a second parser (`envNonNegDuration`) rather than
reusing `envDuration`. Documented inline; flagging in case the two-parser asymmetry should
instead be a single parser with a flag, but the current split reads clearer to me.