CI / CI (pull_request) Successful in 27m12s
NewLLMMetricsCapture/ResponseWriterWrapper had zero call sites, so llm_ttft_seconds, llm_itl_seconds and llm_tokens_total were exported as HELP/TYPE stubs with no samples. These are the only per-request LLM latency series covering the Ollama models (ornith:35b, qwen2.5:3b-instruct), which expose no /metrics of their own. Wire the wrapper around the response writer in ServeHTTP for model-routed requests, and delete the unused io.WriteCloser duplicate that made the gap hard to spot (two near-identical implementations, neither constructed). Correctness details: - Only SSE responses are measured. A non-streaming completion is written in one Write, which would otherwise be recorded as a TTFT sample equal to the whole request latency with no ITL samples. - requestStart is captured at the top of ServeHTTP so TTFT measures from request acceptance, not from when the upstream response is copied. - Token count skips the `data: [DONE]` terminator. - Unwrap() keeps http.ResponseController (Flush/Hijack/deadlines) working through the wrapper, so SSE unbuffering (issue #33) is unaffected. Tests: unit coverage for SSE vs non-streaming, wrapper transparency, and an end-to-end handler test that fails if the wrapper is ever wired nowhere again.
172 lines
5.7 KiB
Go
172 lines
5.7 KiB
Go
package proxy
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
|
)
|
|
|
|
// exportMetrics renders the Prometheus text exposition for m.
|
|
func exportMetrics(m *observability.Metrics) string {
|
|
return observability.NewPrometheusExporter(m).Export()
|
|
}
|
|
|
|
// requireSeries asserts the exporter emitted a sample line with the exact value.
|
|
func requireSeries(t *testing.T, out, want string) {
|
|
t.Helper()
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.TrimSpace(line) == want {
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("missing metrics series %q\n--- got ---\n%s", want, out)
|
|
}
|
|
|
|
// TestResponseWriterWrapperRecordsSSEMetrics guards the regression where the
|
|
// LLM latency wrapper existed but was never constructed, leaving
|
|
// llm_ttft_seconds / llm_itl_seconds / llm_tokens_total as HELP-only stubs.
|
|
func TestResponseWriterWrapperRecordsSSEMetrics(t *testing.T) {
|
|
m := observability.NewMetrics()
|
|
|
|
rec := httptest.NewRecorder()
|
|
rec.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now().Add(-50*time.Millisecond))
|
|
|
|
if _, err := w.Write([]byte("data: {\"token\":\"a\"}\n\n")); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
if _, err := w.Write([]byte("data: {\"token\":\"b\"}\n\n")); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
// The terminator is not a generated token.
|
|
if _, err := w.Write([]byte("data: [DONE]\n\n")); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
out := exportMetrics(m)
|
|
requireSeries(t, out, `llm_ttft_seconds_count{model="reasoning"} 1`)
|
|
requireSeries(t, out, `llm_itl_seconds_count{model="reasoning"} 1`)
|
|
requireSeries(t, out, `llm_tokens_total{model="reasoning"} 2`)
|
|
}
|
|
|
|
// TestResponseWriterWrapperIgnoresNonStreaming verifies a single-shot JSON
|
|
// response does not produce a bogus TTFT sample equal to full request latency.
|
|
func TestResponseWriterWrapperIgnoresNonStreaming(t *testing.T) {
|
|
m := observability.NewMetrics()
|
|
|
|
rec := httptest.NewRecorder()
|
|
rec.Header().Set("Content-Type", "application/json")
|
|
|
|
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now().Add(-50*time.Millisecond))
|
|
if _, err := w.Write([]byte(`{"choices":[]}`)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
out := exportMetrics(m)
|
|
if strings.Contains(out, `llm_ttft_seconds_count{model="reasoning"}`) {
|
|
t.Errorf("non-streaming response recorded TTFT\n--- got ---\n%s", out)
|
|
}
|
|
if strings.Contains(out, `llm_tokens_total{model="reasoning"}`) {
|
|
t.Errorf("non-streaming response recorded tokens\n--- got ---\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestResponseWriterWrapperPreservesStreaming verifies the wrapper stays
|
|
// transparent: bytes reach the client and Flush still works, so wrapping the
|
|
// writer cannot silently break SSE (issue #33).
|
|
func TestResponseWriterWrapperPreservesStreaming(t *testing.T) {
|
|
m := observability.NewMetrics()
|
|
|
|
rec := httptest.NewRecorder()
|
|
rec.Header().Set("Content-Type", "text/event-stream")
|
|
|
|
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now())
|
|
payload := "data: {\"token\":\"a\"}\n\n"
|
|
if _, err := w.Write([]byte(payload)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
w.Flush()
|
|
|
|
if got := rec.Body.String(); got != payload {
|
|
t.Errorf("body = %q, want %q", got, payload)
|
|
}
|
|
if !rec.Flushed {
|
|
t.Error("Flush did not reach the underlying writer")
|
|
}
|
|
}
|
|
|
|
// TestHandlerRecordsLLMMetricsForStreaming is the end-to-end guard: it drives a
|
|
// real streaming request through the Handler, so it fails if the wrapper is ever
|
|
// constructed nowhere (the original defect) rather than merely being broken.
|
|
func TestHandlerRecordsLLMMetricsForStreaming(t *testing.T) {
|
|
// Unique model name keeps the observations isolated in the global registry.
|
|
model := fmt.Sprintf("metrics-test-%d", time.Now().UnixNano())
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
rc := http.NewResponseController(w)
|
|
for _, tok := range []string{"a", "b", "c"} {
|
|
time.Sleep(2 * time.Millisecond) // guarantee distinct ITL samples
|
|
fmt.Fprintf(w, "data: {\"token\":%q}\n\n", tok)
|
|
if err := rc.Flush(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
|
_ = rc.Flush()
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
cfg := &config.Config{
|
|
Models: map[string]*config.ModelUpstream{
|
|
model: {
|
|
Name: model,
|
|
Address: strings.TrimPrefix(upstream.URL, "http://"),
|
|
Path: "/v1/chat/completions",
|
|
},
|
|
},
|
|
Routes: make(map[string]*config.Route),
|
|
}
|
|
|
|
handler := New(cfg)
|
|
defer handler.Close()
|
|
srv := httptest.NewServer(handler)
|
|
defer srv.Close()
|
|
|
|
body := fmt.Sprintf(`{"model":%q,"stream":true,"messages":[{"role":"user","content":"hi"}]}`, model)
|
|
resp, err := http.Post(srv.URL+"/v1/chat/completions", "application/json", strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if _, err := io.ReadAll(resp.Body); err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
|
|
out := exportMetrics(observability.Global())
|
|
requireSeries(t, out, fmt.Sprintf(`llm_ttft_seconds_count{model=%q} 1`, model))
|
|
requireSeries(t, out, fmt.Sprintf(`llm_tokens_total{model=%q} 3`, model))
|
|
|
|
itlCount := regexp.MustCompile(fmt.Sprintf(`llm_itl_seconds_count\{model=%q\} (\d+)`, model))
|
|
match := itlCount.FindStringSubmatch(out)
|
|
if match == nil {
|
|
t.Errorf("no llm_itl_seconds samples recorded for %s\n--- got ---\n%s", model, out)
|
|
} else if match[1] == "0" {
|
|
t.Errorf("llm_itl_seconds_count = 0, want > 0")
|
|
}
|
|
}
|