@prism-d1/cli 1.0.99 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
  2. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +11 -1
  3. package/dist/assets/gitlab-workflows/prism-eval-gate.yml +8 -0
  4. package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +312 -0
  5. package/dist/assets/infra/lib/dashboard-stack.ts +74 -45
  6. package/dist/assets/infra/lib/lambda/otel-receiver.ts +324 -12
  7. package/dist/assets/infra/lib/lambda/pre-token-generation.ts +79 -0
  8. package/dist/assets/infra/lib/lambda/pricing/litellm-snapshot.json +3769 -0
  9. package/dist/assets/infra/lib/lambda/pricing/price-source.ts +56 -0
  10. package/dist/assets/infra/lib/lambda/pricing/price-table.ts +240 -0
  11. package/dist/assets/infra/lib/lambda/pricing/prices-key.ts +6 -0
  12. package/dist/assets/infra/lib/lambda/pricing/reprice.ts +108 -0
  13. package/dist/assets/infra/lib/lambda/pricing-refresh.ts +352 -0
  14. package/dist/assets/infra/lib/lambda/velocity-widget.ts +397 -1
  15. package/dist/assets/infra/lib/metrics-pipeline-stack.ts +13 -0
  16. package/dist/assets/infra/package.json +5 -2
  17. package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
  18. package/dist/src/codewatch/config.d.ts +17 -0
  19. package/dist/src/codewatch/config.d.ts.map +1 -0
  20. package/dist/src/codewatch/config.js +40 -0
  21. package/dist/src/codewatch/config.js.map +1 -0
  22. package/dist/src/codewatch/gitleaks.d.ts +49 -0
  23. package/dist/src/codewatch/gitleaks.d.ts.map +1 -0
  24. package/dist/src/codewatch/gitleaks.js +160 -0
  25. package/dist/src/codewatch/gitleaks.js.map +1 -0
  26. package/dist/src/codewatch/globalHooks.d.ts +52 -0
  27. package/dist/src/codewatch/globalHooks.d.ts.map +1 -0
  28. package/dist/src/codewatch/globalHooks.js +235 -0
  29. package/dist/src/codewatch/globalHooks.js.map +1 -0
  30. package/dist/src/codewatch/hooks.d.ts +38 -0
  31. package/dist/src/codewatch/hooks.d.ts.map +1 -0
  32. package/dist/src/codewatch/hooks.js +176 -0
  33. package/dist/src/codewatch/hooks.js.map +1 -0
  34. package/dist/src/codewatch/prepush.d.ts +37 -0
  35. package/dist/src/codewatch/prepush.d.ts.map +1 -0
  36. package/dist/src/codewatch/prepush.js +231 -0
  37. package/dist/src/codewatch/prepush.js.map +1 -0
  38. package/dist/src/codewatch/results.d.ts +90 -0
  39. package/dist/src/codewatch/results.d.ts.map +1 -0
  40. package/dist/src/codewatch/results.js +121 -0
  41. package/dist/src/codewatch/results.js.map +1 -0
  42. package/dist/src/codewatch/reviewers/claude.d.ts +9 -0
  43. package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -0
  44. package/dist/src/codewatch/reviewers/claude.js +48 -0
  45. package/dist/src/codewatch/reviewers/claude.js.map +1 -0
  46. package/dist/src/codewatch/reviewers/index.d.ts +39 -0
  47. package/dist/src/codewatch/reviewers/index.d.ts.map +1 -0
  48. package/dist/src/codewatch/reviewers/index.js +97 -0
  49. package/dist/src/codewatch/reviewers/index.js.map +1 -0
  50. package/dist/src/codewatch/reviewers/kiro.d.ts +14 -0
  51. package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -0
  52. package/dist/src/codewatch/reviewers/kiro.js +32 -0
  53. package/dist/src/codewatch/reviewers/kiro.js.map +1 -0
  54. package/dist/src/codewatch/reviewers/types.d.ts +56 -0
  55. package/dist/src/codewatch/reviewers/types.d.ts.map +1 -0
  56. package/dist/src/codewatch/reviewers/types.js +91 -0
  57. package/dist/src/codewatch/reviewers/types.js.map +1 -0
  58. package/dist/src/codewatch/rules.d.ts +12 -0
  59. package/dist/src/codewatch/rules.d.ts.map +1 -0
  60. package/dist/src/codewatch/rules.js +27 -0
  61. package/dist/src/codewatch/rules.js.map +1 -0
  62. package/dist/src/commands/_top/dashboard.d.ts +17 -0
  63. package/dist/src/commands/_top/dashboard.d.ts.map +1 -0
  64. package/dist/src/commands/_top/dashboard.js +76 -0
  65. package/dist/src/commands/_top/dashboard.js.map +1 -0
  66. package/dist/src/commands/bedrock-protection/scan-account.d.ts.map +1 -1
  67. package/dist/src/commands/bedrock-protection/scan-account.js +2 -1
  68. package/dist/src/commands/bedrock-protection/scan-account.js.map +1 -1
  69. package/dist/src/commands/bedrock-protection/scan-org.d.ts.map +1 -1
  70. package/dist/src/commands/bedrock-protection/scan-org.js +16 -4
  71. package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
  72. package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
  73. package/dist/src/commands/bootstrapper/install-eval-harness.js +15 -48
  74. package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
  75. package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +13 -0
  76. package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
  77. package/dist/src/commands/bootstrapper/install-git-hooks.js +28 -147
  78. package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -1
  79. package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
  80. package/dist/src/commands/bootstrapper/setup-otel-sync.js +5 -39
  81. package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
  82. package/dist/src/commands/codewatch/pre-commit.d.ts +7 -0
  83. package/dist/src/commands/codewatch/pre-commit.d.ts.map +1 -0
  84. package/dist/src/commands/codewatch/pre-commit.js +30 -0
  85. package/dist/src/commands/codewatch/pre-commit.js.map +1 -0
  86. package/dist/src/commands/codewatch/pre-push.d.ts +16 -0
  87. package/dist/src/commands/codewatch/pre-push.d.ts.map +1 -0
  88. package/dist/src/commands/codewatch/pre-push.js +32 -0
  89. package/dist/src/commands/codewatch/pre-push.js.map +1 -0
  90. package/dist/src/commands/codewatch/report.d.ts +22 -0
  91. package/dist/src/commands/codewatch/report.d.ts.map +1 -0
  92. package/dist/src/commands/codewatch/report.js +64 -0
  93. package/dist/src/commands/codewatch/report.js.map +1 -0
  94. package/dist/src/commands/enable/codewatch.d.ts +15 -0
  95. package/dist/src/commands/enable/codewatch.d.ts.map +1 -0
  96. package/dist/src/commands/enable/codewatch.js +186 -0
  97. package/dist/src/commands/enable/codewatch.js.map +1 -0
  98. package/dist/src/commands/git/claude-session-context.d.ts.map +1 -1
  99. package/dist/src/commands/git/claude-session-context.js +2 -1
  100. package/dist/src/commands/git/claude-session-context.js.map +1 -1
  101. package/dist/src/commands/git/commit-trailers.js +1 -1
  102. package/dist/src/commands/git/commit-trailers.js.map +1 -1
  103. package/dist/src/commands/workshop/verify-setup.d.ts.map +1 -1
  104. package/dist/src/commands/workshop/verify-setup.js +5 -3
  105. package/dist/src/commands/workshop/verify-setup.js.map +1 -1
  106. package/dist/src/dashboard/codewatch.d.ts +106 -0
  107. package/dist/src/dashboard/codewatch.d.ts.map +1 -0
  108. package/dist/src/dashboard/codewatch.js +119 -0
  109. package/dist/src/dashboard/codewatch.js.map +1 -0
  110. package/dist/src/dashboard/commits.d.ts +96 -0
  111. package/dist/src/dashboard/commits.d.ts.map +1 -0
  112. package/dist/src/dashboard/commits.js +207 -0
  113. package/dist/src/dashboard/commits.js.map +1 -0
  114. package/dist/src/dashboard/page.d.ts +10 -0
  115. package/dist/src/dashboard/page.d.ts.map +1 -0
  116. package/dist/src/dashboard/page.js +315 -0
  117. package/dist/src/dashboard/page.js.map +1 -0
  118. package/dist/src/dashboard/payload.d.ts +111 -0
  119. package/dist/src/dashboard/payload.d.ts.map +1 -0
  120. package/dist/src/dashboard/payload.js +93 -0
  121. package/dist/src/dashboard/payload.js.map +1 -0
  122. package/dist/src/dashboard/report.d.ts +99 -0
  123. package/dist/src/dashboard/report.d.ts.map +1 -0
  124. package/dist/src/dashboard/report.js +300 -0
  125. package/dist/src/dashboard/report.js.map +1 -0
  126. package/dist/src/dashboard/repos.d.ts +58 -0
  127. package/dist/src/dashboard/repos.d.ts.map +1 -0
  128. package/dist/src/dashboard/repos.js +111 -0
  129. package/dist/src/dashboard/repos.js.map +1 -0
  130. package/dist/src/dashboard/server.d.ts +52 -0
  131. package/dist/src/dashboard/server.d.ts.map +1 -0
  132. package/dist/src/dashboard/server.js +163 -0
  133. package/dist/src/dashboard/server.js.map +1 -0
  134. package/dist/src/dashboard/setup.d.ts +52 -0
  135. package/dist/src/dashboard/setup.d.ts.map +1 -0
  136. package/dist/src/dashboard/setup.js +129 -0
  137. package/dist/src/dashboard/setup.js.map +1 -0
  138. package/dist/src/dashboard/usage.d.ts +43 -0
  139. package/dist/src/dashboard/usage.d.ts.map +1 -0
  140. package/dist/src/dashboard/usage.js +88 -0
  141. package/dist/src/dashboard/usage.js.map +1 -0
  142. package/dist/src/index.d.ts +1 -0
  143. package/dist/src/index.d.ts.map +1 -1
  144. package/dist/src/index.js +40 -1
  145. package/dist/src/index.js.map +1 -1
  146. package/dist/src/utils/audit-bedrock.d.ts +1 -0
  147. package/dist/src/utils/audit-bedrock.d.ts.map +1 -1
  148. package/dist/src/utils/audit-bedrock.js +133 -0
  149. package/dist/src/utils/audit-bedrock.js.map +1 -1
  150. package/dist/src/utils/audit-org.d.ts +1 -0
  151. package/dist/src/utils/audit-org.d.ts.map +1 -1
  152. package/dist/src/utils/audit-org.js +155 -0
  153. package/dist/src/utils/audit-org.js.map +1 -1
  154. package/dist/src/utils/otel-sync-schedule.d.ts +22 -0
  155. package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
  156. package/dist/src/utils/otel-sync-schedule.js +68 -0
  157. package/dist/src/utils/otel-sync-schedule.js.map +1 -0
  158. package/dist/src/utils/tool-check.d.ts +14 -0
  159. package/dist/src/utils/tool-check.d.ts.map +1 -0
  160. package/dist/src/utils/tool-check.js +42 -0
  161. package/dist/src/utils/tool-check.js.map +1 -0
  162. package/package.json +3 -2
