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:
2026-09-03 01:42:49 +01:00
parent d6eebd5a84
commit a31647f652
4 changed files with 295 additions and 0 deletions
+44
View File
@@ -1235,13 +1235,54 @@ type adminTargetCountersView struct {
// convention (adminUsageEntryView) — rather than always listing every
// group name, which would grow with the group catalog for no reason once
// nothing is actually restricted.
//
// Health is feat/target-health's own readout (targetHealthView, below).
// Field order (Health first, the struct-typed field, then the strings/
// slice, then Counters last) is fieldalignment-sensitive, derived
// against a scratch copy the same way this package's other structs
// already document (Config's own doc comment, llmgateway.go).
type adminTargetView struct {
Health adminTargetHealthView `json:"health"`
Name string `json:"name"`
URL string `json:"url"`
Access []string `json:"access,omitempty"`
Counters adminTargetCountersView `json:"counters"`
}
// adminTargetHealthView is one target's feat/target-health readout in
// GET /admin/api/targets — the exact JSON contract the webui's MCP &
// Agents panel codes against. State and ConsecutiveFailures are always
// present; every other field is omitted while State is
// targetHealthUnknown (this tracker has never observed the target),
// since none of them carry a meaningful value yet. LatencyMs is a
// pointer so a genuine 0ms observation still serializes as
// "latencyMs":0 rather than being indistinguishable from "omitted".
// Field order is fieldalignment-derived, the same convention
// adminTargetView's own doc comment above explains.
type adminTargetHealthView struct {
LatencyMs *int64 `json:"latencyMs,omitempty"`
State targetHealthState `json:"state"`
LastCheck string `json:"lastCheck,omitempty"`
LastError string `json:"lastError,omitempty"`
Source string `json:"source,omitempty"`
ConsecutiveFailures int `json:"consecutiveFailures"`
}
// targetHealthView converts one targetHealthTracker.snapshot result
// (target_health.go) into its JSON view.
func targetHealthView(snap targetHealthSnapshot) adminTargetHealthView {
view := adminTargetHealthView{State: snap.state, ConsecutiveFailures: snap.consecutiveFailures}
if snap.state == targetHealthUnknown {
return view
}
view.LastCheck = snap.lastCheck.UTC().Format(time.RFC3339)
view.LastError = snap.lastError
view.Source = snap.source
ms := snap.latency.Milliseconds()
view.LatencyMs = &ms
return view
}
// adminTargetsResponse is the full body of GET /admin/api/targets (Feature
// B, v0.21): every configured MCP server and every configured agent, each
// with its own access list and current-window request counters. Both
@@ -1339,6 +1380,7 @@ func (g *Gateway) buildAdminTargets() adminTargetsResponse {
mcpServers := make([]adminTargetView, len(mcpNames))
for i, name := range mcpNames {
mcpServers[i] = adminTargetView{
Health: targetHealthView(g.targetHealth.snapshot(targetKindMCP, name)),
Name: name,
URL: sanitizeBaseURL(g.cfg.MCPServers[name].URL),
Access: adminTargetAccess(groupSummaries, func(gs groupSummary) []string { return gs.mcpServers }, name),
@@ -1348,6 +1390,7 @@ func (g *Gateway) buildAdminTargets() adminTargetsResponse {
agents := make([]adminTargetView, len(agentNames))
for i, name := range agentNames {
agents[i] = adminTargetView{
Health: targetHealthView(g.targetHealth.snapshot(targetKindAgent, name)),
Name: name,
URL: sanitizeBaseURL(g.cfg.Agents[name].URL),
Access: adminTargetAccess(groupSummaries, func(gs groupSummary) []string { return gs.agents }, name),
@@ -1360,6 +1403,7 @@ func (g *Gateway) buildAdminTargets() adminTargetsResponse {
// serveAdminTargets writes buildAdminTargets's result as JSON.
func (g *Gateway) serveAdminTargets(w http.ResponseWriter) {
g.maybeSweepTargetHealth()
setAdminJSONHeaders(w)
_ = json.NewEncoder(w).Encode(g.buildAdminTargets())
}