Skip to main content

Metrics

The daemon serves Prometheus metrics at GET /metrics, on 127.0.0.1:10229 by default. They lead with the thing harness list cannot tell you: whether a running agent can actually get a model to answer.

On 2026-09-14 a provider's weekly quota ran out. Four agents failed every model call for about twenty hours, and harness list showed all four as running the whole time. That was accurate, because the processes were up. The supervisor watches processes, and a process can stay up while the provider refuses every request it sends. The only record of the refusals was in each agent's own transcript. The daemon now reads those transcripts and publishes what it finds next to the process state, so one alert can check both.

Scraping it​

Loopback needs no auth:

# prometheus.yml (vmagent accepts the same block)
scrape_configs:
- job_name: harness
static_configs:
- targets: ["127.0.0.1:10229"]

To scrape from another machine, bind a non-loopback address and give the daemon a bearer token. If a non-loopback bind has no token, the daemon refuses to start, before it launches any harness:

[server]
metrics_listen = "0.0.0.0:10229"
metrics_token_file = "~/.config/harness/metrics.token" # chmod 600
scrape_configs:
- job_name: harness
authorization:
type: Bearer
credentials_file: /etc/prometheus/harness-metrics.token
static_configs:
- targets: ["agentbox.lan:10229"]

The token has to come from a file, for three reasons:

  • harness.toml holds no secrets (ADR-0008).
  • HARNESS_* variables may not carry credentials (SPEC-0010).
  • Every harness inherits the daemon's environment when it is spawned, so a token set as a daemon environment variable would reach every agent the daemon runs.

A file is also what Vault Agent or OpenBao templates write out. If the file is readable by group or others, the daemon logs a warning.

metrics_listen = "off" turns the listener off. You may want that on a shared machine, because any local account can connect to 127.0.0.1.

Changing either key requires a daemon restart. harness reload re-reads harness definitions but does not rebind listeners. The token file is also read once, at startup, so rotating the token means rewriting the file and then restarting the daemon.

If the port is already in use, the daemon logs an error and keeps supervising without the listener. Your scraper then sees the target as down (up == 0), and it already alerts on that. Exiting would stop every harness on the host just because one port was taken.

Alerts​

"Running, but no model call has succeeded in fifteen minutes." This is the shape of the 2026-09-14 outage:

time() - harness_last_successful_call_timestamp > 900
and on(instance, harness) harness_harness_state{state="running"} == 1

The on(instance, harness) clause is required. The state series has an extra state label, and a bare and matches only series whose label sets are identical, so without the clause this expression would never fire.

# A quota wall. It usually hits every harness that shares a provider at the same time.
sum by (instance, harness) (increase(harness_model_call_errors_total{class="quota"}[10m])) > 0

# Terminal failed state: the supervisor gave up, and only a person can restart the harness.
harness_harness_state{state="failed"} == 1

# Approaching failed: consecutive failures climb before the harness gets there.
harness_consecutive_failures >= 3

# Restart churn: a crash loop, or launches that burn a metered budget.
increase(harness_restarts_total[1h]) > 10

# The error classifier no longer recognises a provider's errors (the wording probably changed).
increase(harness_model_call_errors_unclassified_total[1h]) > 0

# The [notify] hook stopped reaching anyone: it errors, times out, or its queue overflows.
increase(harness_notify_deliveries_total{result=~"error|timeout|dropped"}[1h]) > 0

# The daemon's own collection is broken or losing events.
increase(harness_metrics_collection_errors_total[15m]) > 0

# The endpoint is unreachable: the daemon is down, or the port could not bind.
up{job="harness"} == 0

# A trigger source has been deaf for ten minutes: a channel out of `connected`,
# or a webhook source that is not being served.
max_over_time(harness_trigger_source_up[10m]) == 0

# Someone is knocking with the wrong credential, or a sender's secret rotated.
increase(harness_trigger_events_total{outcome="unauthorized"}[15m]) > 0

# A channel link that keeps dropping.
increase(harness_trigger_reconnects_total[1h]) > 5