@@ -13,6 +13,12 @@
13
13
  * Item shapes in the AI-usage table:
14
14
  * pk=USER#<identity> sk=SPAN#<timestamp>#<spanId> raw span, TTL 90d
15
15
  * pk=USER#<identity> sk=OTEL#DAY#<yyyy-mm-dd>#<tool>#<model> daily aggregate
16
+ * pk=USER#<identity> sk=OTEL#TOOLDAY#<yyyy-mm-dd>#<tool>#<toolName>
17
+ * daily tool-call rollup
18
+ *
19
+ * "tool" in the first two shapes is the AI tool (claude-code, kiro, ...).
20
+ * `toolName` is a tool the model invoked inside one call (Bash, Edit,
21
+ * mcp__<server>__<tool>), taken from codeburn's `ai.tools` attribute.
16
22
  */
17
23
 
18
24
  import {
@@ -26,6 +32,8 @@ import {
26
32
  } from '@aws-sdk/client-dynamodb';
27
33
  import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
28
34
  import { randomUUID } from 'crypto';
35
+ import { PriceTable, lookupPrice, computeCost, hasTokens } from './pricing/price-table';
36
+ import { loadPrices, BUNDLED_PRICES } from './pricing/price-source';
29
37
 
30
38
  const dynamoClient = new DynamoDBClient({});
31
39
  const s3Client = new S3Client({});
@@ -115,14 +123,29 @@ interface ParsedSpan {
115
123
  model: string;
116
124
  inputTokens: number;
117
125
  outputTokens: number;
126
+ cacheReadTokens: number;
127
+ cacheWriteTokens: number;
118
128
  costUsd: number;
119
129
  project: string;
120
130
  costEstimated: boolean;
131
+ /**
132
+ * 'client' when codeburn priced the call, 'receiver' when the LiteLLM
133
+ * fallback did, 'unpriced' when neither could (the reprice job retries these).
134
+ */
135
+ costSource: 'client' | 'receiver' | 'unpriced';
136
+ /** LiteLLM key the receiver priced from, for audit. Empty when client-priced. */
137
+ pricedKey: string;
121
138
  /** True for spans the PRISM coding agent emitted: work no human prompted. */
122
139
  autonomous?: boolean;
123
140
  /** The issue the agent was handed. 0 when not an agent span. */
124
141
  issueNumber?: number;
125
142
  deviceId: string;
143
+ /**
144
+ * Distinct tool names the model invoked in this call, from `ai.tools`.
145
+ * Empty when the provider reports none -- which is not the same as "used no
146
+ * tools" for providers codeburn cannot read tool calls from.
147
+ */
148
+ toolNames: string[];
126
149
  }
127
150
 
128
151
  // ---- Attribution span types ----
@@ -193,6 +216,40 @@ function bool(v: OtlpValue | undefined): boolean {
193
216
  }
194
217
 
195
218
  /** Convert OTLP unix-nano string to ISO timestamp; empty string if invalid. */
219
+ const MAX_TOOL_NAMES = 64;
220
+ const MAX_TOOL_NAME_LEN = 128;
221
+
222
+ /**
223
+ * Read `ai.tools` into a list of distinct, key-safe tool names.
224
+ *
225
+ * Deduplicated because codeburn providers disagree: most send a distinct set
226
+ * per call, Claude Code sends one entry per tool_use block. Counting distinct
227
+ * names makes the rollup mean "calls that used tool X" for every provider.
228
+ *
229
+ * The attribute is client-supplied, so it is bounded here regardless of what
230
+ * codeburn already caps: '#' is the sort-key delimiter and is stripped, control
231
+ * characters are dropped, and anything over the limits is ignored.
232
+ */
233
+ export function parseToolNames(v: OtlpValue | undefined): string[] {
234
+ const values = v && 'arrayValue' in v ? v.arrayValue?.values ?? [] : [];
235
+ const out = new Set<string>();
236
+ for (const item of values) {
237
+ if (out.size >= MAX_TOOL_NAMES) break;
238
+ const raw = typeof item?.stringValue === 'string' ? item.stringValue : '';
239
+ // eslint-disable-next-line no-control-regex
240
+ const name = raw.replace(/[#\u0000-\u001f\u007f]/g, '').trim();
241
+ if (!name || name.length > MAX_TOOL_NAME_LEN) continue;
242
+ out.add(name);
243
+ }
244
+ return [...out];
245
+ }
246
+
247
+ /** `mcp__<server>__<tool>` -> `<server>`; '' for non-MCP tools. */
248
+ export function mcpServerOf(toolName: string): string {
249
+ const m = /^mcp__(.+?)__.+$/.exec(toolName);
250
+ return m ? m[1]! : '';
251
+ }
252
+
196
253
  function nanoToIso(nano: string | undefined): string {
197
254
  if (!nano || !/^\d+$/.test(nano)) return '';
198
255
  try {
@@ -227,7 +284,11 @@ function resolveIdentity(event: HttpApiEvent): string | null {
227
284
  }
228
285
 
229
286
  /** Extract and sanity-check spans from an OTLP payload. Returns usage spans, attribution spans, and rejected count. */
230
- export function parseOtlpSpans(payload: OtlpPayload): {
287
+ export function parseOtlpSpans(
288
+ payload: OtlpPayload,
289
+ /** Fallback rates for spans the client sent unpriced. Defaults to the bundled snapshot. */
290
+ prices: PriceTable = BUNDLED_PRICES.entries,
291
+ ): {
231
292
  spans: ParsedSpan[];
232
293
  attributionSpans: ParsedAttributionSpan[];
233
294
  rejected: number;
@@ -248,6 +309,12 @@ export function parseOtlpSpans(payload: OtlpPayload): {
248
309
  noProvider: number;
249
310
  badTokens: number;
250
311
  };
312
+ /**
313
+ * Usage spans that arrived with cost 0 but tokens > 0. `repriced` were priced
314
+ * from the LiteLLM table; `unpriced` matched no entry and stayed $0.
315
+ * `unpricedModels` names the models so a table gap is one log line to fix.
316
+ */
317
+ pricing: { repriced: number; unpriced: number; unpricedModels: string[] };
251
318
  } {
252
319
  const spans: ParsedSpan[] = [];
253
320
  const attributionSpans: ParsedAttributionSpan[] = [];
@@ -258,6 +325,9 @@ export function parseOtlpSpans(payload: OtlpPayload): {
258
325
  let rejectedNoRepo = 0;
259
326
  let rejectedNoProvider = 0;
260
327
  let rejectedBadTokens = 0;
328
+ let repriced = 0;
329
+ let unpriced = 0;
330
+ const unpricedModels = new Set<string>();
261
331
 
262
332
  for (const rs of payload.resourceSpans ?? []) {
263
333
  const resourceAttrs = attrMap(rs.resource?.attributes);
@@ -364,33 +434,67 @@ export function parseOtlpSpans(payload: OtlpPayload): {
364
434
 
365
435
  const inputTokens = num(attrs.get('ai.input_tokens'));
366
436
  const outputTokens = num(attrs.get('ai.output_tokens'));
367
- const costUsd = num(attrs.get('ai.cost_usd'));
437
+ const cacheReadTokens = num(attrs.get('ai.cache_read_tokens'));
438
+ const cacheWriteTokens = num(attrs.get('ai.cache_write_tokens'));
439
+ let costUsd = num(attrs.get('ai.cost_usd'));
368
440
  if (
369
441
  inputTokens < 0 || outputTokens < 0 || costUsd < 0 ||
370
- inputTokens > 100_000_000 || outputTokens > 100_000_000 || costUsd > 10_000
442
+ cacheReadTokens < 0 || cacheWriteTokens < 0 ||
443
+ inputTokens > 100_000_000 || outputTokens > 100_000_000 ||
444
+ cacheReadTokens > 100_000_000 || cacheWriteTokens > 100_000_000 ||
445
+ costUsd > 10_000
371
446
  ) {
372
447
  rejectedBadTokens++;
373
448
  rejected++;
374
449
  continue;
375
450
  }
376
451
 
452
+ const model = str(attrs.get('ai.model')).slice(0, 128);
453
+ let costEstimated = bool(attrs.get('ai.cost_estimated'));
454
+ let costSource: ParsedSpan['costSource'] = 'client';
455
+ let pricedKey = '';
456
+ // codeburn sends 0 for a model missing from its price table and does not
457
+ // flag it estimated. Tokens with no cost is that case, not free usage.
458
+ // A client-supplied non-zero cost is never overwritten.
459
+ const tokens = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
460
+ if (costUsd === 0 && hasTokens(tokens)) {
461
+ const match = lookupPrice(prices, model);
462
+ const priced = match ? computeCost(match.entry, tokens) : 0;
463
+ if (match && priced > 0) {
464
+ costUsd = priced;
465
+ costEstimated = true;
466
+ costSource = 'receiver';
467
+ pricedKey = match.key;
468
+ repriced++;
469
+ } else {
470
+ costSource = 'unpriced';
471
+ unpriced++;
472
+ if (unpricedModels.size < 20) unpricedModels.add(model || 'unknown');
473
+ }
474
+ }
475
+
377
476
  spans.push({
378
477
  spanId: spanId.toLowerCase(),
379
478
  traceId: traceId.toLowerCase(),
380
479
  timestamp,
381
480
  tool: PROVIDER_TO_TOOL[provider] ?? provider.slice(0, 32),
382
- model: str(attrs.get('ai.model')).slice(0, 128),
481
+ model,
383
482
  inputTokens,
384
483
  outputTokens,
484
+ cacheReadTokens,
485
+ cacheWriteTokens,
385
486
  costUsd,
386
487
  project: str(attrs.get('ai.project')).slice(0, 256),
387
- costEstimated: bool(attrs.get('ai.cost_estimated')),
488
+ costEstimated,
489
+ costSource,
490
+ pricedKey,
388
491
  deviceId,
389
492
  // Emitted by the PRISM coding agent. `autonomous` separates work no human
390
493
  // prompted from human-assisted AI use, which the PRISM levels treat as
391
494
  // different things -- L5 is about autonomous deployments, not AI share.
392
495
  autonomous: bool(attrs.get('prism.autonomous')),
393
496
  issueNumber: num(attrs.get('prism.issue_number')),
497
+ toolNames: parseToolNames(attrs.get('ai.tools')),
394
498
  });
395
499
  }
396
500
  }
@@ -408,6 +512,7 @@ export function parseOtlpSpans(payload: OtlpPayload): {
408
512
  noProvider: rejectedNoProvider,
409
513
  badTokens: rejectedBadTokens,
410
514
  },
515
+ pricing: { repriced, unpriced, unpricedModels: [...unpricedModels] },
411
516
  };
412
517
  }
413
518
 
@@ -455,10 +560,19 @@ async function writeSpanIfNew(user: string, s: ParsedSpan): Promise<boolean> {
455
560
  model: { S: s.model },
456
561
  input_tokens: { N: String(s.inputTokens) },
457
562
  output_tokens: { N: String(s.outputTokens) },
563
+ // Stored so a span priced by the fallback can be re-priced later from
564
+ // the record alone, without the raw archive.
565
+ cache_read_tokens: { N: String(s.cacheReadTokens) },
566
+ cache_write_tokens: { N: String(s.cacheWriteTokens) },
458
567
  cost_usd: { N: String(s.costUsd) },
459
568
  project: { S: s.project },
460
569
  device_id: { S: s.deviceId },
461
570
  cost_estimated: { BOOL: s.costEstimated },
571
+ cost_source: { S: s.costSource },
572
+ ...(s.pricedKey ? { priced_key: { S: s.pricedKey } } : {}),
573
+ // A DynamoDB string set cannot be empty, so the attribute is omitted
574
+ // rather than written as [] when the call reported no tools.
575
+ ...(s.toolNames.length > 0 ? { tool_names: { SS: s.toolNames } } : {}),
462
576
  timestamp: { S: s.timestamp },
463
577
  ttl: { N: String(ttl) },
464
578
  },
@@ -537,6 +651,44 @@ async function bumpDailyAggregate(user: string, s: ParsedSpan): Promise<void> {
537
651
  }));
538
652
  }
539
653
 
654
+ /**
655
+ * ADD-increment one daily tool-call rollup per distinct tool in a newly-seen
656
+ * span. Same dedup contract as bumpDailyAggregate: the caller only reaches
657
+ * this after writeSpanIfNew succeeded, so a re-pushed span adds nothing.
658
+ *
659
+ * Kept under its own sk prefix (OTEL#TOOLDAY#, not OTEL#DAY#) so the existing
660
+ * OTEL#DAY range queries, the metrics-publisher stream filter and the reprice
661
+ * job never see these items.
662
+ */
663
+ async function bumpToolAggregates(user: string, s: ParsedSpan): Promise<void> {
664
+ if (s.toolNames.length === 0) return;
665
+ const day = s.timestamp.slice(0, 10);
666
+ const now = new Date().toISOString();
667
+ await Promise.all(s.toolNames.map((name) => {
668
+ const server = mcpServerOf(name);
669
+ return dynamoClient.send(new UpdateItemCommand({
670
+ TableName: AI_USAGE_TABLE,
671
+ Key: {
672
+ pk: { S: `USER#${user}` },
673
+ sk: { S: `OTEL#TOOLDAY#${day}#${s.tool}#${name}` },
674
+ },
675
+ UpdateExpression:
676
+ 'ADD call_count :one ' +
677
+ 'SET record_type = :rt, tool = :tool, tool_name = :name, mcp_server = :srv, #day = :day, updated_at = :now',
678
+ ExpressionAttributeNames: { '#day': 'day' },
679
+ ExpressionAttributeValues: {
680
+ ':one': { N: '1' },
681
+ ':rt': { S: 'OTEL_TOOL_DAY' },
682
+ ':tool': { S: s.tool },
683
+ ':name': { S: name },
684
+ ':srv': { S: server },
685
+ ':day': { S: day },
686
+ ':now': { S: now },
687
+ },
688
+ }));
689
+ }));
690
+ }
691
+
540
692
  // ---- Attribution DDB writes ----
541
693
 
542
694
  /**
@@ -758,7 +910,10 @@ async function processSpans(user: string, spans: ParsedSpan[]): Promise<number>
758
910
  const chunk = spans.slice(i, i + CONCURRENCY);
759
911
  const results = await Promise.all(chunk.map(async (s) => {
760
912
  const isNew = await writeSpanIfNew(user, s);
761
- if (isNew) await bumpDailyAggregate(user, s);
913
+ if (isNew) {
914
+ await bumpDailyAggregate(user, s);
915
+ await bumpToolAggregates(user, s);
916
+ }
762
917
  return isNew;
763
918
  }));
764
919
  written += results.filter(Boolean).length;
@@ -783,7 +938,8 @@ async function handleTraces(event: HttpApiEvent): Promise<HttpApiResponse> {
783
938
  return jsonResponse(400, { message: 'Body is not valid JSON' });
784
939
  }
785
940
 
786
- const { spans, attributionSpans, rejected, rejectedReasons } = parseOtlpSpans(payload);
941
+ const prices = await loadPrices(s3Client, ARCHIVE_BUCKET);
942
+ const { spans, attributionSpans, rejected, rejectedReasons, pricing } = parseOtlpSpans(payload, prices.doc.entries);
787
943
  const totalAccepted = spans.length + attributionSpans.length;
788
944
 
789
945
  if (totalAccepted > MAX_BATCH_SIZE) {
@@ -810,7 +966,12 @@ async function handleTraces(event: HttpApiEvent): Promise<HttpApiResponse> {
810
966
  `[otel-receiver] user=${user} ` +
811
967
  `usage: received=${spans.length} new=${usageWritten} dupes=${spans.length - usageWritten} | ` +
812
968
  `attribution: received=${attributionSpans.length} written=${attrWritten} | ` +
813
- `rejected=${rejected}${reasonDetail ? ` (${reasonDetail})` : ''}`,
969
+ `rejected=${rejected}${reasonDetail ? ` (${reasonDetail})` : ''}` +
970
+ (pricing.repriced || pricing.unpriced
971
+ ? ` | pricing(${prices.origin} ${prices.doc.fetchedAt.slice(0, 10)}): ` +
972
+ `repriced=${pricing.repriced} unpriced=${pricing.unpriced}` +
973
+ (pricing.unpricedModels.length ? ` (${pricing.unpricedModels.join(',')})` : '')
974
+ : ''),
814
975
  );
815
976
 
816
977
  if (rejected > 0) {
@@ -1539,7 +1700,7 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
1539
1700
  }
1540
1701
 
1541
1702
  // Scan COMMIT# items in the date range.
1542
- const days = new Map<string, { ai: number; human: number; mergedAi: number; mergedHuman: number }>();
1703
+ const days = new Map<string, { ai: number; human: number; mergedAi: number; mergedHuman: number; costUsd: number }>();
1543
1704
  let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1544
1705
  do {
1545
1706
  const result = await dynamoClient.send(new ScanCommand({
@@ -1561,7 +1722,7 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
1561
1722
  const origin = item.ai_origin?.S ?? 'human';
1562
1723
  const inMain = item.in_main?.BOOL ?? false;
1563
1724
  let bucket = days.get(day);
1564
- if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0 }; days.set(day, bucket); }
1725
+ if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0, costUsd: 0 }; days.set(day, bucket); }
1565
1726
  if (origin === 'ai-generated') {
1566
1727
  bucket.ai++;
1567
1728
  if (inMain) bucket.mergedAi++;
@@ -1573,10 +1734,52 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
1573
1734
  lastKey = result.LastEvaluatedKey;
1574
1735
  } while (lastKey);
1575
1736
 
1576
- // Sort ascending by date and return as an array.
1737
+ // Second pass: fold daily AI spend into the same buckets, so a cost-per-commit
1738
+ // trend can be computed from one source instead of dividing a CloudWatch cost
1739
+ // metric by a CloudWatch commit metric.
1740
+ //
1741
+ // The two passes key on DIFFERENT timestamps by design, and that is the whole
1742
+ // point of serving this from the store. A COMMIT# item is bucketed by its real
1743
+ // commit timestamp and an OTEL_DAY item by the day the usage happened, so both
1744
+ // sides of the ratio land on the day the work actually occurred. The CloudWatch
1745
+ // equivalent buckets by INGEST time, which is why a backfill dumps months of
1746
+ // commits into the week it was pushed and collapses that week's ratio.
1747
+ {
1748
+ let costKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1749
+ do {
1750
+ const result = await dynamoClient.send(new ScanCommand({
1751
+ TableName: AI_USAGE_TABLE,
1752
+ FilterExpression: 'record_type = :rt AND #day BETWEEN :lo AND :hi',
1753
+ ExpressionAttributeNames: { '#day': 'day' },
1754
+ ExpressionAttributeValues: {
1755
+ ':rt': { S: 'OTEL_DAY' },
1756
+ ':lo': { S: from },
1757
+ ':hi': { S: to },
1758
+ },
1759
+ ProjectionExpression: '#day, cost_usd',
1760
+ ...(costKey ? { ExclusiveStartKey: costKey } : {}),
1761
+ }));
1762
+ for (const item of result.Items ?? []) {
1763
+ const day = item.day?.S;
1764
+ if (!day) continue;
1765
+ const cost = Number(item.cost_usd?.N ?? '0');
1766
+ if (!Number.isFinite(cost)) continue;
1767
+ let bucket = days.get(day);
1768
+ // A day with spend but no commits is a real observation — AI work that
1769
+ // shipped nothing — so it gets a bucket rather than being dropped. The
1770
+ // consumer renders its ratio as null, not as zero.
1771
+ if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0, costUsd: 0 }; days.set(day, bucket); }
1772
+ bucket.costUsd += cost;
1773
+ }
1774
+ costKey = result.LastEvaluatedKey;
1775
+ } while (costKey);
1776
+ }
1777
+
1778
+ // Sort ascending by date and return as an array. Cost is rounded once, here,
1779
+ // so every consumer renders the same figure.
1577
1780
  const sorted = [...days.entries()]
1578
1781
  .sort((a, b) => a[0].localeCompare(b[0]))
1579
- .map(([date, counts]) => ({ date, ...counts }));
1782
+ .map(([date, counts]) => ({ ...counts, date, costUsd: Math.round(counts.costUsd * 100) / 100 }));
1580
1783
 
1581
1784
  return jsonResponse(200, {
1582
1785
  range: { from, to },
@@ -1631,6 +1834,112 @@ function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string;
1631
1834
 
1632
1835
  // ---- Handler ----
1633
1836
 
1837
+ // ---- Route: tool usage ----
1838
+
1839
+ export interface ToolUsageReport {
1840
+ range: { from: string; to: string };
1841
+ user: string;
1842
+ generatedAt: string;
1843
+ /** Sum of per-tool counts. One model call using three tools counts three. */
1844
+ totalToolCalls: number;
1845
+ tools: Array<{ name: string; mcpServer: string; calls: number; byAiTool: Record<string, number> }>;
1846
+ mcpServers: Array<{ server: string; calls: number; tools: number }>;
1847
+ days: Array<{ date: string; calls: number }>;
1848
+ }
1849
+
1850
+ type DdbItem = Record<string, { S?: string; N?: string }>;
1851
+
1852
+ /** Fold OTEL_TOOL_DAY items into the report shape. Pure, for the contract check. */
1853
+ export function aggregateToolUsage(items: DdbItem[], from: string, to: string, user: string): ToolUsageReport {
1854
+ const tools = new Map<string, { name: string; mcpServer: string; calls: number; byAiTool: Record<string, number> }>();
1855
+ const servers = new Map<string, { calls: number; tools: Set<string> }>();
1856
+ const days = new Map<string, number>();
1857
+ let total = 0;
1858
+ for (const item of items) {
1859
+ const name = item.tool_name?.S;
1860
+ const day = item.day?.S;
1861
+ const calls = Number(item.call_count?.N ?? '0');
1862
+ if (!name || !day || !Number.isFinite(calls) || calls <= 0) continue;
1863
+ const aiTool = item.tool?.S ?? 'unknown';
1864
+ const server = item.mcp_server?.S ?? '';
1865
+ let t = tools.get(name);
1866
+ if (!t) { t = { name, mcpServer: server, calls: 0, byAiTool: {} }; tools.set(name, t); }
1867
+ t.calls += calls;
1868
+ t.byAiTool[aiTool] = (t.byAiTool[aiTool] ?? 0) + calls;
1869
+ if (server) {
1870
+ let sv = servers.get(server);
1871
+ if (!sv) { sv = { calls: 0, tools: new Set() }; servers.set(server, sv); }
1872
+ sv.calls += calls;
1873
+ sv.tools.add(name);
1874
+ }
1875
+ days.set(day, (days.get(day) ?? 0) + calls);
1876
+ total += calls;
1877
+ }
1878
+ return {
1879
+ range: { from, to },
1880
+ user,
1881
+ generatedAt: new Date().toISOString(),
1882
+ totalToolCalls: total,
1883
+ tools: [...tools.values()].sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name)),
1884
+ mcpServers: [...servers.entries()]
1885
+ .map(([server, v]) => ({ server, calls: v.calls, tools: v.tools.size }))
1886
+ .sort((a, b) => b.calls - a.calls || a.server.localeCompare(b.server)),
1887
+ days: [...days.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, calls]) => ({ date, calls })),
1888
+ };
1889
+ }
1890
+
1891
+ /**
1892
+ * GET /v1/tool-usage?from=YYYY-MM-DD&to=YYYY-MM-DD&user=<identity>|all
1893
+ *
1894
+ * Reads the OTEL#TOOLDAY rollups, bucketed by the day the call happened, so a
1895
+ * backfilled `codeburn sync push` lands on the right days and keeps full history.
1896
+ */
1897
+ async function handleToolUsageQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
1898
+ const caller = resolveIdentity(event);
1899
+ if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
1900
+
1901
+ const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
1902
+ const userParam = url.searchParams.get('user') ?? 'all';
1903
+ const now = new Date();
1904
+ const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
1905
+ const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
1906
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
1907
+ return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
1908
+ }
1909
+
1910
+ const items: DdbItem[] = [];
1911
+ let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
1912
+ do {
1913
+ const result = userParam !== 'all'
1914
+ ? await dynamoClient.send(new QueryCommand({
1915
+ TableName: AI_USAGE_TABLE,
1916
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :lo AND :hi',
1917
+ ExpressionAttributeValues: {
1918
+ ':pk': { S: `USER#${userParam}` },
1919
+ ':lo': { S: `OTEL#TOOLDAY#${from}` },
1920
+ ':hi': { S: `OTEL#TOOLDAY#${to}\uffff` },
1921
+ },
1922
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
1923
+ }))
1924
+ // Fleet scope: same scan-with-filter shape as the OTEL_DAY fleet query.
1925
+ : await dynamoClient.send(new ScanCommand({
1926
+ TableName: AI_USAGE_TABLE,
1927
+ FilterExpression: 'record_type = :rt AND #day BETWEEN :lo AND :hi',
1928
+ ExpressionAttributeNames: { '#day': 'day' },
1929
+ ExpressionAttributeValues: {
1930
+ ':rt': { S: 'OTEL_TOOL_DAY' },
1931
+ ':lo': { S: from },
1932
+ ':hi': { S: to },
1933
+ },
1934
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
1935
+ }));
1936
+ for (const item of result.Items ?? []) items.push(item as DdbItem);
1937
+ lastKey = result.LastEvaluatedKey;
1938
+ } while (lastKey);
1939
+
1940
+ return jsonResponse(200, aggregateToolUsage(items, from, to, userParam));
1941
+ }
1942
+
1634
1943
  export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
