2feb638329
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.
82 lines
2.5 KiB
Go
82 lines
2.5 KiB
Go
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)
|
|
}
|
|
}
|