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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user