diff --git a/common/metrics/metrics.go b/common/metrics/metrics.go new file mode 100644 index 0000000..58f6784 --- /dev/null +++ b/common/metrics/metrics.go @@ -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{}) +} diff --git a/common/metrics/metrics_test.go b/common/metrics/metrics_test.go new file mode 100644 index 0000000..1736d7f --- /dev/null +++ b/common/metrics/metrics_test.go @@ -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) +} diff --git a/go.mod b/go.mod index 8c48a84..73d3f66 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index a8103c5..a687387 100644 --- a/go.sum +++ b/go.sum @@ -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=