feat(metrics): Prometheus collectors + typed API + HTTP middleware (ADR-030, #15)
New internal/metrics package: AI metrics (summarize/caption/chat latency, LLM tokens), session metrics (requests+latency by bounded route pattern, logins), and a /metrics Handler. Adapters call a typed API; never touch prometheus types. Dep: github.com/prometheus/client_golang (standard Go client; cluster runs prometheus-operator). TDD: collectors + middleware route-pattern cardinality covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,23 +9,33 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/time v0.15.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -4,6 +4,10 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
@@ -37,8 +41,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -49,10 +53,14 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -61,6 +69,8 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
@@ -70,6 +80,14 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -91,8 +109,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
@@ -103,6 +121,8 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package metrics is Tapir's Prometheus instrumentation (ADR-030, issue #15). It
|
||||
// owns the collectors and a small typed API the rest of the app calls — adapters
|
||||
// never touch prometheus types directly. Two themes:
|
||||
//
|
||||
// - HTTP/session: request count + latency by route (the matched pattern, so
|
||||
// cardinality stays bounded), and logins.
|
||||
// - AI (the priority): summarization latency by model/outcome/fallback, caption
|
||||
// fetch latency by outcome, chat latency by model, and LLM token usage.
|
||||
//
|
||||
// Handler() is served on a dedicated port (never the public app port) so a scrape
|
||||
// is in-cluster only. slog timing lines are emitted at the call sites too.
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// latencyBuckets spans sub-second UI calls up to multi-minute model calls (a cold
|
||||
// local model load is tens of seconds; the cloud fallback can be longer).
|
||||
var latencyBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 30, 60, 120, 300}
|
||||
|
||||
var (
|
||||
httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "tapir_http_requests_total",
|
||||
Help: "HTTP requests by method, matched route pattern, and status code.",
|
||||
}, []string{"method", "route", "code"})
|
||||
|
||||
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "tapir_http_request_duration_seconds",
|
||||
Help: "HTTP request latency by method and matched route pattern.",
|
||||
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5},
|
||||
}, []string{"method", "route"})
|
||||
|
||||
logins = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "tapir_logins_total",
|
||||
Help: "Successful OIDC logins (session established).",
|
||||
})
|
||||
|
||||
summarizeDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "tapir_summarize_duration_seconds",
|
||||
Help: "Per-endpoint summarization latency by model, outcome (success|parse_error|error), and whether it was a fallback.",
|
||||
Buckets: latencyBuckets,
|
||||
}, []string{"model", "outcome", "fallback"})
|
||||
|
||||
captionFetchDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "tapir_caption_fetch_duration_seconds",
|
||||
Help: "Caption fetch latency by outcome (captions|none|rate_limited).",
|
||||
Buckets: latencyBuckets,
|
||||
}, []string{"outcome"})
|
||||
|
||||
chatDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "tapir_chat_duration_seconds",
|
||||
Help: "Per-video Q&A answer latency by model.",
|
||||
Buckets: latencyBuckets,
|
||||
}, []string{"model"})
|
||||
|
||||
llmTokens = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "tapir_llm_tokens_total",
|
||||
Help: "LLM tokens consumed by model and kind (prompt|completion).",
|
||||
}, []string{"model", "kind"})
|
||||
)
|
||||
|
||||
// Handler serves the Prometheus exposition format. Mount on the dedicated metrics
|
||||
// port, never the public app mux.
|
||||
func Handler() http.Handler { return promhttp.Handler() }
|
||||
|
||||
// IncLogin records a successful login.
|
||||
func IncLogin() { logins.Inc() }
|
||||
|
||||
// ObserveSummarize records one summarization endpoint attempt.
|
||||
func ObserveSummarize(model, outcome string, fallback bool, d time.Duration) {
|
||||
summarizeDuration.WithLabelValues(model, outcome, strconv.FormatBool(fallback)).Observe(d.Seconds())
|
||||
}
|
||||
|
||||
// ObserveCaptionFetch records one caption fetch by outcome.
|
||||
func ObserveCaptionFetch(outcome string, d time.Duration) {
|
||||
captionFetchDuration.WithLabelValues(outcome).Observe(d.Seconds())
|
||||
}
|
||||
|
||||
// ObserveChat records one Q&A answer latency.
|
||||
func ObserveChat(model string, d time.Duration) {
|
||||
chatDuration.WithLabelValues(model).Observe(d.Seconds())
|
||||
}
|
||||
|
||||
// RecordTokens records LLM token usage from a completion's usage block. Zero
|
||||
// counts are skipped so a provider that omits usage adds nothing.
|
||||
func RecordTokens(model string, prompt, completion int) {
|
||||
if prompt > 0 {
|
||||
llmTokens.WithLabelValues(model, "prompt").Add(float64(prompt))
|
||||
}
|
||||
if completion > 0 {
|
||||
llmTokens.WithLabelValues(model, "completion").Add(float64(completion))
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPMiddleware records request count + latency. It reads r.Pattern AFTER the
|
||||
// inner handler routes (Go 1.22 sets it during ServeMux matching), so the label is
|
||||
// the bounded registered pattern (e.g. "GET /v/{videoId}"), never the raw path
|
||||
// with its high-cardinality ids. Unmatched requests bucket as "other".
|
||||
func HTTPMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
|
||||
route := r.Pattern
|
||||
if route == "" {
|
||||
route = "other"
|
||||
}
|
||||
httpRequests.WithLabelValues(r.Method, route, strconv.Itoa(sw.code)).Inc()
|
||||
httpDuration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds())
|
||||
})
|
||||
}
|
||||
|
||||
// statusWriter captures the response status for the request-count label.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (s *statusWriter) WriteHeader(code int) {
|
||||
if !s.wroteHeader {
|
||||
s.code = code
|
||||
s.wroteHeader = true
|
||||
}
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (s *statusWriter) Write(b []byte) (int, error) {
|
||||
s.wroteHeader = true // an implicit 200
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// histCount reads a histogram child's observation count (testutil.ToFloat64 only
|
||||
// works on counters/gauges; a histogram's WithLabelValues child is an Observer).
|
||||
func histCount(t *testing.T, o prometheus.Observer) uint64 {
|
||||
t.Helper()
|
||||
m, ok := o.(prometheus.Metric)
|
||||
require.True(t, ok, "histogram child must be a prometheus.Metric")
|
||||
var d dto.Metric
|
||||
require.NoError(t, m.Write(&d))
|
||||
return d.GetHistogram().GetSampleCount()
|
||||
}
|
||||
|
||||
// TestObserveSummarizeRecordsModelOutcomeFallback: a success observation lands on
|
||||
// the right model/outcome/fallback series.
|
||||
func TestObserveSummarizeRecordsModelOutcomeFallback(t *testing.T) {
|
||||
before := histCount(t, summarizeDuration.WithLabelValues("koala/phi4-mini", "success", "false"))
|
||||
ObserveSummarize("koala/phi4-mini", "success", false, 1200*time.Millisecond)
|
||||
after := histCount(t, summarizeDuration.WithLabelValues("koala/phi4-mini", "success", "false"))
|
||||
require.Equal(t, before+1, after, "one success observation recorded for the model")
|
||||
}
|
||||
|
||||
// TestObserveSummarizeRecordsFailureOutcomes: error and parse_error are distinct
|
||||
// series so a fallback chain's failures are visible.
|
||||
func TestObserveSummarizeRecordsFailureOutcomes(t *testing.T) {
|
||||
e0 := histCount(t, summarizeDuration.WithLabelValues("m", "error", "false"))
|
||||
p0 := histCount(t, summarizeDuration.WithLabelValues("m", "parse_error", "false"))
|
||||
ObserveSummarize("m", "error", false, time.Second)
|
||||
ObserveSummarize("m", "parse_error", false, time.Second)
|
||||
require.Equal(t, e0+1, histCount(t, summarizeDuration.WithLabelValues("m", "error", "false")))
|
||||
require.Equal(t, p0+1, histCount(t, summarizeDuration.WithLabelValues("m", "parse_error", "false")))
|
||||
}
|
||||
|
||||
func TestObserveCaptionFetchByOutcome(t *testing.T) {
|
||||
b := histCount(t, captionFetchDuration.WithLabelValues("captions"))
|
||||
ObserveCaptionFetch("captions", 3*time.Second)
|
||||
require.Equal(t, b+1, histCount(t, captionFetchDuration.WithLabelValues("captions")))
|
||||
}
|
||||
|
||||
func TestChatAnswerLatencyRecorded(t *testing.T) {
|
||||
b := histCount(t, chatDuration.WithLabelValues("iguana/gemma4-26b"))
|
||||
ObserveChat("iguana/gemma4-26b", 2*time.Second)
|
||||
require.Equal(t, b+1, histCount(t, chatDuration.WithLabelValues("iguana/gemma4-26b")))
|
||||
}
|
||||
|
||||
// TestRecordTokens: prompt + completion land on their kind series; zero is skipped.
|
||||
func TestRecordTokens(t *testing.T) {
|
||||
p0 := testutil.ToFloat64(llmTokens.WithLabelValues("m", "prompt"))
|
||||
c0 := testutil.ToFloat64(llmTokens.WithLabelValues("m", "completion"))
|
||||
RecordTokens("m", 100, 40)
|
||||
RecordTokens("m", 0, 0) // skipped, no panic
|
||||
require.Equal(t, p0+100, testutil.ToFloat64(llmTokens.WithLabelValues("m", "prompt")))
|
||||
require.Equal(t, c0+40, testutil.ToFloat64(llmTokens.WithLabelValues("m", "completion")))
|
||||
}
|
||||
|
||||
func TestLoginCounted(t *testing.T) {
|
||||
b := testutil.ToFloat64(logins)
|
||||
IncLogin()
|
||||
require.Equal(t, b+1, testutil.ToFloat64(logins))
|
||||
}
|
||||
|
||||
// TestHTTPMiddlewareRecordsByRoutePattern: the request is counted under the bounded
|
||||
// registered pattern (r.Pattern after routing), not the raw path with its ids.
|
||||
func TestHTTPMiddlewareRecordsByRoutePattern(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v/{videoId}", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
})
|
||||
h := HTTPMiddleware(mux)
|
||||
|
||||
before := testutil.ToFloat64(httpRequests.WithLabelValues("GET", "GET /v/{videoId}", "418"))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v/abc-123", nil))
|
||||
|
||||
require.Equal(t, http.StatusTeapot, rec.Code)
|
||||
after := testutil.ToFloat64(httpRequests.WithLabelValues("GET", "GET /v/{videoId}", "418"))
|
||||
require.Equal(t, before+1, after, "counted under the pattern, not /v/abc-123")
|
||||
require.Equal(t, float64(0), testutil.ToFloat64(httpRequests.WithLabelValues("GET", "/v/abc-123", "418")),
|
||||
"raw path must never be a label value")
|
||||
}
|
||||
Reference in New Issue
Block a user