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>
140 lines
5.1 KiB
Go
140 lines
5.1 KiB
Go
// 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)
|
|
}
|