harness_trigger_source_up is 0 for a source that is disabled or unbound as well, so scope the first rule to the sources you expect to be live, for example with {source=~"channel.sb|webhook.gitea-pr"}.

Daemon memory​

The daemon's own memory is on the same scrape. In September 2026 a daemon reached 12 GB because every spawn leaked a terminal emulator of about 98 MiB (https://github.com/stump-wtf/harness/issues/18). The leak signature is something that should return to a baseline climbing in step with the run count instead:

  • go_goroutines rising by a fixed step per spawn and never falling back;
  • go_memory_classes_heap_objects_bytes or go_memstats_heap_objects rising in step with harness_state_transitions_total{to="starting"}, which counts spawns.

A high footprint with a flat live heap is GC headroom, not a leak: go_memory_classes_heap_free_bytes and go_gc_heap_goal_bytes are high, and [daemon] memory_limit is the lever. The two need different fixes, so graph them together:

# Live heap against the GC's target and the total mapped.
go_memory_classes_heap_objects_bytes{job="harness"}
go_gc_heap_goal_bytes{job="harness"}
go_memory_classes_total_bytes{job="harness"}

# Goroutines left behind per spawn over six hours. A healthy daemon hovers
# near 0; a leak holds near a constant (1 or more per spawn).
delta(go_goroutines{job="harness"}[6h])
/ on(instance) clamp_min(sum by (instance) (increase(harness_state_transitions_total{job="harness",to="starting"}[6h])), 1)

An alert on the second expression at > 0.5 fires on a per-spawn leak within a few dozen runs. Tune the window to how often your harnesses spawn. To find what is leaking, see Diagnosing daemon memory.

What is exported​

