mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-11 04:47:00 +00:00
Counters (always on when admin is enabled): - last seen per user and group, written asynchronously after admission, throttled to one write per scope per minute per replica - hourly provider and model attempts, failures and timeouts - failovers per provider, rejections per hour, and unpriced (402) refusals - cache hits, misses and cost saved - request counts per target and caller Opt-in counters under admin.stats (both default off): - userModel: usage per user and model - latency: latency histograms per provider and model All counters ride the existing Redis batches. A test measures three round trips per request with defaults and with every statistic on. A cache hit takes two. A model request is no longer counted for failed failover candidates. New admin endpoints: - usage/series and usage/totals - performance, with percentiles from the histograms - catalog, with the price billing actually uses and its source - consumers, with key source, grants and model access - config, with secrets redacted The overview gains features and a pricing summary. Usage gains lastSeen. usage/models gains offset and more detail fields. Reads are chunked at 1600 keys per round trip and capped at 64000 keys per request. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
1131 lines
39 KiB
Go
1131 lines
39 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// adminUsageSeriesPath, adminUsageTotalsPath and adminPerformancePath are
|
|
// GET /admin/api/usage/series, GET /admin/api/usage/totals and GET
|
|
// /admin/api/performance's routes (admin dashboard redesign, WP-B) —
|
|
// defined here, alongside the rest of these features' own constants,
|
|
// mirroring adminEventsPath's own precedent (events.go).
|
|
const (
|
|
adminUsageSeriesPath = "/admin/api/usage/series"
|
|
adminUsageTotalsPath = "/admin/api/usage/totals"
|
|
adminPerformancePath = "/admin/api/performance"
|
|
)
|
|
|
|
// adminMaxKeysPerRequest is the hard cap on how many counterStore keys any
|
|
// single stats_read.go endpoint may read in one request (DECISIONS Q8): a
|
|
// caller whose scope/span/offset/metric combination would read more than
|
|
// this many keys gets 400 "range too large; narrow span or filter"
|
|
// instead of an unbounded pipeline against the shared store.
|
|
const adminMaxKeysPerRequest = 64000
|
|
|
|
// adminSeriesMaxScopes bounds GET /admin/api/usage/series' repeated
|
|
// "scope" query parameter (plan §1.3(iv): "1..100").
|
|
const adminSeriesMaxScopes = 100
|
|
|
|
// parseStatsOffset parses an optional "offset" query parameter shared by
|
|
// GET /admin/api/usage/models (admin.go), .../usage/series, .../usage/
|
|
// totals and .../performance: empty means 0 (the current, most-recent
|
|
// span — every endpoint's own pre-offset behavior), otherwise an integer
|
|
// between 0 and window's historyMaxSpan minus the already-validated span,
|
|
// inclusive — span+offset never exceeds what that window's TTL could ever
|
|
// have kept alive (historyMaxSpan's own doc comment, admin.go). ok is
|
|
// false for anything else.
|
|
func parseStatsOffset(raw, window string, span int) (offset int, ok bool) {
|
|
if raw == "" {
|
|
return 0, true
|
|
}
|
|
max := historyMaxSpan(window) - span
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 0 || n > max {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
// readSpanTotals reads, for every id in ids (all sharing kind), each
|
|
// metric in metrics summed over span buckets ending offset window-steps
|
|
// back from now, keyed by id then metric name. P6/P12 fix (admin
|
|
// dashboard redesign verify round): this used to build its own single
|
|
// unbounded storeGetMulti pipeline for the whole batch; it now delegates
|
|
// to limits.go's spanTotalsMulti — the ONE production span-sum reader
|
|
// admin.go's chunkedModelSpanTotals/chunkedModelSpanTotalsMulti also
|
|
// call — which chunks every read at usageModelsChunkKeys keys per round
|
|
// trip. ok is false on any store read failure or a fail-open outage in
|
|
// progress (configuredStoreDown), exactly like every other admin stats
|
|
// reader in this package — spanTotalsMulti itself already applies that
|
|
// check per chunk.
|
|
func (g *Gateway) readSpanTotals(kind string, ids, metrics []string, window string, now time.Time, span, offset int) (map[string]map[string]int64, bool) {
|
|
out := make(map[string]map[string]int64, len(ids))
|
|
if len(ids) == 0 || len(metrics) == 0 {
|
|
return out, true
|
|
}
|
|
totalsByMetric, ok := g.limiter.spanTotalsMulti(kind, ids, metrics, window, now, span, offset)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
for i, id := range ids {
|
|
m := make(map[string]int64, len(metrics))
|
|
for mi, metric := range metrics {
|
|
m[metric] = totalsByMetric[mi][i]
|
|
}
|
|
out[id] = m
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// GET /admin/api/usage/series
|
|
// ---------------------------------------------------------------------
|
|
|
|
// adminSeriesView is one requested scope's bucketed series in GET
|
|
// /admin/api/usage/series.
|
|
type adminSeriesView struct {
|
|
Scope string `json:"scope"`
|
|
Points []int64 `json:"points"`
|
|
}
|
|
|
|
// adminSeriesResponse is the full body of GET /admin/api/usage/series.
|
|
type adminSeriesResponse struct {
|
|
Metric string `json:"metric"`
|
|
Window string `json:"window"`
|
|
Buckets []string `json:"buckets"` // oldest first
|
|
Series []adminSeriesView `json:"series"`
|
|
Unknown []string `json:"unknown"` // never nil
|
|
Span int `json:"span"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
// parseSeriesScope parses one GET /admin/api/usage/series "scope" value:
|
|
// "total", "user:{id}", "group:{id}", "model:{canonical}",
|
|
// "provider:{name}" or "provmodel:{canonical}". ok is false for anything
|
|
// else.
|
|
func parseSeriesScope(raw string) (kind, id string, ok bool) {
|
|
if raw == totalScopeKind {
|
|
return totalScopeKind, totalScopeID, true
|
|
}
|
|
prefix, rest, found := strings.Cut(raw, ":")
|
|
if !found || rest == "" {
|
|
return "", "", false
|
|
}
|
|
switch prefix {
|
|
case "user", "group":
|
|
return prefix, rest, true
|
|
case "model":
|
|
return kindModel, rest, true
|
|
case "provider":
|
|
return kindProvider, rest, true
|
|
case "provmodel":
|
|
return kindProviderModel, rest, true
|
|
default:
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
// seriesScopeExists extends scopeExists (admin.go) to also validate a
|
|
// kindProvider or kindProviderModel scope against the live registry —
|
|
// the two additional scope kinds GET /admin/api/usage/series accepts
|
|
// that GET /admin/api/usage/history (scopeExists' only other caller)
|
|
// never did.
|
|
func (g *Gateway) seriesScopeExists(kind, id string) bool {
|
|
switch kind {
|
|
case kindProvider:
|
|
for _, s := range g.registry.snapshot() {
|
|
if s.name == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case kindProviderModel:
|
|
return g.scopeExists(kindModel, id)
|
|
default:
|
|
return g.scopeExists(kind, id)
|
|
}
|
|
}
|
|
|
|
// seriesValidMetric reports whether metric is a valid reading for
|
|
// scopeKind at window — plan §1.3(iv)'s metric x scope-kind matrix.
|
|
func seriesValidMetric(scopeKind, metric, window string) bool {
|
|
hourOrDay := window == windowHour || window == windowDay
|
|
switch scopeKind {
|
|
case "user", "group", totalScopeKind:
|
|
switch metric {
|
|
case metricReq, metricTokIn, metricTokOut, metricCost:
|
|
return true
|
|
case metricRej:
|
|
return hourOrDay
|
|
}
|
|
if scopeKind == totalScopeKind {
|
|
switch metric {
|
|
case metricChit, metricCmiss, metricCsave, metricR402:
|
|
return hourOrDay
|
|
}
|
|
}
|
|
return false
|
|
case kindModel:
|
|
switch metric {
|
|
case metricReq, metricTokIn, metricTokOut, metricCost:
|
|
return true
|
|
case metricChit, metricCsave, metricR402:
|
|
return window == windowDay
|
|
}
|
|
return false
|
|
case kindProvider:
|
|
switch metric {
|
|
case metricProvAttempt, metricProvFail, metricProvTimeout, metricFover:
|
|
return hourOrDay
|
|
}
|
|
return false
|
|
case kindProviderModel:
|
|
switch metric {
|
|
case metricProvAttempt, metricProvFail:
|
|
return hourOrDay
|
|
}
|
|
return false
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// serveAdminUsageSeries writes GET /admin/api/usage/series: one or more
|
|
// scopes' bucketed history for a single metric/window/span/offset, in one
|
|
// batched store read. Every parameter is validated before the store is
|
|
// touched (400 for anything malformed or out of range); an unknown scope
|
|
// id is skipped and listed in Unknown rather than failing the whole
|
|
// request, mirroring the free-models ranking's own "skip, don't fail"
|
|
// convention for an individual bad element within an otherwise valid
|
|
// request.
|
|
func (g *Gateway) serveAdminUsageSeries(sw *statusTrackingWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
rawScopes := q["scope"]
|
|
if len(rawScopes) == 0 {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "at least one scope is required")
|
|
return
|
|
}
|
|
if len(rawScopes) > adminSeriesMaxScopes {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "too many scopes")
|
|
return
|
|
}
|
|
|
|
window := q.Get("window")
|
|
if !validHistoryWindow(window) {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window")
|
|
return
|
|
}
|
|
metric := q.Get("metric")
|
|
if !validHistoryMetric(metric) && !isKnownStatsMetric(metric) {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown metric")
|
|
return
|
|
}
|
|
span, ok := parseHistorySpan(q.Get("span"), window)
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span")
|
|
return
|
|
}
|
|
offset, ok := parseStatsOffset(q.Get("offset"), window, span)
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset")
|
|
return
|
|
}
|
|
|
|
type parsedScope struct {
|
|
raw, kind, id string
|
|
}
|
|
parsed := make([]parsedScope, 0, len(rawScopes))
|
|
unknown := []string{}
|
|
for _, raw := range rawScopes {
|
|
kind, id, ok := parseSeriesScope(raw)
|
|
if !ok {
|
|
unknown = append(unknown, raw)
|
|
continue
|
|
}
|
|
if !seriesValidMetric(kind, metric, window) {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown metric for scope kind")
|
|
return
|
|
}
|
|
if kind != totalScopeKind && !g.seriesScopeExists(kind, id) {
|
|
unknown = append(unknown, raw)
|
|
continue
|
|
}
|
|
parsed = append(parsed, parsedScope{raw: raw, kind: kind, id: id})
|
|
}
|
|
|
|
if len(parsed)*span > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
|
|
now := g.limiter.now()
|
|
var buckets []string
|
|
series := make([]adminSeriesView, 0, len(parsed))
|
|
allKeys := make([]string, 0, len(parsed)*span)
|
|
for _, p := range parsed {
|
|
keys, bkts := historyBucketKeysAt(p.kind, p.id, metric, window, now, span, offset)
|
|
if buckets == nil {
|
|
buckets = bkts
|
|
}
|
|
allKeys = append(allKeys, keys...)
|
|
}
|
|
|
|
// P6 fix (admin dashboard redesign verify round): chunked at
|
|
// usageModelsChunkKeys keys per round trip (chunkedGetMulti, limits.go)
|
|
// instead of one unbounded pipeline covering every requested scope's
|
|
// whole span at once — a 100-scope request at span=48 used to build a
|
|
// single 4,800-key pipeline in one call.
|
|
vals, storeOK := g.limiter.chunkedGetMulti(allKeys, usageModelsChunkKeys)
|
|
if !storeOK || len(vals) != len(allKeys) {
|
|
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
|
|
return
|
|
}
|
|
for i, p := range parsed {
|
|
points := append([]int64(nil), vals[i*span:i*span+span]...)
|
|
series = append(series, adminSeriesView{Scope: p.raw, Points: points})
|
|
}
|
|
if buckets == nil {
|
|
buckets = []string{}
|
|
}
|
|
|
|
setAdminJSONHeaders(sw)
|
|
_ = json.NewEncoder(sw).Encode(adminSeriesResponse{
|
|
Metric: metric, Window: window, Span: span, Offset: offset,
|
|
Buckets: buckets, Series: series, Unknown: unknown,
|
|
})
|
|
}
|
|
|
|
// isKnownStatsMetric reports whether metric is one of the admin-redesign
|
|
// counter families' metric names (fover/timeout/r402/chit/cmiss/csave/
|
|
// attempt/fail) — validHistoryMetric (admin.go) only ever knew the four
|
|
// original req/tokin/tokout/cost metrics, so GET /admin/api/usage/series
|
|
// and GET /admin/api/usage/totals — the two endpoints that also read
|
|
// these newer families — check both.
|
|
func isKnownStatsMetric(metric string) bool {
|
|
switch metric {
|
|
case metricRej, metricProvAttempt, metricProvFail, metricProvTimeout, metricFover,
|
|
metricR402, metricChit, metricCmiss, metricCsave:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// GET /admin/api/usage/totals
|
|
// ---------------------------------------------------------------------
|
|
|
|
// adminTotalsRow is one entity's metric values in GET
|
|
// /admin/api/usage/totals. ID's shape depends on Kind: a plain name for
|
|
// user/group, a canonical "provider/model" id for usermodel, a user name
|
|
// for modeluser, or "mcp/{target}/{caller}"/"agent/{target}/{caller}"
|
|
// for targetcaller (targetCallerScopeID's own format, limits.go).
|
|
type adminTotalsRow struct {
|
|
Values map[string]int64 `json:"values"`
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
// adminTotalsResponse is the full body of GET /admin/api/usage/totals.
|
|
type adminTotalsResponse struct {
|
|
Kind string `json:"kind"`
|
|
Window string `json:"window"`
|
|
Metrics []string `json:"metrics"`
|
|
Rows []adminTotalsRow `json:"rows"`
|
|
Span int `json:"span"`
|
|
Offset int `json:"offset"`
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
}
|
|
|
|
const (
|
|
adminTotalsDefaultLimit = 200
|
|
adminTotalsMaxLimit = 1000
|
|
)
|
|
|
|
// totalsKindMetrics enumerates every metric allowed for kind, in a fixed
|
|
// order — the default `metrics` list when a request omits that query
|
|
// parameter, and the set a caller's own comma list is validated against
|
|
// (plan §1.3(v): "usermodel/modeluser/targetcaller: day/month only;
|
|
// req/tokin/tokout/cost (targetcaller req only)").
|
|
func totalsKindMetrics(kind string) []string {
|
|
switch kind {
|
|
case "user", "group", "usermodel", "modeluser":
|
|
return []string{metricReq, metricTokIn, metricTokOut, metricCost}
|
|
case "targetcaller":
|
|
return []string{metricReq}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// totalsValidWindow reports whether window is valid for kind: usermodel/
|
|
// modeluser/targetcaller accept only day/month (plan §1.3(v)); user/group
|
|
// accept any of hour/day/month (validHistoryWindow, admin.go).
|
|
func totalsValidWindow(kind, window string) bool {
|
|
switch kind {
|
|
case "usermodel", "modeluser", "targetcaller":
|
|
return window == windowDay || window == windowMonth
|
|
default:
|
|
return validHistoryWindow(window)
|
|
}
|
|
}
|
|
|
|
// parseTotalsMetrics parses GET /admin/api/usage/totals' optional
|
|
// "metrics" query parameter: empty means every metric totalsKindMetrics
|
|
// allows for kind, in that fixed order; otherwise a comma-separated list,
|
|
// each entry one of those allowed metrics, no duplicates. ok is false for
|
|
// an unknown metric, a duplicate, or an empty list.
|
|
func parseTotalsMetrics(raw, kind string) (metrics []string, ok bool) {
|
|
allowed := totalsKindMetrics(kind)
|
|
if raw == "" {
|
|
return allowed, true
|
|
}
|
|
allowedSet := make(map[string]bool, len(allowed))
|
|
for _, m := range allowed {
|
|
allowedSet[m] = true
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
seen := make(map[string]bool, len(parts))
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if !allowedSet[p] || seen[p] {
|
|
return nil, false
|
|
}
|
|
seen[p] = true
|
|
out = append(out, p)
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// parseTotalsLimit parses GET /admin/api/usage/totals' optional "limit"
|
|
// query parameter: empty means adminTotalsDefaultLimit, otherwise an
|
|
// integer between 1 and adminTotalsMaxLimit inclusive.
|
|
func parseTotalsLimit(raw string) (limit int, ok bool) {
|
|
if raw == "" {
|
|
return adminTotalsDefaultLimit, true
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 1 || n > adminTotalsMaxLimit {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
// parseTargetRef parses a "target" query value ("mcp/{name}" or
|
|
// "agent/{name}") into its kind ("mcp"|"agent") and name.
|
|
func parseTargetRef(raw string) (kind, name string, ok bool) {
|
|
k, n, found := strings.Cut(raw, "/")
|
|
if !found || n == "" {
|
|
return "", "", false
|
|
}
|
|
if k != "mcp" && k != "agent" {
|
|
return "", "", false
|
|
}
|
|
return k, n, true
|
|
}
|
|
|
|
// stringSliceContains reports whether s contains v.
|
|
func stringSliceContains(s []string, v string) bool {
|
|
for _, x := range s {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// totalsCandidate is one row-to-be, before the drop-all-zero/sort/limit
|
|
// pass buildTotalsRows applies.
|
|
type totalsCandidate struct {
|
|
values map[string]int64
|
|
id string
|
|
}
|
|
|
|
// buildTotalsRows drops every candidate whose every requested metric is
|
|
// zero, sorts the rest by metrics[0] descending then id ascending (plan
|
|
// §1.3(v): "Drop all-zero rows; sort by first metric desc then id"), and
|
|
// truncates to limit, reporting whether it did.
|
|
func buildTotalsRows(candidates []totalsCandidate, metrics []string, limit int) ([]adminTotalsRow, bool) {
|
|
rows := make([]adminTotalsRow, 0, len(candidates))
|
|
for _, c := range candidates {
|
|
allZero := true
|
|
for _, m := range metrics {
|
|
if c.values[m] != 0 {
|
|
allZero = false
|
|
break
|
|
}
|
|
}
|
|
if allZero {
|
|
continue
|
|
}
|
|
rows = append(rows, adminTotalsRow{ID: c.id, Values: c.values})
|
|
}
|
|
first := metrics[0]
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
vi, vj := rows[i].Values[first], rows[j].Values[first]
|
|
if vi != vj {
|
|
return vi > vj
|
|
}
|
|
return rows[i].ID < rows[j].ID
|
|
})
|
|
truncated := len(rows) > limit
|
|
if truncated {
|
|
rows = rows[:limit]
|
|
}
|
|
return rows, truncated
|
|
}
|
|
|
|
// serveAdminUsageTotals writes GET /admin/api/usage/totals: ranked
|
|
// per-entity metric totals for one of five kinds (plan §1.3(v)).
|
|
// usermodel/modeluser additionally 404 when admin.stats.userModel is off
|
|
// (parity with GET /admin/api/usage/models' own detail extras).
|
|
func (g *Gateway) serveAdminUsageTotals(sw *statusTrackingWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
kind := q.Get("kind")
|
|
switch kind {
|
|
case "user", "group", "usermodel", "modeluser", "targetcaller":
|
|
default:
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown kind")
|
|
return
|
|
}
|
|
|
|
window := q.Get("window")
|
|
if window == "" {
|
|
window = windowDay
|
|
}
|
|
if !totalsValidWindow(kind, window) {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window")
|
|
return
|
|
}
|
|
span, ok := parseUsageModelsSpan(q.Get("span"), window)
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span")
|
|
return
|
|
}
|
|
offset, ok := parseStatsOffset(q.Get("offset"), window, span)
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset")
|
|
return
|
|
}
|
|
metrics, ok := parseTotalsMetrics(q.Get("metrics"), kind)
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid metrics")
|
|
return
|
|
}
|
|
limit, ok := parseTotalsLimit(q.Get("limit"))
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid limit")
|
|
return
|
|
}
|
|
|
|
userParam := q.Get("user")
|
|
if kind == "usermodel" && userParam == "" {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "user is required for kind=usermodel")
|
|
return
|
|
}
|
|
modelParam := q.Get("model")
|
|
if kind == "modeluser" && modelParam == "" {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "model is required for kind=modeluser")
|
|
return
|
|
}
|
|
var targetKind, targetName string
|
|
if raw := q.Get("target"); kind == "targetcaller" && raw != "" {
|
|
targetKind, targetName, ok = parseTargetRef(raw)
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid target")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Feature gate checked LAST, after every parameter is already known
|
|
// valid (plan §1.3's intro paragraph: "validate params first... 404
|
|
// ...statistics are not enabled" — a malformed request must always
|
|
// answer 400 for the thing that is actually wrong with it, never a
|
|
// 404 that reads as "this whole kind doesn't exist" when the real
|
|
// problem is a missing/invalid parameter the caller can fix on its
|
|
// own regardless of whether the feature is enabled).
|
|
if (kind == "usermodel" || kind == "modeluser") && !g.buildAdminFeatures().UserModelStats {
|
|
writeOAIError(sw, http.StatusNotFound, "invalid_request_error", "per-user-model statistics are not enabled (admin.stats.userModel)")
|
|
return
|
|
}
|
|
|
|
now := g.limiter.now()
|
|
userSummaries, groupSummaries := g.auth.snapshot()
|
|
|
|
var candidates []totalsCandidate
|
|
var storeOK bool
|
|
|
|
switch kind {
|
|
case "user":
|
|
ids := make([]string, 0, len(userSummaries))
|
|
for _, us := range userSummaries {
|
|
if grp := q.Get("group"); grp != "" && !stringSliceContains(us.groups, grp) {
|
|
continue
|
|
}
|
|
ids = append(ids, us.name)
|
|
}
|
|
if len(ids)*len(metrics)*span > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
var raw map[string]map[string]int64
|
|
raw, storeOK = g.readSpanTotals("user", ids, metrics, window, now, span, offset)
|
|
candidates = make([]totalsCandidate, len(ids))
|
|
for i, id := range ids {
|
|
candidates[i] = totalsCandidate{id: id, values: raw[id]}
|
|
}
|
|
|
|
case "group":
|
|
ids := make([]string, len(groupSummaries))
|
|
for i, gs := range groupSummaries {
|
|
ids[i] = gs.name
|
|
}
|
|
if len(ids)*len(metrics)*span > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
var raw map[string]map[string]int64
|
|
raw, storeOK = g.readSpanTotals("group", ids, metrics, window, now, span, offset)
|
|
candidates = make([]totalsCandidate, len(ids))
|
|
for i, id := range ids {
|
|
candidates[i] = totalsCandidate{id: id, values: raw[id]}
|
|
}
|
|
|
|
case "usermodel":
|
|
// Cheap phase (plan §1.3(v)): narrow the full catalog down to
|
|
// models with ANY recent usage at all, via a single-metric read
|
|
// over covering month buckets, before ever touching a per-user
|
|
// umodel counter — the catalog can run to hundreds of models,
|
|
// while a real user typically used only a handful of them.
|
|
modelIDs := g.catalogModelScopeIDs()
|
|
coverSpan := historyMaxSpan(windowMonth)
|
|
if len(modelIDs)*coverSpan > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
cheap, cheapOK := g.readSpanTotals(kindModel, modelIDs, []string{metricReq}, windowMonth, now, coverSpan, 0)
|
|
if !cheapOK {
|
|
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
|
|
return
|
|
}
|
|
var nonzero []string
|
|
for _, id := range modelIDs {
|
|
if cheap[id][metricReq] != 0 {
|
|
nonzero = append(nonzero, id)
|
|
}
|
|
}
|
|
umodelIDs := make([]string, len(nonzero))
|
|
for i, canonical := range nonzero {
|
|
umodelIDs[i] = userModelScopeID(userParam, canonical)
|
|
}
|
|
if len(umodelIDs)*len(metrics)*span > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
var raw map[string]map[string]int64
|
|
raw, storeOK = g.readSpanTotals(kindUserModel, umodelIDs, metrics, window, now, span, offset)
|
|
candidates = make([]totalsCandidate, len(nonzero))
|
|
for i, canonical := range nonzero {
|
|
candidates[i] = totalsCandidate{id: canonical, values: raw[umodelIDs[i]]}
|
|
}
|
|
|
|
case "modeluser":
|
|
umodelIDs := make([]string, len(userSummaries))
|
|
for i, us := range userSummaries {
|
|
umodelIDs[i] = userModelScopeID(us.name, modelParam)
|
|
}
|
|
if len(umodelIDs)*len(metrics)*span > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
var raw map[string]map[string]int64
|
|
raw, storeOK = g.readSpanTotals(kindUserModel, umodelIDs, metrics, window, now, span, offset)
|
|
candidates = make([]totalsCandidate, len(userSummaries))
|
|
for i, us := range userSummaries {
|
|
candidates[i] = totalsCandidate{id: us.name, values: raw[umodelIDs[i]]}
|
|
}
|
|
|
|
case "targetcaller":
|
|
type targetRef struct{ kind, name string }
|
|
var targets []targetRef
|
|
if targetName != "" {
|
|
targets = []targetRef{{targetKind, targetName}}
|
|
} else {
|
|
for _, name := range sortedMCPServerNames(g.cfg.MCPServers) {
|
|
targets = append(targets, targetRef{"mcp", name})
|
|
}
|
|
for _, name := range sortedAgentNames(g.cfg.Agents) {
|
|
targets = append(targets, targetRef{"agent", name})
|
|
}
|
|
}
|
|
ids := make([]string, 0, len(targets)*len(userSummaries))
|
|
for _, t := range targets {
|
|
for _, us := range userSummaries {
|
|
ids = append(ids, targetCallerScopeID(t.kind, t.name, us.name))
|
|
}
|
|
}
|
|
if len(ids)*len(metrics)*span > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
var raw map[string]map[string]int64
|
|
raw, storeOK = g.readSpanTotals(kindTargetCaller, ids, metrics, window, now, span, offset)
|
|
candidates = make([]totalsCandidate, len(ids))
|
|
for i, id := range ids {
|
|
candidates[i] = totalsCandidate{id: id, values: raw[id]}
|
|
}
|
|
}
|
|
|
|
if !storeOK {
|
|
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
|
|
return
|
|
}
|
|
|
|
rows, truncated := buildTotalsRows(candidates, metrics, limit)
|
|
setAdminJSONHeaders(sw)
|
|
_ = json.NewEncoder(sw).Encode(adminTotalsResponse{
|
|
Kind: kind, Window: window, Metrics: metrics, Rows: rows,
|
|
Span: span, Offset: offset, Truncated: truncated,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// GET /admin/api/performance
|
|
// ---------------------------------------------------------------------
|
|
|
|
// adminPerfMaxIDs bounds GET /admin/api/performance's repeated "id" query
|
|
// parameter (plan §1.3(vi): "<=50").
|
|
const adminPerfMaxIDs = 50
|
|
|
|
// adminPerfRow is one entity's (provider's or model's) availability and
|
|
// latency summary in GET /admin/api/performance, over the requested span.
|
|
// The five *Ms percentile fields and Count are all latency-derived and
|
|
// stay at their zero value (nil pointers, Count 0) when latency stats are
|
|
// disabled (LatencyEnabled false) or this entity has no latency
|
|
// observations at all in the span; Attempts/Failures[/Timeouts/
|
|
// Failovers] are always populated regardless.
|
|
type adminPerfRow struct {
|
|
P50Ms *float64 `json:"p50Ms,omitempty"`
|
|
P95Ms *float64 `json:"p95Ms,omitempty"`
|
|
P99Ms *float64 `json:"p99Ms,omitempty"`
|
|
TTFBP50Ms *float64 `json:"ttfbP50Ms,omitempty"`
|
|
TTFBP95Ms *float64 `json:"ttfbP95Ms,omitempty"`
|
|
ID string `json:"id"`
|
|
Count int64 `json:"count"`
|
|
Attempts int64 `json:"attempts"`
|
|
Failures int64 `json:"failures"`
|
|
// Timeouts and Failovers are populated only for kind=provider — a
|
|
// per-(provider,model) scope tracks neither (plan §1.2's family
|
|
// table: prov/{name} only) — so they stay 0 (omitted) for kind=model.
|
|
Timeouts int64 `json:"timeouts,omitempty"`
|
|
Failovers int64 `json:"failovers,omitempty"`
|
|
Overflow bool `json:"overflow,omitempty"`
|
|
}
|
|
|
|
// adminPerfResponse is the full body of GET /admin/api/performance.
|
|
// Rows is populated for the default (series=0) aggregate mode — one row
|
|
// per entity, summed over the whole span. Buckets/Points are populated
|
|
// only for series=1 (exactly one id) instead: one adminPerfRow per
|
|
// bucket, ID set to that bucket's own label. Rows is ALWAYS present and
|
|
// NEVER nil (an empty array `[]`, never `null`, in series=1 mode or when
|
|
// there are zero rows to report) — every other admin stats endpoint's own
|
|
// array-typed field (adminSeriesResponse.Unknown, adminUsageModelsResponse
|
|
// .Models, ...) already carries this same "never nil" contract, and a
|
|
// client that always does `for (const r of resp.rows)` without a null
|
|
// check must never see this one field be the exception.
|
|
type adminPerfResponse struct {
|
|
Kind string `json:"kind"`
|
|
Window string `json:"window"`
|
|
Rows []adminPerfRow `json:"rows"`
|
|
Buckets []string `json:"buckets,omitempty"`
|
|
Points []adminPerfRow `json:"points,omitempty"`
|
|
Span int `json:"span"`
|
|
Offset int `json:"offset"`
|
|
LatencyEnabled bool `json:"latencyEnabled"`
|
|
}
|
|
|
|
// performanceValidWindow reports whether window is one of GET
|
|
// /admin/api/performance's two accepted values (plan §1.3(vi): "window
|
|
// hour|day" — no month, unlike most other stats endpoints: a monthly
|
|
// latency percentile would smear together far too much variance to be
|
|
// actionable).
|
|
func performanceValidWindow(window string) bool {
|
|
return window == windowHour || window == windowDay
|
|
}
|
|
|
|
// latencyMetricNames returns every latencyDurationMetric/
|
|
// latencyTTFBMetric name (limits.go) for the full bucket range: indices
|
|
// 0..len(latencyBucketBounds)-1 for each real bound, plus
|
|
// len(latencyBucketBounds) itself for the overflow bucket — matching
|
|
// latencyBucketIndex's own return range (metrics.go, WP-A).
|
|
func latencyMetricNames() []string {
|
|
n := len(latencyBucketBounds) + 1
|
|
names := make([]string, 0, 2*n)
|
|
for i := 0; i < n; i++ {
|
|
names = append(names, latencyDurationMetric(i))
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
names = append(names, latencyTTFBMetric(i))
|
|
}
|
|
return names
|
|
}
|
|
|
|
// histogramQuantile computes the linearly-interpolated q-quantile (0..1)
|
|
// of a dense latency histogram: bounds are latencyBucketBounds' own
|
|
// ascending bucket upper bounds, in SECONDS; counts has len(bounds)+1
|
|
// entries — counts[i] is bounds[i]'s own dense occurrence count for
|
|
// i < len(bounds), and counts[len(bounds)] is the overflow bucket (every
|
|
// observation past the largest bound). Returns ms (the quantile value in
|
|
// MILLISECONDS) and overflow=true when the computed rank falls in the
|
|
// overflow bucket, in which case ms is the largest bound in ms — the best
|
|
// lower bound available, since an overflow observation's true value is
|
|
// unknown past that point (plan §1.3(vi): "overflow bucket -> 600000 +
|
|
// overflow:true"). (0, false) when counts holds no observations at all.
|
|
func histogramQuantile(bounds []float64, counts []int64, q float64) (ms float64, overflow bool) {
|
|
var total int64
|
|
for _, c := range counts {
|
|
total += c
|
|
}
|
|
if total == 0 {
|
|
return 0, false
|
|
}
|
|
target := q * float64(total)
|
|
var cum float64
|
|
lower := 0.0
|
|
for i, bound := range bounds {
|
|
c := float64(counts[i])
|
|
if cum+c >= target {
|
|
if c <= 0 {
|
|
return bound * 1000, false
|
|
}
|
|
frac := (target - cum) / c
|
|
return (lower + frac*(bound-lower)) * 1000, false
|
|
}
|
|
cum += c
|
|
lower = bound
|
|
}
|
|
return bounds[len(bounds)-1] * 1000, true
|
|
}
|
|
|
|
// perfScopeKinds resolves the counterStore scope kind for a performance
|
|
// entity's core availability counters (attempt/fail[/timeout/fover]) and
|
|
// its latency buckets, given the endpoint's own "kind" query value
|
|
// ("provider"|"model"): a provider entity reads both families under
|
|
// kindProvider (id = provider name); a model entity reads its core
|
|
// counters under kindProviderModel (attempt/fail only — plan §1.2's
|
|
// family table has no per-model timeout/fover) and its latency buckets
|
|
// under kindModel (id = canonical "provider/model" either way).
|
|
func perfScopeKinds(kind string) (coreKind, latencyKind string) {
|
|
if kind == "model" {
|
|
return kindProviderModel, kindModel
|
|
}
|
|
return kindProvider, kindProvider
|
|
}
|
|
|
|
// buildPerfRow assembles one adminPerfRow from its already-read core
|
|
// counters (core, never nil) and latency bucket counts (lat, nil when
|
|
// latency stats are disabled or unread).
|
|
func buildPerfRow(id, coreKind string, core, lat map[string]int64, latencyEnabled bool) adminPerfRow {
|
|
row := adminPerfRow{
|
|
ID: id,
|
|
Attempts: core[metricProvAttempt],
|
|
Failures: core[metricProvFail],
|
|
}
|
|
if coreKind == kindProvider {
|
|
row.Timeouts = core[metricProvTimeout]
|
|
row.Failovers = core[metricFover]
|
|
}
|
|
if !latencyEnabled || lat == nil {
|
|
return row
|
|
}
|
|
|
|
n := len(latencyBucketBounds) + 1
|
|
durationCounts := make([]int64, n)
|
|
ttfbCounts := make([]int64, n)
|
|
var durTotal, ttfbTotal int64
|
|
for i := 0; i < n; i++ {
|
|
durationCounts[i] = lat[latencyDurationMetric(i)]
|
|
ttfbCounts[i] = lat[latencyTTFBMetric(i)]
|
|
durTotal += durationCounts[i]
|
|
ttfbTotal += ttfbCounts[i]
|
|
}
|
|
row.Count = durTotal
|
|
|
|
var overflow bool
|
|
if durTotal > 0 {
|
|
p50, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.5)
|
|
row.P50Ms = &p50
|
|
overflow = overflow || of
|
|
p95, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.95)
|
|
row.P95Ms = &p95
|
|
overflow = overflow || of
|
|
p99, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.99)
|
|
row.P99Ms = &p99
|
|
overflow = overflow || of
|
|
}
|
|
if ttfbTotal > 0 {
|
|
t50, of := histogramQuantile(latencyBucketBounds, ttfbCounts, 0.5)
|
|
row.TTFBP50Ms = &t50
|
|
overflow = overflow || of
|
|
t95, of := histogramQuantile(latencyBucketBounds, ttfbCounts, 0.95)
|
|
row.TTFBP95Ms = &t95
|
|
overflow = overflow || of
|
|
}
|
|
row.Overflow = overflow
|
|
return row
|
|
}
|
|
|
|
// serveAdminPerformance writes GET /admin/api/performance: provider or
|
|
// model availability + (opt-in) latency percentiles, aggregated over the
|
|
// requested span (series=0, the default) or bucket-by-bucket for exactly
|
|
// one id (series=1).
|
|
func (g *Gateway) serveAdminPerformance(sw *statusTrackingWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
kind := q.Get("kind")
|
|
if kind != "provider" && kind != "model" {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown kind")
|
|
return
|
|
}
|
|
window := q.Get("window")
|
|
if !performanceValidWindow(window) {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window")
|
|
return
|
|
}
|
|
span, ok := parseUsageModelsSpan(q.Get("span"), window)
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span")
|
|
return
|
|
}
|
|
offset, ok := parseStatsOffset(q.Get("offset"), window, span)
|
|
if !ok {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset")
|
|
return
|
|
}
|
|
rawIDs := q["id"]
|
|
if len(rawIDs) > adminPerfMaxIDs {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "too many ids")
|
|
return
|
|
}
|
|
seriesRaw := q.Get("series")
|
|
var series bool
|
|
switch seriesRaw {
|
|
case "", "0":
|
|
series = false
|
|
case "1":
|
|
series = true
|
|
default:
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid series")
|
|
return
|
|
}
|
|
if series && len(rawIDs) != 1 {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "series requires exactly one id")
|
|
return
|
|
}
|
|
|
|
now := g.limiter.now()
|
|
coreKind, latencyKind := perfScopeKinds(kind)
|
|
latencyEnabled := g.buildAdminFeatures().LatencyStats
|
|
|
|
var ids []string
|
|
if len(rawIDs) > 0 {
|
|
for _, id := range rawIDs {
|
|
if kind == "provider" && g.seriesScopeExists(kindProvider, id) {
|
|
ids = append(ids, id)
|
|
} else if kind == "model" && g.scopeExists(kindModel, id) {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
} else if kind == "provider" {
|
|
for _, s := range g.registry.snapshot() {
|
|
ids = append(ids, s.name)
|
|
}
|
|
} else {
|
|
// Default: top 50 by request count in span (plan §1.3(vi)).
|
|
catalog := g.catalogModelScopeIDs()
|
|
// P7 fix (admin dashboard redesign verify round): this cap used to
|
|
// check len(catalog) alone, ignoring span entirely — the actual
|
|
// read below is len(catalog)*span keys (one metricReq bucket per
|
|
// model per span step), so a wide catalog combined with a large
|
|
// span could read far more than adminMaxKeysPerRequest ever meant
|
|
// to allow through before this 400 ever triggered.
|
|
if len(catalog)*span > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
totals, storeOK := g.chunkedModelSpanTotals(catalog, metricReq, window, now, span, offset)
|
|
if !storeOK {
|
|
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
|
|
return
|
|
}
|
|
type ranked struct {
|
|
id string
|
|
req int64
|
|
}
|
|
var nonzero []ranked
|
|
for i, id := range catalog {
|
|
if totals[i] > 0 {
|
|
nonzero = append(nonzero, ranked{id: id, req: totals[i]})
|
|
}
|
|
}
|
|
sort.Slice(nonzero, func(i, j int) bool {
|
|
if nonzero[i].req != nonzero[j].req {
|
|
return nonzero[i].req > nonzero[j].req
|
|
}
|
|
return nonzero[i].id < nonzero[j].id
|
|
})
|
|
if len(nonzero) > 50 {
|
|
nonzero = nonzero[:50]
|
|
}
|
|
for _, rk := range nonzero {
|
|
ids = append(ids, rk.id)
|
|
}
|
|
}
|
|
|
|
// P1 fix (admin dashboard redesign verify round): the earlier
|
|
// `len(rawIDs) != 1` check only bounds how many id= parameters the
|
|
// CALLER sent — it says nothing about how many of them actually
|
|
// resolved to a real provider/model above. An unknown or renamed id
|
|
// (`series=1&id=nope`) resolves ids to an EMPTY slice, and ids[0]
|
|
// below panicked (recovered as a 500 "internal error" by errors.go's
|
|
// recoverPanic, but still a logged panic on every dashboard click
|
|
// against a stale id) instead of the 400 this endpoint's own README
|
|
// section already promised.
|
|
if series && len(ids) != 1 {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "series requires exactly one id")
|
|
return
|
|
}
|
|
|
|
if series {
|
|
buckets, points, storeOK := g.perfSeries(coreKind, latencyKind, ids[0], window, now, span, offset, latencyEnabled)
|
|
if !storeOK {
|
|
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
|
|
return
|
|
}
|
|
setAdminJSONHeaders(sw)
|
|
_ = json.NewEncoder(sw).Encode(adminPerfResponse{
|
|
Kind: kind, Window: window, Span: span, Offset: offset,
|
|
// Rows stays non-nil even though series=1 never populates it —
|
|
// see adminPerfResponse's own "never nil" doc comment.
|
|
Rows: []adminPerfRow{}, Buckets: buckets, Points: points, LatencyEnabled: latencyEnabled,
|
|
})
|
|
return
|
|
}
|
|
|
|
coreMetrics := []string{metricProvAttempt, metricProvFail}
|
|
if coreKind == kindProvider {
|
|
coreMetrics = append(coreMetrics, metricProvTimeout, metricFover)
|
|
}
|
|
estimateKeys := len(ids) * len(coreMetrics) * span
|
|
if latencyEnabled {
|
|
estimateKeys += len(ids) * len(latencyMetricNames()) * span
|
|
}
|
|
if estimateKeys > adminMaxKeysPerRequest {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
|
return
|
|
}
|
|
|
|
coreVals, storeOK := g.readSpanTotals(coreKind, ids, coreMetrics, window, now, span, offset)
|
|
if !storeOK {
|
|
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
|
|
return
|
|
}
|
|
var latVals map[string]map[string]int64
|
|
if latencyEnabled {
|
|
latVals, storeOK = g.readSpanTotals(latencyKind, ids, latencyMetricNames(), window, now, span, offset)
|
|
if !storeOK {
|
|
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
|
|
return
|
|
}
|
|
}
|
|
|
|
rows := make([]adminPerfRow, len(ids))
|
|
for i, id := range ids {
|
|
rows[i] = buildPerfRow(id, coreKind, coreVals[id], latVals[id], latencyEnabled)
|
|
}
|
|
|
|
setAdminJSONHeaders(sw)
|
|
_ = json.NewEncoder(sw).Encode(adminPerfResponse{
|
|
Kind: kind, Window: window, Span: span, Offset: offset,
|
|
Rows: rows, LatencyEnabled: latencyEnabled,
|
|
})
|
|
}
|
|
|
|
// perfSeries reads bucket-by-bucket (never summed) core+latency counters
|
|
// for exactly one entity — GET /admin/api/performance's series=1 mode —
|
|
// returning span bucket labels and one adminPerfRow per bucket, ID set to
|
|
// that bucket's own label.
|
|
func (g *Gateway) perfSeries(coreKind, latencyKind, id, window string, now time.Time, span, offset int, latencyEnabled bool) (buckets []string, points []adminPerfRow, ok bool) {
|
|
coreMetrics := []string{metricProvAttempt, metricProvFail}
|
|
if coreKind == kindProvider {
|
|
coreMetrics = append(coreMetrics, metricProvTimeout, metricFover)
|
|
}
|
|
var latMetrics []string
|
|
if latencyEnabled {
|
|
latMetrics = latencyMetricNames()
|
|
}
|
|
|
|
// P6 fix (admin dashboard redesign verify round): every metric used to
|
|
// read via its own separate storeGetMulti round trip — a provider's
|
|
// series=1 with latency on cost 4 core + 28 latency metrics = 32
|
|
// sequential round trips. Every metric's keys are built up front into
|
|
// ONE flat list instead, read through chunkedGetMulti's own bounded
|
|
// pipelining (limits.go), and sliced back apart per metric below —
|
|
// buckets come from the first metric's own key build since every
|
|
// metric shares identical bucket labels for the same kind/id/window/
|
|
// span/offset.
|
|
allKeys := make([]string, 0, (len(coreMetrics)+len(latMetrics))*span)
|
|
for _, m := range coreMetrics {
|
|
keys, bkts := historyBucketKeysAt(coreKind, id, m, window, now, span, offset)
|
|
if buckets == nil {
|
|
buckets = bkts
|
|
}
|
|
allKeys = append(allKeys, keys...)
|
|
}
|
|
for _, m := range latMetrics {
|
|
keys, _ := historyBucketKeysAt(latencyKind, id, m, window, now, span, offset)
|
|
allKeys = append(allKeys, keys...)
|
|
}
|
|
|
|
vals, storeOK := g.limiter.chunkedGetMulti(allKeys, usageModelsChunkKeys)
|
|
if !storeOK || len(vals) != len(allKeys) {
|
|
return nil, nil, false
|
|
}
|
|
|
|
coreVals := make(map[string][]int64, len(coreMetrics))
|
|
idx := 0
|
|
for _, m := range coreMetrics {
|
|
coreVals[m] = vals[idx : idx+span]
|
|
idx += span
|
|
}
|
|
latVals := make(map[string][]int64, len(latMetrics))
|
|
for _, m := range latMetrics {
|
|
latVals[m] = vals[idx : idx+span]
|
|
idx += span
|
|
}
|
|
|
|
points = make([]adminPerfRow, span)
|
|
for b := 0; b < span; b++ {
|
|
core := make(map[string]int64, len(coreMetrics))
|
|
for _, m := range coreMetrics {
|
|
core[m] = coreVals[m][b]
|
|
}
|
|
var lat map[string]int64
|
|
if latencyEnabled {
|
|
lat = make(map[string]int64, len(latMetrics))
|
|
for _, m := range latMetrics {
|
|
lat[m] = latVals[m][b]
|
|
}
|
|
}
|
|
row := buildPerfRow(buckets[b], coreKind, core, lat, latencyEnabled)
|
|
points[b] = row
|
|
}
|
|
return buckets, points, true
|
|
}
|