package gateway import ( "strconv" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 指标收集器调参。编译期常量。 const ( // metricsWindow 限定请求时间戳为 QPS 保留多久。 metricsWindow = 2 * time.Second // metricsQPSSpan 是测量 QPS 的滑动窗口。 metricsQPSSpan = time.Second ) // AdminMetrics 是 GET /api/v1/admin/observability/metrics 的线上结构。 // LLM/Token 成本刻意缺位:网关不跟踪 token 用量或 // 花费,编造一个数字会违反「不伪造指标」红线。 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 为管理可观测面板累积请求指标与派生 // 计数器。它对并发安全。 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 构建一个空指标收集器。 func newMetricsCollector() *MetricsCollector { return &MetricsCollector{ perMethod: make(map[string]int64), perPath: make(map[string]int64), statuses: make(map[int]int64), } } // Record 用给定的 method、path、status 与 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 在记录到该类 span 时递增 LLM/子代理计数器。 // 它由 ObserveSpan 调用,使计数器反映真实工作。 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 丢弃早于 metricsWindow 的请求时间,使 recent 保持小。 // 调用方必须持有 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 返回指标的时间点副本。qps 统计在 now 的 // metricsQPSSpan 之内开始的请求。 // // 结果中的每个 map 都是全新副本,绝不是收集器自己的:调用方 // 在本锁释放之后才序列化该值(管理 handler 对它做 JSON // 编码),因此返回活 map 会让并发的 Record 在编码器 // 遍历它时改动该 map —— Go 运行时对此的回答是不可恢复的 // 致命错误 "concurrent map iteration and map write",它会让整个进程 // 倒下(2026-10-03 21:00:28 在 GET /api/v1/admin/observability/metrics 上的崩溃)。 func (m *MetricsCollector) Snapshot(now time.Time) AdminMetrics { m.mu.Lock() defer m.mu.Unlock() 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: copyCounts(m.perMethod), PerPath: copyCounts(m.perPath), StatusCounts: copyStatusCounts(m.statuses), LLMCalls: m.llm, SubagentEvents: m.subagent, } } // copyCounts 返回字符串键计数器 map 的脱离副本,使 // 快照能在收集器锁释放后编码。调用方在复制时 // 必须持有 m.mu(复制会遍历活 map)。 func copyCounts(src map[string]int64) map[string]int64 { out := make(map[string]int64, len(src)) for k, v := range src { out[k] = v } return out } // copyStatusCounts 把 int 键的状态计数器渲染为 AdminMetrics JSON // 形状所声明的字符串键线上 map。调用方在复制时必须持有 m.mu。 func copyStatusCounts(src map[int]int64) map[string]int64 { out := make(map[string]int64, len(src)) for k, v := range src { out[strconv.Itoa(k)] = v } return out }