@prism-d1/cli 1.1.2 → 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 (160) 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 +96 -0
  5. package/dist/assets/infra/lib/dashboard-stack.ts +27 -0
  6. package/dist/assets/infra/lib/lambda/otel-receiver.ts +278 -8
  7. package/dist/assets/infra/lib/lambda/pricing/litellm-snapshot.json +3769 -0
  8. package/dist/assets/infra/lib/lambda/pricing/price-source.ts +56 -0
  9. package/dist/assets/infra/lib/lambda/pricing/price-table.ts +240 -0
  10. package/dist/assets/infra/lib/lambda/pricing/prices-key.ts +6 -0
  11. package/dist/assets/infra/lib/lambda/pricing/reprice.ts +108 -0
  12. package/dist/assets/infra/lib/lambda/pricing-refresh.ts +352 -0
  13. package/dist/assets/infra/lib/lambda/velocity-widget.ts +124 -1
  14. package/dist/assets/infra/package.json +4 -1
  15. package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
  16. package/dist/src/codewatch/config.d.ts +17 -0
  17. package/dist/src/codewatch/config.d.ts.map +1 -0
  18. package/dist/src/codewatch/config.js +40 -0
  19. package/dist/src/codewatch/config.js.map +1 -0
  20. package/dist/src/codewatch/gitleaks.d.ts +49 -0
  21. package/dist/src/codewatch/gitleaks.d.ts.map +1 -0
  22. package/dist/src/codewatch/gitleaks.js +160 -0
  23. package/dist/src/codewatch/gitleaks.js.map +1 -0
  24. package/dist/src/codewatch/globalHooks.d.ts +52 -0
  25. package/dist/src/codewatch/globalHooks.d.ts.map +1 -0
  26. package/dist/src/codewatch/globalHooks.js +235 -0
  27. package/dist/src/codewatch/globalHooks.js.map +1 -0
  28. package/dist/src/codewatch/hooks.d.ts +38 -0
  29. package/dist/src/codewatch/hooks.d.ts.map +1 -0
  30. package/dist/src/codewatch/hooks.js +176 -0
  31. package/dist/src/codewatch/hooks.js.map +1 -0
  32. package/dist/src/codewatch/prepush.d.ts +37 -0
  33. package/dist/src/codewatch/prepush.d.ts.map +1 -0
  34. package/dist/src/codewatch/prepush.js +231 -0
  35. package/dist/src/codewatch/prepush.js.map +1 -0
  36. package/dist/src/codewatch/results.d.ts +90 -0
  37. package/dist/src/codewatch/results.d.ts.map +1 -0
  38. package/dist/src/codewatch/results.js +121 -0
  39. package/dist/src/codewatch/results.js.map +1 -0
  40. package/dist/src/codewatch/reviewers/claude.d.ts +9 -0
  41. package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -0
  42. package/dist/src/codewatch/reviewers/claude.js +48 -0
  43. package/dist/src/codewatch/reviewers/claude.js.map +1 -0
  44. package/dist/src/codewatch/reviewers/index.d.ts +39 -0
  45. package/dist/src/codewatch/reviewers/index.d.ts.map +1 -0
  46. package/dist/src/codewatch/reviewers/index.js +97 -0
  47. package/dist/src/codewatch/reviewers/index.js.map +1 -0
  48. package/dist/src/codewatch/reviewers/kiro.d.ts +14 -0
  49. package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -0
  50. package/dist/src/codewatch/reviewers/kiro.js +32 -0
  51. package/dist/src/codewatch/reviewers/kiro.js.map +1 -0
  52. package/dist/src/codewatch/reviewers/types.d.ts +56 -0
  53. package/dist/src/codewatch/reviewers/types.d.ts.map +1 -0
  54. package/dist/src/codewatch/reviewers/types.js +91 -0
  55. package/dist/src/codewatch/reviewers/types.js.map +1 -0
  56. package/dist/src/codewatch/rules.d.ts +12 -0
  57. package/dist/src/codewatch/rules.d.ts.map +1 -0
  58. package/dist/src/codewatch/rules.js +27 -0
  59. package/dist/src/codewatch/rules.js.map +1 -0
  60. package/dist/src/commands/_top/dashboard.d.ts +17 -0
  61. package/dist/src/commands/_top/dashboard.d.ts.map +1 -0
  62. package/dist/src/commands/_top/dashboard.js +76 -0
  63. package/dist/src/commands/_top/dashboard.js.map +1 -0
  64. package/dist/src/commands/bedrock-protection/scan-account.d.ts.map +1 -1
  65. package/dist/src/commands/bedrock-protection/scan-account.js +2 -1
  66. package/dist/src/commands/bedrock-protection/scan-account.js.map +1 -1
  67. package/dist/src/commands/bedrock-protection/scan-org.d.ts.map +1 -1
  68. package/dist/src/commands/bedrock-protection/scan-org.js +16 -4
  69. package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
  70. package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
  71. package/dist/src/commands/bootstrapper/install-eval-harness.js +15 -48
  72. package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
  73. package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +13 -0
  74. package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
  75. package/dist/src/commands/bootstrapper/install-git-hooks.js +28 -147
  76. package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -1
  77. package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
  78. package/dist/src/commands/bootstrapper/setup-otel-sync.js +5 -39
  79. package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
  80. package/dist/src/commands/codewatch/pre-commit.d.ts +7 -0
  81. package/dist/src/commands/codewatch/pre-commit.d.ts.map +1 -0
  82. package/dist/src/commands/codewatch/pre-commit.js +30 -0
  83. package/dist/src/commands/codewatch/pre-commit.js.map +1 -0
  84. package/dist/src/commands/codewatch/pre-push.d.ts +16 -0
  85. package/dist/src/commands/codewatch/pre-push.d.ts.map +1 -0
  86. package/dist/src/commands/codewatch/pre-push.js +32 -0
  87. package/dist/src/commands/codewatch/pre-push.js.map +1 -0
  88. package/dist/src/commands/codewatch/report.d.ts +22 -0
  89. package/dist/src/commands/codewatch/report.d.ts.map +1 -0
  90. package/dist/src/commands/codewatch/report.js +64 -0
  91. package/dist/src/commands/codewatch/report.js.map +1 -0
  92. package/dist/src/commands/enable/codewatch.d.ts +15 -0
  93. package/dist/src/commands/enable/codewatch.d.ts.map +1 -0
  94. package/dist/src/commands/enable/codewatch.js +186 -0
  95. package/dist/src/commands/enable/codewatch.js.map +1 -0
  96. package/dist/src/commands/git/claude-session-context.d.ts.map +1 -1
  97. package/dist/src/commands/git/claude-session-context.js +2 -1
  98. package/dist/src/commands/git/claude-session-context.js.map +1 -1
  99. package/dist/src/commands/git/commit-trailers.js +1 -1
  100. package/dist/src/commands/git/commit-trailers.js.map +1 -1
  101. package/dist/src/commands/workshop/verify-setup.d.ts.map +1 -1
  102. package/dist/src/commands/workshop/verify-setup.js +5 -3
  103. package/dist/src/commands/workshop/verify-setup.js.map +1 -1
  104. package/dist/src/dashboard/codewatch.d.ts +106 -0
  105. package/dist/src/dashboard/codewatch.d.ts.map +1 -0
  106. package/dist/src/dashboard/codewatch.js +119 -0
  107. package/dist/src/dashboard/codewatch.js.map +1 -0
  108. package/dist/src/dashboard/commits.d.ts +96 -0
  109. package/dist/src/dashboard/commits.d.ts.map +1 -0
  110. package/dist/src/dashboard/commits.js +207 -0
  111. package/dist/src/dashboard/commits.js.map +1 -0
  112. package/dist/src/dashboard/page.d.ts +10 -0
  113. package/dist/src/dashboard/page.d.ts.map +1 -0
  114. package/dist/src/dashboard/page.js +315 -0
  115. package/dist/src/dashboard/page.js.map +1 -0
  116. package/dist/src/dashboard/payload.d.ts +111 -0
  117. package/dist/src/dashboard/payload.d.ts.map +1 -0
  118. package/dist/src/dashboard/payload.js +93 -0
  119. package/dist/src/dashboard/payload.js.map +1 -0
  120. package/dist/src/dashboard/report.d.ts +99 -0
  121. package/dist/src/dashboard/report.d.ts.map +1 -0
  122. package/dist/src/dashboard/report.js +300 -0
  123. package/dist/src/dashboard/report.js.map +1 -0
  124. package/dist/src/dashboard/repos.d.ts +58 -0
  125. package/dist/src/dashboard/repos.d.ts.map +1 -0
  126. package/dist/src/dashboard/repos.js +111 -0
  127. package/dist/src/dashboard/repos.js.map +1 -0
  128. package/dist/src/dashboard/server.d.ts +52 -0
  129. package/dist/src/dashboard/server.d.ts.map +1 -0
  130. package/dist/src/dashboard/server.js +163 -0
  131. package/dist/src/dashboard/server.js.map +1 -0
  132. package/dist/src/dashboard/setup.d.ts +52 -0
  133. package/dist/src/dashboard/setup.d.ts.map +1 -0
  134. package/dist/src/dashboard/setup.js +129 -0
  135. package/dist/src/dashboard/setup.js.map +1 -0
  136. package/dist/src/dashboard/usage.d.ts +43 -0
  137. package/dist/src/dashboard/usage.d.ts.map +1 -0
  138. package/dist/src/dashboard/usage.js +88 -0
  139. package/dist/src/dashboard/usage.js.map +1 -0
  140. package/dist/src/index.d.ts +1 -0
  141. package/dist/src/index.d.ts.map +1 -1
  142. package/dist/src/index.js +40 -1
  143. package/dist/src/index.js.map +1 -1
  144. package/dist/src/utils/audit-bedrock.d.ts +1 -0
  145. package/dist/src/utils/audit-bedrock.d.ts.map +1 -1
  146. package/dist/src/utils/audit-bedrock.js +133 -0
  147. package/dist/src/utils/audit-bedrock.js.map +1 -1
  148. package/dist/src/utils/audit-org.d.ts +1 -0
  149. package/dist/src/utils/audit-org.d.ts.map +1 -1
  150. package/dist/src/utils/audit-org.js +155 -0
  151. package/dist/src/utils/audit-org.js.map +1 -1
  152. package/dist/src/utils/otel-sync-schedule.d.ts +22 -0
  153. package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
  154. package/dist/src/utils/otel-sync-schedule.js +68 -0
  155. package/dist/src/utils/otel-sync-schedule.js.map +1 -0
  156. package/dist/src/utils/tool-check.d.ts +14 -0
  157. package/dist/src/utils/tool-check.d.ts.map +1 -0
  158. package/dist/src/utils/tool-check.js +42 -0
  159. package/dist/src/utils/tool-check.js.map +1 -0
  160. package/package.json +3 -2
