mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 23:32:26 +00:00
feat(multiplayer): export bounded API metrics
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metrics is a bounded in-process collector for API request health. Operation
|
||||
// names are normalized to a fixed vocabulary before storage.
|
||||
type Metrics struct {
|
||||
mu sync.Mutex
|
||||
counts map[metricKey]uint64
|
||||
sums map[metricKey]time.Duration
|
||||
}
|
||||
|
||||
type metricKey struct{ operation, status string }
|
||||
|
||||
func NewMetrics() *Metrics {
|
||||
return &Metrics{counts: make(map[metricKey]uint64), sums: make(map[metricKey]time.Duration)}
|
||||
}
|
||||
|
||||
func (m *Metrics) ObserveAPI(operation string, statusCode int, duration time.Duration) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
key := metricKey{normalizeOperation(operation), statusClass(statusCode)}
|
||||
m.mu.Lock()
|
||||
m.counts[key]++
|
||||
m.sums[key] += duration
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Metrics) WritePrometheus(w io.Writer) error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
keys := make([]metricKey, 0, len(m.counts))
|
||||
for key := range m.counts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].operation != keys[j].operation {
|
||||
return keys[i].operation < keys[j].operation
|
||||
}
|
||||
return keys[i].status < keys[j].status
|
||||
})
|
||||
counts := make(map[metricKey]uint64, len(keys))
|
||||
sums := make(map[metricKey]time.Duration, len(keys))
|
||||
for _, key := range keys {
|
||||
counts[key], sums[key] = m.counts[key], m.sums[key]
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if _, err := io.WriteString(w, "# TYPE cosmic_clash_api_requests_total counter\n# TYPE cosmic_clash_api_latency_seconds summary\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
labels := fmt.Sprintf(`operation="%s",status="%s"`, key.operation, key.status)
|
||||
if _, err := fmt.Fprintf(w, "cosmic_clash_api_requests_total{%s} %d\ncosmic_clash_api_latency_seconds_count{%s} %d\ncosmic_clash_api_latency_seconds_sum{%s} %.9f\n", labels, counts[key], labels, counts[key], labels, sums[key].Seconds()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOperation(operation string) string {
|
||||
for _, allowed := range []string{"queue", "proposal", "assignment", "profile", "ranked_profile", "server", "events", "session", "probe"} {
|
||||
if operation == allowed {
|
||||
return allowed
|
||||
}
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func statusClass(code int) string {
|
||||
if code < 100 || code > 599 {
|
||||
return "unknown"
|
||||
}
|
||||
return fmt.Sprintf("%dxx", code/100)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMetricsNormalizesOperationsAndExportsBoundedLabels(t *testing.T) {
|
||||
m := NewMetrics()
|
||||
m.ObserveAPI("queue", 201, 10*time.Millisecond)
|
||||
m.ObserveAPI("/crafted/path/with-secret", 500, time.Second)
|
||||
var output strings.Builder
|
||||
if err := m.WritePrometheus(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := output.String()
|
||||
if !strings.Contains(text, `operation="queue",status="2xx"`) || !strings.Contains(text, `operation="other",status="5xx"`) {
|
||||
t.Fatalf("metrics output = %s", text)
|
||||
}
|
||||
if strings.Contains(text, "crafted") || strings.Contains(text, "secret") {
|
||||
t.Fatalf("unbounded operation label leaked: %s", text)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user