feat(gateway): unsupported-engine telemetry beacon + Grafana counter
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m5s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user