@@ -6,7 +6,8 @@
6
6
  # - OIDC provider configured for GitHub Actions in your AWS account
7
7
  # - IAM role with bedrock:InvokeModel and events:PutEvents permissions
8
8
  # - Repository secret PRISM_METRICS_ROLE_ARN set to the OIDC role ARN
9
- # - .prism/config.json committed to the repo (created by: bash prism-cli bootstrapper install-git-hooks)
9
+ # - .prism/config.json committed to the repo with a "team_id" key; add it by hand, e.g.
10
+ # {"team_id": "team-payments"} (prism-cli enable codewatch keeps existing keys in this file)
10
11
  # - .prism/eval-harness/ installed (run: bash prism-cli bootstrapper install-eval-harness)
11
12
  # - (Optional) Agent entry point supports --mock flag for dry-run execution
12
13
 
@@ -4,7 +4,8 @@
4
4
  #
5
5
  # Required setup:
6
6
  # - Kiro API key stored as repository secret: KIRO_API_KEY
7
- # - .kiro/steering/code-review.md in your repo (review rules)
7
+ # - .prism/review/rules.md (shared with the local pre-push review) or
8
+ # .kiro/steering/code-review.md in your repo (review rules)
8
9
  # - (Optional) PRISM_METRICS_ROLE_ARN for EventBridge metrics emission
