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