feat(multiplayer): export bounded API metrics

This commit is contained in:
Josh Creek
2026-09-01 17:06:38 +01:00
parent d85c8b8194
commit f1b8366531
7 changed files with 217 additions and 5 deletions
+73 -5
View File
@@ -132,6 +132,7 @@ type Service struct {
// 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)
Metrics *observability.Metrics
proposalMu sync.Mutex
eventsMu sync.Mutex
events *eventHub
@@ -182,6 +183,7 @@ func (s *Service) logProposalOutcome(proposalID string, proposal domain.Proposal
func (s *Service) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.health)
mux.HandleFunc("/metrics", s.metrics)
mux.HandleFunc("/v1/session/steam", s.steamSession)
mux.HandleFunc("/v1/queue", s.queueCreate)
mux.HandleFunc("/v1/queue/", s.queueMutation)
@@ -201,18 +203,84 @@ func (s *Service) Handler() http.Handler {
mux.HandleFunc("/api/v1/assignments/", s.contractAssignment)
mux.HandleFunc("/api/v1/events", s.controlPlaneEvent)
mux.HandleFunc("/api/v1/servers/", s.contractServerMutation)
if s.RateLimiter == nil {
return mux
var handler http.Handler = mux
if s.RateLimiter != nil {
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.RateLimiter.Allow(requestRateKey(r), s.now()) {
writeError(w, http.StatusTooManyRequests, "rate_limited")
return
}
mux.ServeHTTP(w, r)
})
}
if s.Metrics == nil {
return handler
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.RateLimiter.Allow(requestRateKey(r), s.now()) {
writeError(w, http.StatusTooManyRequests, "rate_limited")
if r.URL.Path == "/metrics" || r.URL.Path == "/healthz" || strings.HasSuffix(r.URL.Path, "/events") {
handler.ServeHTTP(w, r)
return
}
mux.ServeHTTP(w, r)
started := time.Now()
recorder := &statusRecorder{ResponseWriter: w}
handler.ServeHTTP(recorder, r)
code := recorder.code
if code == 0 {
code = http.StatusOK
}
s.Metrics.ObserveAPI(metricOperation(r.URL.Path), code, time.Since(started))
})
}
type statusRecorder struct {
http.ResponseWriter
code int
}
func (w *statusRecorder) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Write(body []byte) (int, error) {
if w.code == 0 {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(body)
}
func (s *Service) metrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || s.Metrics == nil {
writeError(w, http.StatusNotFound, "not_found")
return
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
_ = s.Metrics.WritePrometheus(w)
}
func metricOperation(path string) string {
switch {
case strings.Contains(path, "/queue"):
return "queue"
case strings.Contains(path, "/proposals"):
return "proposal"
case strings.Contains(path, "/assignments"):
return "assignment"
case strings.Contains(path, "ranked"):
return "ranked_profile"
case strings.Contains(path, "/profile"):
return "profile"
case strings.Contains(path, "/servers"):
return "server"
case strings.Contains(path, "/session"):
return "session"
case strings.Contains(path, "/probes"):
return "probe"
default:
return "other"
}
}
type steamSessionRequest struct {
WebAPITicket string `json:"web_api_ticket"`
}
+29
View File
@@ -1406,6 +1406,35 @@ func TestServerShutdownAPIRequiresBoundWorkloadAndDelegatesAcknowledgement(t *te
response.Body.Close()
}
func TestMetricsEndpointExportsBoundedAPILatencyAndSkipsItsOwnScrape(t *testing.T) {
metrics := observability.NewMetrics()
service := &Service{Metrics: metrics, Now: time.Now}
server := httptest.NewServer(service.Handler())
defer server.Close()
response, err := http.Get(server.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
response.Body.Close()
response, err = http.Get(server.URL + "/unknown/secret-token")
if err != nil {
t.Fatal(err)
}
response.Body.Close()
response, err = http.Get(server.URL + "/metrics")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusOK || !strings.Contains(string(body), `operation="other",status="4xx"`) || strings.Contains(string(body), "secret-token") {
t.Fatalf("metrics status=%d body=%s", response.StatusCode, body)
}
}
func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()