9
10
  # - (Optional) AWS Continuum configured for security scanning
10
11
  #
@@ -236,6 +237,15 @@ jobs:
236
237
  exit 0
237
238
  fi
238
239
 
240
+ # Shared rule set: .prism/review/rules.md is the one source for this
241
+ # gate and the local pre-push review (prism-cli enable security).
242
+ # kiro-cli loads rules from .kiro/steering/, so sync it in for this run.
243
+ if [ -f .prism/review/rules.md ]; then
244
+ mkdir -p .kiro/steering
245
+ cp .prism/review/rules.md .kiro/steering/code-review.md
246
+ echo "Review rules: .prism/review/rules.md"
247
+ fi
248
+
239
249
  # Get all changed source files in this PR
240
250
  FILES=$(git diff --name-only "${BASE_SHA}..${HEAD_SHA}" \
241
251
  | grep -E '\.(ts|js|py|go|java|rs|tsx|jsx)$' \
@@ -186,6 +186,14 @@ prism-eval-gate:
186
186
  elif [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
187
187
  echo "⚠️ Missing base/head SHA — review skipped."
188
188
  else
189
+ # Shared rule set (also used by the local pre-push review). kiro-cli
190
+ # loads rules from .kiro/steering/, so sync it in for this run.
191
+ if [ -f .prism/review/rules.md ]; then
192
+ mkdir -p .kiro/steering
193
+ cp .prism/review/rules.md .kiro/steering/code-review.md
194
+ echo "Review rules: .prism/review/rules.md"
195
+ fi
196
+
189
197
  FILES=$(git diff --name-only "${BASE_SHA}..${HEAD_SHA}" \
190
198
  | grep -E '\.(ts|js|py|go|java|rs|tsx|jsx)$' \
191
199
  | grep -vE '\.test\.|\.spec\.|__test__|_test\.' \
@@ -24,6 +24,9 @@ import * as cognito from 'aws-cdk-lib/aws-cognito';
24
24
  import * as s3 from 'aws-cdk-lib/aws-s3';
25
25
  import * as kms from 'aws-cdk-lib/aws-kms';
26
26
  import * as logs from 'aws-cdk-lib/aws-logs';
27
+ import * as events from 'aws-cdk-lib/aws-events';
28
+ import * as targets from 'aws-cdk-lib/aws-events-targets';
29
+ import * as iam from 'aws-cdk-lib/aws-iam';
27
30
  import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
28
31
  import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
29
32
  import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
@@ -32,6 +35,7 @@ import * as ssm from 'aws-cdk-lib/aws-ssm';
32
35
  import * as path from 'path';
33
36
  import { Construct } from 'constructs';
34
37
  import { NagSuppressions } from 'cdk-nag';
38
+ import { PRICES_KEY } from '../lambda/pricing/prices-key';
35
39
 
36
40
  /** codeburn sync fixed loopback callback ports (see codeburn src/sync/auth.ts). */
37
41
  const CODEBURN_CALLBACK_PORTS = [19876, 19877, 19878];
@@ -509,9 +513,101 @@ export class OtelCollectorConstruct extends Construct {
509
513
 
510
514
  props.aiUsageTable.grantReadWriteData(receiver);
511
515
  this.archiveBucket.grantPut(receiver);
516
+ // The LiteLLM price table the refresh Lambda maintains (pricing/price-source.ts).
517
+ // Exact key, read only. KMS decrypt comes from grantEncryptDecrypt below.
518
+ const pricesObjectArn = this.archiveBucket.arnForObjects(PRICES_KEY);
519
+ receiver.addToRolePolicy(new iam.PolicyStatement({
520
+ actions: ['s3:GetObject'],
521
+ resources: [pricesObjectArn],
522
+ }));
512
523
  props.kmsKey.grantEncryptDecrypt(receiver);
513
524
  this.receiverFunction = receiver;
514
525
 
526
+ // -------------------------------------------------------
527
+ // Price refresh + reprice — daily
528
+ // Keeps the receiver's LiteLLM fallback table current in S3, then
529
+ // corrects spans stored at $0 whose model has since been priced.
530
+ // See lib/lambda/pricing-refresh.ts.
531
+ //
532
+ // Deliberately NOT in the VPC: the PRISM VPC has no NAT gateway, and this
533
+ // function's one job that needs a network path is an HTTPS GET to
534
+ // raw.githubusercontent.com. It accepts no inbound traffic, and reaches
535
+ // only S3 and DynamoDB besides, both over their public endpoints with IAM.
536
+ // -------------------------------------------------------
537
+ const pricingRefresh = new lambda.Function(this, 'PricingRefresh', {
538
+ functionName: 'prism-d1-pricing-refresh',
539
+ runtime: lambda.Runtime.NODEJS_22_X,
540
+ handler: 'pricing-refresh.handler',
541
+ code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda'), {
542
+ bundling: {
543
+ image: lambda.Runtime.NODEJS_22_X.bundlingImage,
544
+ command: [
545
+ 'bash', '-c',
546
+ [
547
+ 'npm init -y > /dev/null 2>&1',
548
+ 'npm install --save @aws-sdk/client-dynamodb @aws-sdk/client-s3 esbuild > /dev/null 2>&1',
549
+ 'npx esbuild pricing-refresh.ts --bundle --platform=node --target=node22 --outfile=/asset-output/pricing-refresh.js --external:@aws-sdk/*',
550
+ ].join(' && '),
551
+ ],
552
+ local: {
553
+ tryBundle(outputDir: string): boolean {
554
+ try {
555
+ const { execSync } = require('child_process');
556
+ execSync(
557
+ `npx esbuild ${path.join(__dirname, '..', 'lambda', 'pricing-refresh.ts')} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, 'pricing-refresh.js')} --external:@aws-sdk/*`,
558
+ { stdio: 'pipe' },
559
+ );
560
+ return true;
561
+ } catch {
562
+ return false;
563
+ }
564
+ },
565
+ },
566
+ },
567
+ }),
568
+ // A full-table scan plus, on the first run, a read of the raw archive to
569
+ // recover cache-token counts for spans stored before they were kept.
570
+ timeout: cdk.Duration.minutes(15),
571
+ memorySize: 1024,
572
+ environment: {
573
+ AI_USAGE_TABLE: props.aiUsageTable.tableName,
574
+ ARCHIVE_BUCKET: this.archiveBucket.bucketName,
575
+ },
576
+ logRetention: logs.RetentionDays.ONE_MONTH,
577
+ description: 'Daily: refresh the LiteLLM price table in S3, then reprice usage spans stored at $0',
578
+ });
579
+ props.aiUsageTable.grantReadWriteData(pricingRefresh);
580
+ // Read the raw archive (to recover cache-token counts), and read/write the
581
+ // one price-table object. No delete: an old table is only ever overwritten.
582
+ this.archiveBucket.grantRead(pricingRefresh, 'otlp/*');
583
+ pricingRefresh.addToRolePolicy(new iam.PolicyStatement({
584
+ actions: ['s3:GetObject', 's3:PutObject'],
585
+ resources: [pricesObjectArn],
586
+ }));
587
+ props.kmsKey.grantEncryptDecrypt(pricingRefresh);
588
+
589
+ new events.Rule(this, 'PricingRefreshSchedule', {
590
+ ruleName: 'prism-d1-pricing-refresh-daily',
591
+ description: 'Refresh LiteLLM prices and reprice $0 spans',
592
+ // 06:17 UTC: off the hour so it is not queued behind other schedules.
593
+ schedule: events.Schedule.cron({ minute: '17', hour: '6' }),
594
+ // No retry: a failed run already repriced what it could and the next day's
595
+ // run starts from the same state. Errors surface on the Lambda metric.
596
+ targets: [new targets.LambdaFunction(pricingRefresh, { retryAttempts: 0 })],
597
+ });
598
+
599
+ NagSuppressions.addResourceSuppressions(pricingRefresh, [
600
+ {
601
+ id: 'AwsSolutions-IAM5',
602
+ reason: 'grantRead scopes S3 reads to the otlp/* prefix of the single archive bucket (raw OTLP batches, read to recover cache-token counts); grantReadWriteData adds the AI-usage table GSI index wildcard.',
603
+ appliesTo: [
604
+ 'Action::s3:GetObject*', 'Action::s3:GetBucket*', 'Action::s3:List*',
605
+ { regex: '/^Resource::<OtelCollectorArchiveBucket.*\\.Arn>\\/otlp\\/\\*$/g' },
606
+ { regex: '/^Resource::<AiUsageTable.*\\.Arn>\\/index\\/\\*$/g' },
607
+ ],
608
+ },
609
+ ], true);
610
+
515
611
  // -------------------------------------------------------
516
612
  // CloudWatch Custom Widget: Developer Productivity
517
613
  // Invoked by CloudWatch dashboards with the VIEWING user's IAM creds
@@ -808,6 +808,33 @@ export class DashboardStack extends cdk.Stack {
808
808
  }),
809
809
  );
