From a5a8cf6f6d093ab126a9b1c7ece716c48b4193d3 Mon Sep 17 00:00:00 2001 From: Mathias Date: Fri, 12 Jun 2026 08:32:41 +0200 Subject: [PATCH] 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) --- go.mod | 12 ++- go.sum | 32 +++++-- internal/metrics/metrics.go | 139 +++++++++++++++++++++++++++++++ internal/metrics/metrics_test.go | 92 ++++++++++++++++++++ 4 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 internal/metrics/metrics.go create mode 100644 internal/metrics/metrics_test.go diff --git a/go.mod b/go.mod index a9fa266..3da295a 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 9993d10..be13148 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..aa62824 --- /dev/null +++ b/internal/metrics/metrics.go @@ -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) +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..b72fade --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -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") +}