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>
93 lines
4.0 KiB
Go
93 lines
4.0 KiB
Go
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")
|
|
}
|