From 10d1825ed70305275ff97f2ca6d6dfaefd6e89aa Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 3 Sep 2026 02:47:07 +0100 Subject: [PATCH] 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 --- README.md | 4 +++- metrics.go | 8 ++++++++ metrics_test.go | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f05c4d0..6a094d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/metrics.go b/metrics.go index b39ad8b..a242d87 100644 --- a/metrics.go +++ b/metrics.go @@ -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) diff --git a/metrics_test.go b/metrics_test.go index 0bf64a3..234206a 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -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