810
810
 
811
+ // --- Row 5: Tools the model invoked (codeburn ai.tools) ---
812
+ // Store-backed like the spend tiles: OTEL#TOOLDAY rollups are bucketed by
813
+ // the call's own day and keep full history, so a backfilled sync push is
814
+ // not cut off at CloudWatch's two-week window. Follows the Developer filter.
815
+ devDashboard.addWidgets(
816
+ new cloudwatch.CustomWidget({
817
+ functionArn: props.velocityWidgetArn ?? '',
818
+ title: 'Tool Usage: Top Tools & MCP Servers',
819
+ width: 16,
820
+ height: 9,
821
+ params: { view: 'tool-usage-top', user: USER_TOKEN },
822
+ updateOnRefresh: true,
823
+ updateOnResize: false,
824
+ updateOnTimeRangeChange: true,
825
+ }),
826
+ new cloudwatch.CustomWidget({
827
+ functionArn: props.velocityWidgetArn ?? '',
828
+ title: 'Tool Calls / Day',
829
+ width: 8,
830
+ height: 9,
831
+ params: { view: 'tool-usage-trend', user: USER_TOKEN },
832
+ updateOnRefresh: true,
833
+ updateOnResize: false,
834
+ updateOnTimeRangeChange: true,
835
+ }),
836
+ );
837
+
811
838
  new cdk.CfnOutput(this, 'DevProductivityDashboardUrl', {
812
839
  value: `https://${this.region}.console.aws.amazon.com/cloudwatch/home?region=${this.region}#dashboards:name=PRISM-D1-Developer-Productivity`,
813
840
  description: 'Developer Productivity Dashboard URL',
@@ -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) {
@@ -1673,6 +1834,112 @@ function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string;
1673
1834
 
1674
1835
  // ---- Handler ----
1675
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
+
1676
1943
  export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
1677
1944
  const method = event.requestContext.http.method;
1678
1945
  const path = event.rawPath;
@@ -1695,5 +1962,8 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
1695
1962
  if (method === 'GET' && path === '/v1/commits-daily') {
1696
1963
  return handleCommitsDailyQuery(event);
1697
1964
  }
1965
+ if (method === 'GET' && path === '/v1/tool-usage') {
1966
+ return handleToolUsageQuery(event);
1967
+ }
1698
1968
  return jsonResponse(404, { message: 'Not found' });
1699
1969
  }