From 2ccc67bc7f5160d9badcac484d3b3753d38da4f8 Mon Sep 17 00:00:00 2001 From: walcz-de Date: Mon, 6 Jul 2026 01:06:15 +0200 Subject: [PATCH] feat(agents): native Prometheus metrics for agent chat runs (#10689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators need a scrape-friendly signal for agent-turn health (completing, erroring, cancelled, duration) — log-derived counters proved brittle (ANSI/ timezone parsing, restart gaps). Adds localai_agent_runs_total{agent,outcome} and localai_agent_run_seconds histogram, recorded at the Chat() response handoff (single choke point of the local execution path). Lazy meter init, same pattern as the PII events counter (#10641). Signed-off-by: Stefan Walcz --- core/services/agentpool/agent_pool.go | 8 ++++ core/services/agentpool/metrics.go | 54 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 core/services/agentpool/metrics.go diff --git a/core/services/agentpool/agent_pool.go b/core/services/agentpool/agent_pool.go index 8744b8a35..21d3e2661 100644 --- a/core/services/agentpool/agent_pool.go +++ b/core/services/agentpool/agent_pool.go @@ -426,7 +426,15 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) { // Process asynchronously go func() { + started := time.Now() response := ag.Ask(coreTypes.WithText(message)) + outcome := "completed" + if response == nil { + outcome = "cancelled" + } else if response.Error != nil { + outcome = "error" + } + recordAgentRun(name, outcome, time.Since(started).Seconds()) if response == nil { errMsg, _ := json.Marshal(map[string]any{ diff --git a/core/services/agentpool/metrics.go b/core/services/agentpool/metrics.go new file mode 100644 index 000000000..191da8780 --- /dev/null +++ b/core/services/agentpool/metrics.go @@ -0,0 +1,54 @@ +package agentpool + +import ( + "context" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// Prometheus metrics for agent chat runs. Operators need a scrape-friendly +// signal for "are agent turns completing, erroring or getting cancelled, +// and how long do they take" — log-derived counters proved brittle +// (ANSI/timezone parsing, container-restart gaps). Chat() is the single +// choke point of the local execution path, so instrumenting the response +// handoff covers UI chats, API chats and connector-triggered asks alike. +// +// Lazily initialised on first record so the package works no matter when +// (or whether) the Prometheus-backed global MeterProvider is installed — +// same pattern as core/services/routing/pii. +var ( + agentMetricsOnce sync.Once + runsCounter metric.Int64Counter + runSeconds metric.Float64Histogram +) + +func recordAgentRun(agent, outcome string, seconds float64) { + agentMetricsOnce.Do(func() { + meter := otel.Meter("github.com/mudler/LocalAI") + if c, err := meter.Int64Counter( + "localai_agent_runs_total", + metric.WithDescription("Agent chat runs, labeled by agent and outcome (completed|error|cancelled)"), + ); err == nil { + runsCounter = c + } + if h, err := meter.Float64Histogram( + "localai_agent_run_seconds", + metric.WithDescription("Wall-clock duration of agent chat runs in seconds"), + ); err == nil { + runSeconds = h + } + }) + attrs := metric.WithAttributes( + attribute.String("agent", agent), + attribute.String("outcome", outcome), + ) + if runsCounter != nil { + runsCounter.Add(context.Background(), 1, attrs) + } + if runSeconds != nil { + runSeconds.Record(context.Background(), seconds, attrs) + } +}