You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* feat: lock-free bucketed histogram for latency metrics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: histogram instruments + collector plumbing for latency metrics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: record per-tier delta staleness histogram in SendDelta (M1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: tick-duration and drain-cycle timing metrics (M2, M3)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: native Prometheus histogram exposition for latency metrics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: latency histograms on console dashboard + docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: polish latency histograms per final review
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: honest nullable annotations on HistogramSnapshot arrays
default(HistogramSnapshot) leaves both arrays null and that default is reachable (unset snapshot members reach PrometheusFormatter), so the non-nullable declaration lied; all consumers already guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: continent resolver over CC0 geo-whois-asn-country database
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: per-continent peer RTT instruments + Prometheus export
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: sample peer RTT by continent on the ENet thread (M4)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: peer RTT on dashboard, geo DB in Docker images, docs (M4)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: harden geo CSV parsing + final-review polish (M4)
TryParse geo CSV numeric fields — skip + count corrupt rows instead of
crashing startup on an unpinned re-fetch. Assert shared Merge bounds,
fix RTT test comment + add index-label lock, point debug compose at /app/geodb.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: force LF checkout for shell scripts (Docker builds on Windows)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: predownload geo CSVs at local build time (M4)
Local (non-Docker) runs had no geodb/ and resolved every peer to
region="unknown". Add a FetchGeoDb MSBuild target that caches the two
CSVs in packages/geodb and copies them next to the build output. Docker
builds/containers (FetchGeoDb=false) and CI (CI=true) skip it, so the
images' build-time ADD stays authoritative.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: source country-continent map from GeoNames countryInfo.txt (M4)
Replace the six hardcoded continent constant tables with GeoNames
countryInfo.txt, fetched through the existing channels (ADD in all three
Dockerfiles, FetchGeoDb download + copy in the csproj). ParseCountryInfo
reads field 8 (continent), MapContinentCode folds AN/garbage to UNKNOWN.
Merge connectedPeers + continentByPeer into one ConnectedPeer(Peer,
Continent) map. Finish the Continent UPPER_SNAKE rename and lock
local/private addresses to UNKNOWN with explicit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: harden geo load + histogram merge (review P2s)
Degrade to Empty on IO errors in LoadFromDirectory instead of crashing
startup; make Merge's bounds guard always-on (ArgumentException); document
the deliberate unpinned-download posture in docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/metrics.md
+81-1Lines changed: 81 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -260,6 +260,85 @@ Counter of post-auth messages rejected for invalid fields (oversized `EmoteId`/`
260
260
261
261
---
262
262
263
+
## Latency metrics
264
+
265
+
Histogram-backed timing metrics for the simulation and outbound-drain hot paths. The collector holds raw per-bucket counts; the dashboard's percentile columns describe the **value distribution** (ms/µs) — Window over the buckets that filled since the previous 500 ms snapshot, Lifetime over the cumulative buckets — not a rate. The sparkline plots the window P99, the tail we care about.
266
+
267
+
### Δ Staleness T0 / T1 / T2 (ms)
268
+
269
+
Publish→fan-out staleness of `STATE_DELTA` per AoI tier — `MonotonicTime − target.ServerTick` measured at `SendDelta`. Each tier gets its own histogram because tiers fan out on different cadences (`tierDivisor`: T0 every tick, T1 every 2nd, T2 every 4th).
270
+
271
+
**Expected**: bounded by `tierDivisor × BaseTickMs` plus fan-out compute. T0 p99 is the **KR1.1 SLI**. Resync-path deltas are excluded by design — their target can be arbitrarily old when a subject idled after the client lost packets, which would pollute the histogram.
272
+
273
+
| Signal | Meaning |
274
+
|---|---|
275
+
| P99 within the tier budget | Normal — deltas fan out promptly after publish |
276
+
| Sustained P99 above the tier budget | Tick overrun or input backlog — cross-check Tick Duration and Incoming Queue |
277
+
| T0 climbing while T1/T2 flat | Every-tick fan-out is the bottleneck — AoI set for the hot tier grew |
278
+
279
+
### Tick Duration (µs)
280
+
281
+
`SimulateTick` wall time across workers, recorded per tick in `PeersManager.RecordTickDuration`.
282
+
283
+
**Expected**: well under `BaseTickMs × 1000`.
284
+
285
+
| Signal | Meaning |
286
+
|---|---|
287
+
| Flat, well under budget | Healthy — plenty of headroom in the tick |
288
+
| Creeping toward `BaseTickMs × 1000`| CPU saturation or AoI fan-out growth — precursor to Tick Overruns |
289
+
290
+
### Tick Overruns
291
+
292
+
Count of ticks that exceeded `BaseTickMs`. Rendered as a rate row (per-second), not a histogram.
293
+
294
+
**Expected**: 0. Any sustained rate is an SLO breach — the simulation is not keeping tick cadence.
295
+
296
+
### Drain Cycle (µs)
297
+
298
+
Outbound drain-cycle duration on the ENet thread, non-empty cycles only (empty cycles are not recorded). Upper-bounds how long an outgoing message can wait for the ENet thread to service the queue.
299
+
300
+
| Signal | Meaning |
301
+
|---|---|
302
+
| Low, stable | ENet thread drains each burst well within a service loop |
**Prometheus**: these are exposed as native histograms — `dcl_pulse_delta_staleness_ms{tier="0|1|2"}`, `dcl_pulse_tick_duration_us`, `dcl_pulse_outgoing_drain_cycle_us` (Tick Overruns is the `dcl_pulse_tick_overruns_total` counter). Use `histogram_quantile()` over the `_bucket` series for fleet-level percentiles; the dashboard percentile columns show the local value distribution (window / lifetime), not rate percentiles.
306
+
307
+
### Peer RTT (ms)
308
+
309
+
Distribution of connected peers' smoothed round-trip time, bucketed by peer continent. ENet maintains `peer->roundTripTime` automatically from the reliable-channel ACK flow — no probe packets, no client cooperation. A sweep on the ENet thread samples **every connected peer every 5 s** (`RTT_SAMPLE_INTERVAL_MS`) and records each peer's current RTT into its region's histogram, so the distribution is **peer-weighted**: a region with more connected peers contributes proportionally more samples.
310
+
311
+
The `region` label is the continent resolved from the peer's IP against the geo-whois-asn-country (IP-allocation-registry) database described below. `unknown` folds together private/loopback IPs (local dev), addresses outside the loaded ranges or carrying unassigned country codes, and — when the database is absent from the image — every peer.
312
+
313
+
**500 ms seed caveat**: ENet seeds `roundTripTime` at 500 ms until the first reliable-channel ACK sample lands. A peer connected for less than one ACK round can therefore contribute that 500 ms seed to its first sweep entry — accepted noise at a 5 s cadence rather than a reason to track per-peer connect ages.
314
+
315
+
The console dashboard shows a single **Peer RTT (ms)** row that merges all seven per-continent histograms (`HistogramSnapshots.Merge`); the per-region breakdown is Grafana-only.
316
+
317
+
**Prometheus**: exposed as a native histogram `dcl_pulse_peer_rtt_ms` with one `region` label per continent (`af`, `as`, `eu`, `na`, `oc`, `sa`, `unknown`). Per-region percentile:
318
+
319
+
```promql
320
+
histogram_quantile(0.5, sum by (le) (rate(dcl_pulse_peer_rtt_ms_bucket{region="as"}[5m])))
321
+
```
322
+
323
+
| Signal | Meaning |
324
+
|---|---|
325
+
| A region's p50 stable and low | Peers there are close to the deployment — healthy |
326
+
| One region's p50 ≫ the others | Distance-dominated latency — a case for a closer regional deployment |
327
+
| A region's p99 ≫ its own p50 | Tail of poorly-connected peers (mobile, congested last mile) in that region |
328
+
|`unknown` dominating with a real player population | Geo database missing from the image, or peers behind private/CGNAT egress the DB can't place |
329
+
| Everything near 500 ms right after a connect burst | The ENet seed showing through before ACK samples arrive — transient, ignore |
330
+
331
+
**Data source**: two inputs, both fetched fresh at build time. IP-range → country comes from [geo-whois-asn-country](https://github.com/sapics/ip-location-db) (CC0, public domain), the `-num` CSV variants. Country → continent comes from [GeoNames `countryInfo.txt`](https://download.geonames.org/export/dump/countryInfo.txt) (CC-BY 4.0 — *"Contains data from GeoNames (geonames.org), licensed under CC BY 4.0"*), keyed on the ISO 3166-1 alpha-2 code with the continent read from the file's continent column (Antarctica folds to `unknown`). `ContinentResolver` loads all three once at startup from `Transport:GeoDbDirectory` (default `geodb`, resolved against the app base directory; absolute paths are used as-is). A missing mapping file or IPv4 CSV is tolerated — every peer then reports under `region="unknown"`.
332
+
333
+
The downloads are deliberately unpinned (no checksum, no version tag) so every image build ships current IP-allocation data, which is a stated requirement — geo ranges churn constantly and a stale pin would silently misplace peers. The risk of an unpinned fetch is contained on three fronts: the files are data-only, parsed into a lookup table with no execution path; the parser skips-and-counts malformed rows rather than throwing (see `ContinentResolver.ParseInto`); and an unusable or empty dataset — including a fetch that failed or returned garbage — degrades to `region="unknown"` with a warning rather than crashing startup.
334
+
335
+
Two ways the files get to that directory:
336
+
337
+
-**Docker**: each of the three Dockerfiles fetches the fresh IPv4 CSV, IPv6 CSV, and `countryInfo.txt` into the image's `geodb/` directory at build time via a single `ADD` — no runtime download. These freshly-fetched copies are authoritative for images.
338
+
-**Local (non-Docker) builds**: the `DCLPulse.csproj``FetchGeoDb` target predownloads the three files into the gitignored `packages/geodb/` cache (once) and copies them next to the build output, so local runs resolve regions without a Docker image. Delete `packages/geodb` to force a refresh. Offline builds warn and continue — the app then degrades to `region="unknown"`. The download is skipped in Docker builds and containers (`FetchGeoDb=false`) and on CI (`CI=true`), so it never runs where the `ADD`-provided or in-memory test copies already apply.
339
+
340
+
---
341
+
263
342
## Incoming Messages
264
343
265
344
Per-message-type rates for `ClientMessage` variants. Shows how many of each message type the server processes per second.
@@ -304,7 +383,8 @@ Per-message-type rates for `ServerMessage` variants. Shows the server's output c
304
383
305
384
## Adding new metrics
306
385
307
-
See the `/add-metric` skill (`/.claude/skills/add-metric/SKILL.md`) for step-by-step instructions covering three patterns:
386
+
See the `/add-metric` skill (`/.claude/skills/add-metric/SKILL.md`) for step-by-step instructions covering four patterns:
308
387
-**Pattern A**: Counter-based (System.Diagnostics.Metrics) — for hot-path values
309
388
-**Pattern B**: Sampled (direct read) — for queue depths and gauges
310
389
-**Pattern C**: Per-enum collection — for counting by message type or enum variant
390
+
-**Pattern D**: Histogram — for latency/duration value distributions (percentiles over buckets)
0 commit comments