package gateway import ( "strconv" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // Metrics collector tuning. Compile-time constants. const ( // metricsWindow bounds how long request timestamps are retained for QPS. metricsWindow = 2 * time.Second // metricsQPSSpan is the sliding window over which QPS is measured. metricsQPSSpan = time.Second ) // AdminMetrics is the wire shape of GET /api/v1/admin/observability/metrics. // LLM/Token cost is deliberately absent: the gateway does not track token usage or // spend, and a fabricated number would violate the "不伪造指标" red line. type AdminMetrics struct { TotalRequests int64 `json:"totalRequests"` QPS float64 `json:"qps"` AvgLatencyMs float64 `json:"avgLatencyMs"` MaxLatencyMs int64 `json:"maxLatencyMs"` PerMethod map[string]int64 `json:"perMethod"` PerPath map[string]int64 `json:"perPath"` StatusCounts map[string]int64 `json:"statusCounts"` LLMCalls int64 `json:"llmCalls"` SubagentEvents int64 `json:"subagentEvents"` } // MetricsCollector accumulates request metrics and derived counters for the admin // observability panel. It is concurrency-safe. type MetricsCollector struct { mu sync.Mutex total int64 perMethod map[string]int64 perPath map[string]int64 statuses map[int]int64 sumDur int64 maxDur int64 recent []time.Time llm int64 subagent int64 } // newMetricsCollector builds an empty metrics collector. func newMetricsCollector() *MetricsCollector { return &MetricsCollector{ perMethod: make(map[string]int64), perPath: make(map[string]int64), statuses: make(map[int]int64), } } // Record counts one request with the given method, path, status and duration. func (m *MetricsCollector) Record(method, path string, status int, durMs int64) { m.mu.Lock() defer m.mu.Unlock() now := time.Now() m.total++ m.perMethod[method]++ m.perPath[path]++ m.statuses[status]++ m.sumDur += durMs if durMs > m.maxDur { m.maxDur = durMs } m.recent = append(m.recent, now) m.prune(now) } // Observe increments the LLM/subagent counters when a span of that kind is // recorded. It is called by ObserveSpan so the counters reflect real work. func (m *MetricsCollector) Observe(rec trace.SpanRecord) { m.mu.Lock() defer m.mu.Unlock() switch rec.Kind { case trace.SpanKindLLM: m.llm++ case trace.SpanKindSubagent: m.subagent++ } } // prune drops request times older than metricsWindow so recent stays small. The // caller must hold m.mu. func (m *MetricsCollector) prune(now time.Time) { min := now.Add(-metricsWindow) i := 0 for i < len(m.recent) && m.recent[i].Before(min) { i++ } if i > 0 { m.recent = append([]time.Time(nil), m.recent[i:]...) } } // Snapshot returns a point-in-time copy of the metrics. qps counts the requests // started within metricsQPSSpan of now. func (m *MetricsCollector) Snapshot(now time.Time) AdminMetrics { m.mu.Lock() defer m.mu.Unlock() statuses := make(map[string]int64, len(m.statuses)) for k, v := range m.statuses { statuses[strconv.Itoa(k)] = v } var qps int64 for _, t := range m.recent { if now.Sub(t) <= metricsQPSSpan { qps++ } } avg := int64(0) if m.total > 0 { avg = m.sumDur / m.total } return AdminMetrics{ TotalRequests: m.total, QPS: float64(qps), AvgLatencyMs: float64(avg), MaxLatencyMs: m.maxDur, PerMethod: m.perMethod, PerPath: m.perPath, StatusCounts: statuses, LLMCalls: m.llm, SubagentEvents: m.subagent, } }