@prism-d1/cli 1.1.2 → 1.3.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 +12 -2
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +8 -0
- package/dist/assets/infra/bin/app.ts +61 -37
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +96 -0
- package/dist/assets/infra/lib/constructs/security-agent-construct.ts +35 -30
- 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/lib/metrics-pipeline-stack.ts +2 -110
- package/dist/assets/infra/lib/security-agent-resources.ts +136 -0
- package/dist/assets/infra/lib/security-agent-stack.ts +69 -0
- package/dist/assets/infra/package.json +4 -1
- package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
- package/dist/assets/pentest-cdk/bin/app.ts +12 -0
- package/dist/assets/pentest-cdk/cdk.json +7 -0
- package/dist/assets/pentest-cdk/lib/pentest-target-stack.ts +206 -0
- package/dist/assets/pentest-cdk/package.json +18 -0
- package/dist/assets/pentest-cdk/tsconfig.json +12 -0
- 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 +210 -0
- package/dist/src/codewatch/results.d.ts.map +1 -0
- package/dist/src/codewatch/results.js +127 -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/_top/version.d.ts +62 -0
- package/dist/src/commands/_top/version.d.ts.map +1 -0
- package/dist/src/commands/_top/version.js +175 -0
- package/dist/src/commands/_top/version.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 +12 -6
- package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.js +18 -443
- 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/codeburn.d.ts +59 -0
- package/dist/src/commands/enable/codeburn.d.ts.map +1 -0
- package/dist/src/commands/enable/codeburn.js +531 -0
- package/dist/src/commands/enable/codeburn.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/enable/security.d.ts +41 -0
- package/dist/src/commands/enable/security.d.ts.map +1 -0
- package/dist/src/commands/enable/security.js +130 -0
- package/dist/src/commands/enable/security.js.map +1 -0
- package/dist/src/commands/export/otel.d.ts +31 -0
- package/dist/src/commands/export/otel.d.ts.map +1 -0
- package/dist/src/commands/export/otel.js +52 -0
- package/dist/src/commands/export/otel.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/security/code-review.d.ts +22 -0
- package/dist/src/commands/security/code-review.d.ts.map +1 -0
- package/dist/src/commands/security/code-review.js +43 -0
- package/dist/src/commands/security/code-review.js.map +1 -0
- package/dist/src/commands/security/findings.d.ts +15 -0
- package/dist/src/commands/security/findings.d.ts.map +1 -0
- package/dist/src/commands/security/findings.js +31 -0
- package/dist/src/commands/security/findings.js.map +1 -0
- package/dist/src/commands/security/pentest.d.ts +18 -0
- package/dist/src/commands/security/pentest.d.ts.map +1 -0
- package/dist/src/commands/security/pentest.js +32 -0
- package/dist/src/commands/security/pentest.js.map +1 -0
- package/dist/src/commands/security/threat-model.d.ts +16 -0
- package/dist/src/commands/security/threat-model.d.ts.map +1 -0
- package/dist/src/commands/security/threat-model.js +24 -0
- package/dist/src/commands/security/threat-model.js.map +1 -0
- package/dist/src/commands/security/threats.d.ts +12 -0
- package/dist/src/commands/security/threats.d.ts.map +1 -0
- package/dist/src/commands/security/threats.js +23 -0
- package/dist/src/commands/security/threats.js.map +1 -0
- package/dist/src/commands/securityagent/setup.d.ts +19 -5
- package/dist/src/commands/securityagent/setup.d.ts.map +1 -1
- package/dist/src/commands/securityagent/setup.js +25 -189
- package/dist/src/commands/securityagent/setup.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 +120 -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 +470 -0
- package/dist/src/dashboard/page.js.map +1 -0
- package/dist/src/dashboard/payload.d.ts +114 -0
- package/dist/src/dashboard/payload.d.ts.map +1 -0
- package/dist/src/dashboard/payload.js +96 -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/security.d.ts +130 -0
- package/dist/src/dashboard/security.d.ts.map +1 -0
- package/dist/src/dashboard/security.js +144 -0
- package/dist/src/dashboard/security.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 +67 -0
- package/dist/src/dashboard/setup.d.ts.map +1 -0
- package/dist/src/dashboard/setup.js +146 -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 +48 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/security/cfn-diff.d.ts +57 -0
- package/dist/src/security/cfn-diff.d.ts.map +1 -0
- package/dist/src/security/cfn-diff.js +112 -0
- package/dist/src/security/cfn-diff.js.map +1 -0
- package/dist/src/security/errors.d.ts +45 -0
- package/dist/src/security/errors.d.ts.map +1 -0
- package/dist/src/security/errors.js +116 -0
- package/dist/src/security/errors.js.map +1 -0
- package/dist/src/security/marker.d.ts +46 -0
- package/dist/src/security/marker.d.ts.map +1 -0
- package/dist/src/security/marker.js +120 -0
- package/dist/src/security/marker.js.map +1 -0
- package/dist/src/security/pentest-dockerfile.d.ts +8 -0
- package/dist/src/security/pentest-dockerfile.d.ts.map +1 -0
- package/dist/src/security/pentest-dockerfile.js +163 -0
- package/dist/src/security/pentest-dockerfile.js.map +1 -0
- package/dist/src/security/pentest.d.ts +233 -0
- package/dist/src/security/pentest.d.ts.map +1 -0
- package/dist/src/security/pentest.js +1057 -0
- package/dist/src/security/pentest.js.map +1 -0
- package/dist/src/security/repo.d.ts +25 -0
- package/dist/src/security/repo.d.ts.map +1 -0
- package/dist/src/security/repo.js +38 -0
- package/dist/src/security/repo.js.map +1 -0
- package/dist/src/security/review.d.ts +202 -0
- package/dist/src/security/review.d.ts.map +1 -0
- package/dist/src/security/review.js +706 -0
- package/dist/src/security/review.js.map +1 -0
- package/dist/src/security/setup.d.ts +193 -0
- package/dist/src/security/setup.d.ts.map +1 -0
- package/dist/src/security/setup.js +728 -0
- package/dist/src/security/setup.js.map +1 -0
- package/dist/src/security/threat-model.d.ts +133 -0
- package/dist/src/security/threat-model.d.ts.map +1 -0
- package/dist/src/security/threat-model.js +653 -0
- package/dist/src/security/threat-model.js.map +1 -0
- 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/exec.d.ts +6 -0
- package/dist/src/utils/exec.d.ts.map +1 -1
- package/dist/src/utils/exec.js +12 -1
- package/dist/src/utils/exec.js.map +1 -1
- package/dist/src/utils/otel-sync-schedule.d.ts +41 -0
- package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
- package/dist/src/utils/otel-sync-schedule.js +90 -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
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRISM D1 — daily price-table refresh and $0-span reprice.
|
|
3
|
+
*
|
|
4
|
+
* Runs once a day on an EventBridge schedule:
|
|
5
|
+
*
|
|
6
|
+
* 1. Refresh. Fetch LiteLLM's price file, trim it to four rates per model,
|
|
7
|
+
* validate it against the table currently in S3, and write it to
|
|
8
|
+
* s3://<archive>/pricing/litellm-prices.json. A refused or failed fetch
|
|
9
|
+
* keeps the previous table in service and fails the invocation at the end,
|
|
10
|
+
* so the Lambda Errors metric shows it.
|
|
11
|
+
*
|
|
12
|
+
* 2. Reprice. Scan for usage spans stored with cost 0 but tokens > 0. These
|
|
13
|
+
* are spans no price table matched when they arrived; a model is often
|
|
14
|
+
* added to LiteLLM days after people start using it. Each one that now
|
|
15
|
+
* matches is corrected in one transaction with its OTEL#DAY aggregate, so
|
|
16
|
+
* the dashboards and the CloudWatch metrics (via the table stream) pick up
|
|
17
|
+
* the difference.
|
|
18
|
+
*
|
|
19
|
+
* Spans written before the receiver stored cache-token counts lack them. For
|
|
20
|
+
* those, the cache counts are recovered from the raw OTLP archive; without
|
|
21
|
+
* them, cached input would go unpriced or be billed at the full input rate.
|
|
22
|
+
*
|
|
23
|
+
* Invoke manually with {"skipRefresh": true} to reprice without fetching, or
|
|
24
|
+
* {"dryRun": true} to report what would change without writing.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
DynamoDBClient,
|
|
28
|
+
ScanCommand,
|
|
29
|
+
TransactWriteItemsCommand,
|
|
30
|
+
TransactionCanceledException,
|
|
31
|
+
type ScanCommandOutput,
|
|
32
|
+
} from '@aws-sdk/client-dynamodb';
|
|
33
|
+
import {
|
|
34
|
+
S3Client,
|
|
35
|
+
GetObjectCommand,
|
|
36
|
+
PutObjectCommand,
|
|
37
|
+
ListObjectsV2Command,
|
|
38
|
+
NoSuchKey,
|
|
39
|
+
} from '@aws-sdk/client-s3';
|
|
40
|
+
import {
|
|
41
|
+
LITELLM_PRICES_URL, slimLiteLLM, validateTable, toDocument, parseDocument, PriceDocument,
|
|
42
|
+
} from './pricing/price-table';
|
|
43
|
+
import { PRICES_KEY, BUNDLED_PRICES } from './pricing/price-source';
|
|
44
|
+
import { planReprice, lacksCacheCounts, groupByAggregate, RepricePlan, SpanItem } from './pricing/reprice';
|
|
45
|
+
|
|
46
|
+
const dynamo = new DynamoDBClient({});
|
|
47
|
+
const s3 = new S3Client({});
|
|
48
|
+
|
|
49
|
+
const TABLE = process.env.AI_USAGE_TABLE || 'prism-d1-ai-usage';
|
|
50
|
+
const BUCKET = process.env.ARCHIVE_BUCKET || '';
|
|
51
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
52
|
+
const WRITE_CONCURRENCY = 10;
|
|
53
|
+
/** Upper bound on the raw download; LiteLLM's file is about 3 MB today. */
|
|
54
|
+
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024;
|
|
55
|
+
|
|
56
|
+
interface RefreshEvent {
|
|
57
|
+
skipRefresh?: boolean;
|
|
58
|
+
dryRun?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readCurrentTable(): Promise<PriceDocument | null> {
|
|
62
|
+
try {
|
|
63
|
+
const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: PRICES_KEY }));
|
|
64
|
+
return parseDocument(JSON.parse(await res.Body!.transformToString()));
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e instanceof NoSuchKey) return null;
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchLiteLLM(): Promise<unknown> {
|
|
72
|
+
const res = await fetch(LITELLM_PRICES_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
73
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} from ${LITELLM_PRICES_URL}`);
|
|
74
|
+
const len = Number(res.headers.get('content-length') ?? 0);
|
|
75
|
+
if (len > MAX_DOWNLOAD_BYTES) throw new Error(`response is ${len} bytes, over the ${MAX_DOWNLOAD_BYTES} limit`);
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
if (text.length > MAX_DOWNLOAD_BYTES) throw new Error(`response over the ${MAX_DOWNLOAD_BYTES} byte limit`);
|
|
78
|
+
return JSON.parse(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Step 1. Returns the table to reprice with and, if the refresh failed, why. */
|
|
82
|
+
async function refresh(dryRun: boolean): Promise<{ doc: PriceDocument; failure: string | null }> {
|
|
83
|
+
const current = await readCurrentTable();
|
|
84
|
+
const fallback = current ?? BUNDLED_PRICES;
|
|
85
|
+
let next;
|
|
86
|
+
try {
|
|
87
|
+
next = slimLiteLLM(await fetchLiteLLM());
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { doc: fallback, failure: `fetch failed: ${(e as Error).message}` };
|
|
90
|
+
}
|
|
91
|
+
// Validate against the S3 table, or the bundled snapshot on first run, so
|
|
92
|
+
// even the first write is checked against something.
|
|
93
|
+
const v = validateTable(next, fallback.entries);
|
|
94
|
+
if (!v.ok) {
|
|
95
|
+
return { doc: fallback, failure: `refused new table: ${v.reasons.join('; ')}` };
|
|
96
|
+
}
|
|
97
|
+
const doc = toDocument(next, new Date().toISOString());
|
|
98
|
+
const added = Object.keys(next).filter((k) => !fallback.entries[k]);
|
|
99
|
+
console.log(JSON.stringify({
|
|
100
|
+
msg: 'price table refreshed',
|
|
101
|
+
entries: doc.entryCount,
|
|
102
|
+
previousEntries: fallback.entryCount,
|
|
103
|
+
previousFetchedAt: fallback.fetchedAt,
|
|
104
|
+
added: added.length,
|
|
105
|
+
addedSample: added.slice(0, 20),
|
|
106
|
+
rateJumps: v.jumps.slice(0, 20),
|
|
107
|
+
dryRun,
|
|
108
|
+
}));
|
|
109
|
+
if (!dryRun) {
|
|
110
|
+
await s3.send(new PutObjectCommand({
|
|
111
|
+
Bucket: BUCKET,
|
|
112
|
+
Key: PRICES_KEY,
|
|
113
|
+
Body: JSON.stringify(doc),
|
|
114
|
+
ContentType: 'application/json',
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
return { doc, failure: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every span item stored at cost 0. Usage spans only; tokens are checked in planReprice. */
|
|
121
|
+
async function scanZeroCostSpans(): Promise<SpanItem[]> {
|
|
122
|
+
const items: SpanItem[] = [];
|
|
123
|
+
let key: ScanCommandOutput['LastEvaluatedKey'];
|
|
124
|
+
do {
|
|
125
|
+
const res = await dynamo.send(new ScanCommand({
|
|
126
|
+
TableName: TABLE,
|
|
127
|
+
// Every attribute name is a placeholder: several (timestamp, model) are
|
|
128
|
+
// or may become DynamoDB reserved words, and a missed one fails the scan.
|
|
129
|
+
FilterExpression: '#rt = :span AND #cost = :zero',
|
|
130
|
+
ProjectionExpression: '#pk, #sk, #rt, #cost, #tool, #model, #ts, #in, #out, #cr, #cw',
|
|
131
|
+
ExpressionAttributeNames: {
|
|
132
|
+
'#pk': 'pk', '#sk': 'sk', '#rt': 'record_type', '#cost': 'cost_usd', '#tool': 'tool',
|
|
133
|
+
'#model': 'model', '#ts': 'timestamp', '#in': 'input_tokens', '#out': 'output_tokens',
|
|
134
|
+
'#cr': 'cache_read_tokens', '#cw': 'cache_write_tokens',
|
|
135
|
+
},
|
|
136
|
+
ExpressionAttributeValues: { ':span': { S: 'OTEL_SPAN' }, ':zero': { N: '0' } },
|
|
137
|
+
...(key ? { ExclusiveStartKey: key } : {}),
|
|
138
|
+
}));
|
|
139
|
+
items.push(...((res.Items ?? []) as SpanItem[]));
|
|
140
|
+
key = res.LastEvaluatedKey;
|
|
141
|
+
} while (key);
|
|
142
|
+
return items;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Recover cache-token counts for the given span ids from the raw OTLP archive.
|
|
147
|
+
* Reads only partitions on or after the earliest span's date (a span is
|
|
148
|
+
* archived when pushed, which is never before it happened), and stops once
|
|
149
|
+
* every id is found.
|
|
150
|
+
*/
|
|
151
|
+
async function cacheCountsFromArchive(
|
|
152
|
+
spanIds: Set<string>,
|
|
153
|
+
earliestDay: string,
|
|
154
|
+
): Promise<Map<string, { cacheReadTokens: number; cacheWriteTokens: number }>> {
|
|
155
|
+
const found = new Map<string, { cacheReadTokens: number; cacheWriteTokens: number }>();
|
|
156
|
+
let token: string | undefined;
|
|
157
|
+
let objects = 0;
|
|
158
|
+
do {
|
|
159
|
+
const page = await s3.send(new ListObjectsV2Command({
|
|
160
|
+
Bucket: BUCKET,
|
|
161
|
+
Prefix: 'otlp/',
|
|
162
|
+
StartAfter: `otlp/dt=${earliestDay}`,
|
|
163
|
+
ContinuationToken: token,
|
|
164
|
+
}));
|
|
165
|
+
for (const obj of page.Contents ?? []) {
|
|
166
|
+
if (!obj.Key) continue;
|
|
167
|
+
objects++;
|
|
168
|
+
const res = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: obj.Key }));
|
|
169
|
+
let payload: { resourceSpans?: Array<{ scopeSpans?: Array<{ spans?: Array<Record<string, unknown>> }> }> };
|
|
170
|
+
try {
|
|
171
|
+
payload = JSON.parse(await res.Body!.transformToString());
|
|
172
|
+
} catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
for (const rs of payload.resourceSpans ?? []) {
|
|
176
|
+
for (const ss of rs.scopeSpans ?? []) {
|
|
177
|
+
for (const sp of ss.spans ?? []) {
|
|
178
|
+
const id = String(sp.spanId ?? '').toLowerCase();
|
|
179
|
+
if (!spanIds.has(id) || found.has(id)) continue;
|
|
180
|
+
const attrs = (sp.attributes ?? []) as Array<{ key: string; value: { intValue?: string | number } }>;
|
|
181
|
+
const get = (k: string) => Math.max(0, Number(attrs.find((a) => a.key === k)?.value?.intValue ?? 0) || 0);
|
|
182
|
+
found.set(id, {
|
|
183
|
+
cacheReadTokens: get('ai.cache_read_tokens'),
|
|
184
|
+
cacheWriteTokens: get('ai.cache_write_tokens'),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (found.size === spanIds.size) {
|
|
190
|
+
console.log(`[archive] found all ${found.size} spans after ${objects} objects`);
|
|
191
|
+
return found;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
token = page.NextContinuationToken;
|
|
195
|
+
} while (token);
|
|
196
|
+
console.log(`[archive] found ${found.size} of ${spanIds.size} spans in ${objects} objects`);
|
|
197
|
+
return found;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type WriteOutcome = 'repriced' | 'alreadyPriced' | 'aggregateMissing';
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One transaction: set the span's cost (only if it is still 0) and add the
|
|
204
|
+
* same amount to its daily aggregate. The condition makes a re-run, or two
|
|
205
|
+
* overlapping runs, a no-op instead of a double count.
|
|
206
|
+
*/
|
|
207
|
+
async function applyPlan(p: RepricePlan, now: string): Promise<WriteOutcome> {
|
|
208
|
+
try {
|
|
209
|
+
await dynamo.send(new TransactWriteItemsCommand({
|
|
210
|
+
TransactItems: [
|
|
211
|
+
{
|
|
212
|
+
Update: {
|
|
213
|
+
TableName: TABLE,
|
|
214
|
+
Key: { pk: { S: p.pk }, sk: { S: p.sk } },
|
|
215
|
+
UpdateExpression:
|
|
216
|
+
'SET #cost = :cost, #est = :t, #src = :src, #key = :key, #cr = :cr, #cw = :cw, #at = :now',
|
|
217
|
+
ConditionExpression: '#cost = :zero',
|
|
218
|
+
ExpressionAttributeNames: {
|
|
219
|
+
'#cost': 'cost_usd', '#est': 'cost_estimated', '#src': 'cost_source', '#key': 'priced_key',
|
|
220
|
+
'#cr': 'cache_read_tokens', '#cw': 'cache_write_tokens', '#at': 'repriced_at',
|
|
221
|
+
},
|
|
222
|
+
ExpressionAttributeValues: {
|
|
223
|
+
':cost': { N: String(p.costUsd) }, ':t': { BOOL: true }, ':src': { S: 'reprice' },
|
|
224
|
+
':key': { S: p.pricedKey }, ':cr': { N: String(p.cacheReadTokens) },
|
|
225
|
+
':cw': { N: String(p.cacheWriteTokens) }, ':now': { S: now }, ':zero': { N: '0' },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
Update: {
|
|
231
|
+
TableName: TABLE,
|
|
232
|
+
Key: { pk: { S: p.pk }, sk: { S: p.aggregateSk } },
|
|
233
|
+
UpdateExpression: 'ADD #cost :cost SET #upd = :now',
|
|
234
|
+
ConditionExpression: 'attribute_exists(#pk)',
|
|
235
|
+
ExpressionAttributeNames: { '#cost': 'cost_usd', '#upd': 'updated_at', '#pk': 'pk' },
|
|
236
|
+
ExpressionAttributeValues: { ':cost': { N: String(p.costUsd) }, ':now': { S: now } },
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
}));
|
|
241
|
+
return 'repriced';
|
|
242
|
+
} catch (e) {
|
|
243
|
+
if (e instanceof TransactionCanceledException) {
|
|
244
|
+
const [span, agg] = e.CancellationReasons ?? [];
|
|
245
|
+
if (span?.Code === 'ConditionalCheckFailed') return 'alreadyPriced';
|
|
246
|
+
if (agg?.Code === 'ConditionalCheckFailed') return 'aggregateMissing';
|
|
247
|
+
}
|
|
248
|
+
throw e;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const CONFLICT_RETRIES = 5;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The receiver can still update the same aggregate while a reprice runs, so a
|
|
256
|
+
* TransactionConflict is retried with backoff. Retrying is safe: the span's
|
|
257
|
+
* `cost_usd = 0` condition means a write that did land is never applied twice.
|
|
258
|
+
*/
|
|
259
|
+
async function applyPlanWithRetry(p: RepricePlan, now: string): Promise<WriteOutcome> {
|
|
260
|
+
for (let attempt = 0; ; attempt++) {
|
|
261
|
+
try {
|
|
262
|
+
return await applyPlan(p, now);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
const conflict = e instanceof TransactionCanceledException
|
|
265
|
+
&& (e.CancellationReasons ?? []).some((r) => r?.Code === 'TransactionConflict');
|
|
266
|
+
if (!conflict || attempt >= CONFLICT_RETRIES) throw e;
|
|
267
|
+
await new Promise((r) => setTimeout(r, 100 * 2 ** attempt + Math.random() * 100));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Step 2. */
|
|
273
|
+
async function reprice(doc: PriceDocument, dryRun: boolean) {
|
|
274
|
+
const items = await scanZeroCostSpans();
|
|
275
|
+
|
|
276
|
+
// Price once without archive data to learn which legacy spans are worth
|
|
277
|
+
// the archive read: only spans whose model now has a price.
|
|
278
|
+
const legacy = items.filter((i) => lacksCacheCounts(i) && planReprice(i, doc.entries));
|
|
279
|
+
let archived = new Map<string, { cacheReadTokens: number; cacheWriteTokens: number }>();
|
|
280
|
+
if (legacy.length > 0 && BUCKET) {
|
|
281
|
+
const ids = new Set(legacy.map((i) => i.sk!.S!.slice(i.sk!.S!.lastIndexOf('#') + 1)));
|
|
282
|
+
const earliest = legacy.map((i) => i.timestamp!.S!.slice(0, 10)).sort()[0];
|
|
283
|
+
archived = await cacheCountsFromArchive(ids, earliest);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const plans = items.map((i) => planReprice(i, doc.entries, archived)).filter((p): p is RepricePlan => p !== null);
|
|
287
|
+
const unmatched = new Map<string, number>();
|
|
288
|
+
for (const i of items) {
|
|
289
|
+
if (!planReprice(i, doc.entries, archived)) {
|
|
290
|
+
const tokens = Number(i.input_tokens?.N ?? 0) + Number(i.output_tokens?.N ?? 0);
|
|
291
|
+
if (tokens > 0) {
|
|
292
|
+
const m = i.model?.S || 'unknown';
|
|
293
|
+
unmatched.set(m, (unmatched.get(m) ?? 0) + 1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const counts: Record<WriteOutcome, number> = { repriced: 0, alreadyPriced: 0, aggregateMissing: 0 };
|
|
299
|
+
let addedUsd = 0;
|
|
300
|
+
const byModel: Record<string, { spans: number; usd: number; key: string }> = {};
|
|
301
|
+
const cacheSources: Record<string, number> = {};
|
|
302
|
+
const now = new Date().toISOString();
|
|
303
|
+
|
|
304
|
+
const record = (p: RepricePlan, o: WriteOutcome) => {
|
|
305
|
+
counts[o]++;
|
|
306
|
+
if (o !== 'repriced') return;
|
|
307
|
+
addedUsd += p.costUsd;
|
|
308
|
+
const m = (byModel[p.model || 'unknown'] ??= { spans: 0, usd: 0, key: p.pricedKey });
|
|
309
|
+
m.spans++;
|
|
310
|
+
m.usd += p.costUsd;
|
|
311
|
+
cacheSources[p.cacheSource] = (cacheSources[p.cacheSource] ?? 0) + 1;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Every span of one user-day adds to the same OTEL#DAY item, and DynamoDB
|
|
315
|
+
// cancels concurrent transactions that touch one item (TransactionConflict).
|
|
316
|
+
// So spans sharing an aggregate are written one after another, while
|
|
317
|
+
// different aggregates run in parallel.
|
|
318
|
+
const groups = [...groupByAggregate(plans).values()];
|
|
319
|
+
for (let i = 0; i < groups.length; i += WRITE_CONCURRENCY) {
|
|
320
|
+
const chunk = groups.slice(i, i + WRITE_CONCURRENCY);
|
|
321
|
+
await Promise.all(chunk.map(async (group) => {
|
|
322
|
+
for (const p of group) record(p, dryRun ? 'repriced' : await applyPlanWithRetry(p, now));
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
for (const m of Object.values(byModel)) m.usd = Math.round(m.usd * 100) / 100;
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
zeroCostSpans: items.length,
|
|
329
|
+
...counts,
|
|
330
|
+
addedUsd: Math.round(addedUsd * 100) / 100,
|
|
331
|
+
byModel,
|
|
332
|
+
cacheSources,
|
|
333
|
+
stillUnpriced: Object.fromEntries([...unmatched.entries()].slice(0, 30)),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function handler(event: RefreshEvent = {}): Promise<Record<string, unknown>> {
|
|
338
|
+
const dryRun = event.dryRun === true;
|
|
339
|
+
const { doc, failure } = event.skipRefresh
|
|
340
|
+
? { doc: (await readCurrentTable()) ?? BUNDLED_PRICES, failure: null }
|
|
341
|
+
: await refresh(dryRun);
|
|
342
|
+
if (failure) console.error(`[pricing] ${failure}; repricing with table from ${doc.fetchedAt || 'bundle'}`);
|
|
343
|
+
|
|
344
|
+
const result = await reprice(doc, dryRun);
|
|
345
|
+
const summary = { msg: 'reprice complete', dryRun, tableFetchedAt: doc.fetchedAt, ...result };
|
|
346
|
+
console.log(JSON.stringify(summary));
|
|
347
|
+
|
|
348
|
+
// Fail after repricing, not before: a stale table still prices what it can,
|
|
349
|
+
// and the error makes the stale table visible on the Lambda Errors metric.
|
|
350
|
+
if (failure) throw new Error(`price refresh failed: ${failure}`);
|
|
351
|
+
return summary;
|
|
352
|
+
}
|
|
@@ -27,6 +27,8 @@ const RECEIVER_FUNCTION = process.env.RECEIVER_FUNCTION || 'prism-d1-otel-receiv
|
|
|
27
27
|
interface WidgetEvent {
|
|
28
28
|
describe?: boolean;
|
|
29
29
|
view?: string;
|
|
30
|
+
/** Developer filter for views that support it; 'all' or unset = organization. */
|
|
31
|
+
user?: string;
|
|
30
32
|
widgetContext?: { timeRange?: { start: number; end: number }; theme?: string };
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -37,7 +39,8 @@ the attribution store. Full history, real event timestamps.
|
|
|
37
39
|
### Parameters
|
|
38
40
|
| Name | Type | Default | Description |
|
|
39
41
|
|------|------|---------|-------------|
|
|
40
|
-
| view | string | dora | dora, aidora, repos, eval, governance, agents, security, exec, exec-security, ciso-exposure, ciso-sla, ciso-risk, ciso-shiftleft, ciso-classes |
|
|
42
|
+
| view | string | dora | dora, aidora, repos, eval, governance, agents, security, exec, exec-security, ciso-exposure, ciso-sla, ciso-risk, ciso-shiftleft, ciso-classes, tool-usage-top, tool-usage-trend |
|
|
43
|
+
| user | string | all | Developer identity for tool-usage-* views |
|
|
41
44
|
|
|
42
45
|
The \`exec\` view additionally computes an **observed** PRISM level from
|
|
43
46
|
outcome metrics (capped at L4 — L5 requires an autonomy signal that no
|
|
@@ -1990,6 +1993,124 @@ async function renderMergeRateTrend(fromIso: string, toIso: string, days: number
|
|
|
1990
1993
|
|
|
1991
1994
|
// ---- Handler ----
|
|
1992
1995
|
|
|
1996
|
+
// ---- Views: tool-usage-top / tool-usage-trend (tools the model invoked) ----
|
|
1997
|
+
|
|
1998
|
+
/** Dashboard PATTERN token left unreplaced, or blank, means org scope. */
|
|
1999
|
+
function widgetUser(user: string | undefined): string {
|
|
2000
|
+
const u = (user ?? '').trim();
|
|
2001
|
+
return !u || u === 'PRISM_USER_FILTER_TOKEN' ? 'all' : u;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
async function fetchToolUsage(fromIso: string, toIso: string, user: string): Promise<any> {
|
|
2005
|
+
return invokeReceiver('/v1/tool-usage',
|
|
2006
|
+
`user=${encodeURIComponent(user)}&from=${fromIso.slice(0, 10)}&to=${toIso.slice(0, 10)}`);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const TOOL_USAGE_NOTE =
|
|
2010
|
+
'Counts model calls that invoked each tool (codeburn ai.tools), by the day of the call. ' +
|
|
2011
|
+
'Providers that do not report tool calls contribute nothing here.';
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* Top tools and MCP servers. Reads the receiver's OTEL#TOOLDAY rollups rather
|
|
2015
|
+
* than a CloudWatch metric for the same reasons as the spend tiles: full
|
|
2016
|
+
* history, bucketed by call date, and scoped by the Developer variable.
|
|
2017
|
+
*/
|
|
2018
|
+
async function renderToolUsageTop(fromIso: string, toIso: string, user: string, p: Palette): Promise<string> {
|
|
2019
|
+
let r: any;
|
|
2020
|
+
try {
|
|
2021
|
+
r = await fetchToolUsage(fromIso, toIso, user);
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
return emptyState('tool usage', `Attribution store unreachable (${(err as Error).message}).`, p);
|
|
2024
|
+
}
|
|
2025
|
+
const tools: Array<{ name: string; mcpServer: string; calls: number; byAiTool: Record<string, number> }> = r?.tools ?? [];
|
|
2026
|
+
if (tools.length === 0) {
|
|
2027
|
+
return emptyState('tool usage', 'Run `codeburn sync push` with a codeburn version that sends ai.tools.', p);
|
|
2028
|
+
}
|
|
2029
|
+
const servers: Array<{ server: string; calls: number; tools: number }> = r?.mcpServers ?? [];
|
|
2030
|
+
const mcpCalls = servers.reduce((s, x) => s + x.calls, 0);
|
|
2031
|
+
const total = r?.totalToolCalls ?? 0;
|
|
2032
|
+
|
|
2033
|
+
const top = tools.slice(0, 12);
|
|
2034
|
+
const maxT = top[0]?.calls ?? 0;
|
|
2035
|
+
const toolRows = top.map(t => [
|
|
2036
|
+
`${esc(t.name)}${bar(t.calls, maxT, p.accent)}`,
|
|
2037
|
+
esc(Object.entries(t.byAiTool).sort((a, b) => b[1] - a[1]).map(([k]) => k).join(', ')),
|
|
2038
|
+
num(t.calls),
|
|
2039
|
+
]);
|
|
2040
|
+
const topS = servers.slice(0, 8);
|
|
2041
|
+
const maxS = topS[0]?.calls ?? 0;
|
|
2042
|
+
const serverRows = topS.map(s => [`${esc(s.server)}${bar(s.calls, maxS, p.ok)}`, num(s.tools), num(s.calls)]);
|
|
2043
|
+
|
|
2044
|
+
return `
|
|
2045
|
+
${kpiRow([
|
|
2046
|
+
{ label: 'Tool calls', value: num(total), note: user === 'all' ? 'organization' : user },
|
|
2047
|
+
{ label: 'Distinct tools', value: num(tools.length) },
|
|
2048
|
+
{ label: 'MCP share', value: pct(total > 0 ? (mcpCalls / total) * 100 : null), note: `${num(servers.length)} MCP servers` },
|
|
2049
|
+
], p)}
|
|
2050
|
+
<div style="display:flex;gap:16px;flex-wrap:wrap">
|
|
2051
|
+
<div style="flex:3;min-width:280px">${table(['Tool', 'AI tools', 'Calls'], toolRows, p, 2)}</div>
|
|
2052
|
+
<div style="flex:2;min-width:220px">${serverRows.length > 0
|
|
2053
|
+
? table(['MCP server', 'Tools', 'Calls'], serverRows, p, 1)
|
|
2054
|
+
: `<div style="color:${p.mut};font-size:11px;margin-top:14px">No MCP tool calls in range.</div>`}</div>
|
|
2055
|
+
</div>
|
|
2056
|
+
${footnote(TOOL_USAGE_NOTE, p)}
|
|
2057
|
+
`;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/** Daily (or weekly, past 21 days) tool calls, same bucketing as ai-spend-trend. */
|
|
2061
|
+
async function renderToolUsageTrend(fromIso: string, toIso: string, days: number, user: string, p: Palette): Promise<string> {
|
|
2062
|
+
let r: any;
|
|
2063
|
+
try {
|
|
2064
|
+
r = await fetchToolUsage(fromIso, toIso, user);
|
|
2065
|
+
} catch (err) {
|
|
2066
|
+
return emptyState('tool usage', `Attribution store unreachable (${(err as Error).message}).`, p);
|
|
2067
|
+
}
|
|
2068
|
+
const dayBuckets: Array<{ date: string; calls: number }> = r?.days ?? [];
|
|
2069
|
+
if (dayBuckets.length === 0) {
|
|
2070
|
+
return emptyState('tool usage', 'Run `codeburn sync push` with a codeburn version that sends ai.tools.', p);
|
|
2071
|
+
}
|
|
2072
|
+
const useWeekly = days > 21;
|
|
2073
|
+
let buckets: Array<{ label: string; calls: number }>;
|
|
2074
|
+
if (useWeekly) {
|
|
2075
|
+
const weeks = new Map<string, number>();
|
|
2076
|
+
for (const d of dayBuckets) {
|
|
2077
|
+
const dt = new Date(d.date);
|
|
2078
|
+
dt.setDate(dt.getDate() - dt.getDay());
|
|
2079
|
+
const key = dt.toISOString().slice(0, 10);
|
|
2080
|
+
weeks.set(key, (weeks.get(key) ?? 0) + d.calls);
|
|
2081
|
+
}
|
|
2082
|
+
buckets = [...weeks.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([label, calls]) => ({ label, calls }));
|
|
2083
|
+
} else {
|
|
2084
|
+
buckets = dayBuckets.map(d => ({ label: d.date, calls: d.calls }));
|
|
2085
|
+
}
|
|
2086
|
+
const total = buckets.reduce((s, b) => s + b.calls, 0);
|
|
2087
|
+
const maxC = Math.max(...buckets.map(b => b.calls), 0);
|
|
2088
|
+
const chartW = 500;
|
|
2089
|
+
const chartH = 100;
|
|
2090
|
+
const slot = chartW / buckets.length;
|
|
2091
|
+
const barW = Math.max(2, slot * 0.7);
|
|
2092
|
+
const bars = buckets.map((b, i) => {
|
|
2093
|
+
const h = maxC > 0 ? (b.calls / maxC) * (chartH - 10) : 0;
|
|
2094
|
+
return `<rect x="${i * slot + (slot - barW) / 2}" y="${chartH - h}" width="${barW}" height="${h}" fill="${p.accent}" opacity="0.85" />`;
|
|
2095
|
+
}).join('');
|
|
2096
|
+
return `
|
|
2097
|
+
<div style="padding:8px 12px">
|
|
2098
|
+
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px">
|
|
2099
|
+
<span style="color:${p.mut};font-size:11px">Tool calls (${useWeekly ? 'weekly' : 'daily'})${user === 'all' ? '' : ` · ${esc(user)}`}</span>
|
|
2100
|
+
<span style="color:${p.fg};font-size:13px;font-weight:600">${num(total)} total</span>
|
|
2101
|
+
</div>
|
|
2102
|
+
<svg viewBox="0 0 ${chartW} ${chartH + 16}" style="width:100%;height:auto">
|
|
2103
|
+
${bars}
|
|
2104
|
+
<text x="2" y="${chartH + 13}" fill="${p.mut}" font-size="9">${esc(buckets[0]!.label)}</text>
|
|
2105
|
+
<text x="${chartW - 2}" y="${chartH + 13}" fill="${p.mut}" font-size="9" text-anchor="end">${esc(buckets[buckets.length - 1]!.label)}</text>
|
|
2106
|
+
</svg>
|
|
2107
|
+
${footnote(TOOL_USAGE_NOTE, p)}
|
|
2108
|
+
</div>
|
|
2109
|
+
`;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// ---- Handler ----
|
|
2113
|
+
|
|
1993
2114
|
export async function handler(event: WidgetEvent): Promise<string> {
|
|
1994
2115
|
if (event.describe) return DOCS;
|
|
1995
2116
|
|
|
@@ -2028,6 +2149,8 @@ export async function handler(event: WidgetEvent): Promise<string> {
|
|
|
2028
2149
|
case 'cost-per-commit-trend': body = await renderCostPerCommitTrend(fromIso, toIso, days, p); break;
|
|
2029
2150
|
case 'ai-spend-trend': body = await renderAiSpendTrend(fromIso, toIso, days, p); break;
|
|
2030
2151
|
case 'merge-rate-trend': body = await renderMergeRateTrend(fromIso, toIso, days, p); break;
|
|
2152
|
+
case 'tool-usage-top': body = await renderToolUsageTop(fromIso, toIso, widgetUser(event.user), p); break;
|
|
2153
|
+
case 'tool-usage-trend': body = await renderToolUsageTrend(fromIso, toIso, days, widgetUser(event.user), p); break;
|
|
2031
2154
|
default: body = `<div style="color:${p.danger}">Unknown view: ${esc(view)}</div>`;
|
|
2032
2155
|
}
|
|
2033
2156
|
return `<div style="font-family:'Amazon Ember',Helvetica,Arial,sans-serif;color:${p.fg};font-size:13px">${body}</div>`;
|
|
@@ -15,6 +15,7 @@ import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
|
15
15
|
import { PrismVpcConstruct } from './constructs/prism-vpc-construct';
|
|
16
16
|
import { GuardrailEnforcerConstruct } from './constructs/guardrail-enforcer-construct';
|
|
17
17
|
import { SecurityAgentConstruct } from './constructs/security-agent-construct';
|
|
18
|
+
import { addSecurityAgentResources } from './security-agent-resources';
|
|
18
19
|
import { OtelCollectorConstruct } from './constructs/otel-collector-construct';
|
|
19
20
|
import { NagSuppressions } from 'cdk-nag';
|
|
20
21
|
|
|
@@ -613,116 +614,7 @@ export class MetricsPipelineStack extends cdk.Stack {
|
|
|
613
614
|
// -------------------------------------------------------
|
|
614
615
|
const enableSecurityAgent = this.node.tryGetContext('enableSecurityAgent') === 'true';
|
|
615
616
|
if (enableSecurityAgent) {
|
|
616
|
-
this.securityAgent =
|
|
617
|
-
agentSpaceName: 'prism-d1-security',
|
|
618
|
-
description: 'PRISM D1 Continuum space for design review, code review, and pen testing',
|
|
619
|
-
kmsKey: prismKmsKey,
|
|
620
|
-
codeRemediationStrategy: 'DISABLED',
|
|
621
|
-
tags: {
|
|
622
|
-
'prism:pillar': 'security',
|
|
623
|
-
},
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
// Grant GitHub OIDC roles access to scan bucket and Continuum APIs.
|
|
627
|
-
// The OIDC role is created by `prism-cli bootstrapper setup-github-oidc`
|
|
628
|
-
// with name pattern prism-d1-github-oidc-*.
|
|
629
|
-
this.securityAgent.scanBucket.addToResourcePolicy(
|
|
630
|
-
new iam.PolicyStatement({
|
|
631
|
-
effect: iam.Effect.ALLOW,
|
|
632
|
-
principals: [new iam.ArnPrincipal(`arn:aws:iam::${cdk.Aws.ACCOUNT_ID}:root`)],
|
|
633
|
-
actions: ['s3:PutObject', 's3:GetObject'],
|
|
634
|
-
resources: [this.securityAgent.scanBucket.arnForObjects('*')],
|
|
635
|
-
conditions: {
|
|
636
|
-
ArnLike: {
|
|
637
|
-
'aws:PrincipalArn': `arn:aws:iam::${cdk.Aws.ACCOUNT_ID}:role/prism-d1-github-oidc-*`,
|
|
638
|
-
},
|
|
639
|
-
},
|
|
640
|
-
}),
|
|
641
|
-
);
|
|
642
|
-
|
|
643
|
-
// Create a managed policy for Continuum scan API access.
|
|
644
|
-
// Attached to the OIDC role by the bootstrapper setup script.
|
|
645
|
-
const continuumCiPolicy = new iam.ManagedPolicy(this, 'ContinuumCiPolicy', {
|
|
646
|
-
managedPolicyName: 'prism-d1-continuum-ci-scan',
|
|
647
|
-
description: 'Grants GitHub Actions workflows access to Continuum diff scan APIs and SSM config',
|
|
648
|
-
statements: [
|
|
649
|
-
new iam.PolicyStatement({
|
|
650
|
-
sid: 'ContinuumScanAPIs',
|
|
651
|
-
effect: iam.Effect.ALLOW,
|
|
652
|
-
actions: [
|
|
653
|
-
'securityagent:StartCodeReviewJob',
|
|
654
|
-
'securityagent:BatchGetCodeReviewJobs',
|
|
655
|
-
'securityagent:BatchGetCodeReviewJobTasks',
|
|
656
|
-
'securityagent:ListFindings',
|
|
657
|
-
'securityagent:BatchGetFindings',
|
|
658
|
-
'securityagent:CreateCodeReview',
|
|
659
|
-
'securityagent:ListCodeReviews',
|
|
660
|
-
],
|
|
661
|
-
resources: [
|
|
662
|
-
`arn:aws:securityagent:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:agent-space/*`,
|
|
663
|
-
],
|
|
664
|
-
}),
|
|
665
|
-
new iam.PolicyStatement({
|
|
666
|
-
sid: 'PassRoleForCodeReview',
|
|
667
|
-
effect: iam.Effect.ALLOW,
|
|
668
|
-
actions: ['iam:PassRole'],
|
|
669
|
-
resources: [this.securityAgent.serviceRole.roleArn],
|
|
670
|
-
conditions: {
|
|
671
|
-
StringEquals: { 'iam:PassedToService': 'securityagent.amazonaws.com' },
|
|
672
|
-
},
|
|
673
|
-
}),
|
|
674
|
-
new iam.PolicyStatement({
|
|
675
|
-
sid: 'ScanBucketWrite',
|
|
676
|
-
effect: iam.Effect.ALLOW,
|
|
677
|
-
actions: ['s3:PutObject', 's3:GetObject', 's3:ListBucket'],
|
|
678
|
-
resources: [
|
|
679
|
-
this.securityAgent.scanBucket.bucketArn,
|
|
680
|
-
this.securityAgent.scanBucket.arnForObjects('*'),
|
|
681
|
-
],
|
|
682
|
-
}),
|
|
683
|
-
new iam.PolicyStatement({
|
|
684
|
-
sid: 'KMSForScanBucket',
|
|
685
|
-
effect: iam.Effect.ALLOW,
|
|
686
|
-
actions: ['kms:Encrypt', 'kms:Decrypt', 'kms:GenerateDataKey*', 'kms:DescribeKey'],
|
|
687
|
-
resources: [prismKmsKey.keyArn],
|
|
688
|
-
}),
|
|
689
|
-
new iam.PolicyStatement({
|
|
690
|
-
sid: 'SSMReadConfig',
|
|
691
|
-
effect: iam.Effect.ALLOW,
|
|
692
|
-
actions: ['ssm:GetParameter', 'ssm:GetParameters', 'ssm:PutParameter'],
|
|
693
|
-
resources: [
|
|
694
|
-
`arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter/prism/continuum/*`,
|
|
695
|
-
],
|
|
696
|
-
}),
|
|
697
|
-
],
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
NagSuppressions.addResourceSuppressions(
|
|
701
|
-
continuumCiPolicy,
|
|
702
|
-
[
|
|
703
|
-
{
|
|
704
|
-
id: 'AwsSolutions-IAM5',
|
|
705
|
-
reason: 'Agent space ID is dynamic; wildcard scoped to securityagent service resources only',
|
|
706
|
-
appliesTo: [`Resource::arn:aws:securityagent:<AWS::Region>:<AWS::AccountId>:agent-space/*`],
|
|
707
|
-
},
|
|
708
|
-
{
|
|
709
|
-
id: 'AwsSolutions-IAM5',
|
|
710
|
-
reason: 'Scan bucket objects include dynamic diff filenames (SHA-based); wildcard on objects only',
|
|
711
|
-
appliesTo: ['Resource::<SecurityAgentScanBucketB752F4A9.Arn>/*'],
|
|
712
|
-
},
|
|
713
|
-
{
|
|
714
|
-
id: 'AwsSolutions-IAM5',
|
|
715
|
-
reason: 'SSM parameters under /prism/continuum/ are all non-sensitive config values (IDs and bucket names)',
|
|
716
|
-
appliesTo: ['Resource::arn:aws:ssm:<AWS::Region>:<AWS::AccountId>:parameter/prism/continuum/*'],
|
|
717
|
-
},
|
|
718
|
-
],
|
|
719
|
-
true,
|
|
720
|
-
);
|
|
721
|
-
|
|
722
|
-
new cdk.CfnOutput(this, 'ContinuumCiPolicyArn', {
|
|
723
|
-
value: continuumCiPolicy.managedPolicyArn,
|
|
724
|
-
description: 'Attach this policy to your GitHub OIDC role for Continuum CI scans',
|
|
725
|
-
});
|
|
617
|
+
this.securityAgent = addSecurityAgentResources(this, prismKmsKey);
|
|
726
618
|
}
|
|
727
619
|
|
|
728
620
|
// -------------------------------------------------------
|