feat: Observability and metrics - #848
Merged
Merged
Conversation
lmorchard
reviewed
Aug 19, 2026
lmorchard
left a comment
Owner
There was a problem hiding this comment.
Do these metrics need to use a SQLite table? Or could we just maintain these metrics in a JSONL file?
lmorchard
added a commit
that referenced
this pull request
Sep 15, 2026
Four issues from the review of #848, each with a regression test: - The `model` label was `ctx.active_model`, which is "" unless the conversation pinned a named model config — so every default-model call landed in `llm_calls_total{model=""}`, which Prometheus cannot tell apart from an absent label. Publish the model actually used: explicit override, else pinned config, else `default_model`. - `record_metric` opened a fresh SQLite connection and re-ran the DDL on every metric, then committed, then appended JSONL — all synchronously. `EventBus.publish` awaits subscribers inline on the publishing coroutine, so that stalled the agent loop (and every concurrent tool call, stream chunk, and WebSocket send) for ~3ms per tool call. Split the in-memory update (cheap, stays on the loop) from `_persist` (offloaded via `asyncio.to_thread`), and reuse one connection per path. - The subscriber was wired unconditionally while its four siblings are each gated on `config.telemetry.*_enabled`, leaving no way to switch off the disk writes. Add `telemetry.metrics_enabled` and gate it. - Neither sidecar rotated or pruned, and both sat at the workspace root where the Files tab and `workspace_recent` walks surface them. Move them under `telemetry/` via config, rotate the JSONL through the shared `rotate_if_needed`, prune the table on connection open, and index it. Also hoists the function-level `import time` in agent.py to module level per CLAUDE.md, since there is no cycle to break there.
lmorchard
force-pushed
the
feat/metrics
branch
from
September 15, 2026 20:28
b1d659b to
7c90347
Compare
Five issues from review, each with a regression test verified to fail
without its fix:
- The `model` label was `ctx.active_model`, which is "" unless the
conversation pinned a named model config — so every default-model call
landed in `llm_calls_total{model=""}`, which Prometheus cannot
distinguish from an absent label. Publish the model actually used:
explicit override, else pinned config, else `default_model`.
- Label values were not escaped. A quote, backslash, or newline produced
a malformed exposition line, and Prometheus fails the *entire* scrape
on one parse error — so a single oddly-named model config would drop
every metric, not just its own.
- The subscriber was wired unconditionally while its four siblings are
each gated on `config.telemetry.*_enabled`. Added
`telemetry.metrics_enabled`.
- `record_metric` opened a fresh SQLite connection, re-ran the DDL,
committed, and appended JSONL on every metric — all synchronously,
while `EventBus.publish` awaits subscribers inline on the publishing
coroutine. That stalled the agent loop, and with it every concurrent
tool call, stream chunk, and WebSocket send.
- Neither sidecar rotated or pruned, and both sat at the workspace root
where the Files tab and `workspace_recent` walks surface them.
The last two are fixed by removing their cause rather than managing it.
Neither sidecar had a reader — no module, no `make` target, no query
helper — and Prometheus, the intended consumer, owns retention and
querying on its side of the scrape. They were two write-only copies of
data the scrape target already stores, so they are gone, along with the
thread offload, the pooled connection and lock, the rotation call, the
retention prune, and the index. `record_metric` is now allocation-only,
which removes the event-loop stall by construction instead of relocating
it to a worker thread. `metrics.py` is 139 lines, down from 264.
`make_metrics_subscriber()` no longer takes `config`. That is
load-bearing: with no config there is no route to `workspace_path`, so a
sidecar cannot return by accident. A structural test pins it alongside
"this module imports nothing that does I/O".
Trade-off accepted: counters reset on restart. Prometheus handles that
for `rate()`/`increase()` via counter-reset detection, so rate-based
dashboards are unaffected, but lifetime totals across restarts are not
available. If local history without Prometheus is ever wanted, the answer
is a scrape target, not a sidecar here.
Docs: new docs/metrics.md wired into docs/index.md and cross-linked from
observability.md and config.md; `metrics.py` added to the CLAUDE.md
key-files list. docs/data-layout.md was missing `telemetry/` from the
workspace tree entirely, so the directory was added with its existing
contents. Known gaps recorded rather than left silent: histograms declare
a type whose `_bucket` series they do not emit, cancelled LLM calls count
as completed, and the OpenAPI schema declares the wrong media type (#843).
Also hoists a function-level `import time` in agent.py per CLAUDE.md, and
stubs both LLM seams in the model-label tests — `_call_llm_with_events`
branches on `resolve_streaming` and the streaming branch imports
`call_llm_streaming` from `.llm` inside the function, so patching one
seam left the other live against a LAN default URL. Filed as #851.
lmorchard
force-pushed
the
feat/metrics
branch
from
September 15, 2026 21:09
a3207e4 to
02e34e7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #10
Prometheus-scrapable counters and latency for the agent loop, derived entirely from EventBus events (
llm_end,tool_end,loop_breaker) — no new call sites in the agent loop. Exposed atGET /metrics, with durable sidecars undertelemetry/for after-the-fact analysis. See docs/metrics.md.Acceptance criteria
pytest tests/test_metrics.py::test_llm_call_latency_recordedpytest tests/test_metrics.py::test_tool_usage_metrics_recordedpytest tests/test_metrics.py::test_metrics_endpoint_or_querymodellabel resolves on the default-model pathpytest tests/test_metrics.py -k llm_end_carries_resolved_modelpytest tests/test_metrics.py -k does_no_ioconfig.telemetry.metrics_enabled-gatedpytest tests/test_metrics.py -k is_config_gatedpytest tests/test_metrics.py -k escapedReview fixes
Five issues from review, each with a regression test verified to fail without its fix:
modellabel was empty for nearly every call. It publishedctx.active_model, which is""unless the conversation pinned a named model config — so all default-model traffic collapsed intollm_calls_total{model=""}, which Prometheus cannot distinguish from an absent label. Now publishes the model actually used: explicit override, else pinned config, elsedefault_model.record_metricopened a fresh SQLite connection and re-ran the DDL per metric, then committed, then appended JSONL — all synchronously, whileEventBus.publishawaits subscribers inline on the publishing coroutine. That stalled every concurrent tool call, stream chunk, and WebSocket send for ~3ms per tool call. Initially fixed with a thread offload and a pooled connection — then superseded: see below.runner.pyare each gated onconfig.telemetry.*_enabled, leaving no way to switch off the disk writes. Addedtelemetry.metrics_enabled.workspace_recentwalks surface them. Initially fixed by moving them undertelemetry/with rotation and pruning — then superseded: see below.Also hoisted a function-level
import timeinagent.pyper CLAUDE.md.Then: the sidecars came out entirely (
a3207e46)Reviewing the fixes surfaced a better question than "how do we make these writes safe." Neither sidecar had a reader — no module, no
maketarget, no query helper — and Prometheus, the intended consumer, owns retention and querying on its side of the scrape. They were two write-only copies of data the scrape target already stores.So they're gone, and most of the machinery above went with them: no
_persist, noasyncio.to_threadoffload, no pooled connection or lock, no rotation call, no retention prune, no index.record_metricis allocation-only, which solves the event-loop stall by construction instead of relocating it to a worker thread.metrics.pywent from 264 lines to 139; the commit is net −255.The subscriber factory no longer takes
config. That's load-bearing rather than cosmetic: with no config there's no route toworkspace_path, so a sidecar can't reappear by accident. A structural test pins that alongside "this module imports nothing that does I/O."What survives from the original review: the model-label fix and the label escaping, both of which are about the exposition — now the only surface.
metrics_enabledsurvives as the gate on wiring the subscriber;metrics_pathandmetrics_db_pathare gone.Trade-off accepted: counters reset on restart. Prometheus handles that for
rate()/increase()via counter-reset detection, so rate-based dashboards and alerts are unaffected, but lifetime totals across restarts aren't available. If local history without Prometheus is ever wanted, the answer is a scrape target, not a sidecar here.Docs
New
docs/metrics.md, wired intodocs/index.mdand cross-linked fromobservability.mdandconfig.md; three new rows in the config table;metrics.pyadded to the CLAUDE.md key-files list.docs/data-layout.mdwas missingtelemetry/from the workspace tree entirely, so the directory was added with its existing contents alongside the new files.Known gaps, documented not fixed
Recorded in docs/metrics.md rather than left silent:
_sum/_count, no_bucketseries, sohistogram_quantile()returns empty and the# TYPE ... histogramline overstates what is there.llm_endwith full wall-clock duration, inflating the latency average.application/jsonfor this endpoint'stext/plainresponse. Latent until the generated client is imported (Generated OpenAPI TS client is unusable: no emit step, and every route signature is untyped #843)./metricsis unauthenticated, like/health, on a default0.0.0.0bind — a deliberate choice to confirm, not an omission./metricsis only useful with something scraping it on an interval. Deliberate — see the trade-off above.Merge gate
Rebased onto
main(d78f1095) and force-pushed, so the prior gate's CI reference at3487ae9dno longer exists. Re-run against the current head.Note on the first CI run at this head
The two
model-label tests initially failed CI withhttpx.ConnectErrorwhile passing locally. They stubbedagent.call_llm, but_call_llm_with_eventstakes thecall_llm_streamingbranch by default (llm.streamingdefaults true) and imports it from.llminside the function, so that seam stayed live — and the defaultllm.urlis a LAN address that is reachable from a dev machine but not from CI. Fixed in083443dcby stubbing both seams, asserting the stub recorded the call, and parametrizing over streaming on/off. Filed as #851 so the next test to make this mistake fails locally instead of only in CI.