| @@ -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/jinzhu/copier v0.4.0 | ||||
| github.com/joho/godotenv v1.5.1 | github.com/joho/godotenv v1.5.1 | ||||
| github.com/mewkiz/flac v1.0.13 | 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/nicksnyder/go-i18n/v2 v2.6.1 | ||||
| github.com/pkg/errors v0.9.1 | github.com/pkg/errors v0.9.1 | ||||
| github.com/pquerna/otp v1.5.0 | 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/hot v0.11.0 | ||||
| github.com/samber/lo v1.52.0 | github.com/samber/lo v1.52.0 | ||||
| github.com/shirou/gopsutil v3.21.11+incompatible | github.com/shirou/gopsutil v3.21.11+incompatible | ||||
| @@ -111,6 +113,7 @@ require ( | |||||
| github.com/json-iterator/go v1.1.12 // indirect | github.com/json-iterator/go v1.1.12 // indirect | ||||
| github.com/klauspost/compress v1.18.0 // indirect | github.com/klauspost/compress v1.18.0 // indirect | ||||
| github.com/klauspost/cpuid/v2 v2.3.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/leodido/go-urn v1.4.0 // indirect | ||||
| github.com/mattn/go-isatty v0.0.20 // indirect | github.com/mattn/go-isatty v0.0.20 // indirect | ||||
| github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // 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/mitchellh/mapstructure v1.5.0 // indirect | ||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | ||||
| github.com/modern-go/reflect2 v1.0.2 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect | ||||
| github.com/ncruces/go-strftime v0.1.9 // indirect | github.com/ncruces/go-strftime v0.1.9 // indirect | ||||
| github.com/pelletier/go-toml/v2 v2.2.1 // 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/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/client_model v0.6.1 // indirect | ||||
| github.com/prometheus/common v0.62.0 // indirect | github.com/prometheus/common v0.62.0 // indirect | ||||
| github.com/prometheus/procfs v0.15.1 // 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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | 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.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.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= | ||||
| github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | ||||