SeriesTypeNotes
harness_harness_state{harness,state}gaugerunning, stopped, failed, flapping. Every declared harness reports all four, with 1 for its current state and 0 for the others.
harness_restarts_total{harness}counterThe RESTARTS column of harness list.
harness_consecutive_failures{harness}gaugeThe supervisor's give-up count. Once it exceeds the restart budget, the harness moves to failed.
harness_state_transitions_total{harness,to}counterOne series per supervisor state (to has 7 values), starting at zero.
harness_model_calls_total{harness,outcome}countersuccess counts tool calls. error counts agent error marks.
harness_agent_program_calls_total{harness,program,outcome}counterShell tool calls by the program they run. outcome is ok or error, taken from the tool result. A program is a lowercase executable name; anything else, and every name past 64 for one harness, is counted as program="other". A program never run is absent, not zero.
harness_model_call_errors_total{harness,class}counterquota, auth, timeout, transport, other. Crush and Claude Code harnesses only, for now; see below.
harness_model_call_errors_unclassified_total{harness}counterErrors that matched no known pattern. Each one is also counted as class="other".
harness_last_successful_call_timestamp{harness}gaugeUnix seconds. Absent until the harness's first success in this daemon's lifetime. It is never reported as zero, because zero would read as 1970.
harness_sessions_started_total{harness}counterAgent sessions the daemon has seen become active.
harness_session_active{harness}gauge1 when the process is up and the agent wrote to a session in the last 10 minutes.
harness_scheduled_runs_total{harness,outcome}counterScheduled harnesses only, counted from the run ledger. success, or failure (a run that failed, timed_out, budget_exceeded, model_mismatch or model_unattested). Skipped, missed, cancelled, interrupted and quota-parked runs are not counted.
harness_runs_total{harness,kind,outcome}counterEvery closed run in the run ledger, one-shot (kind="oneshot") and resident process lifetimes (kind="resident"). Every harness reports every outcome its kind can produce, starting at zero. harness runs --since … --outcome … --json returns the same records.
harness_run_duration_seconds{harness,kind}histogramClosed runs with a known end. Buckets from 1s to 24h.
harness_run_tokens_total{harness,type}counterinput, output, cache_read, cache_write. Absent until a run records token usage, which needs agent-trace usage items.
harness_run_cost_usd_total{harness,source}counterrecorded (the agent recorded it) or priced (from a price table). An unknown cost is not counted. Absent until a run carries a cost.
harness_ledger_append_errors_totalcounterFailed run ledger writes and syncs, retries included. Non-zero means the ledger's disk is failing.
harness_ledger_bytesgaugeTotal size of the run ledger's day files.
harness_run_feed_dropped_total{subscriber}counterRun records a feed subscriber missed because it fell behind. subscriber="metrics" above zero means the run counters above read low.
harness_scheduled_next_run_timestamp{harness}gaugeScheduled harnesses only. Absent when there is no next window.
harness_template_render_failures_total{harness,reason}countercommand harnesses only, both reasons starting at zero. unresolved: a required argv template value was absent, so the run was recorded skipped (template_unresolved) or the start failed. grammar: a template did not parse at spawn. Nothing was exec'd either way.
harness_trigger_source_up{source,kind}gaugeEvery declared [channel.*] and [webhook.*] source. 1 while a channel is connected or a webhook source is listening; 0 in every other state (connecting, backoff, error, no_listener, disabled, unbound).
harness_trigger_events_total{source,outcome}counterfired, ignored, duplicate, unauthorized, too_large, rate_limited, invalid. Every declared source reports all seven, starting at zero. See below.
harness_trigger_last_event_timestamp{source}gaugeUnix seconds of the source's latest firing. Absent until it first fires in this daemon's lifetime.
harness_trigger_reconnects_total{source}counterChannel sources only. Times the stream came back to connected after the first connection.
harness_metrics_collection_errors_total{collector}countersupervisor, schedule, observer, lifecycle, triggers, runs. observer and lifecycle also count events the collector lost because it fell behind, so the matching counters read low; runs counts a run feed that closed while the daemon ran.
harness_metrics_harnesses_overflowedgaugeHow many harnesses were folded into __other__.
harness_notify_deliveries_total{event,result}counterRuns of the [notify] hook: ok, error, timeout, and notifications that never ran it, dropped (queue full) and suppressed (inside the cooldown).
harness_observer_*mixedHealth of the transcript reader: delivered and dropped events, ambiguous and unattributed items, parse errors, scan errors, sessions tracked.
go_goroutinesgaugeThe daemon's goroutines. It returns to a baseline between runs; a count that stays up by a fixed step per spawn is a leak. See Daemon memory.
go_memory_classes_heap_objects_bytesgaugeLive heap: memory held by objects the program can still reach, plus garbage not yet swept. This is what a leak grows.
go_memory_classes_heap_free_bytes, go_memory_classes_heap_released_bytesgaugeHeap the GC has freed. free is still mapped; released has been returned to the OS. Growth here is GC headroom, not a leak.
go_memory_classes_*_bytesgaugeThe rest of the runtime's breakdown: stacks, metadata, profiling buckets, other. go_memory_classes_total_bytes is all of it, which is close to what the OS sees as the daemon's Go memory.
go_gc_heap_goal_bytesgaugeThe heap size the GC is steering for. With the default GOGC=100 it sits near twice the live heap. Under [daemon] memory_limit it flattens below the limit.
go_gc_gomemlimit_bytesgaugeThe soft memory limit in effect, from memory_limit or GOMEMLIMIT. 9.223372036854776e+18 (MaxInt64) means none.
go_memstats_heap_objectsgaugeAllocated heap objects. It rises with a leak, in step with spawns.
other go_*, process_*The rest of the Go collector's defaults (GC pauses, go_memstats_*, threads) and the process collector (process_resident_memory_bytes, CPU, file descriptors).

What "running" means​

The supervisor has seven states and the metric has four:

Supervisor statestate label
failedfailed
held by its operating_hours (any state other than failed)stopped
degraded, or any state while the crash-loop flag is setflapping
running, startingrunning
stopped, stopping, restarting (not in a crash loop)stopped

A harness held outside its operating hours is down on purpose, so it reads stopped, never failed or flapping. The metric does not tell a held harness apart from one an operator stopped.

