@prism-d1/cli 1.0.77 → 1.0.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/assets/github-workflows/prism-ai-metrics.yml +83 -122
  2. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +66 -0
  3. package/dist/assets/github-workflows/prism-eval-gate.yml +1 -1
  4. package/dist/assets/gitlab-workflows/.gitlab-ci.yml +0 -3
  5. package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +53 -56
  6. package/dist/assets/infra/bin/app.ts +2 -0
  7. package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +62 -0
  8. package/dist/assets/infra/lib/dashboard-stack.ts +541 -1144
  9. package/dist/assets/infra/lib/lambda/attribution-metrics-publisher.ts +248 -0
  10. package/dist/assets/infra/lib/lambda/event-schema.ts +144 -0
  11. package/dist/assets/infra/lib/lambda/metrics-processor.ts +124 -36
  12. package/dist/assets/infra/lib/lambda/otel-receiver.ts +927 -16
  13. package/dist/assets/infra/lib/lambda/productivity-widget.ts +203 -0
  14. package/dist/assets/infra/lib/lambda/security-remediation-tracker.ts +17 -2
  15. package/dist/assets/infra/lib/lambda/velocity-widget.ts +1483 -0
  16. package/dist/assets/infra/lib/metrics-pipeline-stack.ts +191 -119
  17. package/dist/assets/infra/package.json +2 -1
  18. package/dist/assets/infra/tsconfig.json +9 -2
  19. package/dist/src/commands/bootstrapper/install-eval-harness.d.ts +8 -2
  20. package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
  21. package/dist/src/commands/bootstrapper/install-eval-harness.js +32 -32
  22. package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
  23. package/dist/src/commands/bootstrapper/install-github-workflows.d.ts.map +1 -1
  24. package/dist/src/commands/bootstrapper/install-github-workflows.js +7 -2
  25. package/dist/src/commands/bootstrapper/install-github-workflows.js.map +1 -1
  26. package/dist/src/commands/bootstrapper/install-gitlab-workflows.d.ts.map +1 -1
  27. package/dist/src/commands/bootstrapper/install-gitlab-workflows.js +7 -1
  28. package/dist/src/commands/bootstrapper/install-gitlab-workflows.js.map +1 -1
  29. package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
  30. package/dist/src/commands/bootstrapper/setup-otel-sync.js +3 -2
  31. package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
  32. package/dist/src/utils/region.d.ts +50 -0
  33. package/dist/src/utils/region.d.ts.map +1 -0
  34. package/dist/src/utils/region.js +62 -0
  35. package/dist/src/utils/region.js.map +1 -0
  36. package/package.json +2 -2
  37. package/dist/assets/eval-harness/README.md +0 -144
  38. package/dist/assets/github-workflows/README.md +0 -110
  39. package/dist/assets/github-workflows/prism-dora-weekly.yml +0 -334
  40. package/dist/assets/gitlab-workflows/prism-dora-weekly.yml +0 -181
  41. package/dist/assets/infra/lib/lambda/defect-correlator.ts +0 -142
  42. package/dist/assets/infra/lib/lambda/spec-to-code-calculator.ts +0 -123
  43. package/dist/assets/metric-hooks/README.md +0 -107
@@ -19,6 +19,9 @@ import {
19
19
  DynamoDBClient,
20
20
  PutItemCommand,
21
21
  UpdateItemCommand,
22
+ QueryCommand,
23
+ ScanCommand,
24
+ GetItemCommand,
22
25
  ConditionalCheckFailedException,
23
26
  } from '@aws-sdk/client-dynamodb';
24
27
  import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
