You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user