Where the model-call numbers come from​

The daemon reads the transcript each agent writes: Claude Code's JSONL, Crush's SQLite store, Codex's session files, or Pi's session files. It counts a tool call as a successful model call, because the model answered with work. It counts an error mark as a failed one. A turn that ends in plain text with no tool call is not counted.

Only a harness whose adapter writes a readable transcript (claude-code, crush, codex, pi, omp, opencode, or a command harness bound to one of those with transcripts) and that has a workdir gets model-call series. A generic harness, an unbound command harness, or an agent harness with no workdir, has none. The daemon omits values it cannot compute rather than reporting a zero, which would look like a healthy, idle agent.

Crush records provider errors in its transcript as a failed turn. Claude Code records them as flagged API-error records (rate_limit (429): …, server_error: …), which the daemon reads and classifies. Codex successes are counted, but its provider errors do not appear yet.

Until an adapter's errors do appear, its harnesses have no error-side series: harness_model_calls_total{outcome="error"}, harness_model_call_errors_total and harness_model_call_errors_unclassified_total are absent for codex, pi, omp and opencode harnesses today, not zero. A zero would say "no quota errors" through the very outage the quota alert exists to catch. For those harnesses, the staleness alert on harness_last_successful_call_timestamp is the one that fires.

Error classes​

The daemon classifies each error when it reads it, so alert rules never need to match provider wording:

  • quota: rate limits, spent weekly or monthly allowances, credit balances (429, 402, rate_limit_error, insufficient_quota, "usage limit"). Restarting does not fix these.
  • auth: missing, invalid or expired credentials (401, 403, "invalid api key", authentication_error).
  • timeout: the provider accepted the request but took too long (408, 504, "deadline exceeded").
  • transport: the provider could not be reached or could not serve the request: refused or reset connections, DNS and TLS errors, timeouts while connecting (dial tcp …: i/o timeout, a TLS handshake timeout), 500, 502, 503, and provider overload (529 overloaded_error). Overload counts as transport, not quota. It reflects the provider's capacity, has no reset time, and clears on its own.
  • other: everything else. This includes context-window rejections. The daemon recognises those (its session guard rotates a Crush session that is wedged on them), so they are counted as other but never as unclassified.

If the unclassified counter starts rising, a provider has probably changed its error wording.

Trigger outcomes​

Each webhook delivery to a served route, and each channel doorbell, is counted once, under how it ended:

outcomeMeaning
firedReached the bound harnesses. Each harness then starts, queues or skips the run, and that decision is in its run history, not here.
ignoredA webhook event not in the source's events list, or an event for a source a reload had just unbound. Answered 202, nothing runs.
duplicateA delivery ID that already fired on the route in the last 24 hours.
unauthorizedFailed verification: 401.
too_largeBody over max_body: 413.
rate_limitedOver the route's rate_limit: 429.
invalidA channel message that is not a well-formed doorbell, or a webhook body the sender stopped sending partway.

A request to a route that is not served (unknown, disabled or unbound) is a 404 and is not counted anywhere, so a stranger probing /hooks/ cannot create series. A 405 and a 503 (the listener at its concurrency limit) are not counted either: they say something about the request or the listener, not about the source.

Cardinality​

The harness label is capped at 50 distinct values. Harnesses beyond the cap share harness="__other__", where counters add up and harness_harness_state counts how many overflow harnesses are in each state. When a harness is removed, its slot is freed. Session IDs, prompts, model names, credentials and environment values never appear as labels.

harness_agent_program_calls_total's program label is capped at 64 distinct names per harness, and only names shaped like a lowercase executable are kept, so an env assignment, an argument or a path is counted as program="other" rather than becoming a label. Overflow folds the same way. A query for one program is still exact; sum by (program) just shows other as the long tail.

The source label takes only the names of sources your config declares, so it has no cap of its own. A source that a reload removes or renames disappears from the next scrape; the new name starts from zero. Delivery IDs, event names, payloads and doorbell text never appear as labels.