@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.
- package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +11 -1
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +8 -0
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +96 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +27 -0
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +278 -8
- package/dist/assets/infra/lib/lambda/pricing/litellm-snapshot.json +3769 -0
- package/dist/assets/infra/lib/lambda/pricing/price-source.ts +56 -0
- package/dist/assets/infra/lib/lambda/pricing/price-table.ts +240 -0
- package/dist/assets/infra/lib/lambda/pricing/prices-key.ts +6 -0
- package/dist/assets/infra/lib/lambda/pricing/reprice.ts +108 -0
- package/dist/assets/infra/lib/lambda/pricing-refresh.ts +352 -0
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +124 -1
- package/dist/assets/infra/package.json +4 -1
- package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
- package/dist/src/codewatch/config.d.ts +17 -0
- package/dist/src/codewatch/config.d.ts.map +1 -0
- package/dist/src/codewatch/config.js +40 -0
- package/dist/src/codewatch/config.js.map +1 -0
- package/dist/src/codewatch/gitleaks.d.ts +49 -0
- package/dist/src/codewatch/gitleaks.d.ts.map +1 -0
- package/dist/src/codewatch/gitleaks.js +160 -0
- package/dist/src/codewatch/gitleaks.js.map +1 -0
- package/dist/src/codewatch/globalHooks.d.ts +52 -0
- package/dist/src/codewatch/globalHooks.d.ts.map +1 -0
- package/dist/src/codewatch/globalHooks.js +235 -0
- package/dist/src/codewatch/globalHooks.js.map +1 -0
- package/dist/src/codewatch/hooks.d.ts +38 -0
- package/dist/src/codewatch/hooks.d.ts.map +1 -0
- package/dist/src/codewatch/hooks.js +176 -0
- package/dist/src/codewatch/hooks.js.map +1 -0
- package/dist/src/codewatch/prepush.d.ts +37 -0
- package/dist/src/codewatch/prepush.d.ts.map +1 -0
- package/dist/src/codewatch/prepush.js +231 -0
- package/dist/src/codewatch/prepush.js.map +1 -0
- package/dist/src/codewatch/results.d.ts +90 -0
- package/dist/src/codewatch/results.d.ts.map +1 -0
- package/dist/src/codewatch/results.js +121 -0
- package/dist/src/codewatch/results.js.map +1 -0
- package/dist/src/codewatch/reviewers/claude.d.ts +9 -0
- package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/claude.js +48 -0
- package/dist/src/codewatch/reviewers/claude.js.map +1 -0
- package/dist/src/codewatch/reviewers/index.d.ts +39 -0
- package/dist/src/codewatch/reviewers/index.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/index.js +97 -0
- package/dist/src/codewatch/reviewers/index.js.map +1 -0
- package/dist/src/codewatch/reviewers/kiro.d.ts +14 -0
- package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/kiro.js +32 -0
- package/dist/src/codewatch/reviewers/kiro.js.map +1 -0
- package/dist/src/codewatch/reviewers/types.d.ts +56 -0
- package/dist/src/codewatch/reviewers/types.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/types.js +91 -0
- package/dist/src/codewatch/reviewers/types.js.map +1 -0
- package/dist/src/codewatch/rules.d.ts +12 -0
- package/dist/src/codewatch/rules.d.ts.map +1 -0
- package/dist/src/codewatch/rules.js +27 -0
- package/dist/src/codewatch/rules.js.map +1 -0
- package/dist/src/commands/_top/dashboard.d.ts +17 -0
- package/dist/src/commands/_top/dashboard.d.ts.map +1 -0
- package/dist/src/commands/_top/dashboard.js +76 -0
- package/dist/src/commands/_top/dashboard.js.map +1 -0
- package/dist/src/commands/bedrock-protection/scan-account.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-account.js +2 -1
- package/dist/src/commands/bedrock-protection/scan-account.js.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-org.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-org.js +16 -4
- package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.js +15 -48
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +13 -0
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.js +28 -147
- package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.js +5 -39
- package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
- package/dist/src/commands/codewatch/pre-commit.d.ts +7 -0
- package/dist/src/commands/codewatch/pre-commit.d.ts.map +1 -0
- package/dist/src/commands/codewatch/pre-commit.js +30 -0
- package/dist/src/commands/codewatch/pre-commit.js.map +1 -0
- package/dist/src/commands/codewatch/pre-push.d.ts +16 -0
- package/dist/src/commands/codewatch/pre-push.d.ts.map +1 -0
- package/dist/src/commands/codewatch/pre-push.js +32 -0
- package/dist/src/commands/codewatch/pre-push.js.map +1 -0
- package/dist/src/commands/codewatch/report.d.ts +22 -0
- package/dist/src/commands/codewatch/report.d.ts.map +1 -0
- package/dist/src/commands/codewatch/report.js +64 -0
- package/dist/src/commands/codewatch/report.js.map +1 -0
- package/dist/src/commands/enable/codewatch.d.ts +15 -0
- package/dist/src/commands/enable/codewatch.d.ts.map +1 -0
- package/dist/src/commands/enable/codewatch.js +186 -0
- package/dist/src/commands/enable/codewatch.js.map +1 -0
- package/dist/src/commands/git/claude-session-context.d.ts.map +1 -1
- package/dist/src/commands/git/claude-session-context.js +2 -1
- package/dist/src/commands/git/claude-session-context.js.map +1 -1
- package/dist/src/commands/git/commit-trailers.js +1 -1
- package/dist/src/commands/git/commit-trailers.js.map +1 -1
- package/dist/src/commands/workshop/verify-setup.d.ts.map +1 -1
- package/dist/src/commands/workshop/verify-setup.js +5 -3
- package/dist/src/commands/workshop/verify-setup.js.map +1 -1
- package/dist/src/dashboard/codewatch.d.ts +106 -0
- package/dist/src/dashboard/codewatch.d.ts.map +1 -0
- package/dist/src/dashboard/codewatch.js +119 -0
- package/dist/src/dashboard/codewatch.js.map +1 -0
- package/dist/src/dashboard/commits.d.ts +96 -0
- package/dist/src/dashboard/commits.d.ts.map +1 -0
- package/dist/src/dashboard/commits.js +207 -0
- package/dist/src/dashboard/commits.js.map +1 -0
- package/dist/src/dashboard/page.d.ts +10 -0
- package/dist/src/dashboard/page.d.ts.map +1 -0
- package/dist/src/dashboard/page.js +315 -0
- package/dist/src/dashboard/page.js.map +1 -0
- package/dist/src/dashboard/payload.d.ts +111 -0
- package/dist/src/dashboard/payload.d.ts.map +1 -0
- package/dist/src/dashboard/payload.js +93 -0
- package/dist/src/dashboard/payload.js.map +1 -0
- package/dist/src/dashboard/report.d.ts +99 -0
- package/dist/src/dashboard/report.d.ts.map +1 -0
- package/dist/src/dashboard/report.js +300 -0
- package/dist/src/dashboard/report.js.map +1 -0
- package/dist/src/dashboard/repos.d.ts +58 -0
- package/dist/src/dashboard/repos.d.ts.map +1 -0
- package/dist/src/dashboard/repos.js +111 -0
- package/dist/src/dashboard/repos.js.map +1 -0
- package/dist/src/dashboard/server.d.ts +52 -0
- package/dist/src/dashboard/server.d.ts.map +1 -0
- package/dist/src/dashboard/server.js +163 -0
- package/dist/src/dashboard/server.js.map +1 -0
- package/dist/src/dashboard/setup.d.ts +52 -0
- package/dist/src/dashboard/setup.d.ts.map +1 -0
- package/dist/src/dashboard/setup.js +129 -0
- package/dist/src/dashboard/setup.js.map +1 -0
- package/dist/src/dashboard/usage.d.ts +43 -0
- package/dist/src/dashboard/usage.d.ts.map +1 -0
- package/dist/src/dashboard/usage.js +88 -0
- package/dist/src/dashboard/usage.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +40 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/utils/audit-bedrock.d.ts +1 -0
- package/dist/src/utils/audit-bedrock.d.ts.map +1 -1
- package/dist/src/utils/audit-bedrock.js +133 -0
- package/dist/src/utils/audit-bedrock.js.map +1 -1
- package/dist/src/utils/audit-org.d.ts +1 -0
- package/dist/src/utils/audit-org.d.ts.map +1 -1
- package/dist/src/utils/audit-org.js +155 -0
- package/dist/src/utils/audit-org.js.map +1 -1
- package/dist/src/utils/otel-sync-schedule.d.ts +22 -0
- package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
- package/dist/src/utils/otel-sync-schedule.js +68 -0
- package/dist/src/utils/otel-sync-schedule.js.map +1 -0
- package/dist/src/utils/tool-check.d.ts +14 -0
- package/dist/src/utils/tool-check.d.ts.map +1 -0
- package/dist/src/utils/tool-check.js +42 -0
- package/dist/src/utils/tool-check.js.map +1 -0
- 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
|
|
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
|
-
# - .
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
}
|