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

Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions coderd/x/chatd/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,32 @@ Reconstructed stages are recorded after the fact from timestamps captured elsewh
- `thinking`: one per reasoning part, from the part's start to its completion timestamp in the persisted step.
- `tool_call`: one per local tool call, from the tool billing recorder's start and completion stamps.

#### Turn accounting

When a turn finishes normally, its stages are rolled up and emitted once, labelled with the turn's `chat_kind` and `model`. The model is the first one resolved in the turn. Turns that end with an error or an interruption are invalidated and emit nothing.

Per stage, `coderd_chatd_turn_stage_seconds`, `coderd_chatd_turn_stage_count`, and `coderd_chatd_stage_share_of_turn` record the total seconds, the number of occurrences, and the fraction of the turn's duration spent in that stage. Stages overlap in wall time (a `provider_attempt` runs inside `time_to_first_token`, which runs inside `stream`, which runs inside `generation_step`), so these totals do not partition the turn and a share can exceed 1. These three families and `coderd_chatd_turn_time_share` are registered only at `--chat-stage-metrics=full`; the accumulator still runs at every level, so `coderd_chatd_turn_time_seconds` and the anomaly counter below are unaffected by the level.

`coderd_chatd_turn_time_seconds` and `coderd_chatd_turn_time_share` partition the turn's wall time into disjoint categories that sum to the turn duration. Every category is emitted for every accounted turn, including the ones with no time, so shares are comparable across turns. The categories and the stages that feed them:

| Category | Source |
|----------|--------|
| `scheduling` | `acquisition` and `queue_wait` |
| `time_to_first_token` | `time_to_first_token`, when a part arrived |
| `streaming` | `stream` minus its `time_to_first_token`, when the stream succeeded |
| `provider_error` | `stream` and `time_to_first_token` when the attempt failed |
| `retry_backoff` | `retry_backoff` |
| `tool_execution` | `generation_step` own time when the step ran local tools |
| `compaction` | `compaction` |
| `preparation` | `prepare` own time and `mcp_connect` |
| `persistence` | `commit` |
| `chatd_overhead` | `generation_step` own time for every other action: decision logic, transitions other than `CommitStep`, hook dispatch, and buffer bookkeeping |
| `unattributed` | the remainder of the turn not covered by any stage above |

The partition is computed from the stage tree. 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 the parent's own time, which keeps the categories disjoint. `provider_attempt`, `thinking`, and `tool_call` contribute to the per-stage totals but not to any category, because they overlap stages that are already categorized. `capacity_wait` is excluded for the same reason: its window lies inside `acquisition`. Only turn-scoped stages report to the accumulator, so background work never lands in a turn.

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 not emitted and is counted as `nonpositive_turn`.

### Event shape

Every event that the runner loop processes has the following shape:
Expand Down
111 changes: 108 additions & 3 deletions coderd/x/chatd/chatloop/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ const (
// timestamps whose end preceded its start, or which lacked one of
// them, and was not observed.
StageAnomalyInvertedWindow = "inverted_window"
// StageAnomalyNonPositiveTurn is a finished turn whose duration
// was not positive, so its accounting was not emitted.
StageAnomalyNonPositiveTurn = "nonpositive_turn"
// StageAnomalyOverattributed is a finished turn whose categories
// summed to more than its duration. The categories were emitted as
// measured and the turn's shares sum to more than 1.
StageAnomalyOverattributed = "overattributed"
)

