feat: add chat lifecycle stage tracing, turn accounting, and Grafana dashboard - #28741
Draft
jscottmiller wants to merge 19 commits into
Draft
feat: add chat lifecycle stage tracing, turn accounting, and Grafana dashboard#28741jscottmiller wants to merge 19 commits into
jscottmiller wants to merge 19 commits into
Conversation
Instrument chatd with OpenTelemetry spans and a Prometheus histogram
covering the full chat turn lifecycle: queue wait, capacity wait,
acquisition, preparation, MCP connect, provider attempts, streaming,
time to first token, thinking, tool calls, commit, and compaction.
Spans and coderd_chatd_stage_duration_seconds{stage,scope,model,effort}
observations are emitted from one shared StageTracer path so traces and
metrics cannot drift. Turn-scoped work is separated from detached
background quickgen calls via the scope label, and the resolved model
and effective reasoning effort are recorded as both span attributes and
histogram labels.
Add a Grafana dashboard (examples/monitoring/dashboards/grafana/
chatd-lifecycle) with an aggregate stage flamegraph driven by a
selectable statistic (mean, p50, p90, p95, p99), stage trends, time
share, scheduling waits, TTFT, throughput, and background provider
call panels, filterable by model and effort.
Contributor
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
…e cases Address panel review findings on the chat lifecycle instrumentation: - Carry the stage scope on an explicit context key instead of inferring it from OTel span-context validity, which mislabeled every in-turn stage as background on deployments without tracing enabled. - Extend histogram buckets to ~2.9h so multi-hour turns and waits do not clamp p99 at the previous ~11m top bucket. - Skip the histogram observation for time_to_first_token when the stream fails or ends without a part, so error windows do not pollute the TTFT distribution. - Record queue_wait from PromoteQueued as a standalone turn-scoped span instead of splicing into the promoting HTTP request trace. - Dashboard: drop model/effort matchers from empty-label stages so a concrete model selection no longer zeroes the flamegraph root and wait panels, and report self time as leaf-only instead of duplicating total time.
Carry the chat kind (root or subagent) on the turn context alongside the stage scope so every turn-scoped stage, including the pre-model stages, records it as a histogram label and span attribute from the shared StageTracer path. Detached background work carries the kind of the chat that triggered it when known. Add a chat_kind dashboard variable applied to every turn-scoped panel, including the time-share denominator and wait panels, and document the root versus subagent distinction.
Accumulate per-turn stage totals on the turn context and emit them when chat_turn ends: coderd_chatd_turn_stage_seconds and coderd_chatd_stage_share_of_turn per stage, and an exclusive partition of turn wall time as coderd_chatd_turn_time_seconds and coderd_chatd_turn_time_share per category (scheduling, time_to_first_token, streaming, provider_error, retry_backoff, tool_execution, compaction, chatd_overhead, unattributed). Children attribute self time to their nearest partitioning ancestor so the categories telescope to the turn duration. Only completed turns emit. Rotate chat_turn when a queued message is promoted so one turn covers one prompt, and stamp the resolved model and effort on the turn root. Dashboard: add a turn time partition row (per-model mix, seconds per turn by category, unattributed time, per-turn category share), per-turn stage share and stage seconds panels, and retire the aggregate-ratio time-share panel whose observations landed at different times.
Add coderd_chatd_turn_stage_count, observed per stage per completed turn with the number of occurrences of that stage in the turn, alongside the per-turn seconds and share. Replace the trend panels that mixed nesting levels and units of observation (one sample per turn beside one sample per occurrence) with one row per tree level: turn, turn children, step children, and stream children, each showing duration per occurrence, seconds per turn, occurrences per turn, and share of turn for that level's stages only. The flamegraph remains the deliberate cross-level view.
The bar chart right-anchored its category labels, so the tree indentation was applied at the wrong end and the connectors misaligned in a proportional font. Render the hierarchy as a table with a left-aligned, fixed-width-indented stage column and a gauge cell for the duration, and add the retry_backoff stage to both profile panels.
…e hierarchy Add a Count column to the hierarchy table with the number of occurrences of each stage in the dashboard time range, joined to the duration rows by stage label so depth-first order and zero rows are preserved.
…on column The gauge cell defaulted to the frame-wide max, which the new Count column dominated, so every duration bar rendered as a sliver.
…panel Turns per minute and occurrences per turn at each level cover what it showed without mixing per-turn and per-occurrence rates.
The hierarchy table beside it carries the same rows with level and occurrence counts, so the flamegraph takes its full panel width.
…p ends The finishing transition runs inside the last generation step, and the turn's accounting was emitted from there, before the step's own stage had ended. Complete now only marks the turn finished; StartGeneration settles it once runGenerationStep has returned, so the finishing step is counted and the span closes at turn end instead of at runner teardown. Each task carries a turn token so a task that outlives its turn cannot complete or invalidate the turn that replaced it, and Invalidate no longer finishes the turn, so a retried task continues the same turn rather than opening a second one with a duplicate acquisition.
…accounting Stages started on a context derived from a turn's inherited the turn's accumulator even when they ran in the background scope or under a nil tracer, so detached title, summary, and status label generation was counted as the user's turn. Only turn-scoped stages started by a live tracer now report to the accumulator on their context.
… fix its bookkeeping capacity_wait is measured by the acquisition loop before a turn exists, so it never reached the turn accumulator, and the three per-turn level 1 panels that matched it stayed empty. Its window also lies inside the acquisition stage the turn records, so categorizing it would double count scheduling time. The stage now feeds the per-occurrence profile only, and the per-turn panels and README say so. The acquisition loop now prunes wait starts only when the candidate batch is complete, since a chat missing from a truncated batch is still waiting, and drops the start when a candidate is skipped for a reason other than capacity. Timestamps come from the worker clock.
…tage histogram buckets The turn partition was documented as never exceeding the turn duration, but the code only floored the unattributed remainder at zero, so over-attributed turns were emitted with shares summing past 1 and nothing showed it. Those turns are now counted in a new coderd_chatd_stage_anomalies_total counter, alongside stage windows dropped for inverted clocks and turns dropped for a non-positive duration, which were also silent. Buckets now cover the values the metrics describe: durations start at 0.5ms instead of 10ms so the sub-10ms stages resolve, the per-stage share histogram extends past 1 since overlapping and repeating stages exceed the turn, and per-turn stage counts extend past the 1200 step limit instead of stopping at 128.
The queue wait recorded on promotion ended at the wall clock while every other stage window ends at the tracer's clock.
Drop comment text that restated the code, described a specific caller or callee, or explained the change rather than the mechanism.
This was referenced Sep 4, 2026
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.
Instruments chatd with OpenTelemetry spans and Prometheus histograms covering the full lifecycle of an Agents chat turn, adds per-turn accounting that partitions each turn's wall time into disjoint categories, and adds a Grafana dashboard that renders the stage profile as a flamegraph and by stage tree level.
This is an experimental branch. It will be split into a stack of smaller PRs and closed; the description here is kept current so the branch can be reviewed as a whole in the meantime.
What's included
chatloop.StageTracer: emits each lifecycle stage as both an OTel span and an observation oncoderd_chatd_stage_duration_seconds{stage, scope, chat_kind, model, effort}from a single code path. Stages:chat_turn,queue_wait,capacity_wait,acquisition,generation_step,prepare,mcp_connect,provider_attempt,time_to_first_token,stream,thinking,tool_call,commit,compaction,retry_backoff.chatloop.TurnAccumulator): when a turn finishes normally, it emitsturn_stage_seconds,turn_stage_count, andstage_share_of_turnper stage, andturn_time_secondsandturn_time_shareper category. Categories (scheduling,time_to_first_token,streaming,provider_error,retry_backoff,tool_execution,compaction,chatd_overhead,unattributed) are disjoint and sum to the turn. Nested stages report their time to their parent so the parent's category receives only its own time.chat_turnspan per prompt: the runner opens a turn span on the first generation task and replaces it when a finish transition promotes a queued message (anchored at the moment that message was queued) or when a new prompt starts a task after the previous turn finished. Turns close in two steps: the finishing transition marks the turn complete from inside the generation step, and the span settles once that step's own stage has ended. Each task carries a turn token so a task that outlives its turn cannot complete or invalidate the one that replaced it.scope="background", starts its own trace roots, and never reports to the turn accumulator.chat_kind(root/subagent) is on every span and metric.provider_attemptvia the transport.coderd_chatd_stage_anomalies_total{reason}: counts stage observations dropped for inverted clocks, finished turns dropped for a non-positive duration, and turns whose categories summed to more than their duration.examples/monitoring/dashboards/grafana/chatd-lifecycle/: stage flamegraph and hierarchy table with a$statselector, the turn time partition, and one row per stage tree level (duration per occurrence, seconds per turn, occurrences per turn, share of turn), filterable by$chat_kind/$model/$effort. The README documents every panel.capacity_waitis a per-occurrence stage only: it is measured by the acquisition loop before the turn exists and its window lies insideacquisition, so it is absent from the category map and the per-turn panels.Verification
Verified end to end against a live dev deployment with Tempo + Prometheus + Grafana on the initial instrumentation: 100+ real chat turns across two models, exact count agreement between spans and histogram observations for every stage, clean span nesting, HTTP >= 400 provider attempts marked as span errors, dashboard rendering in Grafana 11.4.
The turn accounting, rotation, and the review fixes below are covered by unit tests in
coderd/x/chatdandcoderd/x/chatd/chatloop(race detector clean) and have not yet been re-verified against a live deployment.Not sampled in the test environment:
capacity_wait(premium license disables the concurrent-agent cap),mcp_connect(no MCP servers configured),compaction(context never neared the threshold).Review fixes
A four-persona panel review (concurrency, lifecycle, observability, hidden behavior change) found no request-path regression but several defects in the numbers. Fixed in the last five commits:
Invalidateafter a promotion hit the newly opened turn rather than the failed one, and a retried task opened a secondchat_turnroot with a duplicateacquisition.capacity_waitcould never reach the accumulator, so three per-turn panels were permanently empty; its acquisition-loop bookkeeping also reset under load and kept stale entries across skips.Notes and known limitations
chat_turnspans are standalone trace roots: the turn executes asynchronously on a worker (possibly another replica) and no trace context is persisted, so linking to the originating HTTP request span is not possible.schedulingor drops the sample (counted instage_anomalies_total).requires_actionwait stays inside the turn span with no category, so human-in-the-loop turns report largeunattributedtime.reasoning_effortvseffortlabel naming, the flamegraph mixing per-occurrence and per-turn units, the provider transport wrapper being installed unconditionally, and the Anthropic quickgen 400 (temperaturewith thinking) that is now visible asscope="background"error spans.Implementation plan
Chat lifecycle observability: flamegraph + summary dashboard
Goal
A Grafana dashboard showing where time goes across all Agents chat sessions: an aggregate flamegraph of lifecycle stages with a selectable summary stat (mean, median, p90/p95/p99), plus per-stage summary panels. Per-session drill-down comes for free via Tempo traces.
Stage model
stream,thinking, andtool_calloverlapprovider_attempt/each other in wall time; the flamegraph is a stage-time profile, not a strict decomposition. The turn time partition is the strict decomposition: attributing stages (generation_step,prepare,mcp_connect,commit,stream,time_to_first_token,compaction,retry_backoff) report their time to their parent, and the remainder of each is categorized.Work items
chat_turnroot span per prompt; child spans per the stage model; attributes for provider, model, effort, chat kind, generation attempt, tool name, error status.Resolved decisions
examples/monitoring/dashboards/grafana/<name>/dashboard.json.chat_turnspans are standalone roots; no trace context is persisted across the async worker boundary.capacity_waitis not categorized; it is a sub-window ofacquisition.Review and fix plan
Panel review with four personas, then fixes in phases: (A) turn accounting correctness in
turn_trace.go/generation.go/stage.go, (B) capacity wait, (C) partition enforcement and bucket calibration, (D) clocks. Deferred to follow-up issues: error dimension,requires_actioncategory, cardinality documentation, semconv attribute names, flamegraph units, unconditional transport wrapper.Then: split into a PR stack
runGenerationStep🤖 This PR was generated by Coder Agents on behalf of @jscottmiller.