feat(metrics): add Prometheus text-exposition endpoint

Serve a hand-written Prometheus text-exposition document at a
configurable path, defaulting to /metrics. The plugin is stdlib-only
under Yaegi, so this cannot use a client library.

Add MetricsConfig: Enabled gates the route, Path overrides it,
AllowedCIDRs lets an unauthenticated caller scrape from a trusted
source address, and ModelLabel opts into a per-(provider,model)
breakdown that defaults off to avoid a series-count explosion.

Reuse the existing admin bearer-token auth path instead of adding a
second credential check. Reuse the batched, chunked reads GET
/admin/api/overview and GET /admin/api/usage already use, so a scrape
costs no extra store round trip.

Expose request, token, and cost counters per scope, provider attempt
and failure counters, a provider-health gauge from the discovery
circuit breaker, and a budget-consumed-ratio gauge per limited scope
for Prometheus alerting. Add a rate-limit-rejection counter in
limits.go: this is the one number with no existing accounting to read
back, so checkAndCount now increments a small in-process,
per-process-lifetime counter on every violation.

Escape every label value for backslash, double quote, and newline. An
unescaped quote in a label value, such as a discovered model id, makes
the whole scrape unparseable, and Prometheus reports an unparseable
scrape as the target being down rather than as one bad line.

Cover the auth gate matrix, exposition structure (TYPE per family,
counters ending _total, no duplicate series), label escaping
round-trips, the model-label opt-in, and counters reflecting real
seeded usage rather than zeros.
This commit is contained in:
2026-08-23 00:18:45 +01:00
parent 208084ac83
commit d58eef9aa7
4 changed files with 1383 additions and 1 deletions
+31 -1
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"runtime"
@@ -29,7 +30,13 @@ type Config struct {
// Admin.Enabled false means the /admin* routes are not registered at
// all — ServeHTTP falls through to its existing 404/passthroughUnknown
// handling for those paths, preserving v0.1 behavior exactly.
Admin *AdminConfig `json:"admin,omitempty"`
Admin *AdminConfig `json:"admin,omitempty"`
// Metrics gates the Prometheus text-exposition endpoint (default
// path /metrics, configurable — see MetricsConfig, metrics.go). nil
// or Metrics.Enabled false means the route is not registered at
// all, the identical nil-disables convention Admin above already
// uses.
Metrics *MetricsConfig `json:"metrics,omitempty"`
Pricing map[string]*ModelPricing `json:"pricing,omitempty"`
MCPServers map[string]*TargetConfig `json:"mcpServers,omitempty"`
Agents map[string]*AgentConfig `json:"agents,omitempty"`
@@ -526,6 +533,11 @@ type Gateway struct {
// contract.
bodyAdmission chan struct{}
name string
// metricsNets is Config.Metrics.AllowedCIDRs, parsed once at
// construction (parseMetricsCIDRs, metrics.go) so the request path
// never re-parses a CIDR string per scrape. nil when Metrics is
// unconfigured or carries no AllowedCIDRs entries.
metricsNets []*net.IPNet
}
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
@@ -634,6 +646,16 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
}
g := &Gateway{next: next, name: name, cfg: config, auth: auth}
// A malformed Config.Metrics.AllowedCIDRs entry is a constructor
// error (validate-at-construction), not a silently-ignored allowlist
// entry an operator would only discover was never applied by testing
// it — parseMetricsCIDRs' own doc comment (metrics.go).
metricsNets, err := parseMetricsCIDRs(config.Metrics)
if err != nil {
return nil, err
}
g.metricsNets = metricsNets
// bodyAdmissionCap: an explicit MaxInFlightBodyRequests always wins
// over the self-tuned default (house rule: explicit override always
// wins) — see Config.MaxInFlightBodyRequests' own doc comment. 0 (the
@@ -878,6 +900,14 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Metrics route (metrics.go): matched only when metricsEnabled — a
// nil or disabled Config.Metrics means the configured path is not
// registered at all, mirroring the admin route's own gate above.
if metricsEnabled(g.cfg) && r.Method == http.MethodGet && r.URL.Path == metricsPath(g.cfg) {
g.handleMetrics(sw, r)
return
}
if r.Method == http.MethodGet && (r.URL.Path == "/v1/mcp/servers" || r.URL.Path == "/v1/agents") {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)