mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-12 18:13:46 +00:00
feat(multiplayer): export bounded API metrics
This commit is contained in:
+73
-5
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user