Skip to content

feat: per-stage token usage + cost tracking and an opt-in metrics emission seam (2.5.40) - #673

Merged
apackeer merged 2 commits into
v2from
feat/usage-cost-metrics
Aug 5, 2026
Merged

feat: per-stage token usage + cost tracking and an opt-in metrics emission seam (2.5.40)#673
apackeer merged 2 commits into
v2from
feat/usage-cost-metrics

Conversation

@apackeer

@apackeer apackeer commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What

Makes AIDLC cost-aware. Three observability surfaces that were token-blind gain per-stage token usage and dollar cost, plus an opt-in seam that streams audit events and usage magnitudes to any StatsD-compatible forwarder. The implementation was validated against an 8-subagent session with 937 usage turns, 423 unique UUIDs, and 191 cross-file collisions.

User-visible changes (Claude Code harness)

  • Statusline: running token counts + cost for the session, read from a durable ledger (never re-parses transcripts).
  • Audit record: STAGE_COMPLETED / WORKFLOW_COMPLETED gain optional rollup fields (tokens in/out, cache read/write, cost, by-model, by-agent). Fields on EXISTING events - the audit taxonomy count is unchanged. "What did inception cost" is now answerable from the record.
  • Optional metrics: set AIDLC_METRICS_ENDPOINT and every audit event plus usage magnitudes emit as StatsD lines over HTTP (detached fire-and-forget; metric loss is always preferred over blocking the audit write). No endpoint shipped: unset = the audit path is byte-untouched.

Other harnesses: no producer wired; every consumer degrades silently to no-data.

Design

  • One extraction seam (core/tools/aidlc-usage.ts): transcript readers (main + per-subagent files with .meta.json sidecars), pure cost math, and a per-source-file-cursor ledger - UUIDs collide across concurrent subagent transcripts, so (sourceFile, uuid) is the only safe dedup key. Nothing throws; a half-written JSONL line is normal; unknown model = tokens recorded, cost null, never fabricated.
  • Rates: public Anthropic list prices as shipped defaults (core/tools/data/model-rates.json), overridable via a project edit or an AIDLC_MODEL_RATES file path; generation-discrete keys with Bedrock/region/converse id normalization.
  • Producers: a new fold hook registered on PreToolUse + PostToolUse (aidlc-fold-usage.ts, hooks 15 -> 16) + a Stop-hook flush. Rollups are computed BEFORE the audit lock opens and try/caught - usage can never block or delay a completion event.
  • Metrics env: AIDLC_METRICS_ENDPOINT (off unless set), AIDLC_METRICS_PREFIX (default "aidlc"), AIDLC_METRICS_HEADERS (optional).