1635
1944
  const method = event.requestContext.http.method;
1636
1945
  const path = event.rawPath;
@@ -1653,5 +1962,8 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
1653
1962
  if (method === 'GET' && path === '/v1/commits-daily') {
1654
1963
  return handleCommitsDailyQuery(event);
1655
1964
  }
1965
+ if (method === 'GET' && path === '/v1/tool-usage') {
1966
+ return handleToolUsageQuery(event);
1967
+ }
1656
1968
  return jsonResponse(404, { message: 'Not found' });
1657
1969
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pre token generation trigger (V2_0) — copies `email` into the ACCESS token.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * Every per-developer number on these dashboards keys on the identity the
7
+ * otel-receiver derives from the caller's JWT, and the commit side of the
8
+ * pipeline keys on the git author email (`metrics-processor.ts` writes
9
+ * `gsi_user: USER#<author email>` from the CI census). For the two to join,
10
+ * the receiver's identity must also be an email.
11
+ *
12
+ * Cognito ID tokens carry `email`. Cognito ACCESS tokens do not — and API
13
+ * Gateway's HttpJwtAuthorizer validates the access token. So with a federated
14
+ * IdP the receiver falls back to `username`, which Cognito prefixes with the
15
+ * provider name (`corpidp_alice`). The same person then appears as two
16
+ * developers: one holding all the token spend and no commits, the other the
17
+ * reverse. On a pool with months of accumulated telemetry, effectively all of
18
+ * the AI-usage history lands on the prefixed identity while commit attribution
19
+ * lands on the email one.
20
+ *
21
+ * Mapping `email` on the identity provider is necessary but NOT sufficient: it
22
+ * populates the user ATTRIBUTE, which never reaches the access token on its
23
+ * own. This trigger is the piece that moves it there.
24
+ *
25
+ * PAIRED WITH `otelIdentityClaim=email`
26
+ *
27
+ * This function is inert by itself. The receiver resolves identity as
28
+ * `[IDENTITY_CLAIM, 'username', 'email', 'sub']`, and IDENTITY_CLAIM defaults
29
+ * to `username` — which is always present, so `email` is unreachable no matter
30
+ * what this trigger adds. Deploy this WITH `-c otelIdentityClaim=email`, or it
31
+ * adds a claim nothing reads.
32
+ *
33
+ * FAIL-OPEN, DELIBERATELY
34
+ *
35
+ * A federated user's `email` is populated by the IdP attribute mapping on
36
+ * sign-in, so a user who has not signed in since the mapping was added has no
37
+ * email yet. Returning the event unmodified in that case lets the receiver's
38
+ * chain fall through to `username` — the pre-existing behaviour — so those users
39
+ * keep writing to their old key until they next sign in, and the migration is
40
+ * self-completing. Throwing here would instead block token issuance and lock
41
+ * them out of sync entirely, turning a cosmetic split into an outage.
42
+ *
43
+ * Requires the Essentials or Plus feature plan; access token customization is
44
+ * unavailable on Lite. Deployed against a pool already on Plus.
45
+ */
46
+
47
+ interface PreTokenGenerationV2Event {
48
+ request?: {
49
+ userAttributes?: Record<string, string>;
50
+ };
51
+ response?: unknown;
52
+ }
53
+
54
+ export async function handler(event: PreTokenGenerationV2Event): Promise<PreTokenGenerationV2Event> {
55
+ const email = event.request?.userAttributes?.email;
56
+
57
+ // Trim before testing: a whitespace-only attribute would satisfy a bare
58
+ // truthiness check here and then be rejected by the receiver's own
59
+ // `v.trim()` guard, silently reverting to `username` with no signal.
60
+ if (typeof email !== 'string' || email.trim() === '') {
61
+ return event;
62
+ }
63
+
64
+ // Lowercased to match the receiver, which lowercases every resolved identity
65
+ // before building `USER#<identity>`. Emitting mixed case here would create a
66
+ // second partition key for the same person the first time an IdP returned a
67
+ // capitalized address.
68
+ const normalized = email.trim().toLowerCase();
69
+
70
+ event.response = {
71
+ claimsAndScopeOverrideDetails: {
72
+ accessTokenGeneration: {
73
+ claimsToAddOrOverride: { email: normalized },
74
+ },
75
+ },
76
+ };
77
+
78
+ return event;
79
+ }