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

Skip to content

feat: account chatd turn time by stage and category - #28974

Draft
jscottmiller wants to merge 1 commit into
scott/chatd-stages/6-capacity-waitfrom
scott/chatd-stages/7-turn-accounting
Draft

feat: account chatd turn time by stage and category#28974
jscottmiller wants to merge 1 commit into
scott/chatd-stages/6-capacity-waitfrom
scott/chatd-stages/7-turn-accounting

Conversation

@jscottmiller

@jscottmiller jscottmiller commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

When a turn finishes normally, its stages are rolled up and emitted once as five histograms labelled {stage|category, chat_kind, model} (reasoning effort is a span attribute, not a metric label):

  • coderd_chatd_turn_stage_seconds, turn_stage_count, stage_share_of_turn: per stage, the total seconds, occurrences, and fraction of the turn. Stages overlap, so shares can exceed 1; the share buckets extend to 10 and the count buckets past the 1200 step limit.
  • coderd_chatd_turn_time_seconds, turn_time_share: an exclusive partition of the turn's wall time into scheduling, time_to_first_token, streaming, provider_error, retry_backoff, tool_execution, compaction, preparation, persistence, chatd_overhead, and unattributed. All eleven are emitted every turn.

turn_time_seconds is registered at the basic and full stage metrics levels; the other four families are registered only at --chat-stage-metrics=full. The accumulator runs at every level, so the partition is available at basic even for stages that the level leaves span-only. The per-turn histograms use an 11-bucket ladder from 1s to 1h, since sub-second resolution carries nothing for a value summed over a turn.

How the partition is built

Stages nest, and a nested stage's time is also its parent's time, so summing stage durations over-counts. That is why the per-stage totals above are a profile rather than a partition. The partition instead assigns each stage's own time (its duration minus its attributing children) to one category. A TurnAccumulator rides on the turn context; each attributing stage reports its full duration to its parent when it ends, and the parent's category receives only what is left.

chat_turn                                        own time -> category
├── acquisition | queue_wait                     full     -> scheduling
└── generation_step (repeats per step)           own      -> chatd_overhead, or
    │                                                        tool_execution when the
    │                                                        step ran local tools
    ├── prepare                                  own      -> preparation
    │   └── mcp_connect                          own      -> preparation
    ├── retry_backoff                            own      -> retry_backoff
    ├── stream                                   own      -> streaming, or
    │   │                                                    provider_error on failure
    │   ├── time_to_first_token                  own      -> time_to_first_token, or
    │   │                                                    provider_error on failure
    │   └── provider_attempt  (overlaps TTFT)    not categorized
    ├── thinking              (inside stream)    not categorized
    ├── tool_call             (inside step)      not categorized
    ├── commit                                   own      -> persistence
    └── compaction                               own      -> compaction
                                                 remainder -> unattributed

provider_attempt, thinking, and tool_call are recorded in the per-stage totals but never claim time from their parent or land in a category: each lies entirely inside a stage that is already categorized, so counting them would double-count. capacity_wait is excluded for the same reason (its window lies inside acquisition). Only turn-scoped stages started by a live tracer report to the accumulator, so background work never lands in a turn. Errored or interrupted turns are invalidated and emit nothing.

A single 10s step, drawn to scale:

t (s)  0    1    2    3    4    5    6    7    8    9    10
generation_step  |=================================================|
prepare          |========|
stream                    |==================================|
  time_to_first_token     |========|
  provider_attempt        |=====|
commit                                                       |===|

durations   step 10   prepare 2   stream 7   ttft 2   attempt 1.5   commit 1
own time    prepare 2, ttft 2, stream 7-2 = 5, commit 1, step 10-(2+7+1) = 0
categories  preparation 2, persistence 1, chatd_overhead 0, time_to_first_token 2, streaming 5
            total 10 = step duration; the attempt's 1.5s appears in the per-stage totals, never in a category

In practice the step's own time is not zero: the gaps between phases (decision logic, buffer bookkeeping, tool dispatch) are what chatd_overhead from the step itself captures.

A turn whose categories sum to more than its duration is emitted as measured and counted in coderd_chatd_stage_anomalies_total{reason="overattributed"}; a finished turn with a non-positive duration is counted as nonpositive_turn. capacity_wait is not categorized because its window lies inside acquisition.


Part of the chatd lifecycle observability stack: 1 extract step · 2 stage tracer · 3 stream stages · 4 provider attempts · 5 turn span · 6 capacity wait · 7 turn accounting · 8 dashboard. Supersedes the experimental branch in #28741.

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

@github-actions

github-actions Bot commented Sep 4, 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.

Each chat_turn carries a TurnAccumulator that turn-scoped stages report
to as they end. Stages that partition the turn form an attribution tree
so a step's own time is split into disjoint categories, with tool
execution separated from chatd overhead by the step's generation
action. When a turn that Complete marked finished settles, its per-stage
totals, counts, shares, and category partition are observed once on the
turn histograms; Invalidate drops the accounting of a turn that stopped
partway through on an error or interruption. Turns whose duration is
not positive or whose categories overrun it are counted as anomalies.

The per-turn families are labelled by chat kind and model; reasoning
effort stays a span attribute. coderd_chatd_turn_time_seconds is
registered at the basic and full stage metrics levels; the per-stage
totals, counts, and share families are registered only at full. The
per-turn histograms use an 11-bucket ladder from 1s to 1h, since
sub-second resolution carries nothing for a value summed over a turn.
@jscottmiller
jscottmiller force-pushed the scott/chatd-stages/6-capacity-wait branch from 09ef319 to 388e95d Compare September 11, 2026 19:17
@jscottmiller
jscottmiller force-pushed the scott/chatd-stages/7-turn-accounting branch from 2eec66e to 948b3e7 Compare September 11, 2026 19:17
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.

2 participants