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:
@@ -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