test(yaegi-check): verify the metrics endpoint under the interpreter

Compiled tests have repeatedly passed on this codebase while the
interpreted shape failed, so the metrics feature needs its own probe
in this harness, not just go test coverage.

Enable metrics in the harness config, with modelLabel on so the
opt-in provider-model breakdown runs interpreted too. Add
exerciseMetricsRoute, driving four real requests: unauthenticated
from outside the allowed CIDR block returns 401, a non-admin key
returns 403, the admin key returns 200 with the expected TYPE lines
and live provider and model data in the body, and a keyless request
from inside the allowed CIDR block returns 200.

Run the new probe last, after the breaker probe settles, since GET
/metrics triggers the same maybeRefresh call every route does and
the breaker probe's own hit counting is timing-sensitive.
This commit is contained in:
2026-08-23 00:19:09 +01:00
parent d58eef9aa7
commit d421f49ff1
+108 -1
View File
@@ -370,6 +370,15 @@ func run() error {
// one testDataWantModel already exercises.
`"modelMeta":{"` + testDataWantModel + `":{"contextTokens":` + yaegiMetaContextTokens + `,"inputCostPerMTokMicroUsd":1250000,"outputCostPerMTokMicroUsd":10000000}},` +
`"mcpServers":{"` + mcpProbeServerName + `":{"url":"` + mcpProbeUpstream.URL + `"}},` +
// metrics (Prometheus text-exposition endpoint): enabled with
// modelLabel on, so exerciseMetricsRoute below exercises the
// opt-in per-(provider,model) breakdown under the interpreter
// too, not just the always-on families. allowedCIDRs names the
// exact block httptest.NewRequest's own default RemoteAddr
// (192.0.2.1:1234, verified empirically) falls inside, so the
// CIDR-bypass path is reachable with a plain httptest.NewRequest
// carrying no explicit RemoteAddr override.
`"metrics":{"enabled":true,"allowedCIDRs":["` + metricsProbeAllowedCIDR + `"],"modelLabel":true},` +
`"admin":{"enabled":true},"users":{"inline":[{"name":"tester","group":"default","apiKey":"` + testDataUserAPIKey + `"},{"name":"admin1","group":"default","apiKey":"` + attemptAccountingAdminAPIKey + `","admin":true}]}}`
if err = json.Unmarshal([]byte(attemptAccountingOverride), cfgVal.Interface()); err != nil {
return fmt.Errorf("decode attempt-accounting harness override into the interpreted Config: %w", err)
@@ -418,7 +427,17 @@ func run() error {
// exactly why this class of divergence needs its own interpreted
// probe: a compiled `go test` pass here would prove nothing about the
// interpreter.
return exerciseBreaker(handler, brkModelsHits, brkHealthy)
if err := exerciseBreaker(handler, brkModelsHits, brkHealthy); err != nil {
return err
}
// exerciseMetricsRoute runs LAST, after exerciseBreaker has already
// settled: GET /metrics runs registry.maybeRefresh like every other
// route (ServeHTTP's own unconditional call at entry), and
// exerciseBreaker's own hit-counting is timing-sensitive against the
// "brk" provider specifically — probing metrics first could perturb
// the very discovery-attempt counts that test samples. Ordering the
// metrics probe after avoids any such interaction.
return exerciseMetricsRoute(handler)
}
// builtinLookupModelID is a real, stable entry in the generated
@@ -439,6 +458,16 @@ const (
mcpProbeToolName = mcpProbeServerName + "_big"
)
// metricsProbeAllowedCIDR is the CIDR block attemptAccountingOverride's
// own metrics.allowedCIDRs names — see that literal's own comment for why
// this must contain httptest.NewRequest's default RemoteAddr.
const metricsProbeAllowedCIDR = "192.0.2.0/24"
// metricsProbeOutsideAddr is a source address deliberately OUTSIDE
// metricsProbeAllowedCIDR (TEST-NET-3, RFC 5737) — exerciseMetricsRoute's
// own proof that the allowlist is not simply matching everything.
const metricsProbeOutsideAddr = "203.0.113.9:5555"
// mcpProbeOversizeBytes is how much filler mcpProbeUpstream writes: over
// mcpBackendCallResponseMaxBytes (maxRequestBytes, 10MiB) so the response
// trips doBackendJSONRPC's cap. Kept as its own named value rather than
@@ -625,6 +654,84 @@ func exerciseBreaker(handler http.Handler, hits, healthy *int64) error {
return nil
}
// exerciseMetricsRoute drives GET /metrics against the interpreted
// handler and proves both its auth gate and its rendering run correctly
// under Yaegi — the metrics feature's own gate requirement: compiled
// tests have repeatedly passed on this codebase while the interpreted
// shape failed (matchesSentinel's own doc comment, limits.go, is the
// canonical example), so a metrics-specific probe is not optional here.
//
// Four requests, mirroring TestMetrics_GateMatrix (metrics_test.go)
// exactly so this harness and the compiled test suite assert the
// identical contract from two different angles:
// 1. unauthenticated, from an address outside metrics.allowedCIDRs -> 401
// 2. authenticated as "tester" (not admin) -> 403
// 3. authenticated as the admin user -> 200, body inspected
// 4. unauthenticated, from an address INSIDE metrics.allowedCIDRs -> 200
//
// Request 3's body is inspected for real, live-data content — not just a
// 200 status — because a Yaegi-specific bug in this codebase's history
// (matchesSentinel, limits.go) was a case where the interpreted code ran
// without panicking yet produced a WRONG answer; a bare status-code check
// would not have caught that class of failure here either. Specifically:
// the "openai" provider's name, and — since attemptAccountingOverride
// enables metrics.modelLabel — testDataWantModel's own id inside the
// opt-in provider-model breakdown, both of which can only appear via a
// live registry.snapshot() walk and a live providerModelScopeID join
// under the real interpreter, not a static string in this harness.
func exerciseMetricsRoute(handler http.Handler) error {
unauthReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
unauthReq.RemoteAddr = metricsProbeOutsideAddr
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, unauthReq)
if rec.Code != http.StatusUnauthorized {
return fmt.Errorf("GET /metrics (no key, outside allowedCIDRs): status = %d, want 401, body=%s", rec.Code, rec.Body.String())
}
nonAdminReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
nonAdminReq.RemoteAddr = metricsProbeOutsideAddr
nonAdminReq.Header.Set("Authorization", "Bearer "+testDataUserAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, nonAdminReq)
if rec.Code != http.StatusForbidden {
return fmt.Errorf("GET /metrics (non-admin key): status = %d, want 403, body=%s", rec.Code, rec.Body.String())
}
adminReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
adminReq.RemoteAddr = metricsProbeOutsideAddr
adminReq.Header.Set("Authorization", "Bearer "+attemptAccountingAdminAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, adminReq)
if rec.Code != http.StatusOK {
return fmt.Errorf("GET /metrics (admin key): status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
return fmt.Errorf("GET /metrics (admin key): Content-Type = %q, want a text/plain prefix", ct)
}
body := rec.Body.String()
for _, want := range []string{
"# TYPE llmgateway_requests_total counter",
"# TYPE llmgateway_provider_healthy gauge",
`llmgateway_provider_attempts_total{provider="openai"}`,
"# TYPE llmgateway_provider_model_attempts_total counter",
`llmgateway_provider_model_attempts_total{provider="openai",model="` + testDataWantModel + `"}`,
} {
if !strings.Contains(body, want) {
return fmt.Errorf("GET /metrics (admin key): body missing %q — interpreted rendering diverged from the compiled shape; body=%s", want, body)
}
}
allowlistedReq := httptest.NewRequest(http.MethodGet, "/metrics", nil) // default RemoteAddr, inside metricsProbeAllowedCIDR
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, allowlistedReq)
if rec.Code != http.StatusOK {
return fmt.Errorf("GET /metrics (no key, inside allowedCIDRs): status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
fmt.Println("yaegi-check: GET /metrics auth gate and rendering both verified interpreted")
return nil
}
// exerciseHandler runs two real requests against the interpreted
// handler, drawn from the plugin's own .traefik.yml testData: an
// authenticated GET /v1/models, which must return the configured