Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: add chat lifecycle stage tracing, turn accounting, and Grafana dashboard - #28741

Draft
jscottmiller wants to merge 19 commits into
mainfrom
scott/x/chatd-lifecycle-observability
Draft

feat: add chat lifecycle stage tracing, turn accounting, and Grafana dashboard#28741
jscottmiller wants to merge 19 commits into
mainfrom
scott/x/chatd-lifecycle-observability

Conversation

@jscottmiller

@jscottmiller jscottmiller commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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 on coderd_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.
  • Per-turn accounting (chatloop.TurnAccumulator): when a turn finishes normally, it emits turn_stage_seconds, turn_stage_count, and stage_share_of_turn per stage, and turn_time_seconds and turn_time_share per 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.
  • One chat_turn span 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 and chat kind: detached background work (title, summary, status label) is scope="background", starts its own trace roots, and never reports to the turn accumulator. chat_kind (root/subagent) is on every span and metric.
  • Model and effort: the resolved model and effective reasoning effort are span attributes and histogram labels, threaded to provider_attempt via 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.
  • Grafana dashboard at examples/monitoring/dashboards/grafana/chatd-lifecycle/: stage flamegraph and hierarchy table with a $stat selector, 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_wait is a per-occurrence stage only: it is measured by the acquisition loop before the turn exists and its window lies inside acquisition, 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/chatd and coderd/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:

  • The turn's accounting was emitted from inside the final generation step, before that step's stage ended, so the finishing step was lost on every turn that promoted a queued message; the span also closed at runner teardown instead of turn end, inflating every share's denominator.
  • Invalidate after a promotion hit the newly opened turn rather than the failed one, and a retried task opened a second chat_turn root with a duplicate acquisition.
  • Background work derived its context from the turn's and inherited the accumulator, so title/summary generation was counted as the user's turn.
  • capacity_wait could 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.
  • Over-attributed turns were emitted silently; duration buckets started at 10ms while several stages are sub-10ms; share buckets stopped at 1 while stages can exceed the turn; stage count buckets stopped at 128 against a 1200 step limit.

Notes and known limitations

  • chat_turn spans 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.
  • The turn's start comes from the trigger message's database timestamp while its end comes from the worker's clock, so clock skew between Postgres and a replica lands in scheduling or drops the sample (counted in stage_anomalies_total).
  • A requires_action wait stays inside the turn span with no category, so human-in-the-loop turns report large unattributed time.
  • No error dimension on the new metrics; turns that fail are excluded from the per-turn rollup, so turn percentiles describe successful turns only.
  • Roughly 20k to 100k new series per replica depending on distinct model/effort pairs. Operator sizing note still to be written.
  • Follow-ups not addressed here: OTel semconv names for the HTTP span attributes, reasoning_effort vs effort label naming, the flamegraph mixing per-occurrence and per-turn units, the provider transport wrapper being installed unconditionally, and the Anthropic quickgen 400 (temperature with thinking) that is now visible as scope="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

chat_turn
├── queue_wait          chat_queued_messages insert -> promotion
├── capacity_wait       capacity.go limiter acquire (per-occurrence profile only)
├── acquisition         trigger message insert -> Acquire() applied
└── generation_step (per step, repeats)
    ├── prepare         prompt build, model resolution, context hydration
    │   └── mcp_connect mcpclient connect
    ├── provider_attempt  one HTTP round trip (per attempt)
    ├── stream          stream open -> stream close
    │   └── time_to_first_token   stream open -> first part
    ├── retry_backoff   wait between provider attempts
    ├── thinking        reasoning part created_at -> completed_at
    ├── tool_call       per local tool call start -> completion
    ├── commit          CommitStep DB txn
    └── compaction      auxiliary compaction LLM call

stream, thinking, and tool_call overlap provider_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

  1. OTel spans in chatd: chat_turn root span per prompt; child spans per the stage model; attributes for provider, model, effort, chat kind, generation attempt, tool name, error status.
  2. Prometheus histograms recorded at the same points the spans end (shared helper so spans and metrics cannot drift).
  3. Per-turn accounting emitted once when a turn finishes normally.
  4. Grafana dashboard JSON in repo, organized by stage tree level.

Resolved decisions

  1. Dashboard JSON path: examples/monitoring/dashboards/grafana/<name>/dashboard.json.
  2. chat_turn spans are standalone roots; no trace context is persisted across the async worker boundary.
  3. A turn is one prompt, not one runner: the span rotates on promotion and on the next prompt.
  4. capacity_wait is not categorized; it is a sub-window of acquisition.
  5. Over-attribution is emitted as measured and counted, not scaled, so the violation stays visible.

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_action category, cardinality documentation, semconv attribute names, flamegraph units, unconditional transport wrapper.

Then: split into a PR stack

  1. refactor: extract runGenerationStep
  2. feat: stage tracer
  3. feat: stream/TTFT stages
  4. feat: provider attempt spans
  5. feat: chat turn root span
  6. feat: capacity wait
  7. feat: turn accounting
  8. docs: Grafana dashboard

🤖 This PR was generated by Coder Agents on behalf of @jscottmiller.

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.
@jscottmiller jscottmiller added the experimental Changes that might not necessarily be merged, until its approved to proceed with. label Aug 28, 2026
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check 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.
@jscottmiller jscottmiller changed the title feat: add chat lifecycle stage tracing, metrics, and Grafana dashboard feat: add chat lifecycle stage tracing, turn accounting, and Grafana dashboard Sep 3, 2026
Drop comment text that restated the code, described a specific caller
or callee, or explained the change rather than the mechanism.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Changes that might not necessarily be merged, until its approved to proceed with.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant