// 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) }