This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+108
View File
@@ -0,0 +1,108 @@
// Package metrics 提供进程内的 Prometheus 指标注册中心与一组类型化的记录助手。
//
// 设计要点:
// - 用私有 *prometheus.Registry(NOT prometheus.DefaultRegisterer),避免在
// 热重载 / 测试中重复注册默认注册表导致 panic。
// - 通过 promhttp.HandlerFor(reg, ...) 暴露 Handler();调用方(router)负责
// 鉴权门禁(/metrics 泄露成本/会话数,必须 admin/内网受限)。
// - Record* 助手对外提供窄 API:其它模块只依赖这几个方法(或 acp 包内定义的
// 窄接口),无需 import prometheus。
//
// 指标清单(spec §11 §1205-1207):
// - acp_sessions_active GaugeFunc —— 当前活跃 ACP 会话数
// - acp_session_exit_total{status} CounterVec —— 会话退出计数(exited/crashed/…)
// - acp_permission_decisions_total{outcome} CounterVec —— 权限决策计数
// (auto/approved/denied/expired)
// - jobs_dead_letter_total{type} CounterVec —— 死信任务计数(按 job type)
// - llm_cost_usd_total GaugeFunc —— 累计 LLM 成本(USD)
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics 持有私有注册表与各 collector。零值不可用,必须经 New 构造。
type Metrics struct {
reg *prometheus.Registry
sessionExits *prometheus.CounterVec
permDecisions *prometheus.CounterVec
deadLetters *prometheus.CounterVec
}
// New 构造 Metrics。activeSessions / llmCostTotal 是供 GaugeFunc 回调的取值
// 函数;二者可为 nil(nil 时对应 gauge 恒为 0),便于测试 / feature-off。
func New(activeSessions func() float64, llmCostTotal func() float64) *Metrics {
reg := prometheus.NewRegistry()
m := &Metrics{
reg: reg,
sessionExits: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "acp_session_exit_total",
Help: "Total number of ACP agent session exits, labeled by terminal status.",
}, []string{"status"}),
permDecisions: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "acp_permission_decisions_total",
Help: "Total number of ACP tool-permission decisions, labeled by outcome.",
}, []string{"outcome"}),
deadLetters: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "jobs_dead_letter_total",
Help: "Total number of jobs that exhausted retries and were dead-lettered, by job type.",
}, []string{"type"}),
}
reg.MustRegister(m.sessionExits, m.permDecisions, m.deadLetters)
if activeSessions == nil {
activeSessions = func() float64 { return 0 }
}
if llmCostTotal == nil {
llmCostTotal = func() float64 { return 0 }
}
reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "acp_sessions_active",
Help: "Current number of active (starting/running) ACP agent sessions.",
}, activeSessions))
reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "llm_cost_usd_total",
Help: "Cumulative LLM usage cost in USD across all endpoints.",
}, llmCostTotal))
return m
}
// Handler 返回 /metrics 的 http.Handler,绑定到本 Metrics 的私有注册表。
func (m *Metrics) Handler() http.Handler {
return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{})
}
// Registry 暴露底层注册表,便于测试直接 Gather。
func (m *Metrics) Registry() *prometheus.Registry { return m.reg }
// RecordSessionExit 记录一次会话退出。status 取 acp.SessionStatus 字符串值
// (exited/crashed/…)。
func (m *Metrics) RecordSessionExit(status string) {
if m == nil {
return
}
m.sessionExits.WithLabelValues(status).Inc()
}
// RecordPermissionDecision 记录一次权限决策。outcome 取 auto/approved/denied/expired。
func (m *Metrics) RecordPermissionDecision(outcome string) {
if m == nil {
return
}
m.permDecisions.WithLabelValues(outcome).Inc()
}
// RecordDeadLetter 记录一次死信任务,按 job type 标签累加。
func (m *Metrics) RecordDeadLetter(jobType string) {
if m == nil {
return
}
m.deadLetters.WithLabelValues(jobType).Inc()
}
+90
View File
@@ -0,0 +1,90 @@
package metrics
import (
"io"
"net/http/httptest"
"strings"
"testing"
)
func scrape(t *testing.T, m *Metrics) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/metrics", nil)
m.Handler().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("metrics handler status = %d, want 200", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
return string(body)
}
func TestNewRegistersAllCollectors(t *testing.T) {
m := New(func() float64 { return 3 }, func() float64 { return 12.5 })
// CounterVecs emit no series until a label set is observed; touch each once
// so the metric family name surfaces in the scrape.
m.RecordSessionExit("exited")
m.RecordPermissionDecision("auto")
m.RecordDeadLetter("webhook.deliver")
body := scrape(t, m)
for _, name := range []string{
"acp_sessions_active",
"acp_session_exit_total",
"acp_permission_decisions_total",
"jobs_dead_letter_total",
"llm_cost_usd_total",
} {
if !strings.Contains(body, name) {
t.Errorf("metrics output missing %q\n%s", name, body)
}
}
// GaugeFunc values surface immediately even without any Record* call.
if !strings.Contains(body, "acp_sessions_active 3") {
t.Errorf("acp_sessions_active gauge not reporting func value; body:\n%s", body)
}
if !strings.Contains(body, "llm_cost_usd_total 12.5") {
t.Errorf("llm_cost_usd_total gauge not reporting func value; body:\n%s", body)
}
}
func TestRecordHelpersIncrementWithLabels(t *testing.T) {
m := New(nil, nil)
m.RecordSessionExit("crashed")
m.RecordSessionExit("crashed")
m.RecordSessionExit("exited")
m.RecordPermissionDecision("approved")
m.RecordPermissionDecision("denied")
m.RecordPermissionDecision("expired")
m.RecordDeadLetter("webhook.deliver")
body := scrape(t, m)
wants := []string{
`acp_session_exit_total{status="crashed"} 2`,
`acp_session_exit_total{status="exited"} 1`,
`acp_permission_decisions_total{outcome="approved"} 1`,
`acp_permission_decisions_total{outcome="denied"} 1`,
`acp_permission_decisions_total{outcome="expired"} 1`,
`jobs_dead_letter_total{type="webhook.deliver"} 1`,
}
for _, w := range wants {
if !strings.Contains(body, w) {
t.Errorf("metrics output missing %q\n%s", w, body)
}
}
}
func TestRegistryIsolatedNotGlobalDefault(t *testing.T) {
// Two independent Metrics must not collide (would panic if both used the
// global default registry).
_ = New(nil, nil)
_ = New(nil, nil)
}
func TestNilReceiverRecordSafe(t *testing.T) {
var m *Metrics
// Must not panic — callers may hold a nil *Metrics when metrics disabled.
m.RecordSessionExit("exited")
m.RecordPermissionDecision("auto")
m.RecordDeadLetter("x")
}