// basicStages is the set of stages observed into StageDurationSeconds
Expand Down Expand Up @@ -74,6 +81,11 @@ type Metrics struct {
TTFTSeconds *prometheus.HistogramVec
StageMetricsLevel *prometheus.GaugeVec
StageDurationSeconds *prometheus.HistogramVec
TurnStageSeconds *prometheus.HistogramVec
TurnStageCount *prometheus.HistogramVec
StageShareOfTurn *prometheus.HistogramVec
TurnTimeSeconds *prometheus.HistogramVec
TurnTimeShare *prometheus.HistogramVec
StageAnomaliesTotal *prometheus.CounterVec
CompactionTotal *prometheus.CounterVec
StepsTotal *prometheus.CounterVec
Expand Down Expand Up @@ -106,6 +118,12 @@ func NewMetricsWithOptions(reg prometheus.Registerer, opts MetricsOptions) *Metr
if level == codersdk.ChatStageMetricsLevelOff {
stageFactory = promauto.With(nil)
}
// fullFactory registers the per-turn distribution families exposed
// only at full.
fullFactory := stageFactory
if level != codersdk.ChatStageMetricsLevelFull {
fullFactory = promauto.With(nil)
}
m := &Metrics{
stageMetrics: level,
Chats: factory.NewGaugeVec(prometheus.GaugeOpts{
Expand Down Expand Up @@ -167,11 +185,46 @@ func NewMetricsWithOptions(reg prometheus.Registerer, opts MetricsOptions) *Metr
Help: "Wall time spent in each chat lifecycle stage. Stages overlap in wall time; this is a stage-time profile, not a partition of the turn. The scope label separates stages that run inside a chat turn from detached background work. The chat_kind label is empty for stages recorded without a known chat, and the model label is empty for stages that are not tied to a model call. At the basic stage metrics level only the wait, connect, and model-call stages are observed.",
Buckets: stageDurationBuckets(),
}, []string{"stage", "scope", "chat_kind", "model"}),
TurnStageSeconds: fullFactory.NewHistogramVec(prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubsystem,
Name: "turn_stage_seconds",
Help: "Total wall time one chat turn spent in a stage, observed once per turn when the turn ends. Stages overlap, so these do not partition the turn. Only turns that finished normally are counted. Registered only at the full stage metrics level.",
Buckets: turnDurationBuckets(),
}, []string{"stage", "chat_kind", "model"}),
TurnStageCount: fullFactory.NewHistogramVec(prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubsystem,
Name: "turn_stage_count",
Help: "Number of times a stage occurred within one chat turn, observed once per turn per stage that occurred. Only turns that finished normally are counted. Registered only at the full stage metrics level.",
Buckets: turnStageCountBuckets(),
}, []string{"stage", "chat_kind", "model"}),
StageShareOfTurn: fullFactory.NewHistogramVec(prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubsystem,
Name: "stage_share_of_turn",
Help: "Fraction of a chat turn's wall time spent in a stage, observed once per turn when the turn ends. Stages overlap, so shares can exceed 1 and do not sum to 1. Only turns that finished normally are counted. Registered only at the full stage metrics level.",
Buckets: stageShareBuckets(),
}, []string{"stage", "chat_kind", "model"}),
TurnTimeSeconds: stageFactory.NewHistogramVec(prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubsystem,
Name: "turn_time_seconds",
Help: "Wall time of one chat turn split into disjoint categories that sum to the turn duration, observed once per turn per category when the turn ends. Every category is observed, including the ones with no time. Only turns that finished normally are counted.",
Buckets: turnDurationBuckets(),
}, []string{"category", "chat_kind", "model"}),
TurnTimeShare: fullFactory.NewHistogramVec(prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubsystem,
Name: "turn_time_share",
Help: "Fraction of a chat turn's wall time in each disjoint category, observed once per turn per category when the turn ends. The shares of one turn sum to 1. Only turns that finished normally are counted. Registered only at the full stage metrics level.",
Buckets: turnShareBuckets(),
}, []string{"category", "chat_kind", "model"}),
StageAnomaliesTotal: stageFactory.NewCounterVec(prometheus.CounterOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubsystem,
Name: "stage_anomalies_total",
Help: "Chat lifecycle stage observations dropped by reason. Reasons: negative_elapsed and inverted_window (clock inconsistencies).",
Help: "Chat lifecycle stage observations dropped or emitted with a known inconsistency, by reason. Reasons: negative_elapsed and inverted_window (clock inconsistencies), nonpositive_turn (finished turn whose accounting was not emitted), overattributed (turn whose categories summed to more than its duration).",
}, []string{"reason"}),
CompactionTotal: factory.NewCounterVec(prometheus.CounterOpts{
Namespace: metricsNamespace,
Expand Down Expand Up @@ -236,6 +289,33 @@ func stageDurationBuckets() []float64 {
return []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 20, 30, 60, 120, 300, 600, 1800, 3600}
}

// turnDurationBuckets returns the duration buckets for histograms of
// per-turn sums: 1s to 1h. Sub-second resolution carries no
// information for a value summed over a whole turn.
func turnDurationBuckets() []float64 {
return []float64{1, 2.5, 5, 10, 30, 60, 120, 300, 600, 1800, 3600}
}

// turnShareBuckets returns the buckets for the category share
// histogram, whose values partition a turn: 0 to 1 in twentieths.
func turnShareBuckets() []float64 {
return prometheus.LinearBuckets(0, 0.05, 21)
}

// stageShareBuckets returns the buckets for the per-stage share
// histogram. Stages overlap in wall time and repeat within a turn, so
// a stage's share can exceed 1; the range extends to 10 so those turns
// are resolved instead of collapsing into the overflow bucket.
func stageShareBuckets() []float64 {
return []float64{0.01, 0.02, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.5, 2, 3, 5, 10}
}

// turnStageCountBuckets returns the buckets for per-turn stage counts:
// every small count, then doubling past the 1200 step limit of a turn.
func turnStageCountBuckets() []float64 {
return []float64{1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 128, 256, 512, 1024, 2048}
}

// NopMetrics returns a Metrics instance that discards all data.
// Useful for tests and when metrics collection is not desired.
func NopMetrics() *Metrics {
Expand Down Expand Up @@ -264,15 +344,40 @@ func (m *Metrics) RecordStageDuration(stage, scope, chatKind, model string, elap
m.StageDurationSeconds.WithLabelValues(stage, scope, chatKind, model).Observe(elapsed.Seconds())
}

// RecordStageAnomaly counts a stage observation that was dropped, by
// reason. No-op when m is nil.
// RecordStageAnomaly counts a stage observation that was dropped or
// emitted inconsistent, by reason. No-op when m is nil.
func (m *Metrics) RecordStageAnomaly(reason string) {
if m == nil {
return
}
m.StageAnomaliesTotal.WithLabelValues(reason).Inc()
}

// RecordTurnStage observes the total time one turn spent in a stage,
// that time as a fraction of the turn, and how many times the stage
// occurred. All three come from the same turn so they cannot describe
// different turns. No-op when m is nil.
func (m *Metrics) RecordTurnStage(stage, chatKind, model string, elapsed time.Duration, share float64, count int) {
if m == nil || elapsed < 0 {
return
}
m.TurnStageSeconds.WithLabelValues(stage, chatKind, model).Observe(elapsed.Seconds())
m.StageShareOfTurn.WithLabelValues(stage, chatKind, model).Observe(share)
m.TurnStageCount.WithLabelValues(stage, chatKind, model).Observe(float64(count))
}

// RecordTurnCategory observes one category of a turn's time partition
// and that category as a fraction of the turn. Categories with no time
// are observed as zero so the shares of a turn always sum to 1. No-op
// when m is nil.
func (m *Metrics) RecordTurnCategory(category, chatKind, model string, elapsed time.Duration, share float64) {
if m == nil || elapsed < 0 {
return
}
m.TurnTimeSeconds.WithLabelValues(category, chatKind, model).Observe(elapsed.Seconds())
m.TurnTimeShare.WithLabelValues(category, chatKind, model).Observe(share)
}

// RecordCompaction classifies and records a compaction attempt.
// It is a no-op when m is nil.
func (m *Metrics) RecordCompaction(provider, model string, compacted bool, err error) {
Expand Down
61 changes: 60 additions & 1 deletion coderd/x/chatd/chatloop/stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ const (
StageRetryBackoff = "retry_backoff"
)

// GenerationActionExecuteLocalTools is the generation_action value of
// a step that runs local tools. Turn accounting compares the value
// passed to SetGenerationAction against it to separate tool execution
// from chatd overhead.
const GenerationActionExecuteLocalTools = "execute_local_tools"

// Span attribute keys. Keys are lowercase snake_case and shared by
// every stage that carries the value.
const (
Expand Down Expand Up @@ -153,6 +159,11 @@ type StageSpan struct {
span trace.Span
start time.Time
ended bool
// acc is the turn the stage runs in, nil outside a turn.
acc *TurnAccumulator
// node is the stage's place in the turn's attribution tree, nil
// for stages that do not partition turn time.
node *stageNode
}

// stageScopeKey keys the stage scope carried by a context. It is
Expand Down Expand Up @@ -261,6 +272,20 @@ func (t *StageTracer) startSpan(
}
chatKind := chatKindFromContext(ctx)
opts = append(opts, trace.WithAttributes(stageIdentityAttributes(scope, chatKind)...))
// Only turn-scoped stages report to the turn on ctx. A background
// stage may run on a context derived from a turn's, and its time is
// not the turn's.
var acc *TurnAccumulator
if scope == ScopeTurn {
acc = turnAccumulatorFromContext(ctx)
}
var node *stageNode
if acc != nil {
if _, attributing := attributingStages[stage]; attributing {
node = &stageNode{stage: stage, parent: stageNodeFromContext(ctx)}
ctx = context.WithValue(ctx, stageNodeKey{}, node)
}
}
ctx, span := t.otelTracer().Start(ContextWithScope(ctx, scope), stage, opts...)
return ctx, &StageSpan{
tracer: t,
Expand All @@ -269,6 +294,8 @@ func (t *StageTracer) startSpan(
chatKind: chatKind,
span: span,
start: start,
acc: acc,
node: node,
}
}

Expand Down Expand Up @@ -300,9 +327,21 @@ func (s *StageSpan) SetModel(model StageModel) {
return
}
s.model = model
s.acc.setModel(model)
s.span.SetAttributes(model.attributes()...)
}

// SetGenerationAction records the action a generation step took, on
// the span and on the step's turn attribution, where it decides
// whether the step's own time counts as tool execution.
func (s *StageSpan) SetGenerationAction(action string) {
if s == nil || s.ended {
return
}
s.node.setAction(action)
s.span.SetAttributes(attribute.String(AttrGenerationAction, action))
}

// SpanContext returns the span context of the stage span, which is
// invalid when tracing is not configured.
func (s *StageSpan) SpanContext() trace.SpanContext {
Expand All @@ -316,8 +355,11 @@ func (s *StageSpan) SpanContext() trace.SpanContext {
// as errored when err is non-nil. Calls after the first are ignored so
// a deferred End cannot double-count a stage.
func (s *StageSpan) End(err error) {
s.adoptTurnModel()
if elapsed, ok := s.closeSpan(err); ok {
s.tracer.observe(s.stage, s.scope, s.chatKind, s.model, elapsed)
s.addTurnStageTotal(elapsed)
s.report(elapsed, err)
}
}

Expand All @@ -327,7 +369,21 @@ func (s *StageSpan) End(err error) {
// would skew the histogram while the span still needs to report the
// failure.
func (s *StageSpan) EndWithoutObservation(err error) {
s.closeSpan(err)
if elapsed, ok := s.closeSpan(err); ok {
s.report(elapsed, err)
}
}

// adoptTurnModel gives the turn's root stage the model identity that
// the turn resolved after the root started, so the root is labeled
// like the stages inside it.
func (s *StageSpan) adoptTurnModel() {
if s == nil || s.stage != StageChatTurn || s.model.Model != "" {
return
}
if model := s.acc.Model(); model.Model != "" {
s.SetModel(model)
}
}

// closeSpan ends the span and returns the window it covered. ok is
Expand Down Expand Up @@ -391,6 +447,9 @@ func (t *StageTracer) RecordAs(
}
span.End(trace.WithTimestamp(end))
t.observe(stage, scope, chatKind, model, end.Sub(start))
if scope == ScopeTurn {
recordAttribution(ctx, stage, end.Sub(start))
}
}

func (t *StageTracer) recordAnomaly(reason string) {
Expand Down
Loading
Loading