Menu

#85 analytics: attach token counts and estimated spend to message_sent

closed
nobody
None
2026-08-10
2026-08-10
Anonymous
No

Originally created by: sosidudku1

Follow-up to [#61], which attached the shape of a turn to message_sent (latency_ms, step_count, outcome) and deliberately left token accounting out. This adds the size.

The problem

message_sent carries no usage data, so token consumption can only be inferred by multiplying step_count by a hand-calibrated constant. A step that reads a long file and a step that answers "yes" count the same, so the estimate is off by multiples on exactly the turns worth looking at.

What this adds

Providers already report per-call usage, and the pricing tables already exist in userModels[].pricing. Neither reached the analytics seam.

TurnUsageMeter sums usage across every LLM call a turn makes, the agent loop's own steps plus any sub-runner (reflection, link-gen, vote, rewriter, distill), on both the buffered and streaming paths. Three optional fields land on message_sent:

  • prompt_tokens
  • completion_tokens
  • cost_usd

Buckets are keyed by session id. TurnController serializes turns within a session but runs different sessions concurrently (sidecar, Telegram, task runner), so a single shared bucket would bill one session's tokens to whichever session finished first.

Absent stays absent

Each field is omitted rather than sent as a zero when it cannot be measured. A provider that reports no usage block sends no token keys; a model with no configured pricing sends no cost_usd. Local runners therefore report real token counts with no cost, instead of a misleading 0.00 that would read as "this turn was free" in a chart. An explicit cost_usd: 0 is still emitted when a priced model genuinely cost nothing.

Privacy

Numbers only. No message content, file paths, tool arguments, or user data, the same boundary [#61] held to. The fields ride the existing analytics opt-out, since nothing is emitted unless captureMessageSent fires, and they stay off first_message_sent, which keeps carrying provider and model alone.

Drive-by fix

CostAccumulator.recordTurn was being called without model, so estimateCost hit its !model?.pricing guard and returned 0 for every turn. The opt-in cost readout has been reporting a constant zero. It now receives the resolved model, which is what makes cost_usd meaningful, and repairs the accumulator for whenever a reader is wired up to it.

Testing

  • tsc clean.
  • Analytics and bootstrap suites pass: 62 tests, 22 of them new (13 in turn-usage-meter.test.ts, including cross-session isolation; 6 added to analytics-events.test.ts covering key-absence and the explicit-zero case).
  • Full suite shows the same pre-existing failures as main (TUI/sidecar/tools), none introduced here.
  • Rebased onto main after [#80], [#81] and [#82] landed; merged without conflicts.

What this unlocks

Real per-user and per-model spend without the step-count multiplier, and the cost-versus-retention question that estimate could not answer. Pricing stops being hardcoded in dashboard SQL and arrives with the event.

Related

Tickets: #61
Tickets: #80
Tickets: #81
Tickets: #82

Discussion

  • Anonymous

    Anonymous - 2026-08-10

    Originally posted by: sosidudku1

    On the drive-by fix: CostAccumulator has been recording a constant zero

    Flagging this separately since it is a pre-existing bug rather than something this PR introduces, and it is worth a decision independent of the telemetry work.

    bootstrap.ts called recordTurn with { modelId, usage } and no model:

    costAccumulator.recordTurn({
      modelId: result.modelId,
      usage: result.usage,
    });
    

    But estimateCost needs the model to reach pricing:

    if (!usage || !model?.pricing) return 0;
    

    So the guard fired on every call. sessionUsd, dayUsd and lastTurnUsd have all been accumulating zero since the accumulator was wired up, regardless of provider or pricing config.

    Two things keep this from having been user-visible so far:

    1. It is opt-in behind costTracking.enabled, which defaults off.
    2. Nothing calls snapshot(). costTracking.showInStatusBar exists in the config schema, but no reader is wired to it, so the zero never reached a screen.

    That makes it latent rather than a live misreport, which is the good version of this news. It does mean the accumulator would have shipped zeros the moment a status-bar reader was added.

    The fix here is one line of plumbing: resolve the model at the provider seam and pass it through, so both recordTurn and the new cost_usd field get real pricing.

    Two follow-ups I did not do in this PR, happy either way:

    • CostAccumulator has no test file. A test asserting that a priced model yields a non-zero sessionUsd would have caught this and would stop it regressing. Worth a small separate PR.
    • If showInStatusBar is meant to work, it still needs a reader. Separate scope from telemetry, so I left it alone.
     
  • Anonymous

    Anonymous - 2026-08-10

    Ticket changed by: Ooooze

    • status: open --> closed
     

Log in to post a comment.