mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
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:
@@ -286,9 +286,26 @@ type adminProviderView struct {
|
||||
// marshals as "{}" for a provider with no known models yet, not
|
||||
// omitted.
|
||||
ModelRates map[string]adminModelRateView `json:"modelRates"`
|
||||
Type string `json:"type"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
LastErr string `json:"lastErr,omitempty"`
|
||||
// Latency is a compact per-stream ("streaming"/"non-streaming")
|
||||
// average TTFB/duration summary for this provider (feat: instrument
|
||||
// upstream latency), sourced from the SAME in-process g.latency
|
||||
// accumulator /metrics reads (metrics.go). Averages only — never a
|
||||
// full histogram or a per-model breakdown — to keep this already-
|
||||
// ~113KB, 5s-polled response from growing meaningfully; see
|
||||
// buildAdminLatencyViews' own doc comment. nil (omitted) for a
|
||||
// provider with no observations yet, or for a deployment with metrics
|
||||
// collection gated off entirely. Grouped here with the other map
|
||||
// fields above, not declared near AttemptsDay/ModelCount below,
|
||||
// because it is a pointer-shaped field (a map header) and this
|
||||
// struct's fields are ordered pointer-shaped-first, then plain
|
||||
// scalars (int64/int/bool) last — fieldalignment (govet, enabled via
|
||||
// this repo's global golangci-lint config) flags a pointer field
|
||||
// declared after the scalar block as costing extra GC pointer-scan
|
||||
// bytes. See timeout.go's watchdogBody for the identical convention.
|
||||
Latency map[string]adminLatencyView `json:"latency,omitempty"`
|
||||
Type string `json:"type"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
LastErr string `json:"lastErr,omitempty"`
|
||||
// HealthState is this provider's discovery circuit breaker state
|
||||
// (feat/provider-health): "closed" (normal), "open" (backing off
|
||||
// after repeated discovery failures — maybeRefresh skips it until
|
||||
@@ -347,6 +364,59 @@ type adminModelRateView struct {
|
||||
FailuresDay int64 `json:"failuresDay"`
|
||||
}
|
||||
|
||||
// adminLatencyView is one provider's one stream-state's compact latency
|
||||
// summary (feat: instrument upstream latency) — adminProviderView.
|
||||
// Latency's value type. Plain averages (sum/count from the same
|
||||
// latencySnapshot /metrics reads, metrics.go), never a percentile: no
|
||||
// interpolation-correctness question to get wrong, and "compact" (the
|
||||
// task brief's own word for this admin surface) rules out shipping a
|
||||
// full bucket set here anyway — that detail belongs on /metrics.
|
||||
// AvgTTFBMs is 0 (omitted) when no successful read was ever observed
|
||||
// for this stream state (latencySnapshot.ttfbCount == 0) — never
|
||||
// fabricated from AvgDurationMs, which is a materially different
|
||||
// number for a genuine stream (see llmgateway_upstream_ttfb_seconds'
|
||||
// own HELP text, metrics.go, for why the two must never be conflated).
|
||||
type adminLatencyView struct {
|
||||
AvgTTFBMs float64 `json:"avgTtfbMs,omitempty"`
|
||||
AvgDurationMs float64 `json:"avgDurationMs,omitempty"`
|
||||
Count int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
// buildAdminLatencyViews groups snaps (g.latency.snapshot(), metrics.go)
|
||||
// by provider, discarding the opt-in model dimension entirely (task
|
||||
// brief: "compact... do not bloat" — GET /admin/api/overview already
|
||||
// sits around 113 KB and is polled every 5s; a full per-model latency
|
||||
// breakdown belongs on /metrics, not here). The outer map is keyed by
|
||||
// provider name, the inner by "streaming"/"non-streaming"; a provider
|
||||
// with no observations at all is simply absent from the outer map, so
|
||||
// adminOverviewResponse's own lookup (buildAdminOverview, below) yields
|
||||
// nil for it — adminProviderView.Latency's own documented "nil means no
|
||||
// observations yet".
|
||||
func buildAdminLatencyViews(snaps []latencySnapshot) map[string]map[string]adminLatencyView {
|
||||
out := make(map[string]map[string]adminLatencyView)
|
||||
for _, s := range snaps {
|
||||
if s.key.model != "" || s.durationCount == 0 {
|
||||
continue
|
||||
}
|
||||
streamKey := "non-streaming"
|
||||
if s.key.streaming {
|
||||
streamKey = "streaming"
|
||||
}
|
||||
v := adminLatencyView{
|
||||
AvgDurationMs: s.durationSum / float64(s.durationCount) * 1000,
|
||||
Count: s.durationCount,
|
||||
}
|
||||
if s.ttfbCount > 0 {
|
||||
v.AvgTTFBMs = s.ttfbSum / float64(s.ttfbCount) * 1000
|
||||
}
|
||||
if out[s.key.provider] == nil {
|
||||
out[s.key.provider] = make(map[string]adminLatencyView)
|
||||
}
|
||||
out[s.key.provider][streamKey] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adminModelMetaView is one upstream model's resolved metadata (feature
|
||||
// v0.23: context window, per-token cost) within its provider —
|
||||
// adminProviderView.ModelMeta's value type. Every field is a pointer,
|
||||
@@ -485,6 +555,11 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
||||
providerLevelCounters := allCounters[:len(snaps)]
|
||||
modelCounters := allCounters[len(snaps):]
|
||||
|
||||
// feat: instrument upstream latency — one g.latency.snapshot() call
|
||||
// for the whole response, mirroring providerUsage's own one-batched-
|
||||
// read-for-every-provider shape above, not a per-provider read.
|
||||
latencyViews := buildAdminLatencyViews(g.latency.snapshot())
|
||||
|
||||
providers := make([]adminProviderView, len(snaps))
|
||||
mi := 0
|
||||
for i, s := range snaps {
|
||||
@@ -517,6 +592,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
|
||||
FailuresMinute: pc.failuresMinute,
|
||||
ModelRates: modelRates,
|
||||
ModelMeta: modelMeta,
|
||||
Latency: latencyViews[s.name],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user