@@ -48,6 +51,7 @@ const PROVIDER_TO_TOOL: Record<string, string> = {
48
51
 
49
52
  interface HttpApiEvent {
50
53
  rawPath: string;
54
+ rawQueryString?: string;
51
55
  requestContext: {
52
56
  http: { method: string; path: string };
53
57
  authorizer?: { jwt?: { claims?: Record<string, unknown> } };
@@ -63,7 +67,7 @@ interface HttpApiResponse {
63
67
  }
64
68
 
65
69
  type OtlpValue =
66
- | { stringValue?: string; intValue?: string | number; doubleValue?: number; boolValue?: boolean };
70
+ | { stringValue?: string; intValue?: string | number; doubleValue?: number; boolValue?: boolean; arrayValue?: { values?: OtlpValue[] } };
67
71
 
68
72
  interface OtlpAttribute {
69
73
  key: string;
@@ -75,6 +79,7 @@ interface OtlpSpan {
75
79
  spanId?: string;
76
80
  name?: string;
77
81
  startTimeUnixNano?: string;
82
+ endTimeUnixNano?: string;
78
83
  attributes?: OtlpAttribute[];
79
84
  }
80
85
 
@@ -87,6 +92,7 @@ interface OtlpPayload {
87
92
 
88
93
  interface ParsedSpan {
89
94
  spanId: string;
95
+ traceId: string;
90
96
  timestamp: string; // ISO from span start time
91
97
  tool: string;
92
98
  model: string;
@@ -98,6 +104,34 @@ interface ParsedSpan {
98
104
  deviceId: string;
99
105
  }
100
106
 
107
+ // ---- Attribution span types ----
108
+
109
+ interface ParsedAttributionSpan {
110
+ kind: 'session' | 'commit';
111
+ spanId: string;
112
+ traceId: string;
113
+ timestamp: string;
114
+ endTimestamp: string;
115
+ sessionId: string;
116
+ project: string;
117
+ repo: string | null;
118
+ deviceId: string;
119
+ // session-specific
120
+ commitCount?: number;
121
+ prLinks?: string[];
122
+ // commit-specific
123
+ sha?: string;
124
+ inMain?: boolean;
125
+ wasReverted?: boolean;
126
+ }
127
+
128
+ const ATTRIBUTION_SPAN_NAMES: Record<string, 'session' | 'commit'> = {
129
+ 'codeburn.session.attribution': 'session',
130
+ 'codeburn.commit': 'commit',
131
+ };
132
+
133
+ const COMMIT_TTL_DAYS = 365; // commit facts are more durable than usage spans
134
+
101
135
  // ---- Helpers ----
102
136
 
103
137
  function jsonResponse(statusCode: number, body: unknown): HttpApiResponse {
@@ -157,9 +191,14 @@ function resolveIdentity(event: HttpApiEvent): string | null {
157
191
  return null;
158
192
  }
159
193
 
160
- /** Extract and sanity-check spans from an OTLP payload. Returns spans + rejected count. */
161
- export function parseOtlpSpans(payload: OtlpPayload): { spans: ParsedSpan[]; rejected: number } {
194
+ /** Extract and sanity-check spans from an OTLP payload. Returns usage spans, attribution spans, and rejected count. */
195
+ export function parseOtlpSpans(payload: OtlpPayload): {
196
+ spans: ParsedSpan[];
197
+ attributionSpans: ParsedAttributionSpan[];
198
+ rejected: number;
199
+ } {
162
200
  const spans: ParsedSpan[] = [];
201
+ const attributionSpans: ParsedAttributionSpan[] = [];
163
202
  let rejected = 0;
164
203
 
165
204
  for (const rs of payload.resourceSpans ?? []) {
@@ -168,13 +207,75 @@ export function parseOtlpSpans(payload: OtlpPayload): { spans: ParsedSpan[]; rej
168
207
 
169
208
  for (const ss of rs.scopeSpans ?? []) {
170
209
  for (const span of ss.spans ?? []) {
210
+ const spanName = typeof span.name === 'string' ? span.name : '';
171
211
  const attrs = attrMap(span.attributes);
172
212
  const spanId = typeof span.spanId === 'string' ? span.spanId : '';
213
+ const traceId = typeof span.traceId === 'string' ? span.traceId : '';
173
214
  const timestamp = nanoToIso(span.startTimeUnixNano);
174
- const provider = str(attrs.get('ai.provider'));
175
215
 
176
- // Minimum viable span: identity key parts must be present and sane.
177
- if (!/^[0-9a-f]{16}$/i.test(spanId) || !timestamp || !provider) {
216
+ if (!/^[0-9a-f]{16}$/i.test(spanId) || !timestamp) {
217
+ rejected++;
218
+ continue;
219
+ }
220
+
221
+ // --- Attribution spans ---
222
+ const attrKind = ATTRIBUTION_SPAN_NAMES[spanName];
223
+ if (attrKind) {
224
+ const sessionId = str(attrs.get('ai.session_id'));
225
+ if (!sessionId) { rejected++; continue; }
226
+
227
+ const endTimestamp = nanoToIso(span.endTimeUnixNano) || timestamp;
228
+ const repo = str(attrs.get('git.repo')) || null;
229
+
230
+ if (attrKind === 'commit') {
231
+ const sha = str(attrs.get('git.sha'));
232
+ if (!sha || !repo) { rejected++; continue; } // commits without repo can't be joined
233
+ attributionSpans.push({
234
+ kind: 'commit',
235
+ spanId: spanId.toLowerCase(),
236
+ traceId: traceId.toLowerCase(),
237
+ timestamp,
238
+ endTimestamp,
239
+ sessionId,
240
+ project: str(attrs.get('ai.project')).slice(0, 256),
241
+ repo,
242
+ deviceId,
243
+ sha: sha.slice(0, 40),
244
+ inMain: bool(attrs.get('git.in_main')),
245
+ wasReverted: bool(attrs.get('git.was_reverted')),
246
+ });
247
+ } else {
248
+ // session attribution
249
+ const prLinksValue = attrs.get('git.pr_links');
250
+ const prLinks: string[] = [];
251
+ if (prLinksValue && 'arrayValue' in prLinksValue) {
252
+ const av = (prLinksValue as { arrayValue?: { values?: OtlpValue[] } }).arrayValue;
253
+ for (const v of av?.values ?? []) {
254
+ const link = typeof (v as { stringValue?: string }).stringValue === 'string'
255
+ ? (v as { stringValue: string }).stringValue : '';
256
+ if (link) prLinks.push(link.slice(0, 256));
257
+ }
258
+ }
259
+ attributionSpans.push({
260
+ kind: 'session',
261
+ spanId: spanId.toLowerCase(),
262
+ traceId: traceId.toLowerCase(),
263
+ timestamp,
264
+ endTimestamp,
265
+ sessionId,
266
+ project: str(attrs.get('ai.project')).slice(0, 256),
267
+ repo,
268
+ deviceId,
269
+ commitCount: num(attrs.get('git.commit_count')),
270
+ prLinks: prLinks.slice(0, 20),
271
+ });
272
+ }
273
+ continue;
274
+ }
275
+
276
+ // --- Usage spans (existing logic) ---
277
+ const provider = str(attrs.get('ai.provider'));
278
+ if (!provider) {
178
279
  rejected++;
179
280
  continue;
180
281
  }
@@ -182,7 +283,6 @@ export function parseOtlpSpans(payload: OtlpPayload): { spans: ParsedSpan[]; rej
182
283
  const inputTokens = num(attrs.get('ai.input_tokens'));
183
284
  const outputTokens = num(attrs.get('ai.output_tokens'));
184
285
  const costUsd = num(attrs.get('ai.cost_usd'));
185
- // Clamp: reject absurd values (mirrors the CI trailer clamps).
186
286
  if (
187
287
  inputTokens < 0 || outputTokens < 0 || costUsd < 0 ||
188
288
  inputTokens > 100_000_000 || outputTokens > 100_000_000 || costUsd > 10_000
@@ -193,6 +293,7 @@ export function parseOtlpSpans(payload: OtlpPayload): { spans: ParsedSpan[]; rej
193
293
 
194
294
  spans.push({
195
295
  spanId: spanId.toLowerCase(),
296
+ traceId: traceId.toLowerCase(),
196
297
  timestamp,
197
298
  tool: PROVIDER_TO_TOOL[provider] ?? provider.slice(0, 32),
198
299
  model: str(attrs.get('ai.model')).slice(0, 128),
@@ -207,7 +308,7 @@ export function parseOtlpSpans(payload: OtlpPayload): { spans: ParsedSpan[]; rej
207
308
  }
208
309
  }
209
310
 
210
- return { spans, rejected };
311
+ return { spans, attributionSpans, rejected };
211
312
  }
212
313
 
213
314
  // ---- Route: discovery doc ----
@@ -248,6 +349,7 @@ async function writeSpanIfNew(user: string, s: ParsedSpan): Promise<boolean> {
248
349
  pk: { S: `USER#${user}` },
249
350
  sk: { S: `SPAN#${s.timestamp}#${s.spanId}` },
250
351
  record_type: { S: 'OTEL_SPAN' },
352
+ trace_id: { S: s.traceId },
251
353
  tool: { S: s.tool },
252
354
  model: { S: s.model },
253
355
  input_tokens: { N: String(s.inputTokens) },
@@ -298,6 +400,167 @@ async function bumpDailyAggregate(user: string, s: ParsedSpan): Promise<void> {
298
400
  }));
299
401
  }
300
402
 
403
+ // ---- Attribution DDB writes ----
404
+
405
+ /**
406
+ * Write a session attribution record. Conditional put (dedup by spanId which
407
+ * encodes state). If state changes, codeburn sends a new spanId → new item.
408
+ * Old items expire via TTL.
409
+ */
410
+ async function writeSessionAttribution(user: string, s: ParsedAttributionSpan): Promise<boolean> {
411
+ const ttl = Math.floor(Date.now() / 1000) + SPAN_TTL_DAYS * 24 * 60 * 60;
412
+ try {
413
+ const item: Record<string, { S?: string; N?: string; BOOL?: boolean; SS?: string[] }> = {
414
+ pk: { S: `USER#${user}` },
415
+ sk: { S: `ATTR#SESSION#${s.sessionId}#${s.spanId}` },
416
+ record_type: { S: 'OTEL_ATTR_SESSION' },
417
+ session_id: { S: s.sessionId },
418
+ trace_id: { S: s.traceId },
419
+ project: { S: s.project },
420
+ device_id: { S: s.deviceId },
421
+ timestamp: { S: s.timestamp },
422
+ end_time: { S: s.endTimestamp },
423
+ commit_count: { N: String(s.commitCount ?? 0) },
424
+ ttl: { N: String(ttl) },
425
+ };
426
+ if (s.repo) item.repo = { S: s.repo };
427
+ if (s.prLinks && s.prLinks.length > 0) item.pr_links = { SS: s.prLinks };
428
+
429
+ await dynamoClient.send(new PutItemCommand({
430
+ TableName: AI_USAGE_TABLE,
431
+ Item: item,
432
+ ConditionExpression: 'attribute_not_exists(pk)',
433
+ }));
434
+ return true;
435
+ } catch (e) {
436
+ if (e instanceof ConditionalCheckFailedException) return false;
437
+ throw e;
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Write/update a commit attribution record. Conditional UPSERT — only writes
443
+ * if the item doesn't exist OR the mutable state (inMain/wasReverted) changed.
444
+ * This prevents redundant DDB stream MODIFY events that would double-count
445
+ * CloudWatch metrics in the attribution-metrics-publisher.
446
+ *
447
+ * AI-Origin is resolved HERE, at write time, and persisted on the item.
448
+ *
449
+ * It used to be inferred at query time by joining against usage spans. That was
450
+ * a latent data-corruption bug: commit facts live COMMIT_TTL_DAYS (365) but
451
+ * usage spans live SPAN_TTL_DAYS (90), so on day 91 the join found nothing and
452
+ * every aging AI commit silently reclassified as `human` — making AI adoption
453
+ * appear to decline over time, depressing the observed PRISM level's L2 gate,
454
+ * and skewing the CISO per-100-commits comparison on both sides.
455
+ *
456
+ * "Derive at read time" is only sound when the inputs outlive the output. They
457
+ * don't here, so the derivation is frozen at ingest while the spans are fresh.
458
+ * Usage spans for this batch are written before attribution spans (see
459
+ * handleTraces), so the trace map passed in already includes them.
460
+ */
461
+ async function writeCommitAttribution(
462
+ user: string,
463
+ s: ParsedAttributionSpan,
464
+ traceMap: Map<string, { tool: string; model: string }>,
465
+ ): Promise<boolean> {
466
+ if (!s.repo || !s.sha) return false;
467
+ const ttl = Math.floor(Date.now() / 1000) + COMMIT_TTL_DAYS * 24 * 60 * 60;
468
+
469
+ // Resolve origin now, against spans that are guaranteed to still exist.
470
+ const usage = s.traceId ? traceMap.get(s.traceId) : undefined;
471
+ const aiOrigin = usage ? 'ai-generated' : 'human';
472
+ const aiTool = usage ? usage.tool : 'none';
473
+ const aiModel = usage ? usage.model : '';
474
+
475
+ try {
476
+ await dynamoClient.send(new UpdateItemCommand({
477
+ TableName: AI_USAGE_TABLE,
478
+ Key: {
479
+ pk: { S: `REPO#${s.repo}` },
480
+ sk: { S: `COMMIT#${s.sha}` },
481
+ },
482
+ UpdateExpression:
483
+ 'SET record_type = :rt, session_id = :sid, trace_id = :tid, ' +
484
+ '#proj = :proj, #usr = :usr, device_id = :did, ' +
485
+ 'in_main = :im, was_reverted = :wr, ' +
486
+ '#ts = :ts, updated_at = :now, #ttl = :ttl, ' +
487
+ // Frozen origin verdict — see the note above.
488
+ 'ai_origin = :ao, ai_tool = :at, ai_model = :am, origin_source = :os, ' +
489
+ // Sparse by-user GSI keys: per-developer commit queries (GET
490
+ // /v1/productivity) without scanning the table.
491
+ 'gsi_user = :gu, gsi_user_sk = :gusk',
492
+ // Write if: item is new, OR inMain/wasReverted changed, OR we can now
493
+ // resolve an origin we previously could not. The last clause lets a
494
+ // commit whose usage spans arrived in a LATER push get upgraded from
495
+ // human to ai-generated, instead of being frozen wrong forever.
496
+ ConditionExpression:
497
+ 'attribute_not_exists(pk) OR in_main <> :im OR was_reverted <> :wr ' +
498
+ 'OR attribute_not_exists(ai_origin) ' +
499
+ 'OR (ai_origin = :human AND :ao = :ai)',
500
+ ExpressionAttributeNames: {
501
+ // 'project', 'ttl', 'user', and 'timestamp' are all DynamoDB
502
+ // reserved keywords — bare use in an expression throws
503
+ // ValidationException at runtime.
504
+ '#proj': 'project',
505
+ '#ttl': 'ttl',
506
+ '#usr': 'user',
507
+ '#ts': 'timestamp',
508
+ },
509
+ ExpressionAttributeValues: {
510
+ ':rt': { S: 'OTEL_ATTR_COMMIT' },
511
+ ':sid': { S: s.sessionId },
512
+ ':tid': { S: s.traceId },
513
+ ':proj': { S: s.project },
514
+ ':usr': { S: user },
515
+ ':did': { S: s.deviceId },
516
+ ':im': { BOOL: s.inMain ?? false },
517
+ ':wr': { BOOL: s.wasReverted ?? false },
518
+ ':ts': { S: s.timestamp },
519
+ ':now': { S: new Date().toISOString() },
520
+ ':ttl': { N: String(ttl) },
521
+ ':ao': { S: aiOrigin },
522
+ ':at': { S: aiTool },
523
+ ':am': { S: aiModel },
524
+ ':os': { S: 'write-time-join' },
525
+ ':human': { S: 'human' },
526
+ ':ai': { S: 'ai-generated' },
527
+ ':gu': { S: `USER#${user}` },
528
+ ':gusk': { S: `COMMIT#${s.timestamp}` },
529
+ },
530
+ }));
531
+ return true;
532
+ } catch (e) {
533
+ if (e instanceof ConditionalCheckFailedException) return false; // no-op, state unchanged
534
+ throw e;
535
+ }
536
+ }
537
+
538
+ /** Process attribution spans. Returns count of writes. */
539
+ async function processAttributionSpans(user: string, spans: ParsedAttributionSpan[]): Promise<number> {
540
+ const CONCURRENCY = 20;
541
+ let written = 0;
542
+
543
+ // Build the user's traceId → usage map ONCE for the whole batch. Commit
544
+ // origin is resolved at write time against this map (see
545
+ // writeCommitAttribution), so it must be built AFTER processSpans has
546
+ // persisted this batch's usage spans. Building it per commit would rescan
547
+ // every span for every commit in the push.
548
+ const needsTraceMap = spans.some(s => s.kind !== 'session');
549
+ const traceMap = needsTraceMap
550
+ ? await buildUserTraceMap(user)
551
+ : new Map<string, { tool: string; model: string }>();
552
+
553
+ for (let i = 0; i < spans.length; i += CONCURRENCY) {
554
+ const chunk = spans.slice(i, i + CONCURRENCY);
555
+ const results = await Promise.all(chunk.map(async (s) => {
556
+ if (s.kind === 'session') return writeSessionAttribution(user, s);
557
+ return writeCommitAttribution(user, s, traceMap);
558
+ }));
559
+ written += results.filter(Boolean).length;
560
+ }
561
+ return written;
562
+ }
563
+
301
564
  /** Process spans with bounded concurrency. Returns count of newly-written spans. */
302
565
  async function processSpans(user: string, spans: ParsedSpan[]): Promise<number> {
303
566
  const CONCURRENCY = 20;
@@ -331,31 +594,670 @@ async function handleTraces(event: HttpApiEvent): Promise<HttpApiResponse> {
331
594
  return jsonResponse(400, { message: 'Body is not valid JSON' });
332
595
  }
333
596
 
334
- const { spans, rejected } = parseOtlpSpans(payload);
597
+ const { spans, attributionSpans, rejected } = parseOtlpSpans(payload);
598
+ const totalAccepted = spans.length + attributionSpans.length;
335
599
 
336
- if (spans.length > MAX_BATCH_SIZE) {
600
+ if (totalAccepted > MAX_BATCH_SIZE) {
337
601
  return jsonResponse(400, { message: `Batch exceeds max_batch_size (${MAX_BATCH_SIZE})` });
338
602
  }
339
603
 
340
604
  // Archive the raw batch first — the S3 OTLP archive is the external contract.
341
- // Archive failures are fatal (client retries the batch; dedup gate makes it safe).
342
605
  await archiveToS3(rawBody);
343
606
 
344
- const written = await processSpans(user, spans);
607
+ // Process usage spans (existing flow)
608
+ const usageWritten = await processSpans(user, spans);
609
+
610
+ // Process attribution spans (new flow)
611
+ const attrWritten = await processAttributionSpans(user, attributionSpans);
612
+
345
613
  console.log(
346
- `[otel-receiver] user=${user} received=${spans.length + rejected} accepted=${spans.length} ` +
347
- `new=${written} duplicates=${spans.length - written} rejected=${rejected}`,
614
+ `[otel-receiver] user=${user} ` +
615
+ `usage: received=${spans.length} new=${usageWritten} dupes=${spans.length - usageWritten} | ` +
616
+ `attribution: received=${attributionSpans.length} written=${attrWritten} | ` +
617
+ `rejected=${rejected}`,
348
618
  );
349
619
 
350
- // OTLP/HTTP success response; report malformed spans via partialSuccess.
351
620
  if (rejected > 0) {
352
621
  return jsonResponse(200, {
353
- partialSuccess: { rejectedSpans: rejected, errorMessage: 'Spans missing required ai.* attributes or failed validation' },
622
+ partialSuccess: { rejectedSpans: rejected, errorMessage: 'Spans missing required attributes or failed validation' },
354
623
  });
355
624
  }
356
625
  return jsonResponse(200, {});
357
626
  }
358
627
 
628
+ // ---- Attribution query helpers ----
629
+
630
+ export interface CommitAttribution {
631
+ sha: string;
632
+ repo: string;
633
+ sessionId: string;
634
+ traceId: string;
635
+ user: string;
636
+ project: string;
637
+ inMain: boolean;
638
+ wasReverted: boolean;
639
+ timestamp: string;
640
+ /** Inferred from presence of correlated usage spans. */
641
+ aiOrigin: 'ai-generated' | 'human';
642
+ /** Tool name from correlated usage spans, or 'none' if human. */
643
+ aiTool: string;
644
+ /** Model from correlated usage spans, or empty if human. */
645
+ aiModel: string;
646
+ /**
647
+ * 'stored' = verdict frozen at ingest (authoritative).
648
+ * 'joined' = re-derived from usage spans for a legacy item; unreliable once
649
+ * the spans have aged past SPAN_TTL_DAYS.
650
+ */
651
+ originSource?: 'stored' | 'joined';
652
+ }
653
+
654
+ /**
655
+ * Query a commit's attribution with AI-origin inference via traceId join.
656
+ *
657
+ * 1. Get REPO#<repo>/COMMIT#<sha> → session_id, trace_id, user
658
+ * 2. Query USER#<user>/SPAN#* filtered by trace_id match
659
+ * 3. If usage spans found → ai-generated + tool from first usage span
660
+ * 4. If no usage spans → human, tool=none
661
+ */
662
+ export async function queryCommitAttribution(repo: string, sha: string): Promise<CommitAttribution | null> {
663
+ // Step 1: Get the commit record
664
+ const commitResult = await dynamoClient.send(new GetItemCommand({
665
+ TableName: AI_USAGE_TABLE,
666
+ Key: {
667
+ pk: { S: `REPO#${repo}` },
668
+ sk: { S: `COMMIT#${sha}` },
669
+ },
670
+ }));
671
+
672
+ const item = commitResult.Item;
673
+ if (!item) return null;
674
+
675
+ const traceId = item.trace_id?.S ?? '';
676
+ const user = item.user?.S ?? '';
677
+ const sessionId = item.session_id?.S ?? '';
678
+
679
+ if (!traceId || !user) return null;
680
+
681
+ // Prefer the origin frozen at ingest. Only fall back to a live span join for
682
+ // legacy items written before write-time resolution existed.
683
+ const stored = item.ai_origin?.S;
684
+ const { aiOrigin, aiTool, aiModel, originSource } = (stored === 'ai-generated' || stored === 'human')
685
+ ? resolveStoredOrigin(item, null)
686
+ : resolveStoredOrigin(item, await buildUserTraceMap(user));
687
+
688
+ return {
689
+ sha,
690
+ repo,
691
+ sessionId,
692
+ traceId,
693
+ user,
694
+ project: item.project?.S ?? '',
695
+ inMain: item.in_main?.BOOL ?? false,
696
+ wasReverted: item.was_reverted?.BOOL ?? false,
697
+ timestamp: item.timestamp?.S ?? '',
698
+ aiOrigin,
699
+ aiTool,
700
+ aiModel,
701
+ originSource,
702
+ };
703
+ }
704
+
705
+ /**
706
+ * Query all commits for a repo, with AI-origin inference for each.
707
+ * Returns commits sorted by timestamp descending.
708
+ */
709
+ export async function queryRepoCommits(repo: string, limit = 50): Promise<CommitAttribution[]> {
710
+ const result = await dynamoClient.send(new QueryCommand({
711
+ TableName: AI_USAGE_TABLE,
712
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
713
+ ExpressionAttributeValues: {
714
+ ':pk': { S: `REPO#${repo}` },
715
+ ':prefix': { S: 'COMMIT#' },
716
+ },
717
+ ScanIndexForward: false, // newest first
718
+ Limit: limit,
719
+ }));
720
+
721
+ const commits: CommitAttribution[] = [];
722
+ // One trace map per distinct user in the result set — replaces the
723
+ // previous per-commit span query (which used Limit:1 + FilterExpression;
724
+ // DynamoDB applies Limit BEFORE the filter, so it examined one span and
725
+ // misclassified nearly every commit as human).
726
+ const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
727
+ for (const item of result.Items ?? []) {
728
+ const sha = item.sk?.S?.replace('COMMIT#', '') ?? '';
729
+ const traceId = item.trace_id?.S ?? '';
730
+ const user = item.user?.S ?? '';
731
+
732
+ let aiOrigin: 'ai-generated' | 'human' = 'human';
733
+ let aiTool = 'none';
734
+ let aiModel = '';
735
+ let originSource: 'stored' | 'joined' = 'stored';
736
+
737
+ const storedOrigin = item.ai_origin?.S;
738
+ if (storedOrigin === 'ai-generated' || storedOrigin === 'human') {
739
+ // Frozen at ingest — authoritative regardless of span age.
740
+ ({ aiOrigin, aiTool, aiModel, originSource } = resolveStoredOrigin(item, null));
741
+ } else if (traceId && user) {
742
+ // Legacy item: re-derive. Reliable only inside SPAN_TTL_DAYS.
743
+ let traceMap = traceMaps.get(user);
744
+ if (!traceMap) {
745
+ traceMap = await buildUserTraceMap(user);
746
+ traceMaps.set(user, traceMap);
747
+ }
748
+ ({ aiOrigin, aiTool, aiModel, originSource } = resolveStoredOrigin(item, traceMap));
749
+ }
750
+
751
+ commits.push({
752
+ sha,
753
+ repo,
754
+ sessionId: item.session_id?.S ?? '',
755
+ traceId,
756
+ user,
757
+ project: item.project?.S ?? '',
758
+ inMain: item.in_main?.BOOL ?? false,
759
+ wasReverted: item.was_reverted?.BOOL ?? false,
760
+ timestamp: item.timestamp?.S ?? '',
761
+ aiOrigin,
762
+ aiTool,
763
+ aiModel,
764
+ });
765
+ }
766
+
767
+ return commits;
768
+ }
769
+
770
+ // ---- Route: attribution query ----
771
+
772
+ async function handleAttributionQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
773
+ const user = resolveIdentity(event);
774
+ if (!user) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
775
+
776
+ // Parse query params from rawPath: /v1/attribution?repo=X&sha=Y or /v1/attribution?repo=X
777
+ const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
778
+ const repo = url.searchParams.get('repo');
779
+ const sha = url.searchParams.get('sha');
780
+
781
+ if (!repo) {
782
+ return jsonResponse(400, { message: 'Missing required query parameter: repo' });
783
+ }
784
+
785
+ if (sha) {
786
+ // Single commit lookup
787
+ const commit = await queryCommitAttribution(repo, sha);
788
+ if (!commit) return jsonResponse(404, { message: 'Commit not found' });
789
+ return jsonResponse(200, commit);
790
+ }
791
+
792
+ // Repo commit list
793
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? '50'), 200);
794
+ const commits = await queryRepoCommits(repo, limit);
795
+ return jsonResponse(200, { repo, commits, count: commits.length });
796
+ }
797
+
798
+ // ---- Trace join helpers ----
799
+
800
+ /**
801
+ * Build a user's traceId → {tool, model} map from their usage spans.
802
+ * One paginated query (projected to three attributes) replaces per-commit
803
+ * span lookups. NOTE: DynamoDB applies Limit BEFORE FilterExpression, so
804
+ * the old Limit:1 + filter pattern examined a single span and almost never
805
+ * found the match — every commit resolved as human.
806
+ */
807
+ async function buildUserTraceMap(user: string): Promise<Map<string, { tool: string; model: string }>> {
808
+ const map = new Map<string, { tool: string; model: string }>();
809
+ let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
810
+ for (let page = 0; page < 100; page++) {
811
+ const result = await dynamoClient.send(new QueryCommand({
812
+ TableName: AI_USAGE_TABLE,
813
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
814
+ ProjectionExpression: 'trace_id, tool, model',
815
+ ExpressionAttributeValues: {
816
+ ':pk': { S: `USER#${user}` },
817
+ ':prefix': { S: 'SPAN#' },
818
+ },
819
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
820
+ }));
821
+ for (const item of result.Items ?? []) {
822
+ const tid = item.trace_id?.S;
823
+ if (tid && !map.has(tid)) {
824
+ map.set(tid, { tool: item.tool?.S ?? 'unknown', model: item.model?.S ?? '' });
825
+ }
826
+ }
827
+ lastKey = result.LastEvaluatedKey;
828
+ if (!lastKey) break;
829
+ }
830
+ return map;
831
+ }
832
+
833
+ /** Single traceId lookup via the trace map (small users) — kept simple. */
834
+ async function lookupTraceUsage(user: string, traceId: string): Promise<{ tool: string; model: string } | null> {
835
+ const map = await buildUserTraceMap(user);
836
+ return map.get(traceId) ?? null;
837
+ }
838
+
839
+ /**
840
+ * Resolve a commit item's AI origin, preferring the verdict frozen at ingest.
841
+ *
842
+ * New items carry ai_origin/ai_tool/ai_model written by writeCommitAttribution
843
+ * while the usage spans were still alive. Items written before that change have
844
+ * no stored verdict, so they fall back to the live traceId join — which stays
845
+ * correct for commits under SPAN_TTL_DAYS old and degrades to `human` beyond
846
+ * that. `originSource` makes the distinction visible to callers rather than
847
+ * silently mixing frozen and re-derived values.
848
+ */
849
+ function resolveStoredOrigin(
850
+ item: Record<string, import('@aws-sdk/client-dynamodb').AttributeValue>,
851
+ traceMap: Map<string, { tool: string; model: string }> | null,
852
+ ): { aiOrigin: 'ai-generated' | 'human'; aiTool: string; aiModel: string; originSource: 'stored' | 'joined' } {
853
+ const stored = item.ai_origin?.S;
854
+ if (stored === 'ai-generated' || stored === 'human') {
855
+ return {
856
+ aiOrigin: stored,
857
+ aiTool: item.ai_tool?.S ?? (stored === 'human' ? 'none' : ''),
858
+ aiModel: item.ai_model?.S ?? '',
859
+ originSource: 'stored',
860
+ };
861
+ }
862
+ const traceId = item.trace_id?.S ?? '';
863
+ const usage = traceId && traceMap ? traceMap.get(traceId) : undefined;
864
+ return usage
865
+ ? { aiOrigin: 'ai-generated', aiTool: usage.tool, aiModel: usage.model, originSource: 'joined' }
866
+ : { aiOrigin: 'human', aiTool: 'none', aiModel: '', originSource: 'joined' };
867
+ }
868
+
869
+ // ---- Route: productivity query ----
870
+
871
+ type UserProductivity = {
872
+ user: string;
873
+ usage: {
874
+ inputTokens: number;
875
+ outputTokens: number;
876
+ costUsd: number;
877
+ calls: number;
878
+ byTool: Record<string, { costUsd: number; calls: number }>;
879
+ byModel: Record<string, { costUsd: number; calls: number }>;
880
+ };
881
+ commits: {
882
+ total: number;
883
+ ai: number;
884
+ human: number;
885
+ mergedAi: number;
886
+ revertedAi: number;
887
+ };
888
+ ratios: {
889
+ aiSharePct: number | null;
890
+ mergeRatePct: number | null;
891
+ defectRatePct: number | null;
892
+ costPerAiCommit: number | null;
893
+ costPerShippedCommit: number | null;
894
+ };
895
+ };
896
+
897
+ function computeRatios(u: UserProductivity): void {
898
+ const c = u.commits;
899
+ const pct = (num: number, den: number): number | null =>
900
+ den > 0 ? Math.round((100 * num / den) * 10) / 10 : null;
901
+ u.ratios = {
902
+ aiSharePct: pct(c.ai, c.total),
903
+ mergeRatePct: pct(c.mergedAi, c.ai),
904
+ defectRatePct: pct(c.revertedAi, c.mergedAi),
905
+ costPerAiCommit: c.ai > 0 ? Math.round((u.usage.costUsd / c.ai) * 100) / 100 : null,
906
+ costPerShippedCommit: c.mergedAi > 0 ? Math.round((u.usage.costUsd / c.mergedAi) * 100) / 100 : null,
907
+ };
908
+ }
909
+
910
+ function emptyUserProductivity(user: string): UserProductivity {
911
+ return {
912
+ user,
913
+ usage: { inputTokens: 0, outputTokens: 0, costUsd: 0, calls: 0, byTool: {}, byModel: {} },
914
+ commits: { total: 0, ai: 0, human: 0, mergedAi: 0, revertedAi: 0 },
915
+ ratios: { aiSharePct: null, mergeRatePct: null, defectRatePct: null, costPerAiCommit: null, costPerShippedCommit: null },
916
+ };
917
+ }
918
+
919
+ /**
920
+ * GET /v1/productivity?user=<email|all>&from=YYYY-MM-DD&to=YYYY-MM-DD
921
+ *
922
+ * Aggregates usage (from OTEL#DAY rollups) and commit outcomes (attribution
923
+ * items, AI-origin via traceId join) per developer, with fleet totals.
924
+ * Defaults: user=all, last 30 days. Full history at real commit timestamps —
925
+ * unlike the CloudWatch metrics, this path has no 2-week ingestion clamp.
926
+ */
927
+ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
928
+ const caller = resolveIdentity(event);
929
+ if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
930
+
931
+ const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
932
+ const userParam = url.searchParams.get('user') ?? 'all';
933
+ const now = new Date();
934
+ const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
935
+ const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
936
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
937
+ return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
938
+ }
939
+
940
+ const users = new Map<string, UserProductivity>();
941
+ const getUser = (email: string): UserProductivity => {
942
+ let u = users.get(email);
943
+ if (!u) { u = emptyUserProductivity(email); users.set(email, u); }
944
+ return u;
945
+ };
946
+
947
+ // --- Usage from OTEL#DAY rollups ---
948
+ if (userParam !== 'all') {
949
+ // Single user: direct key-range query.
950
+ let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
951
+ do {
952
+ const result = await dynamoClient.send(new QueryCommand({
953
+ TableName: AI_USAGE_TABLE,
954
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :lo AND :hi',
955
+ ExpressionAttributeValues: {
956
+ ':pk': { S: `USER#${userParam}` },
957
+ ':lo': { S: `OTEL#DAY#${from}` },
958
+ ':hi': { S: `OTEL#DAY#${to}\uffff` },
959
+ },
960
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
961
+ }));
962
+ for (const item of result.Items ?? []) accumulateUsage(getUser(userParam), item);
963
+ lastKey = result.LastEvaluatedKey;
964
+ } while (lastKey);
965
+ } else {
966
+ // Fleet: paginated scan over the daily rollups. Item count is
967
+ // users × days × tools × models — small at team scale; revisit with a
968
+ // registry item or GSI if the fleet grows past a few hundred users.
969
+ let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
970
+ do {
971
+ const result = await dynamoClient.send(new ScanCommand({
972
+ TableName: AI_USAGE_TABLE,
973
+ FilterExpression: 'record_type = :rt AND #day BETWEEN :lo AND :hi',
974
+ ExpressionAttributeNames: { '#day': 'day' },
975
+ ExpressionAttributeValues: {
976
+ ':rt': { S: 'OTEL_DAY' },
977
+ ':lo': { S: from },
978
+ ':hi': { S: to },
979
+ },
980
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
981
+ }));
982
+ for (const item of result.Items ?? []) {
983
+ const email = item.pk?.S?.replace('USER#', '') ?? '';
984
+ if (email) accumulateUsage(getUser(email), item);
985
+ }
986
+ lastKey = result.LastEvaluatedKey;
987
+ } while (lastKey);
988
+ }
989
+
990
+ // --- Commits ---
991
+ type RawCommit = { user: string; traceId: string; inMain: boolean; wasReverted: boolean; storedOrigin?: string };
992
+ const rawCommits: RawCommit[] = [];
993
+ const commitFrom = `COMMIT#${from}`;
994
+ const commitTo = `COMMIT#${to}\uffff`;
995
+ if (userParam !== 'all') {
996
+ let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
997
+ do {
998
+ const result = await dynamoClient.send(new QueryCommand({
999
+ TableName: AI_USAGE_TABLE,
1000
+ IndexName: 'by-user',
1001
+ KeyConditionExpression: 'gsi_user = :pk AND gsi_user_sk BETWEEN :lo AND :hi',
1002
+ ExpressionAttributeValues: {
1003
+ ':pk': { S: `USER#${userParam}` },
1004
+ ':lo': { S: commitFrom },
1005
+ ':hi': { S: commitTo },
1006
+ },
1007
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
1008
+ }));
1009
+ for (const item of result.Items ?? []) {
1010
+ rawCommits.push({
1011
+ user: userParam,
1012
+ traceId: item.trace_id?.S ?? '',
1013
+ inMain: item.in_main?.BOOL ?? false,
1014
+ wasReverted: item.was_reverted?.BOOL ?? false,
1015
+ storedOrigin: item.ai_origin?.S,
1016
+ });
1017
+ }
1018
+ lastKey = result.LastEvaluatedKey;
1019
+ } while (lastKey);
1020
+ } else {
1021
+ let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1022
+ do {
1023
+ const result = await dynamoClient.send(new ScanCommand({
1024
+ TableName: AI_USAGE_TABLE,
1025
+ FilterExpression: 'record_type = :rt AND #ts BETWEEN :lo AND :hi',
1026
+ ExpressionAttributeNames: { '#ts': 'timestamp' },
1027
+ ExpressionAttributeValues: {
1028
+ ':rt': { S: 'OTEL_ATTR_COMMIT' },
1029
+ ':lo': { S: from },
1030
+ ':hi': { S: `${to}\uffff` },
1031
+ },
1032
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
1033
+ }));
1034
+ for (const item of result.Items ?? []) {
1035
+ rawCommits.push({
1036
+ user: item.user?.S ?? '',
1037
+ traceId: item.trace_id?.S ?? '',
1038
+ inMain: item.in_main?.BOOL ?? false,
1039
+ wasReverted: item.was_reverted?.BOOL ?? false,
1040
+ storedOrigin: item.ai_origin?.S,
1041
+ });
1042
+ }
1043
+ lastKey = result.LastEvaluatedKey;
1044
+ } while (lastKey);
1045
+ }
1046
+
1047
+ // Classify commits via one trace map per user (AI-origin = correlated
1048
+ // usage spans exist for the commit's traceId).
1049
+ const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
1050
+ for (const c of rawCommits) {
1051
+ if (!c.user) continue;
1052
+ const u = getUser(c.user);
1053
+ u.commits.total++;
1054
+ // Prefer the verdict frozen at ingest. Legacy items (no ai_origin) fall
1055
+ // back to a live span join, which is only reliable inside SPAN_TTL_DAYS.
1056
+ let isAi: boolean;
1057
+ if (c.storedOrigin === 'ai-generated' || c.storedOrigin === 'human') {
1058
+ isAi = c.storedOrigin === 'ai-generated';
1059
+ } else {
1060
+ let traceMap = traceMaps.get(c.user);
1061
+ if (!traceMap) {
1062
+ traceMap = await buildUserTraceMap(c.user);
1063
+ traceMaps.set(c.user, traceMap);
1064
+ }
1065
+ isAi = c.traceId !== '' && traceMap.has(c.traceId);
1066
+ }
1067
+ if (isAi) {
1068
+ u.commits.ai++;
1069
+ if (c.inMain) u.commits.mergedAi++;
1070
+ if (c.wasReverted) u.commits.revertedAi++;
1071
+ } else {
1072
+ u.commits.human++;
1073
+ }
1074
+ }
1075
+
1076
+ // --- Ratios + fleet totals ---
1077
+ const totals = emptyUserProductivity('all');
1078
+ for (const u of users.values()) {
1079
+ computeRatios(u);
1080
+ totals.usage.inputTokens += u.usage.inputTokens;
1081
+ totals.usage.outputTokens += u.usage.outputTokens;
1082
+ totals.usage.costUsd += u.usage.costUsd;
1083
+ totals.usage.calls += u.usage.calls;
1084
+ for (const [tool, t] of Object.entries(u.usage.byTool)) {
1085
+ const agg = totals.usage.byTool[tool] ?? { costUsd: 0, calls: 0 };
1086
+ agg.costUsd += t.costUsd;
1087
+ agg.calls += t.calls;
1088
+ totals.usage.byTool[tool] = agg;
1089
+ }
1090
+ for (const [model, m] of Object.entries(u.usage.byModel)) {
1091
+ const agg = totals.usage.byModel[model] ?? { costUsd: 0, calls: 0 };
1092
+ agg.costUsd += m.costUsd;
1093
+ agg.calls += m.calls;
1094
+ totals.usage.byModel[model] = agg;
1095
+ }
1096
+ totals.commits.total += u.commits.total;
1097
+ totals.commits.ai += u.commits.ai;
1098
+ totals.commits.human += u.commits.human;
1099
+ totals.commits.mergedAi += u.commits.mergedAi;
1100
+ totals.commits.revertedAi += u.commits.revertedAi;
1101
+ }
1102
+ computeRatios(totals);
1103
+ totals.usage.costUsd = Math.round(totals.usage.costUsd * 100) / 100;
1104
+
1105
+ return jsonResponse(200, {
1106
+ range: { from, to },
1107
+ scope: userParam === 'all' ? 'org' : 'user',
1108
+ generatedAt: new Date().toISOString(),
1109
+ users: [...users.values()].sort((a, b) => b.usage.costUsd - a.usage.costUsd),
1110
+ totals,
1111
+ });
1112
+ }
1113
+
1114
+ // ---- Route: per-repo attribution query ----
1115
+
1116
+ type RepoProductivity = {
1117
+ repo: string;
1118
+ commits: { total: number; ai: number; human: number; mergedAi: number; revertedAi: number };
1119
+ contributors: number;
1120
+ ratios: { aiSharePct: number | null; mergeRatePct: number | null; defectRatePct: number | null };
1121
+ lastActivity: string | null;
1122
+ };
1123
+
1124
+ /**
1125
+ * GET /v1/repos?from=YYYY-MM-DD&to=YYYY-MM-DD
1126
+ *
1127
+ * Aggregates commit outcomes by repository. The per-developer view
1128
+ * (/v1/productivity) answers "who", this answers "where" — which repos are
1129
+ * AI-heavy, which have low merge rates or high revert rates. Repo comes from
1130
+ * the commit item pk (REPO#<repo>); AI-origin classification uses the same
1131
+ * traceId-join rule as /v1/productivity (a commit is AI-generated only if
1132
+ * correlated usage spans exist for its trace).
1133
+ *
1134
+ * Spend is deliberately NOT included: usage rollups are keyed by user/day,
1135
+ * not by repo, so per-repo cost cannot be derived without double-counting
1136
+ * sessions that touch multiple repos.
1137
+ */
1138
+ async function handleReposQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
1139
+ const caller = resolveIdentity(event);
1140
+ if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
1141
+
1142
+ const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
1143
+ const now = new Date();
1144
+ const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
1145
+ const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
1146
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
1147
+ return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
1148
+ }
1149
+
1150
+ type RawCommit = { repo: string; user: string; traceId: string; inMain: boolean; wasReverted: boolean; ts: string; storedOrigin?: string };
1151
+ const rawCommits: RawCommit[] = [];
1152
+ let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1153
+ do {
1154
+ const result = await dynamoClient.send(new ScanCommand({
1155
+ TableName: AI_USAGE_TABLE,
1156
+ FilterExpression: 'record_type = :rt AND #ts BETWEEN :lo AND :hi',
1157
+ ExpressionAttributeNames: { '#ts': 'timestamp' },
1158
+ ExpressionAttributeValues: {
1159
+ ':rt': { S: 'OTEL_ATTR_COMMIT' },
1160
+ ':lo': { S: from },
1161
+ ':hi': { S: `${to}\uffff` },
1162
+ },
1163
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
1164
+ }));
1165
+ for (const item of result.Items ?? []) {
1166
+ rawCommits.push({
1167
+ // pk is REPO#<repo>; strip the prefix for display.
1168
+ repo: (item.pk?.S ?? '').replace(/^REPO#/, ''),
1169
+ user: item.user?.S ?? '',
1170
+ traceId: item.trace_id?.S ?? '',
1171
+ inMain: item.in_main?.BOOL ?? false,
1172
+ wasReverted: item.was_reverted?.BOOL ?? false,
1173
+ ts: item.timestamp?.S ?? '',
1174
+ storedOrigin: item.ai_origin?.S,
1175
+ });
1176
+ }
1177
+ lastKey = result.LastEvaluatedKey;
1178
+ } while (lastKey);
1179
+
1180
+ // One trace map per user (cached across repos — a user's spans are the same
1181
+ // regardless of which repo the commit landed in).
1182
+ const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
1183
+ const repos = new Map<string, RepoProductivity>();
1184
+ const contributors = new Map<string, Set<string>>();
1185
+
1186
+ for (const c of rawCommits) {
1187
+ if (!c.repo) continue;
1188
+ let r = repos.get(c.repo);
1189
+ if (!r) {
1190
+ r = {
1191
+ repo: c.repo,
1192
+ commits: { total: 0, ai: 0, human: 0, mergedAi: 0, revertedAi: 0 },
1193
+ contributors: 0,
1194
+ ratios: { aiSharePct: null, mergeRatePct: null, defectRatePct: null },
1195
+ lastActivity: null,
1196
+ };
1197
+ repos.set(c.repo, r);
1198
+ contributors.set(c.repo, new Set());
1199
+ }
1200
+ r.commits.total++;
1201
+ if (c.user) contributors.get(c.repo)!.add(c.user);
1202
+ if (!r.lastActivity || c.ts > r.lastActivity) r.lastActivity = c.ts;
1203
+
1204
+ let isAi: boolean;
1205
+ if (c.storedOrigin === 'ai-generated' || c.storedOrigin === 'human') {
1206
+ isAi = c.storedOrigin === 'ai-generated';
1207
+ } else {
1208
+ let traceMap = c.user ? traceMaps.get(c.user) : undefined;
1209
+ if (c.user && !traceMap) {
1210
+ traceMap = await buildUserTraceMap(c.user);
1211
+ traceMaps.set(c.user, traceMap);
1212
+ }
1213
+ isAi = c.traceId !== '' && !!traceMap?.has(c.traceId);
1214
+ }
1215
+ if (isAi) {
1216
+ r.commits.ai++;
1217
+ if (c.inMain) r.commits.mergedAi++;
1218
+ if (c.wasReverted) r.commits.revertedAi++;
1219
+ } else {
1220
+ r.commits.human++;
1221
+ }
1222
+ }
1223
+
1224
+ const pct = (num: number, den: number): number | null =>
1225
+ den > 0 ? Math.round((100 * num / den) * 10) / 10 : null;
1226
+ for (const r of repos.values()) {
1227
+ r.contributors = contributors.get(r.repo)?.size ?? 0;
1228
+ r.ratios = {
1229
+ aiSharePct: pct(r.commits.ai, r.commits.total),
1230
+ mergeRatePct: pct(r.commits.mergedAi, r.commits.ai),
1231
+ defectRatePct: pct(r.commits.revertedAi, r.commits.mergedAi),
1232
+ };
1233
+ }
1234
+
1235
+ return jsonResponse(200, {
1236
+ range: { from, to },
1237
+ generatedAt: new Date().toISOString(),
1238
+ repos: [...repos.values()].sort((a, b) => b.commits.total - a.commits.total),
1239
+ });
1240
+ }
1241
+
1242
+ function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string; N?: string }>): void {
1243
+ const tool = item.tool?.S ?? 'unknown';
1244
+ const cost = Number(item.cost_usd?.N ?? 0);
1245
+ const calls = Number(item.call_count?.N ?? 0);
1246
+ u.usage.inputTokens += Number(item.input_tokens?.N ?? 0);
1247
+ u.usage.outputTokens += Number(item.output_tokens?.N ?? 0);
1248
+ u.usage.costUsd = Math.round((u.usage.costUsd + cost) * 10000) / 10000;
1249
+ u.usage.calls += calls;
1250
+ const t = u.usage.byTool[tool] ?? { costUsd: 0, calls: 0 };
1251
+ t.costUsd = Math.round((t.costUsd + cost) * 10000) / 10000;
1252
+ t.calls += calls;
1253
+ u.usage.byTool[tool] = t;
1254
+ const model = item.model?.S ?? 'unknown';
1255
+ const m = u.usage.byModel[model] ?? { costUsd: 0, calls: 0 };
1256
+ m.costUsd = Math.round((m.costUsd + cost) * 10000) / 10000;
1257
+ m.calls += calls;
1258
+ u.usage.byModel[model] = m;
1259
+ }
1260
+
359
1261
  // ---- Handler ----
360
1262
 
361
1263
  export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
@@ -368,5 +1270,14 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
368
1270
  if (method === 'POST' && path === '/v1/traces') {
369
1271
  return handleTraces(event);
370
1272
  }
1273
+ if (method === 'GET' && path === '/v1/attribution') {
1274
+ return handleAttributionQuery(event);
1275
+ }
1276
+ if (method === 'GET' && path === '/v1/productivity') {
1277
+ return handleProductivityQuery(event);
1278
+ }
1279
+ if (method === 'GET' && path === '/v1/repos') {
1280
+ return handleReposQuery(event);
1281
+ }
371
1282
  return jsonResponse(404, { message: 'Not found' });
372
1283
  }