Testing

  • New t267-usage, t268-metrics-magnitude, t269-statusline-cost, t270-metrics-transport; count guards t01/t02/t132/t219 updated for the new hook.
  • Rebased onto v2 @ ebfa3ce (2.5.39): version re-slotted 2.5.38 -> 2.5.40, tests re-slotted t252-254 -> t267-270 (t252/t262-264 taken on v2), hook counts merged with the review-freeze hook (fix: stop reviewer-receipt invalidation loop with deterministic review freeze (2.5.39) #702): 15 -> 16.
  • smoke+unit+integration on the rebased tree: green (results in the merge notes). package.ts --check, gen-coverage-registry --check, typecheck, biome all clean.

Note for maintainers: open PR #705 claims the t267 number with a different filename; per the renumber convention, whichever merges second re-slots (that will be #705).

@leandrodamascena leandrodamascena left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. The happy-path tests pass, but adversarial validation exposed several data-integrity and security failures in the new observability surfaces.

Blocking findings

  1. The ledger mixes usage across sessions, workflows, and intents

core/tools/aidlc-usage.ts:735-737 stores one workspace-global usage-ledger.json, while core/tools/aidlc-usage.ts:870-877 indexes stage usage only by stage slug. core/hooks/aidlc-statusline.ts:200-209 then displays the global totals as the current session's usage.

Reproduction: I folded 10 input tokens for intent-capture from one session, then 20 from a second session/workflow. The second workflow's stage rollup reported 30 instead of 20.

This makes both statusline totals and completion audit fields increasingly inaccurate over the lifetime of the workspace. The ledger needs session/workflow/intent dimensions or explicit per-workflow baselines.

  1. Concurrent hooks lose ledger updates

foldTranscriptIntoLedger() performs an unlocked read-modify-write at core/tools/aidlc-usage.ts:1376-1420. Atomic rename prevents malformed JSON but does not prevent one writer from replacing another writer's totals and cursors. writeFileAtomic() also uses a shared <path>.tmp filename at core/tools/aidlc-lib.ts:3276-3283.

Reproduction: I launched 24 processes folding distinct transcripts into the same ledger. Only 7 tokens and 7 cursors survived.

This is especially relevant to concurrent sessions and subagent-heavy workflows. The complete ledger transaction needs a cross-process lock; temporary filenames must also be writer-unique.

  1. Usage at stage boundaries is omitted and later attributed to the wrong stage

The PostToolUse hook reads the current stage only after the tool has executed at core/hooks/aidlc-fold-usage.ts:68-86. The holdback logic at core/tools/aidlc-usage.ts:1321-1345 then applies that later stage value when it eventually folds the pending message group.

A lifecycle tool can advance the state before PostToolUse runs. Furthermore, the state tool computes and emits STAGE_COMPLETED before the current assistant tool-call has entered the ledger.

Reproduction: a call made during stage-a was held back; after the state moved forward, the next fold recorded all 100 input tokens under stage-b. stage-a remained empty.

Stage identity must be captured with the pending group before a transition can change it, and the call that completes a stage must be included in that stage's completion rollup.

  1. WORKFLOW_COMPLETED reports only the final stage

core/tools/aidlc-state.ts:1817-1822 calls:

const usageFields = stageRollupFields(pd, completedSlug);

Those same fields are attached to WORKFLOW_COMPLETED at core/tools/aidlc-state.ts:1902-1908. Therefore, the workflow event reports the final stage's usage, not whole-workflow usage.

This contradicts the event name and the advertised workflow rollup. It also makes workflow-level metrics incorrect. WORKFLOW_COMPLETED needs a workflow-scoped aggregate independent of the final stage bucket.

  1. Metrics authorization secrets are exposed in process arguments

core/tools/aidlc-metrics.ts:357-370 expands AIDLC_METRICS_HEADERS into curl -H arguments. Bearer tokens consequently appear in the OS process table and may be collected by endpoint-security/process telemetry.

I reproduced this with a local collector and confirmed the bearer token was visible through ps.

Sensitive headers should be supplied through stdin, a protected file descriptor, or another mechanism that does not place credentials in argv.

Additional findings

  1. Model normalization can silently apply the wrong price

core/tools/aidlc-usage.ts:217-236 uses unbounded substring matching. Confirmed mappings include:

claude-opus-4-80   => opus-4-8
claude-sonnet-4-60 => sonnet-4-6
notclaude-opus-4-8 => opus-4-8

This violates the stated unknown-generation policy. Matching should enforce generation-token boundaries and validate the provider/model shape.

  1. Rate overrides cannot add a new generation

Although the implementation comments say an override may add keys, normalization is limited to the hard-coded GENERATION_MATCHERS. Adding an opus-6 row through AIDLC_MODEL_RATES still leaves claude-opus-6 unpriceable.

Normalization should resolve exact keys from the effective rate table rather than only from the built-in matcher list.

  1. Batch audit events bypass metrics

appendAuditEntries() at core/tools/aidlc-audit.ts:347-375 writes audit blocks directly without calling the metrics tap. Events written through append-batch therefore emit no metrics, contradicting the documented "every audit event" behavior.

  1. A valid final JSONL object is dropped without a trailing newline

core/tools/aidlc-usage.ts:1209-1215 treats every unterminated final line as partial, even during flush=true. A syntactically complete final JSON object is permanently omitted if the session ends without another newline.

During a final flush, a complete JSON object should be parsed even when EOF follows it directly.

Validation

The three new suites passed: 76 tests and 276 assertions. An additional 120 affected hook and integration tests passed. Typecheck and bun scripts/package.ts --check also passed.

These suites currently do not cover cross-process writes, workflow/session isolation, lifecycle-boundary attribution, complete EOF records, or credential exposure.

The branch is also conflicting with current v2 and requires the expected version/changelog rebase and t252 test-number collision resolution.

@apackeer
apackeer force-pushed the feat/usage-cost-metrics branch 2 times, most recently from 31a32f1 to 4b41062 Compare August 4, 2026 23:12

@leandrodamascena leandrodamascena left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed head 4b41062e. All nine previously reported findings are resolved. Local parity and typecheck pass, targeted concurrency testing passed 20 repeated runs, and CI is green. No remaining blocking findings.

@apackeer
apackeer force-pushed the feat/usage-cost-metrics branch from 4b41062 to d6d8c9a Compare August 5, 2026 22:00
@apackeer apackeer changed the title feat: per-stage token usage + cost tracking and an opt-in metrics emission seam (2.5.18) feat: per-stage token usage + cost tracking and an opt-in metrics emission seam (2.5.40) Aug 5, 2026
@apackeer
apackeer merged commit 9608d72 into v2 Aug 5, 2026
5 checks passed
@apackeer
apackeer deleted the feat/usage-cost-metrics branch August 5, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants