| @@ -0,0 +1,118 @@ | |||
| package metrics | |||
| import ( | |||
| "net/http" | |||
| "os" | |||
| "sync" | |||
| "github.com/prometheus/client_golang/prometheus" | |||
| "github.com/prometheus/client_golang/prometheus/promhttp" | |||
| ) | |||
| var ( | |||
| globalMetrics *Metrics | |||
| globalOnce sync.Once | |||
| ) | |||
| // Metrics 包含所有 Prometheus 指标 | |||
| type Metrics struct { | |||
| RequestsTotal *prometheus.CounterVec | |||
| RequestErrorsTotal *prometheus.CounterVec | |||
| RequestRetriesTotal *prometheus.CounterVec | |||
| TokensTotal *prometheus.CounterVec | |||
| QuotaConsumedTotal *prometheus.CounterVec | |||
| RequestDuration *prometheus.HistogramVec | |||
| UpstreamDuration *prometheus.HistogramVec | |||
| FirstTokenDuration *prometheus.HistogramVec | |||
| ActiveRequests *prometheus.GaugeVec | |||
| } | |||
| // NewMetrics 创建并注册所有指标到给定 Registry | |||
| func NewMetrics(reg prometheus.Registerer) *Metrics { | |||
| m := &Metrics{ | |||
| RequestsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ | |||
| Name: "newapi_requests_total", | |||
| Help: "Total number of API requests", | |||
| }, []string{"channel", "model", "code", "is_stream", "relay_mode"}), | |||
| RequestErrorsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ | |||
| Name: "newapi_request_errors_total", | |||
| Help: "Total number of request errors", | |||
| }, []string{"channel", "model", "error_type", "error_code"}), | |||
| RequestRetriesTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ | |||
| Name: "newapi_request_retries_total", | |||
| Help: "Total number of request retries", | |||
| }, []string{"channel", "model"}), | |||
| TokensTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ | |||
| Name: "newapi_tokens_total", | |||
| Help: "Total tokens used", | |||
| }, []string{"channel", "model", "type"}), | |||
| QuotaConsumedTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ | |||
| Name: "newapi_quota_consumed_total", | |||
| Help: "Total quota consumed", | |||
| }, []string{"channel", "model", "billing_source"}), | |||
| RequestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ | |||
| Name: "newapi_request_duration_seconds", | |||
| Help: "End-to-end request duration in seconds", | |||
| Buckets: []float64{0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120}, | |||
| }, []string{"channel", "model", "is_stream", "relay_mode"}), | |||
| UpstreamDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ | |||
| Name: "newapi_upstream_duration_seconds", | |||
| Help: "Upstream response duration in seconds", | |||
| Buckets: []float64{0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120}, | |||
| }, []string{"channel", "model"}), | |||
| FirstTokenDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ | |||
| Name: "newapi_first_token_duration_seconds", | |||
| Help: "Time to first token in seconds (streaming only)", | |||
| Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}, | |||
| }, []string{"channel", "model"}), | |||
| ActiveRequests: prometheus.NewGaugeVec(prometheus.GaugeOpts{ | |||
| Name: "newapi_active_requests", | |||
| Help: "Current number of active requests", | |||
| }, []string{"model"}), | |||
| } | |||
| reg.MustRegister( | |||
| m.RequestsTotal, | |||
| m.RequestErrorsTotal, | |||
| m.RequestRetriesTotal, | |||
| m.TokensTotal, | |||
| m.QuotaConsumedTotal, | |||
| m.RequestDuration, | |||
| m.UpstreamDuration, | |||
| m.FirstTokenDuration, | |||
| m.ActiveRequests, | |||
| ) | |||
| return m | |||
| } | |||
| // GetMetrics 返回全局 Metrics 实例 | |||
| func GetMetrics() *Metrics { | |||
| globalOnce.Do(func() { | |||
| if os.Getenv("METRICS_ENABLED") == "false" { | |||
| globalMetrics = NewMetrics(prometheus.NewPedanticRegistry()) | |||
| return | |||
| } | |||
| globalMetrics = NewMetrics(prometheus.DefaultRegisterer) | |||
| }) | |||
| return globalMetrics | |||
| } | |||
| // IsEnabled 返回 metrics 是否启用 | |||
| func IsEnabled() bool { | |||
| return os.Getenv("METRICS_ENABLED") != "false" | |||
| } | |||
| // NewMetricsHandler 返回默认 registry 的 /metrics HTTP handler | |||
| func NewMetricsHandler() http.Handler { | |||
| return promhttp.Handler() | |||
| } | |||
| // NewMetricsHandlerWithRegistry 返回指定 registry 的 /metrics HTTP handler | |||
| func NewMetricsHandlerWithRegistry(reg *prometheus.Registry) http.Handler { | |||
| return promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) | |||
| } | |||
| @@ -0,0 +1,123 @@ | |||
| package metrics | |||
| import ( | |||
| "testing" | |||
| "github.com/prometheus/client_golang/prometheus" | |||
| "github.com/prometheus/client_golang/prometheus/testutil" | |||
| "github.com/stretchr/testify/assert" | |||
| ) | |||
| func newTestRegistry() *prometheus.Registry { | |||
| return prometheus.NewRegistry() | |||
| } | |||
| func TestRequestsTotalCounter(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.RequestsTotal.WithLabelValues("1", "gpt-4", "200", "true", "chat").Inc() | |||
| m.RequestsTotal.WithLabelValues("1", "gpt-4", "200", "true", "chat").Inc() | |||
| m.RequestsTotal.WithLabelValues("2", "claude-3", "429", "false", "chat").Inc() | |||
| assert.Equal(t, float64(2), testutil.ToFloat64(m.RequestsTotal.WithLabelValues("1", "gpt-4", "200", "true", "chat"))) | |||
| assert.Equal(t, float64(1), testutil.ToFloat64(m.RequestsTotal.WithLabelValues("2", "claude-3", "429", "false", "chat"))) | |||
| } | |||
| func TestRequestErrorsTotalCounter(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.RequestErrorsTotal.WithLabelValues("1", "gpt-4", "upstream", "429").Inc() | |||
| m.RequestErrorsTotal.WithLabelValues("1", "gpt-4", "upstream", "429").Inc() | |||
| m.RequestErrorsTotal.WithLabelValues("2", "claude-3", "timeout", "context_deadline_exceeded").Inc() | |||
| assert.Equal(t, float64(2), testutil.ToFloat64(m.RequestErrorsTotal.WithLabelValues("1", "gpt-4", "upstream", "429"))) | |||
| assert.Equal(t, float64(1), testutil.ToFloat64(m.RequestErrorsTotal.WithLabelValues("2", "claude-3", "timeout", "context_deadline_exceeded"))) | |||
| } | |||
| func TestRequestRetriesTotalCounter(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.RequestRetriesTotal.WithLabelValues("1", "gpt-4").Inc() | |||
| m.RequestRetriesTotal.WithLabelValues("1", "gpt-4").Inc() | |||
| assert.Equal(t, float64(2), testutil.ToFloat64(m.RequestRetriesTotal.WithLabelValues("1", "gpt-4"))) | |||
| } | |||
| func TestTokensTotalCounter(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.TokensTotal.WithLabelValues("1", "gpt-4", "prompt").Add(100) | |||
| m.TokensTotal.WithLabelValues("1", "gpt-4", "completion").Add(50) | |||
| m.TokensTotal.WithLabelValues("1", "gpt-4", "cache").Add(20) | |||
| assert.Equal(t, float64(100), testutil.ToFloat64(m.TokensTotal.WithLabelValues("1", "gpt-4", "prompt"))) | |||
| assert.Equal(t, float64(50), testutil.ToFloat64(m.TokensTotal.WithLabelValues("1", "gpt-4", "completion"))) | |||
| assert.Equal(t, float64(20), testutil.ToFloat64(m.TokensTotal.WithLabelValues("1", "gpt-4", "cache"))) | |||
| } | |||
| func TestQuotaConsumedTotalCounter(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.QuotaConsumedTotal.WithLabelValues("1", "gpt-4", "wallet").Add(5000) | |||
| assert.Equal(t, float64(5000), testutil.ToFloat64(m.QuotaConsumedTotal.WithLabelValues("1", "gpt-4", "wallet"))) | |||
| } | |||
| func TestRequestDurationHistogram(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.RequestDuration.WithLabelValues("1", "gpt-4", "true", "chat").Observe(1.5) | |||
| m.RequestDuration.WithLabelValues("1", "gpt-4", "true", "chat").Observe(2.5) | |||
| // 验证 histogram metric family 被注册且有数据 | |||
| assert.Equal(t, 1, testutil.CollectAndCount(m.RequestDuration)) | |||
| } | |||
| func TestUpstreamDurationHistogram(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.UpstreamDuration.WithLabelValues("1", "gpt-4").Observe(0.5) | |||
| assert.Equal(t, 1, testutil.CollectAndCount(m.UpstreamDuration)) | |||
| } | |||
| func TestFirstTokenDurationHistogram(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.FirstTokenDuration.WithLabelValues("1", "gpt-4").Observe(0.1) | |||
| assert.Equal(t, 1, testutil.CollectAndCount(m.FirstTokenDuration)) | |||
| } | |||
| func TestActiveRequestsGauge(t *testing.T) { | |||
| reg := newTestRegistry() | |||
| m := NewMetrics(reg) | |||
| m.ActiveRequests.WithLabelValues("gpt-4").Inc() | |||
| m.ActiveRequests.WithLabelValues("gpt-4").Inc() | |||
| m.ActiveRequests.WithLabelValues("claude-3").Inc() | |||
| assert.Equal(t, float64(2), testutil.ToFloat64(m.ActiveRequests.WithLabelValues("gpt-4"))) | |||
| assert.Equal(t, float64(1), testutil.ToFloat64(m.ActiveRequests.WithLabelValues("claude-3"))) | |||
| m.ActiveRequests.WithLabelValues("gpt-4").Dec() | |||
| assert.Equal(t, float64(1), testutil.ToFloat64(m.ActiveRequests.WithLabelValues("gpt-4"))) | |||
| } | |||
| func TestMetricsDisabledIsNoOp(t *testing.T) { | |||
| // 当 METRICS_ENABLED=false 时,使用独立 registry(Discard) | |||
| reg := prometheus.NewPedanticRegistry() | |||
| m := NewMetrics(reg) | |||
| // 这些调用不应该 panic | |||
| m.RequestsTotal.WithLabelValues("1", "gpt-4", "200", "true", "chat").Inc() | |||
| m.ActiveRequests.WithLabelValues("gpt-4").Inc() | |||
| m.RequestDuration.WithLabelValues("1", "gpt-4", "true", "chat").Observe(1.0) | |||
| } | |||
| @@ -33,9 +33,11 @@ require ( | |||
| github.com/jinzhu/copier v0.4.0 | |||
| github.com/joho/godotenv v1.5.1 | |||
| github.com/mewkiz/flac v1.0.13 | |||
| github.com/mojocn/base64Captcha v1.3.8 | |||
| github.com/nicksnyder/go-i18n/v2 v2.6.1 | |||
| github.com/pkg/errors v0.9.1 | |||
| github.com/pquerna/otp v1.5.0 | |||
| github.com/prometheus/client_golang v1.22.0 | |||
| github.com/samber/hot v0.11.0 | |||
| github.com/samber/lo v1.52.0 | |||
| github.com/shirou/gopsutil v3.21.11+incompatible | |||
| @@ -111,6 +113,7 @@ require ( | |||
| github.com/json-iterator/go v1.1.12 // indirect | |||
| github.com/klauspost/compress v1.18.0 // indirect | |||
| github.com/klauspost/cpuid/v2 v2.3.0 // indirect | |||
| github.com/kylelemons/godebug v1.1.0 // indirect | |||
| github.com/leodido/go-urn v1.4.0 // indirect | |||
| github.com/mattn/go-isatty v0.0.20 // indirect | |||
| github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect | |||
| @@ -118,12 +121,10 @@ require ( | |||
| github.com/mitchellh/mapstructure v1.5.0 // indirect | |||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | |||
| github.com/modern-go/reflect2 v1.0.2 // indirect | |||
| github.com/mojocn/base64Captcha v1.3.8 // indirect | |||
| github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect | |||
| github.com/ncruces/go-strftime v0.1.9 // indirect | |||
| github.com/pelletier/go-toml/v2 v2.2.1 // indirect | |||
| github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect | |||
| github.com/prometheus/client_golang v1.22.0 // indirect | |||
| github.com/prometheus/client_model v0.6.1 // indirect | |||
| github.com/prometheus/common v0.62.0 // indirect | |||
| github.com/prometheus/procfs v0.15.1 // indirect | |||
| @@ -203,6 +203,8 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= | |||
| github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | |||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | |||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | |||
| github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= | |||
| github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= | |||
| github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= | |||
| github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= | |||
| github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | |||