feat(metrics): instrument upstream latency

Add time-to-first-byte and total-duration histograms for every upstream
call, observation only. No throttling, priorities, or control logic
reads this data. The operator wants strain-based throttling later and
needs real traffic data first.

Extend watchdogBody (timeout.go) rather than add a new interception
layer: it already wraps every upstream response body for provider
adapters, native passthrough, and the MCP/A2A target proxy, and already
observes the first successful read. armLatency arms a watchdogBody
after construction, so newWatchdogBody keeps its existing signature and
behavior; its own pinned tests still pass unchanged. A timestamp plus a
few atomics are the only added cost; Read and Close each pay one nil
check when latency collection is disabled.

Expose llmgateway_upstream_ttfb_seconds and
llmgateway_upstream_duration_seconds on the existing Prometheus
endpoint (metrics.go). Both are in-process, per-replica histograms,
never store-backed, so sum() is always the correct cross-replica
aggregation. Buckets run 0.1s to 600s, chosen to resolve fast TTFB and
short completions finely, cover the bulk of real chat completions, and
bracket the five-minute default request timeout. Labels are provider
and stream always, model only when Config.Metrics.ModelLabel is set,
matching the existing per-model opt-in convention. Add a compact
per-provider, per-stream average summary to GET /admin/api/overview,
sourced from the same in-process store, without a full histogram or a
per-model breakdown.

Every write path is gated on metricsEnabled(cfg), so a deployment with
metrics disabled collects nothing and the histogram families never
render. No behavior change to request handling: same responses, status
codes, headers, and accounting.

Fix a Yaegi-only bug this feature's own context-threading change
exposed: two distinct empty struct types used as context.WithValue keys
compare equal to each other under the interpreter's interface equality,
though never under compiled Go. Threading a second recorder
(latencyRecorder) alongside the existing attemptRecorder made this
observable for the first time, since compiled tests never see it and
the plugin previously carried only one such key. Give
latencyRecorderCtxKey a field so its size differs from
attemptRecorderCtxKey's, confirmed against a minimal Yaegi reproduction
before and after the fix.

Fix a second bug, reproducible under compiled Go too: io.Reader may
legally return its final data together with io.EOF in one call, and
Go's real net/http body reader does this for small, fully-buffered
responses. The original TTFB capture lived inside the err == nil
branch, so it never fired for a typical small non-streaming completion.
Move the check ahead of the error branch so it fires on any n > 0,
leaving the pinned timer-reset and Close semantics untouched.

Tests mutate the real source per case and confirm each one fails first,
recorded in each test's own doc comment.
This commit is contained in:
2026-08-23 15:17:14 +01:00
parent 2a5b086020
commit 063a05e9a7
11 changed files with 1439 additions and 45 deletions
+93 -1
View File
@@ -249,6 +249,73 @@ func attemptRecorderFromContext(ctx context.Context) attemptRecorder {
return rec
}
// latencyRecorder is invoked exactly once per completed upstream body —
// from watchdogBody.Close (timeout.go), never per Read — with the
// latencySample (metrics.go) that body's own watchdog measured: time to
// first byte, total duration, and whether the response was a stream
// (feat: instrument upstream latency, observation-only per the task
// brief — no throttling or control logic reads this).
//
// Threaded via context exactly like attemptRecorder above, for the same
// reason: upstreamBytes/proxyUpstream have no other way to reach
// Gateway-level state (g.latency, metrics.go). A call site that never
// wires one via withLatencyRecorder gets nil back from
// latencyRecorderFromContext, so registry.go's discovery listModels
// calls and the MCP/A2A target proxy (handleTargetProxy, mcp_a2a.go —
// neither ever wraps its context this way, mirroring
// withAttemptRecorder's own identical exclusion) never observe latency
// either — this feature accounts the same traffic Feature A does, not a
// byte more.
type latencyRecorder func(sample latencySample)
// latencyRecorderCtxKey is the unexported context.Value key
// withLatencyRecorder/latencyRecorderFromContext share — its own type,
// never a string, so no other package's context.WithValue call can
// collide with it, mirroring attemptRecorderCtxKey above.
//
// Deliberately NOT a bare `struct{}` like attemptRecorderCtxKey (yaegi-
// check finding, this feature): under the REAL Yaegi interpreter, two
// DISTINCT zero-sized struct types used as `any`-typed context.WithValue
// keys compare EQUAL to each other via `==` — an interpreter-only
// divergence from compiled Go's own interface-equality semantics, where
// two values of different concrete types are never equal regardless of
// value. With attemptRecorderCtxKey ALSO a bare struct{}, a context
// carrying both (routes_unified.go's withAttemptRecorder then
// withLatencyRecorder) let ctx.Value(attemptRecorderCtxKey{}) wrongly
// match the LATER latencyRecorderCtxKey{} entry instead, silently
// returning nil once type-asserted to attemptRecorder — retryPolicy.do
// (retry.go) then never called rec(resp, err) at all, and
// limiter.recordProviderAttempt (Feature A) went permanently dark for
// every metered request, with no error anywhere: exerciseAttemptAccounting
// (tools/yaegi-check/main.go) is what caught it — a compiled `go test`
// run never sees this divergence, since compiled Go's interface equality
// is correct. The `_ byte` field costs nothing at runtime and makes this
// type's size differ from attemptRecorderCtxKey's, which is enough to
// keep the two apart under the interpreter too.
type latencyRecorderCtxKey struct{ _ byte }
// withLatencyRecorder returns a context carrying rec. A nil rec (the
// common case: metrics.go's own callers only ever pass a non-nil one
// when metricsEnabled(g.cfg) was true at the point of the call) returns
// ctx unchanged, so a caller may call this unconditionally, exactly
// mirroring withAttemptRecorder's own nil handling above.
func withLatencyRecorder(ctx context.Context, rec latencyRecorder) context.Context {
if rec == nil {
return ctx
}
return context.WithValue(ctx, latencyRecorderCtxKey{}, rec)
}
// latencyRecorderFromContext returns the latencyRecorder ctx carries via
// withLatencyRecorder, or nil when none was set — the common case for
// any call path this feature does not account, or for any deployment
// with metrics collection disabled (see latencyRecorder's own doc
// comment).
func latencyRecorderFromContext(ctx context.Context) latencyRecorder {
rec, _ := ctx.Value(latencyRecorderCtxKey{}).(latencyRecorder)
return rec
}
// upstreamJSON issues an HTTP request to url: body, when non-nil, is
// marshaled as the JSON request body; hdr's values are added to the
// request (a caller builds this from its adapter's auth and content-type
@@ -320,6 +387,15 @@ func upstreamBytes(ctx context.Context, client *http.Client, method, url string,
// provider-health accounting, since nothing else ever called rec
// again for that attempt.
rec := attemptRecorderFromContext(ctx)
// latRec: same one-lookup-outside-the-retry-loop shape as rec above,
// and the same reasoning applies — every attempt shares ctx, so a
// latencyRecorder wired via withLatencyRecorder (nil when metrics
// collection is disabled, latencyRecorder's own doc comment) is
// identical across attempts. A retried request still gets its own
// independent latency observation per attempt (below, start is
// captured fresh inside the closure), matching how watchdogBody
// itself is rebuilt fresh per attempt.
latRec := latencyRecorderFromContext(ctx)
call := func() (*http.Response, error) {
var r io.Reader
@@ -361,12 +437,28 @@ func upstreamBytes(ctx context.Context, client *http.Client, method, url string,
req.Header.Set("Content-Type", "application/json")
}
// start: captured just before the request is actually sent, so a
// non-nil latRec's eventual sample (armLatency below) measures from
// here — matching the task brief's own "from just before the
// upstream request is sent" definition, not from whatever moment
// newWatchdogBody happens to run at (which is already after
// headers arrived).
start := time.Now()
resp, err := client.Do(req) //nolint:bodyclose,gosec // caller closes resp.Body; upstreamBytes hands the response, not its lifecycle, back — same operator-configured URL as above
if err != nil {
cancel()
return nil, fmt.Errorf("%w: %w", errUpstream, err)
}
resp.Body = newWatchdogBody(resp.Body, cancel, timeout, fmt.Sprintf("provider %q", providerName), rec)
wb := newWatchdogBody(resp.Body, cancel, timeout, fmt.Sprintf("provider %q", providerName), rec)
if latRec != nil {
// streaming: derived from resp's own Content-Type, the SAME
// convention each adapter's forwardStream/forwardJSON split
// already applies (isEventStreamResponse, timeout.go) — decided
// here because it is already knowable, ahead of whichever of
// those two the caller picks moments from now.
wb.armLatency(start, isEventStreamResponse(resp), latRec)
}
resp.Body = wb
return resp, nil
}