mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +00:00
feat(target-health): expose target health on admin API and metrics
Add a health object to each row of GET /admin/api/targets (adminTargetHealthView, admin.go): state, an RFC3339 lastCheck, lastError, source, and latencyMs, every optional field omitted while the target has never been observed. Both serveAdminTargets and the MCP/agent listing routes (handleMCPServers, handleAgents) trigger the active probe sweep before responding, so admin polling and client listings double as the heartbeat that drives it. Add llmgateway_target_healthy to /metrics (metrics.go): a 1/0 gauge labeled by kind (mcp or agent) and target name, with no sample at all for a target the tracker has never observed. serveMetrics triggers the sweep too, so a Prometheus scrape becomes a heartbeat as well. Claude-Session: https://claude.ai/code/session_01EebV5jyWvYocByNQ3oss1w
This commit is contained in:
+127
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
@@ -2436,3 +2437,129 @@ func TestParseHistoryScope_ModelIDKeepsLaterSeparators(t *testing.T) {
|
||||
t.Errorf("parseHistoryScope = (%q, %q, %v), want the full model id preserved", kind, id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// --- feat/target-health: GET /admin/api/targets "health" field ---
|
||||
|
||||
// TestAdminTargets_Health_NeverObserved_UnknownWithOmittedFields proves
|
||||
// the exact JSON contract for a target this tracker has never observed:
|
||||
// "state":"unknown", consecutiveFailures present as 0, and every other
|
||||
// health field (lastCheck/lastError/latencyMs/source) omitted entirely.
|
||||
func TestAdminTargets_Health_NeverObserved_UnknownWithOmittedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := newAdminTestConfig()
|
||||
cfg.MCPServers = map[string]*TargetConfig{"alpha": {URL: "http://mcp-alpha.internal"}}
|
||||
h, _ := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminTargetsPath, "sk-admin1"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"health":{"state":"unknown","consecutiveFailures":0}`) {
|
||||
t.Errorf("body = %s, want an unknown target's health object to carry only state and consecutiveFailures", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTargets_Health_Recorded_AllFieldsPresent proves a target with
|
||||
// at least one recorded observation carries every health field, and that
|
||||
// state/source/consecutiveFailures reflect the tracker exactly.
|
||||
func TestAdminTargets_Health_Recorded_AllFieldsPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := newAdminTestConfig()
|
||||
cfg.MCPServers = map[string]*TargetConfig{"alpha": {URL: "http://mcp-alpha.internal"}}
|
||||
h, gw := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
gw.targetHealth.record(targetKindMCP, "alpha", true, nil, 12*time.Millisecond, targetHealthSourceTraffic)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminTargetsPath, "sk-admin1"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got adminTargetsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got.MCPServers) != 1 {
|
||||
t.Fatalf("mcpServers = %+v, want exactly 1", got.MCPServers)
|
||||
}
|
||||
health := got.MCPServers[0].Health
|
||||
if health.State != targetHealthHealthy {
|
||||
t.Errorf("state = %q, want healthy", health.State)
|
||||
}
|
||||
if health.LastCheck == "" {
|
||||
t.Error("want a non-empty lastCheck (RFC3339)")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, health.LastCheck); err != nil {
|
||||
t.Errorf("lastCheck = %q, want RFC3339: %v", health.LastCheck, err)
|
||||
}
|
||||
if health.Source != targetHealthSourceTraffic {
|
||||
t.Errorf("source = %q, want %q", health.Source, targetHealthSourceTraffic)
|
||||
}
|
||||
if health.LatencyMs == nil || *health.LatencyMs != 12 {
|
||||
t.Errorf("latencyMs = %v, want *12", health.LatencyMs)
|
||||
}
|
||||
if health.ConsecutiveFailures != 0 {
|
||||
t.Errorf("consecutiveFailures = %d, want 0", health.ConsecutiveFailures)
|
||||
}
|
||||
if health.LastError != "" {
|
||||
t.Errorf("lastError = %q, want empty (omitted) after a success", health.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTargets_Health_Unhealthy_LastErrorPresent proves an unhealthy
|
||||
// target's lastError is populated and non-empty.
|
||||
func TestAdminTargets_Health_Unhealthy_LastErrorPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := newAdminTestConfig()
|
||||
cfg.MCPServers = map[string]*TargetConfig{"alpha": {URL: "http://mcp-alpha.internal"}}
|
||||
cfg.TargetHealth = TargetHealthConfig{FailureThreshold: 1}
|
||||
h, gw := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
gw.targetHealth.record(targetKindMCP, "alpha", false, errors.New("dial tcp: connection refused"), 3*time.Millisecond, targetHealthSourceProbe)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminTargetsPath, "sk-admin1"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got adminTargetsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
health := got.MCPServers[0].Health
|
||||
if health.State != targetHealthUnhealthy {
|
||||
t.Errorf("state = %q, want unhealthy", health.State)
|
||||
}
|
||||
if health.LastError != "dial tcp: connection refused" {
|
||||
t.Errorf("lastError = %q, want the recorded error text", health.LastError)
|
||||
}
|
||||
if health.Source != targetHealthSourceProbe {
|
||||
t.Errorf("source = %q, want %q", health.Source, targetHealthSourceProbe)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTargets_Health_AgentUsesAgentKind proves an agent row's health
|
||||
// is read under targetKindAgent, not the mcp kind.
|
||||
func TestAdminTargets_Health_AgentUsesAgentKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := newAdminTestConfig()
|
||||
cfg.Agents = map[string]*AgentConfig{"bot1": {URL: "http://agent-bot1.internal"}}
|
||||
h, gw := newAdminGatewayHandle(t, cfg)
|
||||
|
||||
gw.targetHealth.record(targetKindAgent, "bot1", true, nil, time.Millisecond, targetHealthSourceTraffic)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminTargetsPath, "sk-admin1"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got adminTargetsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got.Agents) != 1 || got.Agents[0].Health.State != targetHealthHealthy {
|
||||
t.Fatalf("agents = %+v, want bot1 healthy", got.Agents)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user