feat(telemetry): local move-preview adoption metrics (Phase 4)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s

Measure uptake of the client-side local move-preview accelerator (§5) so
adoption can be watched before defaulting it on: app cold starts, dictionary
loads by result (fetched / cache_hit / miss) and move previews by path
(local / network — the backend load shed).

A small best-effort client beacon (POST /metrics/local-eval, session-gated)
batches counter deltas and posts them on a 60s timer and when the app is
backgrounded — never on the gameplay path: the in-app counters are plain
in-memory increments, only the periodic flush touches the network and it is
fire-and-forget. The gateway folds each batch into three OTel counters
(local_eval_cold_start_total, local_eval_dict_load_total,
local_eval_preview_total), clamped against a spoofed inflation.

- gateway: counters + recordLocalEval + session-gated /metrics/local-eval handler
- ui: localeval-metrics accumulator/beacon; hooks in the dict loader, in
  Game.recompute and in bootstrap (skipped under the mock harness)
- caddy: route /metrics/* to the gateway
- docs: ARCHITECTURE §11; Grafana "Scrabble — Users" dashboard panels
This commit is contained in:
Ilia Denisov
2026-07-01 23:31:49 +02:00
parent 5f87b3fa43
commit 2e8fa83814
12 changed files with 254 additions and 3 deletions
+28 -1
View File
@@ -4,7 +4,7 @@
"tags": ["scrabble"],
"timezone": "",
"schemaVersion": 39,
"version": 1,
"version": 2,
"refresh": "30s",
"time": { "from": "now-7d", "to": "now" },
"panels": [
@@ -29,6 +29,33 @@
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(accounts_created_total) by (kind)", "legendFormat": "{{kind}}" }]
},
{
"type": "timeseries",
"title": "App opens vs dictionary fills (cumulative)",
"description": "Local move-preview adoption. App opens are client cold starts; dictionary fills are IndexedDB downloads of a dawg. The gap between them is how many launches reuse an already-cached dictionary. Client-reported, best-effort (a batched beacon), so slightly lossy.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{ "refId": "A", "expr": "sum(local_eval_cold_start_total)", "legendFormat": "app opens (cold start)" },
{ "refId": "B", "expr": "sum(local_eval_dict_load_total{result=\"fetched\"})", "legendFormat": "dictionary fills (IndexedDB)" }
]
},
{
"type": "timeseries",
"title": "Dictionary loads by result (rate)",
"description": "Client dictionary loads for the local preview, by tier: fetched (network download), cache_hit (IndexedDB), miss (a warm-up that failed or timed out — trips the bad-connection breaker).",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_dict_load_total[1h])) by (result)", "legendFormat": "{{result}}" }]
},
{
"type": "timeseries",
"title": "Move previews by path (rate)",
"description": "Where the move preview is computed: local (on-device, the accelerator) vs network (the fallback to game.evaluate). The local share is the backend load shed.",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
}
]
}