mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +00:00
fix(target-health): omit target metrics family when unconfigured
writeTargetMetrics now returns before emitting even llmgateway_target_healthy's own HELP/TYPE header when neither an MCP server nor an agent is configured, mirroring writeStoreHealthMetrics' identical behaviour when Redis is unconfigured. Claude-Session: https://claude.ai/code/session_01EebV5jyWvYocByNQ3oss1w
This commit is contained in:
@@ -1684,7 +1684,9 @@ plugin's config.
|
|||||||
- `llmgateway_target_healthy{kind,target}` — `1`/`0` gauge for an MCP
|
- `llmgateway_target_healthy{kind,target}` — `1`/`0` gauge for an MCP
|
||||||
server or A2A agent's tracked health; `kind` is `"mcp"` or `"agent"`.
|
server or A2A agent's tracked health; `kind` is `"mcp"` or `"agent"`.
|
||||||
No sample at all for a target this tracker has never observed — see
|
No sample at all for a target this tracker has never observed — see
|
||||||
[MCP and A2A](#mcp-and-a2a)'s "Target health" section.
|
[MCP and A2A](#mcp-and-a2a)'s "Target health" section. The whole
|
||||||
|
family, HELP and TYPE lines included, is absent entirely when no MCP
|
||||||
|
servers or agents are configured.
|
||||||
- `llmgateway_limit_store_up` — `1`/`0` gauge for this replica's own
|
- `llmgateway_limit_store_up` — `1`/`0` gauge for this replica's own
|
||||||
connection to the configured limit store (Redis); absent entirely
|
connection to the configured limit store (Redis); absent entirely
|
||||||
when no store is configured. Every store-backed family above simply
|
when no store is configured. Every store-backed family above simply
|
||||||
|
|||||||
@@ -694,7 +694,15 @@ func (g *Gateway) writeProviderMetrics(m *metricWriter) {
|
|||||||
// unreadable". Iterates every CONFIGURED MCP server and agent (not just
|
// unreadable". Iterates every CONFIGURED MCP server and agent (not just
|
||||||
// ones the tracker happens to have an entry for), so a target removed
|
// ones the tracker happens to have an entry for), so a target removed
|
||||||
// from config since its last observation contributes no stale series.
|
// from config since its last observation contributes no stale series.
|
||||||
|
// F7, feat/target-health review: returns before emitting even the family's
|
||||||
|
// own HELP/TYPE header when no MCP server or agent is configured at all —
|
||||||
|
// mirrors writeStoreHealthMetrics' identical "nothing to report, no family
|
||||||
|
// either" early return above, rather than a HELP/TYPE line promising a
|
||||||
|
// metric family that can never carry a sample in this deployment.
|
||||||
func (g *Gateway) writeTargetMetrics(m *metricWriter) {
|
func (g *Gateway) writeTargetMetrics(m *metricWriter) {
|
||||||
|
if len(g.cfg.MCPServers) == 0 && len(g.cfg.Agents) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
m.family("llmgateway_target_healthy", "gauge",
|
m.family("llmgateway_target_healthy", "gauge",
|
||||||
"1 when an MCP server or A2A agent's health (last passive observation or active probe) is healthy, 0 when its consecutive-failure count has reached targetHealth.failureThreshold. No sample while never yet observed."+aggregationNoteTargetHealthy)
|
"1 when an MCP server or A2A agent's health (last passive observation or active probe) is healthy, 0 when its consecutive-failure count has reached targetHealth.failureThreshold. No sample while never yet observed."+aggregationNoteTargetHealthy)
|
||||||
|
|
||||||
|
|||||||
@@ -1521,6 +1521,58 @@ func TestMetrics_TargetHealthy_NoSampleWhileUnknown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMetrics_TargetHealthy_NoSampleWhileNeverSucceeded proves F4's
|
||||||
|
// second no-sample case: a target that HAS been observed, but has never
|
||||||
|
// once succeeded and is still below failureThreshold, reads "unknown"
|
||||||
|
// (TestTargetHealthTracker_FirstObservationFailure_ReadsUnknown) and
|
||||||
|
// therefore still emits no llmgateway_target_healthy sample — the same
|
||||||
|
// "a gap is honest, a fabricated value is not" rule the never-observed
|
||||||
|
// case above applies, now extended to "observed but never succeeded".
|
||||||
|
func TestMetrics_TargetHealthy_NoSampleWhileNeverSucceeded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := newMetricsTestConfig()
|
||||||
|
cfg.MCPServers = map[string]*TargetConfig{"alpha": {URL: "http://mcp-alpha.internal"}}
|
||||||
|
h, gw := newMetricsGatewayHandle(t, cfg)
|
||||||
|
|
||||||
|
gw.targetHealth.record(targetKindMCP, "alpha", "", false, errors.New("boom"), time.Millisecond, targetHealthSourceProbe)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, metricsRequest(http.MethodGet, metricsPathDefault, "sk-admin1"))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
_, samples := parsePrometheusText(t, rec.Body.Bytes())
|
||||||
|
for _, s := range samples {
|
||||||
|
if s.name == "llmgateway_target_healthy" {
|
||||||
|
t.Errorf("unexpected sample %v — a target that has never succeeded must emit nothing", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetrics_TargetHealthy_FamilyAbsentWhenNoTargetsConfigured proves
|
||||||
|
// F7: writeTargetMetrics omits even llmgateway_target_healthy's own
|
||||||
|
// HELP/TYPE header when no MCP server or agent is configured at all —
|
||||||
|
// mirroring writeStoreHealthMetrics' identical "nothing to report, no
|
||||||
|
// family either" behavior when Redis is unconfigured.
|
||||||
|
func TestMetrics_TargetHealthy_FamilyAbsentWhenNoTargetsConfigured(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cfg := newMetricsTestConfig()
|
||||||
|
h, _ := newMetricsGatewayHandle(t, cfg)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, metricsRequest(http.MethodGet, metricsPathDefault, "sk-admin1"))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
types, _ := parsePrometheusText(t, rec.Body.Bytes())
|
||||||
|
if _, ok := types["llmgateway_target_healthy"]; ok {
|
||||||
|
t.Error("want llmgateway_target_healthy's TYPE line absent when no MCP servers or agents are configured")
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "llmgateway_target_healthy") {
|
||||||
|
t.Errorf("body unexpectedly mentions llmgateway_target_healthy: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestMetrics_TargetHealthy_ReflectsRecordedState proves the gauge
|
// TestMetrics_TargetHealthy_ReflectsRecordedState proves the gauge
|
||||||
// renders 1 for a healthy MCP server and 0 for an unhealthy agent, with
|
// renders 1 for a healthy MCP server and 0 for an unhealthy agent, with
|
||||||
// the documented kind label values ("mcp"/"agent" — NOT the internal
|
// the documented kind label values ("mcp"/"agent" — NOT the internal
|
||||||
|
|||||||
Reference in New Issue
Block a user