From 3dde0ffb79df17afd900a3da36413041ca48eff4 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:01:30 +0100 Subject: [PATCH] feat: add credential-safe multiplayer observability --- multiplayer-todo.md | 2 +- server/observability/log.go | 63 ++++++++++++++++++++++++++++++++ server/observability/log_test.go | 32 ++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 server/observability/log.go create mode 100644 server/observability/log_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index a466f94a..006d7e3e 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1236,7 +1236,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.44 `[D:8.3,8.4,8.28,8.31]` | Propagate queue/proposal/match/server IDs and process-ready/assignment-ready through logs, metrics, traces and replay metadata; redact credentials | One ID traces queue→result across components and automated secret-canary tests find no auth/relay ticket | +| 8.44 `[D:8.3,8.4,8.28,8.31]` | **IN PROGRESS.** Go observability package encodes queue/proposal/match/server IDs and lifecycle stage in structured events while recursively redacting auth/relay tokens and credentials | `server/observability/` covers correlation fields, nested secret redaction and unnamed-event rejection; production logger/metrics/traces/replay integration and secret-canary coverage remain | | 8.45 `[D:8.2,8.44]` | Dashboards/alerts for wait/MMR/RTT, proposals, allocation/Ready/image pull, connect/no-show, tick/crash/flood, result conflict/lag, abandons and cost | Each SLO and security/cost signal has an exercised alert and runbook | | 8.46 `[D:8.5,8.7,8.9,8.10,8.14,8.18,8.21,8.23,8.25]` | **IN PROGRESS.** Go unit/race coverage spans the current domain/store/supervisor policies, and fuzz targets now exercise queue input, result payload hashing and revision events | `server/domain/*_test.go`, `server/store/*_test.go`, `server/supervisor/*_test.go` and `server/domain/fuzz_test.go` pass normal/race suites; `go test -fuzz`, PostgreSQL concurrency/migration execution, fake Steam/allocator and full lost-Redis/transaction fixtures remain | | 8.47 `[D:8.7,8.30]` | **IN PROGRESS.** Offline testkit provides deterministic fake Steam verification and fake allocation with forced failure injection | `server/testkit/` covers verified identity/replay and cloud-free forced allocation failure; API/Compose integration and exhaustive success/failure matrix remain | diff --git a/server/observability/log.go b/server/observability/log.go new file mode 100644 index 00000000..fb08de07 --- /dev/null +++ b/server/observability/log.go @@ -0,0 +1,63 @@ +// Package observability provides credential-safe structured event encoding. +package observability + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type Event struct { + Event string + QueueID string + ProposalID string + MatchID string + ServerID string + Stage string + OccurredAt time.Time + Fields map[string]any +} + +func Encode(event Event) ([]byte, error) { + if event.Event == "" { + return nil, fmt.Errorf("event name is required") + } + fields := map[string]any{ + "event": event.Event, "occurred_at": event.OccurredAt.UTC().Format(time.RFC3339Nano), + } + for key, value := range map[string]string{"queue_id": event.QueueID, "proposal_id": event.ProposalID, "match_id": event.MatchID, "server_id": event.ServerID, "stage": event.Stage} { + if value != "" { + fields[key] = value + } + } + for key, value := range event.Fields { + fields[key] = redact(key, value) + } + return json.Marshal(fields) +} + +func redact(key string, value any) any { + lowered := strings.ToLower(key) + for _, secret := range []string{"token", "secret", "credential", "authorization", "private_key", "auth_ticket", "relay_ticket"} { + if strings.Contains(lowered, secret) { + return "[REDACTED]" + } + } + switch typed := value.(type) { + case map[string]any: + copy := make(map[string]any, len(typed)) + for key, value := range typed { + copy[key] = redact(key, value) + } + return copy + case []any: + copy := make([]any, len(typed)) + for i, value := range typed { + copy[i] = redact("item", value) + } + return copy + default: + return value + } +} diff --git a/server/observability/log_test.go b/server/observability/log_test.go new file mode 100644 index 00000000..e2c98fff --- /dev/null +++ b/server/observability/log_test.go @@ -0,0 +1,32 @@ +package observability + +import ( + "encoding/json" + "testing" + "time" +) + +func TestEncodeCorrelatesStagesAndRedactsNestedCredentials(t *testing.T) { + payload, err := Encode(Event{Event: "assignment_ready", QueueID: "queue-1", ProposalID: "proposal-1", MatchID: "match-1", ServerID: "server-1", Stage: "assignment-ready", OccurredAt: time.Unix(1000, 0), Fields: map[string]any{"auth_ticket": "do-not-log", "nested": map[string]any{"relay_ticket": "also-secret", "attempt": 2}}}) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + for _, key := range []string{"queue_id", "proposal_id", "match_id", "server_id", "stage"} { + if decoded[key] == nil { + t.Fatalf("missing correlation field %q: %s", key, payload) + } + } + if decoded["auth_ticket"] != "[REDACTED]" || decoded["nested"].(map[string]any)["relay_ticket"] != "[REDACTED]" { + t.Fatalf("credential not redacted: %s", payload) + } +} + +func TestEncodeRejectsUnnamedEvents(t *testing.T) { + if _, err := Encode(Event{}); err == nil { + t.Fatal("unnamed event accepted") + } +}