feat(multiplayer): extend event logging to queue and proposal mutations

Wire the same Service.Log hook added for the server register/result
routes into queue create/heartbeat/cancel and proposal accept/decline:
log the resulting state on success (queue_create, queue_heartbeat,
queue_cancel, proposal_response) or 'rejected' on a domain error,
using only the ticket/proposal ID and outcome -- never the domain
error text itself, which isn't documented as credential-free.

Read-only routes (queue GET, proposal GET, assignment fetch) and the
early availability/not-found rejections that return before reaching
the domain call are deliberately not logged in this pass.

Covered by a new end-to-end test driving real create/heartbeat/cancel
and an accept followed by a stale-revision accept (fenced for real by
the domain layer behind proposalBackendSpy, unlike the dumb queue
spy), asserting the exact sequence of events logged.
This commit is contained in:
Josh Creek
2026-09-01 13:10:08 +01:00
parent 1490ff7fcf
commit 4eaa3304c3
2 changed files with 121 additions and 0 deletions
+34
View File
@@ -134,6 +134,28 @@ func (s *Service) logEvent(event observability.Event) {
}
}
// logQueueOutcome logs a queue-ticket mutation's result: the ticket's
// resulting state on success, or "rejected" on a domain error. It never logs
// the error text itself -- domain errors here are not documented as
// credential-free, and the stage name already tells an operator what to look
// up (the ticket ID, still recorded either way).
func (s *Service) logQueueOutcome(event, ticketID string, ticket domain.QueueTicket, err error, now time.Time) {
if err != nil {
s.logEvent(observability.Event{Event: event, QueueID: ticketID, Stage: "rejected", OccurredAt: now})
return
}
s.logEvent(observability.Event{Event: event, QueueID: ticket.TicketID, Stage: strings.ToLower(string(ticket.State)), OccurredAt: now})
}
// logProposalOutcome mirrors logQueueOutcome for proposal accept/decline.
func (s *Service) logProposalOutcome(proposalID string, proposal domain.Proposal, err error, now time.Time) {
if err != nil {
s.logEvent(observability.Event{Event: "proposal_response", ProposalID: proposalID, Stage: "rejected", OccurredAt: now})
return
}
s.logEvent(observability.Event{Event: "proposal_response", ProposalID: proposal.ProposalID, Stage: strings.ToLower(string(proposal.State)), OccurredAt: now})
}
func (s *Service) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.health)
@@ -263,9 +285,11 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
if s.QueueBackend != nil {
ticket, err := s.QueueBackend.Create(r.Context(), playerID, input.TicketID, key, spec, now)
if err != nil {
s.logQueueOutcome("queue_create", input.TicketID, ticket, err, now)
writeDomainError(w, err)
return
}
s.logQueueOutcome("queue_create", input.TicketID, ticket, nil, now)
s.projectCandidate(r.Context(), ticket)
s.publishTicketEvent(ticket, now)
writeJSON(w, http.StatusCreated, toQueueResponse(ticket))
@@ -293,9 +317,11 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
}
ticket, err := s.Queue.Create(playerID, input.TicketID, key, candidate, now)
if err != nil {
s.logQueueOutcome("queue_create", input.TicketID, ticket, err, now)
writeDomainError(w, err)
return
}
s.logQueueOutcome("queue_create", input.TicketID, ticket, nil, now)
s.projectCandidate(r.Context(), ticket)
s.publishTicketEvent(ticket, now)
writeJSON(w, http.StatusCreated, toQueueResponse(ticket))
@@ -581,10 +607,16 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
ticket, err = s.Queue.Cancel(playerID, ticketID, key, revision, now)
}
}
eventName := "queue_heartbeat"
if parts[1] == "cancel" {
eventName = "queue_cancel"
}
if err != nil {
s.logQueueOutcome(eventName, ticketID, ticket, err, now)
writeDomainError(w, err)
return
}
s.logQueueOutcome(eventName, ticketID, ticket, nil, now)
if ticket.State == domain.Cancelled {
s.removeCandidate(r.Context(), ticket.TicketID)
} else {
@@ -679,9 +711,11 @@ func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) {
updated, err = proposal.Respond(playerID, key, parts[1] == "accept", revision, now)
}
if err != nil {
s.logProposalOutcome(parts[0], updated, err, now)
writeDomainError(w, err)
return
}
s.logProposalOutcome(parts[0], updated, nil, now)
if updated.State == domain.Accepted && s.ProposalPromoter != nil {
if err := s.ProposalPromoter.Promote(r.Context(), updated, now); err != nil {
writeError(w, http.StatusServiceUnavailable, "match_promotion_unavailable")