feat(multiplayer): wire structured event logging into server routes

server/observability existed fully unit-tested but was imported by
nothing outside its own package -- no HTTP handler ever called it, so
its credential redaction protected zero real log output. Wire it into
Service via an optional Log field (nil-safe, so every existing Service
literal keeps compiling unchanged) and call it from the two
workload-authenticated server routes -- register and result -- at
every outcome: unauthorized, rejected, conflict and success. Wire
cmd/control-plane to actually emit those events as JSON lines on
stderr.

Add a secret canary test that drives both routes end to end with
realistic bearer-token and result-nonce values and asserts neither
literal secret appears anywhere in what Service.Log actually received
-- a stronger claim than the existing observability unit test, which
only proves redact() strips a synthetic value under a denylisted key
name. redact() is still key-name-based, not content-based: a future
call site that logs a secret under an unlisted key name would not be
caught by this test or by redact() itself, only by the same discipline
applied here of never putting raw request/token bytes into Fields.

Queue, proposal and assignment mutation routes are not wired yet.
This commit is contained in:
Josh Creek
2026-09-01 13:07:23 +01:00
parent 06a4ea0a02
commit 3817df2a12
3 changed files with 127 additions and 3 deletions
+31 -3
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/observability"
)
const maxBodyBytes = 8 << 10
@@ -116,9 +117,21 @@ type Service struct {
RankedProfiles map[string]domain.RankedProfile
TierPolicy domain.TierPolicy
RateLimiter *RateLimiter
proposalMu sync.Mutex
eventsMu sync.Mutex
events *eventHub
// Log receives a credential-safe structured event for lifecycle-relevant
// mutations (currently: server registration and result submission). Nil
// is a valid, silent no-op -- every call site must stay optional so
// existing Service literals that don't set it keep working unchanged.
Log func(observability.Event)
proposalMu sync.Mutex
eventsMu sync.Mutex
events *eventHub
}
// logEvent is a nil-safe wrapper so call sites never need their own guard.
func (s *Service) logEvent(event observability.Event) {
if s.Log != nil {
s.Log(event)
}
}
func (s *Service) Handler() http.Handler {
@@ -431,6 +444,7 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
now := s.now()
binding, err := s.WorkloadVerify(partsAuth[1], now)
if err != nil || binding.ServerID != parts[0] {
s.logEvent(observability.Event{Event: "server_" + parts[1], ServerID: parts[0], Stage: "unauthorized", OccurredAt: now})
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
@@ -440,17 +454,26 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
return
}
if input.MatchID == "" || input.MatchID != binding.MatchID || input.ProtocolVersion < 1 || !validImageDigest(input.ImageDigest) {
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
return
}
if err := s.ServerRegistrar.RegisterServer(r.Context(), binding, input.ProtocolVersion, input.AssignmentReady, key, now); err != nil {
stage := "invalid"
if errors.Is(err, domain.ErrConflict) {
stage = "conflict"
writeError(w, http.StatusConflict, "conflict")
} else {
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
}
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: stage, OccurredAt: now})
return
}
readyStage := "process_ready"
if input.AssignmentReady {
readyStage = "assignment_ready"
}
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: readyStage, OccurredAt: now, Fields: map[string]any{"protocol_version": input.ProtocolVersion}})
w.WriteHeader(http.StatusNoContent)
return
}
@@ -459,6 +482,7 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
return
}
if input.MatchID == "" || binding.MatchID != input.MatchID || len(input.ResultNonce) < 16 || len(input.ResultNonce) > 128 || input.Score.Team0 < 0 || input.Score.Team1 < 0 || (input.IntegrityState != domain.IntegrityCertified && input.IntegrityState != domain.IntegritySuppressed && input.IntegrityState != domain.IntegrityReview) {
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
return
}
@@ -469,13 +493,17 @@ func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.ResultSubmitter.SubmitResult(r.Context(), key, result, binding, payload, now); err != nil {
stage := "invalid"
if errors.Is(err, domain.ErrResultConflict) || strings.Contains(err.Error(), "conflict") {
stage = "conflict"
writeError(w, http.StatusConflict, "conflict")
} else {
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
}
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: stage, OccurredAt: now})
return
}
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: "accepted", OccurredAt: now, Fields: map[string]any{"integrity_state": string(input.IntegrityState), "team_0": input.Score.Team0, "team_1": input.Score.Team1}})
w.WriteHeader(http.StatusAccepted)
}