mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +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:
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user