@prism-d1/cli 1.0.99 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +312 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +74 -45
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +324 -12
- package/dist/assets/infra/lib/lambda/pre-token-generation.ts +79 -0
- 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 +397 -1
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +13 -0
- package/dist/assets/infra/package.json +5 -2
- 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
|
@@ -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) {
|
|
@@ -1539,7 +1700,7 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1539
1700
|
}
|
|
1540
1701
|
|
|
1541
1702
|
// Scan COMMIT# items in the date range.
|
|
1542
|
-
const days = new Map<string, { ai: number; human: number; mergedAi: number; mergedHuman: number }>();
|
|
1703
|
+
const days = new Map<string, { ai: number; human: number; mergedAi: number; mergedHuman: number; costUsd: number }>();
|
|
1543
1704
|
let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
|
|
1544
1705
|
do {
|
|
1545
1706
|
const result = await dynamoClient.send(new ScanCommand({
|
|
@@ -1561,7 +1722,7 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1561
1722
|
const origin = item.ai_origin?.S ?? 'human';
|
|
1562
1723
|
const inMain = item.in_main?.BOOL ?? false;
|
|
1563
1724
|
let bucket = days.get(day);
|
|
1564
|
-
if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0 }; days.set(day, bucket); }
|
|
1725
|
+
if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0, costUsd: 0 }; days.set(day, bucket); }
|
|
1565
1726
|
if (origin === 'ai-generated') {
|
|
1566
1727
|
bucket.ai++;
|
|
1567
1728
|
if (inMain) bucket.mergedAi++;
|
|
@@ -1573,10 +1734,52 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1573
1734
|
lastKey = result.LastEvaluatedKey;
|
|
1574
1735
|
} while (lastKey);
|
|
1575
1736
|
|
|
1576
|
-
//
|
|
1737
|
+
// Second pass: fold daily AI spend into the same buckets, so a cost-per-commit
|
|
1738
|
+
// trend can be computed from one source instead of dividing a CloudWatch cost
|
|
1739
|
+
// metric by a CloudWatch commit metric.
|
|
1740
|
+
//
|
|
1741
|
+
// The two passes key on DIFFERENT timestamps by design, and that is the whole
|
|
1742
|
+
// point of serving this from the store. A COMMIT# item is bucketed by its real
|
|
1743
|
+
// commit timestamp and an OTEL_DAY item by the day the usage happened, so both
|
|
1744
|
+
// sides of the ratio land on the day the work actually occurred. The CloudWatch
|
|
1745
|
+
// equivalent buckets by INGEST time, which is why a backfill dumps months of
|
|
1746
|
+
// commits into the week it was pushed and collapses that week's ratio.
|
|
1747
|
+
{
|
|
1748
|
+
let costKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
|
|
1749
|
+
do {
|
|
1750
|
+
const result = await dynamoClient.send(new ScanCommand({
|
|
1751
|
+
TableName: AI_USAGE_TABLE,
|
|
1752
|
+
FilterExpression: 'record_type = :rt AND #day BETWEEN :lo AND :hi',
|
|
1753
|
+
ExpressionAttributeNames: { '#day': 'day' },
|
|
1754
|
+
ExpressionAttributeValues: {
|
|
1755
|
+
':rt': { S: 'OTEL_DAY' },
|
|
1756
|
+
':lo': { S: from },
|
|
1757
|
+
':hi': { S: to },
|
|
1758
|
+
},
|
|
1759
|
+
ProjectionExpression: '#day, cost_usd',
|
|
1760
|
+
...(costKey ? { ExclusiveStartKey: costKey } : {}),
|
|
1761
|
+
}));
|
|
1762
|
+
for (const item of result.Items ?? []) {
|
|
1763
|
+
const day = item.day?.S;
|
|
1764
|
+
if (!day) continue;
|
|
1765
|
+
const cost = Number(item.cost_usd?.N ?? '0');
|
|
1766
|
+
if (!Number.isFinite(cost)) continue;
|
|
1767
|
+
let bucket = days.get(day);
|
|
1768
|
+
// A day with spend but no commits is a real observation — AI work that
|
|
1769
|
+
// shipped nothing — so it gets a bucket rather than being dropped. The
|
|
1770
|
+
// consumer renders its ratio as null, not as zero.
|
|
1771
|
+
if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0, costUsd: 0 }; days.set(day, bucket); }
|
|
1772
|
+
bucket.costUsd += cost;
|
|
1773
|
+
}
|
|
1774
|
+
costKey = result.LastEvaluatedKey;
|
|
1775
|
+
} while (costKey);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Sort ascending by date and return as an array. Cost is rounded once, here,
|
|
1779
|
+
// so every consumer renders the same figure.
|
|
1577
1780
|
const sorted = [...days.entries()]
|
|
1578
1781
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
1579
|
-
.map(([date, counts]) => ({ date,
|
|
1782
|
+
.map(([date, counts]) => ({ ...counts, date, costUsd: Math.round(counts.costUsd * 100) / 100 }));
|
|
1580
1783
|
|
|
1581
1784
|
return jsonResponse(200, {
|
|
1582
1785
|
range: { from, to },
|
|
@@ -1631,6 +1834,112 @@ function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string;
|
|
|
1631
1834
|
|
|
1632
1835
|
// ---- Handler ----
|
|
1633
1836
|
|
|
1837
|
+
// ---- Route: tool usage ----
|
|
1838
|
+
|
|
1839
|
+
export interface ToolUsageReport {
|
|
1840
|
+
range: { from: string; to: string };
|
|
1841
|
+
user: string;
|
|
1842
|
+
generatedAt: string;
|
|
1843
|
+
/** Sum of per-tool counts. One model call using three tools counts three. */
|
|
1844
|
+
totalToolCalls: number;
|
|
1845
|
+
tools: Array<{ name: string; mcpServer: string; calls: number; byAiTool: Record<string, number> }>;
|
|
1846
|
+
mcpServers: Array<{ server: string; calls: number; tools: number }>;
|
|
1847
|
+
days: Array<{ date: string; calls: number }>;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
type DdbItem = Record<string, { S?: string; N?: string }>;
|
|
1851
|
+
|
|
1852
|
+
/** Fold OTEL_TOOL_DAY items into the report shape. Pure, for the contract check. */
|
|
1853
|
+
export function aggregateToolUsage(items: DdbItem[], from: string, to: string, user: string): ToolUsageReport {
|
|
1854
|
+
const tools = new Map<string, { name: string; mcpServer: string; calls: number; byAiTool: Record<string, number> }>();
|
|
1855
|
+
const servers = new Map<string, { calls: number; tools: Set<string> }>();
|
|
1856
|
+
const days = new Map<string, number>();
|
|
1857
|
+
let total = 0;
|
|
1858
|
+
for (const item of items) {
|
|
1859
|
+
const name = item.tool_name?.S;
|
|
1860
|
+
const day = item.day?.S;
|
|
1861
|
+
const calls = Number(item.call_count?.N ?? '0');
|
|
1862
|
+
if (!name || !day || !Number.isFinite(calls) || calls <= 0) continue;
|
|
1863
|
+
const aiTool = item.tool?.S ?? 'unknown';
|
|
1864
|
+
const server = item.mcp_server?.S ?? '';
|
|
1865
|
+
let t = tools.get(name);
|
|
1866
|
+
if (!t) { t = { name, mcpServer: server, calls: 0, byAiTool: {} }; tools.set(name, t); }
|
|
1867
|
+
t.calls += calls;
|
|
1868
|
+
t.byAiTool[aiTool] = (t.byAiTool[aiTool] ?? 0) + calls;
|
|
1869
|
+
if (server) {
|
|
1870
|
+
let sv = servers.get(server);
|
|
1871
|
+
if (!sv) { sv = { calls: 0, tools: new Set() }; servers.set(server, sv); }
|
|
1872
|
+
sv.calls += calls;
|
|
1873
|
+
sv.tools.add(name);
|
|
1874
|
+
}
|
|
1875
|
+
days.set(day, (days.get(day) ?? 0) + calls);
|
|
1876
|
+
total += calls;
|
|
1877
|
+
}
|
|
1878
|
+
return {
|
|
1879
|
+
range: { from, to },
|
|
1880
|
+
user,
|
|
1881
|
+
generatedAt: new Date().toISOString(),
|
|
1882
|
+
totalToolCalls: total,
|
|
1883
|
+
tools: [...tools.values()].sort((a, b) => b.calls - a.calls || a.name.localeCompare(b.name)),
|
|
1884
|
+
mcpServers: [...servers.entries()]
|
|
1885
|
+
.map(([server, v]) => ({ server, calls: v.calls, tools: v.tools.size }))
|
|
1886
|
+
.sort((a, b) => b.calls - a.calls || a.server.localeCompare(b.server)),
|
|
1887
|
+
days: [...days.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, calls]) => ({ date, calls })),
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
/**
|
|
1892
|
+
* GET /v1/tool-usage?from=YYYY-MM-DD&to=YYYY-MM-DD&user=<identity>|all
|
|
1893
|
+
*
|
|
1894
|
+
* Reads the OTEL#TOOLDAY rollups, bucketed by the day the call happened, so a
|
|
1895
|
+
* backfilled `codeburn sync push` lands on the right days and keeps full history.
|
|
1896
|
+
*/
|
|
1897
|
+
async function handleToolUsageQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
1898
|
+
const caller = resolveIdentity(event);
|
|
1899
|
+
if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
|
|
1900
|
+
|
|
1901
|
+
const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
|
|
1902
|
+
const userParam = url.searchParams.get('user') ?? 'all';
|
|
1903
|
+
const now = new Date();
|
|
1904
|
+
const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
|
|
1905
|
+
const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
|
|
1906
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
|
|
1907
|
+
return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const items: DdbItem[] = [];
|
|
1911
|
+
let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
|
|
1912
|
+
do {
|
|
1913
|
+
const result = userParam !== 'all'
|
|
1914
|
+
? await dynamoClient.send(new QueryCommand({
|
|
1915
|
+
TableName: AI_USAGE_TABLE,
|
|
1916
|
+
KeyConditionExpression: 'pk = :pk AND sk BETWEEN :lo AND :hi',
|
|
1917
|
+
ExpressionAttributeValues: {
|
|
1918
|
+
':pk': { S: `USER#${userParam}` },
|
|
1919
|
+
':lo': { S: `OTEL#TOOLDAY#${from}` },
|
|
1920
|
+
':hi': { S: `OTEL#TOOLDAY#${to}\uffff` },
|
|
1921
|
+
},
|
|
1922
|
+
...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
|
|
1923
|
+
}))
|
|
1924
|
+
// Fleet scope: same scan-with-filter shape as the OTEL_DAY fleet query.
|
|
1925
|
+
: await dynamoClient.send(new ScanCommand({
|
|
1926
|
+
TableName: AI_USAGE_TABLE,
|
|
1927
|
+
FilterExpression: 'record_type = :rt AND #day BETWEEN :lo AND :hi',
|
|
1928
|
+
ExpressionAttributeNames: { '#day': 'day' },
|
|
1929
|
+
ExpressionAttributeValues: {
|
|
1930
|
+
':rt': { S: 'OTEL_TOOL_DAY' },
|
|
1931
|
+
':lo': { S: from },
|
|
1932
|
+
':hi': { S: to },
|
|
1933
|
+
},
|
|
1934
|
+
...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
|
|
1935
|
+
}));
|
|
1936
|
+
for (const item of result.Items ?? []) items.push(item as DdbItem);
|
|
1937
|
+
lastKey = result.LastEvaluatedKey;
|
|
1938
|
+
} while (lastKey);
|
|
1939
|
+
|
|
1940
|
+
return jsonResponse(200, aggregateToolUsage(items, from, to, userParam));
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1634
1943
|
export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
1635
1944
|
const method = event.requestContext.http.method;
|
|
1636
1945
|
const path = event.rawPath;
|
|
@@ -1653,5 +1962,8 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
|
1653
1962
|
if (method === 'GET' && path === '/v1/commits-daily') {
|
|
1654
1963
|
return handleCommitsDailyQuery(event);
|
|
1655
1964
|
}
|
|
1965
|
+
if (method === 'GET' && path === '/v1/tool-usage') {
|
|
1966
|
+
return handleToolUsageQuery(event);
|
|
1967
|
+
}
|
|
1656
1968
|
return jsonResponse(404, { message: 'Not found' });
|
|
1657
1969
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre token generation trigger (V2_0) — copies `email` into the ACCESS token.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
*
|
|
6
|
+
* Every per-developer number on these dashboards keys on the identity the
|
|
7
|
+
* otel-receiver derives from the caller's JWT, and the commit side of the
|
|
8
|
+
* pipeline keys on the git author email (`metrics-processor.ts` writes
|
|
9
|
+
* `gsi_user: USER#<author email>` from the CI census). For the two to join,
|
|
10
|
+
* the receiver's identity must also be an email.
|
|
11
|
+
*
|
|
12
|
+
* Cognito ID tokens carry `email`. Cognito ACCESS tokens do not — and API
|
|
13
|
+
* Gateway's HttpJwtAuthorizer validates the access token. So with a federated
|
|
14
|
+
* IdP the receiver falls back to `username`, which Cognito prefixes with the
|
|
15
|
+
* provider name (`corpidp_alice`). The same person then appears as two
|
|
16
|
+
* developers: one holding all the token spend and no commits, the other the
|
|
17
|
+
* reverse. On a pool with months of accumulated telemetry, effectively all of
|
|
18
|
+
* the AI-usage history lands on the prefixed identity while commit attribution
|
|
19
|
+
* lands on the email one.
|
|
20
|
+
*
|
|
21
|
+
* Mapping `email` on the identity provider is necessary but NOT sufficient: it
|
|
22
|
+
* populates the user ATTRIBUTE, which never reaches the access token on its
|
|
23
|
+
* own. This trigger is the piece that moves it there.
|
|
24
|
+
*
|
|
25
|
+
* PAIRED WITH `otelIdentityClaim=email`
|
|
26
|
+
*
|
|
27
|
+
* This function is inert by itself. The receiver resolves identity as
|
|
28
|
+
* `[IDENTITY_CLAIM, 'username', 'email', 'sub']`, and IDENTITY_CLAIM defaults
|
|
29
|
+
* to `username` — which is always present, so `email` is unreachable no matter
|
|
30
|
+
* what this trigger adds. Deploy this WITH `-c otelIdentityClaim=email`, or it
|
|
31
|
+
* adds a claim nothing reads.
|
|
32
|
+
*
|
|
33
|
+
* FAIL-OPEN, DELIBERATELY
|
|
34
|
+
*
|
|
35
|
+
* A federated user's `email` is populated by the IdP attribute mapping on
|
|
36
|
+
* sign-in, so a user who has not signed in since the mapping was added has no
|
|
37
|
+
* email yet. Returning the event unmodified in that case lets the receiver's
|
|
38
|
+
* chain fall through to `username` — the pre-existing behaviour — so those users
|
|
39
|
+
* keep writing to their old key until they next sign in, and the migration is
|
|
40
|
+
* self-completing. Throwing here would instead block token issuance and lock
|
|
41
|
+
* them out of sync entirely, turning a cosmetic split into an outage.
|
|
42
|
+
*
|
|
43
|
+
* Requires the Essentials or Plus feature plan; access token customization is
|
|
44
|
+
* unavailable on Lite. Deployed against a pool already on Plus.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
interface PreTokenGenerationV2Event {
|
|
48
|
+
request?: {
|
|
49
|
+
userAttributes?: Record<string, string>;
|
|
50
|
+
};
|
|
51
|
+
response?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function handler(event: PreTokenGenerationV2Event): Promise<PreTokenGenerationV2Event> {
|
|
55
|
+
const email = event.request?.userAttributes?.email;
|
|
56
|
+
|
|
57
|
+
// Trim before testing: a whitespace-only attribute would satisfy a bare
|
|
58
|
+
// truthiness check here and then be rejected by the receiver's own
|
|
59
|
+
// `v.trim()` guard, silently reverting to `username` with no signal.
|
|
60
|
+
if (typeof email !== 'string' || email.trim() === '') {
|
|
61
|
+
return event;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Lowercased to match the receiver, which lowercases every resolved identity
|
|
65
|
+
// before building `USER#<identity>`. Emitting mixed case here would create a
|
|
66
|
+
// second partition key for the same person the first time an IdP returned a
|
|
67
|
+
// capitalized address.
|
|
68
|
+
const normalized = email.trim().toLowerCase();
|
|
69
|
+
|
|
70
|
+
event.response = {
|
|
71
|
+
claimsAndScopeOverrideDetails: {
|
|
72
|
+
accessTokenGeneration: {
|
|
73
|
+
claimsToAddOrOverride: { email: normalized },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return event;
|
|
79
|
+
}
|