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:
2026-09-03 02:47:25 +01:00
parent f5d9014b13
commit 10d1825ed7
3 changed files with 63 additions and 1 deletions
+3 -1
View File
@@ -1684,7 +1684,9 @@ plugin's config.
- `llmgateway_target_healthy{kind,target}` — `1`/`0` gauge for an MCP
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
[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
connection to the configured limit store (Redis); absent entirely
when no store is configured. Every store-backed family above simply
+8
View File
@@ -694,7 +694,15 @@ func (g *Gateway) writeProviderMetrics(m *metricWriter) {
// unreadable". Iterates every CONFIGURED MCP server and agent (not just
// ones the tracker happens to have an entry for), so a target removed
// 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) {
if len(g.cfg.MCPServers) == 0 && len(g.cfg.Agents) == 0 {
return
}
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)
+52
View File
@@ -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
// renders 1 for a healthy MCP server and 0 for an unhealthy agent, with
// the documented kind label values ("mcp"/"agent" — NOT the internal