package connectsrv import ( "context" "time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric/noop" ) // meterName scopes the gateway edge's OpenTelemetry instruments. const meterName = "scrabble/gateway/edge" // activeUserWindows are the rolling windows the active_users gauge reports. var activeUserWindows = []struct { label string dur time.Duration }{ {label: "24h", dur: 24 * time.Hour}, {label: "7d", dur: 7 * 24 * time.Hour}, } // serverMetrics holds the edge's operational instruments. It defaults to no-ops; // NewServer installs the real meter when one is supplied in Deps. type serverMetrics struct { edge metric.Float64Histogram rateLimited metric.Int64Counter banned metric.Int64Counter active *activeUsers // Client-reported local move-preview adoption (see localEvalMetricsHandler). 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), // falling back to a no-op histogram on the (rare) construction error. The // active_users gauge is registered as an observable callback over the in-memory // tracker. func newServerMetrics(meter metric.Meter) *serverMetrics { if meter == nil { meter = noop.NewMeterProvider().Meter(meterName) } h, err := meter.Float64Histogram("edge_request_duration", metric.WithUnit("s"), metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result.")) if err != nil { h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration") } c, err := meter.Int64Counter("gateway_rate_limited_total", metric.WithDescription("Rate-limiter rejections at the edge, by limiter class (user, public, email or admin) — aggregate only, no per-user attributes.")) if err != nil { c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_rate_limited_total") } b, err := meter.Int64Counter("gateway_abuse_banned_total", metric.WithDescription("Temporary IP bans applied at the edge, by reason (rejections, tripwire or honeytoken).")) if err != nil { b, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("gateway_abuse_banned_total") } m := &serverMetrics{ edge: h, rateLimited: c, banned: b, active: newActiveUsers(), 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", metric.WithDescription("Distinct accounts that performed an authenticated action within the window (in-memory, single gateway instance).")) if err == nil { windows := make([]time.Duration, len(activeUserWindows)) for i, w := range activeUserWindows { windows[i] = w.dur } _, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error { counts := m.active.counts(windows) for i, w := range activeUserWindows { o.ObserveInt64(gauge, int64(counts[i]), metric.WithAttributes(attribute.String("window", w.label))) } return nil }, gauge) } return m } // recordEdge records the duration of one Execute call labelled by message type and // outcome (ok, domain, unauthenticated, rate_limited, unknown_type or internal). func (m *serverMetrics) recordEdge(ctx context.Context, msgType, result string, start time.Time) { m.edge.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes( attribute.String("message_type", msgType), attribute.String("result", result), )) } // recordActive marks account uid active now, feeding the active_users gauge. func (m *serverMetrics) recordActive(uid string) { m.active.seen(uid) } // recordRateLimited counts one limiter rejection under class. func (m *serverMetrics) recordRateLimited(ctx context.Context, class string) { m.rateLimited.Add(ctx, 1, metric.WithAttributes(attribute.String("class", class))) } // recordBan counts one temporary IP ban under reason (rejections, tripwire or // honeytoken). 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. type localEvalReport struct { ColdStart int `json:"cold_start"` DictFetched int `json:"dict_fetched"` DictCacheHit int `json:"dict_cache_hit"` DictMiss int `json:"dict_miss"` PreviewLocal int `json:"preview_local"` PreviewNetwork int `json:"preview_network"` } // recordLocalEval folds one client report into the edge's local-eval counters. func (m *serverMetrics) recordLocalEval(ctx context.Context, r localEvalReport) { add := func(c metric.Int64Counter, n int, opts ...metric.AddOption) { if n > 0 { c.Add(ctx, int64(n), opts...) } } add(m.localColdStart, r.ColdStart) add(m.localDictLoad, r.DictFetched, metric.WithAttributes(attribute.String("result", "fetched"))) add(m.localDictLoad, r.DictCacheHit, metric.WithAttributes(attribute.String("result", "cache_hit"))) add(m.localDictLoad, r.DictMiss, metric.WithAttributes(attribute.String("result", "miss"))) add(m.localPreview, r.PreviewLocal, metric.WithAttributes(attribute.String("path", "local"))) add(m.localPreview, r.PreviewNetwork, metric.WithAttributes(attribute.String("path", "network"))) } // counterOf builds an Int64Counter on meter, falling back to a no-op on the rare // construction error so metrics never fail startup. func counterOf(meter metric.Meter, name, desc string) metric.Int64Counter { c, err := meter.Int64Counter(name, metric.WithDescription(desc)) if err != nil { c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter(name) } return c }