From 2feb6383296ae8508ccabbcd3393cb6936e0bdb8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 4 Jul 2026 23:03:47 +0200 Subject: [PATCH] feat(gateway): unsupported-engine telemetry beacon + Grafana counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index.html boot guard now fires a fire-and-forget beacon (POST /telemetry/unsupported) when it turns a client away on the unsupported-engine screen, so the owner can see — on the Users dashboard beside "app opens" — how many real clients hit it and on which engines. - Client: navigator.sendBeacon (fetch fallback) with a localStorage dedup keyed by app version + reason + Chromium, so a user reopening the app is one report, not ten. - Gateway: a new unauthenticated POST /telemetry/unsupported handler (the client never booted, so it carries no session), per-IP public-limited and body-capped, mirroring the export-download route. It folds the beacon into the OTel counter unsupported_engine_total {reason = no_bigint/no_proxy/boot_error/other, chromium}, with reason allow-listed and the Chromium major reduced to a bounded range (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full user agent is logged, not labelled. - Caddy: /telemetry/* added to the @gateway matcher (else it falls to the landing catch-all). - Grafana: two panels on the Scrabble — Users dashboard (by reason, by Chromium major). - Docs: ARCHITECTURE.md §11. Tests: recordUnsupportedEngine (metric split), normalizeUnsupported (cardinality bounding), and the handler route end to end (204 / 405 / counter). go build+vet+test and gofmt clean; ui check/build/e2e green; the client beacon + dedup verified against a forced hard-gate (BigInt removed) — one POST, deduped on reopen, correct reason/chromium/version labels. --- deploy/caddy/Caddyfile | 2 +- deploy/grafana/dashboards/users.json | 16 ++++ docs/ARCHITECTURE.md | 11 +++ gateway/internal/connectsrv/metrics.go | 14 ++++ gateway/internal/connectsrv/metrics_test.go | 67 +++++++++++++++ gateway/internal/connectsrv/server.go | 74 +++++++++++++++++ gateway/internal/connectsrv/telemetry_test.go | 81 +++++++++++++++++++ ui/index.html | 37 ++++++++- 8 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 gateway/internal/connectsrv/telemetry_test.go diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 7a2b9f7..578bd81 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -86,7 +86,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/deploy/grafana/dashboards/users.json b/deploy/grafana/dashboards/users.json index e7eb072..6504813 100644 --- a/deploy/grafana/dashboards/users.json +++ b/deploy/grafana/dashboards/users.json @@ -56,6 +56,22 @@ "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}}" }] + }, + { + "type": "timeseries", + "title": "Unsupported-engine screens (cumulative by reason)", + "description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }] + }, + { + "type": "timeseries", + "title": "Unsupported engines by Chromium (rate)", + "description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }] } ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 030165e..f053c97 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1070,6 +1070,17 @@ browser), so an open in-app session reflects it at once. it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by design (a dropped beacon just retries its batch on the next flush); it is telemetry, never a game input. +- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13) + shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white + screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers + decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is + Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported` — + unauthenticated, since the client never booted, but per-IP public-limited and body-capped), + deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one + report. The gateway folds it into `unsupported_engine_total` (`reason` = + no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so + a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced + on the **Scrabble — Users** dashboard beside app opens. - **Rate-limit observability:** every limiter rejection increments the gateway counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate only, honouring the no-per-user-label discipline above) and logs one **Debug** line; diff --git a/gateway/internal/connectsrv/metrics.go b/gateway/internal/connectsrv/metrics.go index 9c619e7..c531e83 100644 --- a/gateway/internal/connectsrv/metrics.go +++ b/gateway/internal/connectsrv/metrics.go @@ -32,6 +32,8 @@ type serverMetrics struct { localColdStart metric.Int64Counter localDictLoad metric.Int64Counter localPreview metric.Int64Counter + // Clients turned away by the unsupported-engine boot screen (see unsupportedEngineHandler). + unsupportedEngine metric.Int64Counter } // newServerMetrics builds the instruments on meter (nil selects a no-op meter), @@ -63,6 +65,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics { localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."), localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."), localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."), + unsupportedEngine: counterOf(meter, "unsupported_engine_total", + "Clients that hit the unsupported-engine boot screen (the app cannot run), by reason (no_bigint, no_proxy, boot_error, other) and Chromium major — a deduped beacon from the index.html boot guard; the full user agent is logged, not labelled."), } gauge, err := meter.Int64ObservableGauge("active_users", @@ -108,6 +112,16 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) { m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) } +// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen, +// labelled by reason and Chromium major. The caller passes both already reduced to bounded label +// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality. +func (m *serverMetrics) recordUnsupportedEngine(ctx context.Context, reason, chromium string) { + m.unsupportedEngine.Add(ctx, 1, metric.WithAttributes( + attribute.String("reason", reason), + attribute.String("chromium", chromium), + )) +} + // localEvalReport is the client-reported local move-preview telemetry batch — deltas since // the client's previous report. It backs the adoption dashboard: app cold starts vs cached // dictionaries vs on-device previews. diff --git a/gateway/internal/connectsrv/metrics_test.go b/gateway/internal/connectsrv/metrics_test.go index 9e9f314..c2cf8c8 100644 --- a/gateway/internal/connectsrv/metrics_test.go +++ b/gateway/internal/connectsrv/metrics_test.go @@ -128,3 +128,70 @@ func TestBannedMetric(t *testing.T) { t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts) } } + +// TestUnsupportedEngineMetric records unsupported-engine beacons through a manual reader and asserts +// unsupported_engine_total splits by reason and Chromium major. +func TestUnsupportedEngineMetric(t *testing.T) { + ctx := context.Background() + reader := sdkmetric.NewManualReader() + meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") + m := newServerMetrics(meter) + + m.recordUnsupportedEngine(ctx, "no_bigint", "66") + m.recordUnsupportedEngine(ctx, "no_bigint", "66") + m.recordUnsupportedEngine(ctx, "boot_error", "74") + + var rm metricdata.ResourceMetrics + if err := reader.Collect(ctx, &rm); err != nil { + t.Fatalf("collect: %v", err) + } + type key struct{ reason, chromium string } + counts := map[key]int64{} + for _, sm := range rm.ScopeMetrics { + for _, md := range sm.Metrics { + if md.Name != "unsupported_engine_total" { + continue + } + sum, ok := md.Data.(metricdata.Sum[int64]) + if !ok { + t.Fatalf("unsupported_engine_total is not an int64 sum") + } + for _, dp := range sum.DataPoints { + reason, _ := dp.Attributes.Value(attribute.Key("reason")) + chromium, _ := dp.Attributes.Value(attribute.Key("chromium")) + counts[key{reason.AsString(), chromium.AsString()}] += dp.Value + } + } + } + if got := counts[key{"no_bigint", "66"}]; got != 2 { + t.Errorf("unsupported no_bigint/66 = %d, want 2", got) + } + if got := counts[key{"boot_error", "74"}]; got != 1 { + t.Errorf("unsupported boot_error/74 = %d, want 1", got) + } +} + +// TestNormalizeUnsupported checks that a beacon's reason and Chromium are reduced to bounded label +// values, so a spoofed beacon cannot explode the metric cardinality. +func TestNormalizeUnsupported(t *testing.T) { + cases := []struct { + reason, chromium string + wantReason, wantChromium string + }{ + {"no_bigint", "66", "no_bigint", "66"}, + {"no_proxy", " 74 ", "no_proxy", "74"}, + {"boot_error", "105", "boot_error", "105"}, + {"garbage", "66", "other", "66"}, + {"", "", "other", "other"}, + {"no_bigint", "not-a-number", "no_bigint", "other"}, + {"no_bigint", "9999", "no_bigint", "other"}, + {"no_bigint", "0", "no_bigint", "other"}, + {"no_bigint", "-5", "no_bigint", "other"}, + } + for _, c := range cases { + gotR, gotC := normalizeUnsupported(c.reason, c.chromium) + if gotR != c.wantReason || gotC != c.wantChromium { + t.Errorf("normalizeUnsupported(%q,%q) = %q,%q; want %q,%q", c.reason, c.chromium, gotR, gotC, c.wantReason, c.wantChromium) + } + } +} diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index cfe93d3..1863390 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -15,6 +15,7 @@ import ( "io" "net" "net/http" + "strconv" "strings" "time" @@ -197,6 +198,9 @@ func (s *Server) HTTPHandler() http.Handler { mux.Handle("/dl/", s.exportDownloadHandler()) // The client posts its local-move-preview adoption telemetry here (session-gated). mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler()) + // The index.html boot guard beacons here when it turns a client away on the unsupported-engine + // screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited. + mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler()) // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps @@ -582,6 +586,76 @@ func clampReport(r *localEvalReport) { clamp(&r.PreviewNetwork) } +// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when +// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot +// error). It is deduped client-side (one per device / app version / reason). +type unsupportedEngineBeacon struct { + Reason string `json:"reason"` + Chromium string `json:"chromium"` + Version string `json:"version"` + UA string `json:"ua"` +} + +// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is +// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with +// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label +// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full +// user agent is logged, not labelled. Only POST; the reply is always 204. +func (s *Server) unsupportedEngineHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ip := peerIP(r.RemoteAddr, r.Header) + if !s.limiter.Allow("public:"+ip, s.publicPolicy) { + s.noteRateLimited(r.Context(), classPublic, ip, "unsupported") + http.Error(w, "rate limited", http.StatusTooManyRequests) + return + } + var b unsupportedEngineBeacon + if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + reason, chromium := normalizeUnsupported(b.Reason, b.Chromium) + s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium) + s.log.Info("unsupported engine", + zap.String("reason", reason), + zap.String("chromium", chromium), + zap.String("app_version", truncate(b.Version, 40)), + zap.String("user_agent", truncate(b.UA, 400)), + ) + w.WriteHeader(http.StatusNoContent) + }) +} + +// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a +// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is +// parsed as a major version kept only within a plausible range, otherwise "other". +func normalizeUnsupported(reason, chromium string) (string, string) { + switch reason { + case "no_bigint", "no_proxy", "boot_error": + // a recognised reason — keep as-is + default: + reason = "other" + } + major := "other" + if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 { + major = strconv.Itoa(n) + } + return reason, major +} + +// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not +// trusted to be small). +func truncate(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + // resolve extracts and resolves the Authorization bearer token to an account id // and its guest flag, returning a Connect Unauthenticated error when it is missing // or unknown. diff --git a/gateway/internal/connectsrv/telemetry_test.go b/gateway/internal/connectsrv/telemetry_test.go new file mode 100644 index 0000000..5d08aee --- /dev/null +++ b/gateway/internal/connectsrv/telemetry_test.go @@ -0,0 +1,81 @@ +package connectsrv_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "go.opentelemetry.io/otel/attribute" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "scrabble/gateway/internal/config" + "scrabble/gateway/internal/connectsrv" + "scrabble/gateway/internal/ratelimit" +) + +// TestUnsupportedEngineHandler drives the /telemetry/unsupported beacon route end to end: a POST +// increments unsupported_engine_total with the normalised labels and replies 204; a GET is 405. +func TestUnsupportedEngineHandler(t *testing.T) { + reader := sdkmetric.NewManualReader() + meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test") + edge := connectsrv.NewServer(connectsrv.Deps{ + Limiter: ratelimit.New(), + RateLimit: config.DefaultRateLimit(), + Meter: meter, + }) + srv := httptest.NewServer(edge.HTTPHandler()) + defer srv.Close() + url := srv.URL + "/telemetry/unsupported" + + // A GET is rejected. + getResp, err := http.Get(url) + if err != nil { + t.Fatalf("get: %v", err) + } + getResp.Body.Close() + if getResp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET status = %d, want 405", getResp.StatusCode) + } + + // A POST is accepted (204) and folded into the counter with normalised labels. + body := `{"reason":"no_bigint","chromium":"66","version":"v1.2.3","ua":"Mozilla/5.0 ... Chrome/66"}` + resp, err := http.Post(url, "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatalf("post: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("POST status = %d, want 204", resp.StatusCode) + } + + var rm metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &rm); err != nil { + t.Fatalf("collect: %v", err) + } + var total int64 + for _, sm := range rm.ScopeMetrics { + for _, md := range sm.Metrics { + if md.Name != "unsupported_engine_total" { + continue + } + sum, ok := md.Data.(metricdata.Sum[int64]) + if !ok { + t.Fatalf("unsupported_engine_total is not an int64 sum") + } + for _, dp := range sum.DataPoints { + reason, _ := dp.Attributes.Value(attribute.Key("reason")) + chromium, _ := dp.Attributes.Value(attribute.Key("chromium")) + if reason.AsString() != "no_bigint" || chromium.AsString() != "66" { + t.Errorf("labels = %s/%s, want no_bigint/66", reason.AsString(), chromium.AsString()) + } + total += dp.Value + } + } + } + if total != 1 { + t.Errorf("unsupported_engine_total = %d, want 1", total) + } +} diff --git a/ui/index.html b/ui/index.html index be6efac..5095a37 100644 --- a/ui/index.html +++ b/ui/index.html @@ -97,9 +97,40 @@ } var shown = false; - function show(reason) { + // Fire-and-forget beacon so the gateway can count who hits this screen (Grafana). Deduped + // in localStorage by app version + reason + Chromium, so a user reopening the app ten times + // is one report. sendBeacon (Chrome 39+, present on the engines that reach here) with a + // fetch fallback; both are best-effort and never block or throw. + function beacon(code) { + try { + var cm = /Chrom(?:e|ium)\/(\d+)/.exec(ua); + var chromium = cm ? cm[1] : ''; + var sig = VERSION + '|' + code + '|' + chromium; + try { + if (localStorage.getItem('scrabble_unsupp') === sig) return; + } catch (e) {} + var payload = JSON.stringify({ reason: code, chromium: chromium, version: VERSION, ua: ua }); + var sent = false; + try { + if (nav.sendBeacon) sent = nav.sendBeacon('/telemetry/unsupported', new Blob([payload], { type: 'application/json' })); + } catch (e) {} + if (!sent && typeof fetch === 'function') { + try { + fetch('/telemetry/unsupported', { method: 'POST', body: payload, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(function () {}); + sent = true; + } catch (e) {} + } + if (sent) { + try { + localStorage.setItem('scrabble_unsupp', sig); + } catch (e) {} + } + } catch (e) {} + } + function show(reason, code) { if (shown) return; shown = true; + beacon(code); var root = el('div', 'position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;overflow:auto;' + '-webkit-overflow-scrolling:touch;background:#0f1115;color:#e8eaed;box-sizing:border-box;padding:24px;' + 'font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;'); @@ -179,7 +210,7 @@ // 1 + 2: the gate. if (hardMissing.length) { - show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', ')); + show((RU ? 'нет: ' : 'missing: ') + hardMissing.join(', '), hardMissing.indexOf('BigInt') >= 0 ? 'no_bigint' : 'no_proxy'); } else if (softMissing) { document.write(' -- 2.52.0