现场:cy.log:560,2026-10-03 21:00:28.403,栈顶 encoding/json.mapEncoder.encode ← writeJSON(rest.go:383) ← adminObservabilityMetrics(observability.go:89), 不可 recover,网关 recover 中间件接不住 ⇒ 单二进制进程整体退出(端口 10942 零监听、 所有后台服务含 backthink 心跳一起没;比第一次心跳 tick 早 32.6 秒)。 根因:MetricsCollector.Snapshot 在锁内只新造了 StatusCounts,却把 collector 自己的 活 map m.perMethod / m.perPath 直接交出去;锁外(handler 的 writeJSON)才开始遍历, 而每个请求的 logging 中间件(middleware.go:79)在别的 goroutine 里 Record 写这两个 map ⇒ 写有锁、迭代没锁。面板并行轮询十几个端点,/admin/observability/metrics 被打了 29 次 且常见同毫秒两个请求,必踩。 修法:Snapshot 改为交出副本(新增 copyCounts / copyStatusCounts,都在锁内遍历), 并把「必须在锁外可安全编码」的原因写进文档注释。对外读侧语义不变(仍是全量累计)。 测试(新增 observability_metrics_test.go,242 行):① 快照与 collector 双向隔离(确定性) ② 并发「写 + 编码快照」(-race 下复现生产竞态)③ 真 HTTP + 中间件 + 并行轮询 /configmetric 端点(= 21:00:28 现场)。 验证:go build/vet 绿;-race ./internal/services/gateway/... ok;全量 go test ./... 58 包 ok / 0 FAIL; linux/amd64 + darwin/arm64 交叉编译绿。 变异验证:把 Snapshot 改回交活 map → 确定性测试 FAIL("snapshot shares the collector's perPath map") + -race 报出与生产同一处的 DATA RACE(Record 写在 observability_metrics.go:64)。
156 lines
4.7 KiB
Go
156 lines
4.7 KiB
Go
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.
|
|
//
|
|
// Every map in the result is a fresh copy, never the collector's own: the caller
|
|
// serializes the value AFTER this lock is released (the admin handler JSON-encodes
|
|
// it), so returning the live maps would let a concurrent Record mutate a map while
|
|
// the encoder iterates it — the Go runtime answers that with an unrecoverable
|
|
// fatal "concurrent map iteration and map write" that takes the whole process
|
|
// down (the 2026-10-03 21:00:28 crash on 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 returns a detached copy of a string-keyed counter map, so the
|
|
// snapshot can be encoded after the collector's lock is released. The caller must
|
|
// hold m.mu while copying (the copy iterates the live 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 renders the int-keyed status counter as the string-keyed wire
|
|
// map the AdminMetrics JSON shape declares. The caller must hold m.mu while copying.
|
|
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
|
|
}
|