@prism-d1/cli 1.0.76 → 1.0.79

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.
@@ -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,124 @@ 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 inference: done at query time by joining with usage spans via traceId.
448
+ * If no usage spans share this traceId → origin=human, tool=none.
449
+ * If usage spans exist → origin=ai-generated, tool=provider from usage span.
450
+ */
451
+ async function writeCommitAttribution(user: string, s: ParsedAttributionSpan): Promise<boolean> {
452
+ if (!s.repo || !s.sha) return false;
453
+ const ttl = Math.floor(Date.now() / 1000) + COMMIT_TTL_DAYS * 24 * 60 * 60;
454
+
455
+ try {
456
+ await dynamoClient.send(new UpdateItemCommand({
457
+ TableName: AI_USAGE_TABLE,
458
+ Key: {
459
+ pk: { S: `REPO#${s.repo}` },
460
+ sk: { S: `COMMIT#${s.sha}` },
461
+ },
462
+ UpdateExpression:
463
+ 'SET record_type = :rt, session_id = :sid, trace_id = :tid, ' +
464
+ '#proj = :proj, #usr = :usr, device_id = :did, ' +
465
+ 'in_main = :im, was_reverted = :wr, ' +
466
+ '#ts = :ts, updated_at = :now, #ttl = :ttl, ' +
467
+ // Sparse by-user GSI keys: per-developer commit queries (GET
468
+ // /v1/productivity) without scanning the table.
469
+ 'gsi_user = :gu, gsi_user_sk = :gusk',
470
+ // Only write if: item doesn't exist, OR inMain/wasReverted actually changed.
471
+ // Suppresses no-op MODIFY stream events that would double-count metrics.
472
+ ConditionExpression:
473
+ 'attribute_not_exists(pk) OR in_main <> :im OR was_reverted <> :wr',
474
+ ExpressionAttributeNames: {
475
+ // 'project', 'ttl', 'user', and 'timestamp' are all DynamoDB
476
+ // reserved keywords — bare use in an expression throws
477
+ // ValidationException at runtime.
478
+ '#proj': 'project',
479
+ '#ttl': 'ttl',
480
+ '#usr': 'user',
481
+ '#ts': 'timestamp',
482
+ },
483
+ ExpressionAttributeValues: {
484
+ ':rt': { S: 'OTEL_ATTR_COMMIT' },
485
+ ':sid': { S: s.sessionId },
486
+ ':tid': { S: s.traceId },
487
+ ':proj': { S: s.project },
488
+ ':usr': { S: user },
489
+ ':did': { S: s.deviceId },
490
+ ':im': { BOOL: s.inMain ?? false },
491
+ ':wr': { BOOL: s.wasReverted ?? false },
492
+ ':ts': { S: s.timestamp },
493
+ ':now': { S: new Date().toISOString() },
494
+ ':ttl': { N: String(ttl) },
495
+ ':gu': { S: `USER#${user}` },
496
+ ':gusk': { S: `COMMIT#${s.timestamp}` },
497
+ },
498
+ }));
499
+ return true;
500
+ } catch (e) {
501
+ if (e instanceof ConditionalCheckFailedException) return false; // no-op, state unchanged
502
+ throw e;
503
+ }
504
+ }
505
+
506
+ /** Process attribution spans. Returns count of writes. */
507
+ async function processAttributionSpans(user: string, spans: ParsedAttributionSpan[]): Promise<number> {
508
+ const CONCURRENCY = 20;
509
+ let written = 0;
510
+ for (let i = 0; i < spans.length; i += CONCURRENCY) {
511
+ const chunk = spans.slice(i, i + CONCURRENCY);
512
+ const results = await Promise.all(chunk.map(async (s) => {
513
+ if (s.kind === 'session') return writeSessionAttribution(user, s);
514
+ return writeCommitAttribution(user, s);
515
+ }));
516
+ written += results.filter(Boolean).length;
517
+ }
518
+ return written;
519
+ }
520
+
301
521
  /** Process spans with bounded concurrency. Returns count of newly-written spans. */
302
522
  async function processSpans(user: string, spans: ParsedSpan[]): Promise<number> {
303
523
  const CONCURRENCY = 20;
@@ -331,31 +551,502 @@ async function handleTraces(event: HttpApiEvent): Promise<HttpApiResponse> {
331
551
  return jsonResponse(400, { message: 'Body is not valid JSON' });
332
552
  }
333
553
 
334
- const { spans, rejected } = parseOtlpSpans(payload);
554
+ const { spans, attributionSpans, rejected } = parseOtlpSpans(payload);
555
+ const totalAccepted = spans.length + attributionSpans.length;
335
556
 
336
- if (spans.length > MAX_BATCH_SIZE) {
557
+ if (totalAccepted > MAX_BATCH_SIZE) {
337
558
  return jsonResponse(400, { message: `Batch exceeds max_batch_size (${MAX_BATCH_SIZE})` });
338
559
  }
339
560
 
340
561
  // 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
562
  await archiveToS3(rawBody);
343
563
 
344
- const written = await processSpans(user, spans);
564
+ // Process usage spans (existing flow)
565
+ const usageWritten = await processSpans(user, spans);
566
+
567
+ // Process attribution spans (new flow)
568
+ const attrWritten = await processAttributionSpans(user, attributionSpans);
569
+
345
570
  console.log(
346
- `[otel-receiver] user=${user} received=${spans.length + rejected} accepted=${spans.length} ` +
347
- `new=${written} duplicates=${spans.length - written} rejected=${rejected}`,
571
+ `[otel-receiver] user=${user} ` +
572
+ `usage: received=${spans.length} new=${usageWritten} dupes=${spans.length - usageWritten} | ` +
573
+ `attribution: received=${attributionSpans.length} written=${attrWritten} | ` +
574
+ `rejected=${rejected}`,
348
575
  );
349
576
 
350
- // OTLP/HTTP success response; report malformed spans via partialSuccess.
351
577
  if (rejected > 0) {
352
578
  return jsonResponse(200, {
353
- partialSuccess: { rejectedSpans: rejected, errorMessage: 'Spans missing required ai.* attributes or failed validation' },
579
+ partialSuccess: { rejectedSpans: rejected, errorMessage: 'Spans missing required attributes or failed validation' },
354
580
  });
355
581
  }
356
582
  return jsonResponse(200, {});
357
583
  }
358
584
 
585
+ // ---- Attribution query helpers ----
586
+
587
+ export interface CommitAttribution {
588
+ sha: string;
589
+ repo: string;
590
+ sessionId: string;
591
+ traceId: string;
592
+ user: string;
593
+ project: string;
594
+ inMain: boolean;
595
+ wasReverted: boolean;
596
+ timestamp: string;
597
+ /** Inferred from presence of correlated usage spans. */
598
+ aiOrigin: 'ai-generated' | 'human';
599
+ /** Tool name from correlated usage spans, or 'none' if human. */
600
+ aiTool: string;
601
+ /** Model from correlated usage spans, or empty if human. */
602
+ aiModel: string;
603
+ }
604
+
605
+ /**
606
+ * Query a commit's attribution with AI-origin inference via traceId join.
607
+ *
608
+ * 1. Get REPO#<repo>/COMMIT#<sha> → session_id, trace_id, user
609
+ * 2. Query USER#<user>/SPAN#* filtered by trace_id match
610
+ * 3. If usage spans found → ai-generated + tool from first usage span
611
+ * 4. If no usage spans → human, tool=none
612
+ */
613
+ export async function queryCommitAttribution(repo: string, sha: string): Promise<CommitAttribution | null> {
614
+ // Step 1: Get the commit record
615
+ const commitResult = await dynamoClient.send(new GetItemCommand({
616
+ TableName: AI_USAGE_TABLE,
617
+ Key: {
618
+ pk: { S: `REPO#${repo}` },
619
+ sk: { S: `COMMIT#${sha}` },
620
+ },
621
+ }));
622
+
623
+ const item = commitResult.Item;
624
+ if (!item) return null;
625
+
626
+ const traceId = item.trace_id?.S ?? '';
627
+ const user = item.user?.S ?? '';
628
+ const sessionId = item.session_id?.S ?? '';
629
+
630
+ if (!traceId || !user) return null;
631
+
632
+ // Step 2: Find correlated usage spans for this session's traceId.
633
+ const usage = await lookupTraceUsage(user, traceId);
634
+
635
+ // Step 3: Infer AI origin
636
+ let aiOrigin: 'ai-generated' | 'human' = 'human';
637
+ let aiTool = 'none';
638
+ let aiModel = '';
639
+
640
+ if (usage) {
641
+ aiOrigin = 'ai-generated';
642
+ aiTool = usage.tool;
643
+ aiModel = usage.model;
644
+ }
645
+
646
+ return {
647
+ sha,
648
+ repo,
649
+ sessionId,
650
+ traceId,
651
+ user,
652
+ project: item.project?.S ?? '',
653
+ inMain: item.in_main?.BOOL ?? false,
654
+ wasReverted: item.was_reverted?.BOOL ?? false,
655
+ timestamp: item.timestamp?.S ?? '',
656
+ aiOrigin,
657
+ aiTool,
658
+ aiModel,
659
+ };
660
+ }
661
+
662
+ /**
663
+ * Query all commits for a repo, with AI-origin inference for each.
664
+ * Returns commits sorted by timestamp descending.
665
+ */
666
+ export async function queryRepoCommits(repo: string, limit = 50): Promise<CommitAttribution[]> {
667
+ const result = await dynamoClient.send(new QueryCommand({
668
+ TableName: AI_USAGE_TABLE,
669
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
670
+ ExpressionAttributeValues: {
671
+ ':pk': { S: `REPO#${repo}` },
672
+ ':prefix': { S: 'COMMIT#' },
673
+ },
674
+ ScanIndexForward: false, // newest first
675
+ Limit: limit,
676
+ }));
677
+
678
+ const commits: CommitAttribution[] = [];
679
+ // One trace map per distinct user in the result set — replaces the
680
+ // previous per-commit span query (which used Limit:1 + FilterExpression;
681
+ // DynamoDB applies Limit BEFORE the filter, so it examined one span and
682
+ // misclassified nearly every commit as human).
683
+ const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
684
+ for (const item of result.Items ?? []) {
685
+ const sha = item.sk?.S?.replace('COMMIT#', '') ?? '';
686
+ const traceId = item.trace_id?.S ?? '';
687
+ const user = item.user?.S ?? '';
688
+
689
+ let aiOrigin: 'ai-generated' | 'human' = 'human';
690
+ let aiTool = 'none';
691
+ let aiModel = '';
692
+
693
+ if (traceId && user) {
694
+ let traceMap = traceMaps.get(user);
695
+ if (!traceMap) {
696
+ traceMap = await buildUserTraceMap(user);
697
+ traceMaps.set(user, traceMap);
698
+ }
699
+ const usage = traceMap.get(traceId);
700
+ if (usage) {
701
+ aiOrigin = 'ai-generated';
702
+ aiTool = usage.tool;
703
+ aiModel = usage.model;
704
+ }
705
+ }
706
+
707
+ commits.push({
708
+ sha,
709
+ repo,
710
+ sessionId: item.session_id?.S ?? '',
711
+ traceId,
712
+ user,
713
+ project: item.project?.S ?? '',
714
+ inMain: item.in_main?.BOOL ?? false,
715
+ wasReverted: item.was_reverted?.BOOL ?? false,
716
+ timestamp: item.timestamp?.S ?? '',
717
+ aiOrigin,
718
+ aiTool,
719
+ aiModel,
720
+ });
721
+ }
722
+
723
+ return commits;
724
+ }
725
+
726
+ // ---- Route: attribution query ----
727
+
728
+ async function handleAttributionQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
729
+ const user = resolveIdentity(event);
730
+ if (!user) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
731
+
732
+ // Parse query params from rawPath: /v1/attribution?repo=X&sha=Y or /v1/attribution?repo=X
733
+ const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
734
+ const repo = url.searchParams.get('repo');
735
+ const sha = url.searchParams.get('sha');
736
+
737
+ if (!repo) {
738
+ return jsonResponse(400, { message: 'Missing required query parameter: repo' });
739
+ }
740
+
741
+ if (sha) {
742
+ // Single commit lookup
743
+ const commit = await queryCommitAttribution(repo, sha);
744
+ if (!commit) return jsonResponse(404, { message: 'Commit not found' });
745
+ return jsonResponse(200, commit);
746
+ }
747
+
748
+ // Repo commit list
749
+ const limit = Math.min(Number(url.searchParams.get('limit') ?? '50'), 200);
750
+ const commits = await queryRepoCommits(repo, limit);
751
+ return jsonResponse(200, { repo, commits, count: commits.length });
752
+ }
753
+
754
+ // ---- Trace join helpers ----
755
+
756
+ /**
757
+ * Build a user's traceId → {tool, model} map from their usage spans.
758
+ * One paginated query (projected to three attributes) replaces per-commit
759
+ * span lookups. NOTE: DynamoDB applies Limit BEFORE FilterExpression, so
760
+ * the old Limit:1 + filter pattern examined a single span and almost never
761
+ * found the match — every commit resolved as human.
762
+ */
763
+ async function buildUserTraceMap(user: string): Promise<Map<string, { tool: string; model: string }>> {
764
+ const map = new Map<string, { tool: string; model: string }>();
765
+ let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
766
+ for (let page = 0; page < 100; page++) {
767
+ const result = await dynamoClient.send(new QueryCommand({
768
+ TableName: AI_USAGE_TABLE,
769
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
770
+ ProjectionExpression: 'trace_id, tool, model',
771
+ ExpressionAttributeValues: {
772
+ ':pk': { S: `USER#${user}` },
773
+ ':prefix': { S: 'SPAN#' },
774
+ },
775
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
776
+ }));
777
+ for (const item of result.Items ?? []) {
778
+ const tid = item.trace_id?.S;
779
+ if (tid && !map.has(tid)) {
780
+ map.set(tid, { tool: item.tool?.S ?? 'unknown', model: item.model?.S ?? '' });
781
+ }
782
+ }
783
+ lastKey = result.LastEvaluatedKey;
784
+ if (!lastKey) break;
785
+ }
786
+ return map;
787
+ }
788
+
789
+ /** Single traceId lookup via the trace map (small users) — kept simple. */
790
+ async function lookupTraceUsage(user: string, traceId: string): Promise<{ tool: string; model: string } | null> {
791
+ const map = await buildUserTraceMap(user);
792
+ return map.get(traceId) ?? null;
793
+ }
794
+
795
+ // ---- Route: productivity query ----
796
+
797
+ type UserProductivity = {
798
+ user: string;
799
+ usage: {
800
+ inputTokens: number;
801
+ outputTokens: number;
802
+ costUsd: number;
803
+ calls: number;
804
+ byTool: Record<string, { costUsd: number; calls: number }>;
805
+ byModel: Record<string, { costUsd: number; calls: number }>;
806
+ };
807
+ commits: {
808
+ total: number;
809
+ ai: number;
810
+ human: number;
811
+ mergedAi: number;
812
+ revertedAi: number;
813
+ };
814
+ ratios: {
815
+ aiSharePct: number | null;
816
+ mergeRatePct: number | null;
817
+ defectRatePct: number | null;
818
+ costPerAiCommit: number | null;
819
+ costPerShippedCommit: number | null;
820
+ };
821
+ };
822
+
823
+ function computeRatios(u: UserProductivity): void {
824
+ const c = u.commits;
825
+ const pct = (num: number, den: number): number | null =>
826
+ den > 0 ? Math.round((100 * num / den) * 10) / 10 : null;
827
+ u.ratios = {
828
+ aiSharePct: pct(c.ai, c.total),
829
+ mergeRatePct: pct(c.mergedAi, c.ai),
830
+ defectRatePct: pct(c.revertedAi, c.mergedAi),
831
+ costPerAiCommit: c.ai > 0 ? Math.round((u.usage.costUsd / c.ai) * 100) / 100 : null,
832
+ costPerShippedCommit: c.mergedAi > 0 ? Math.round((u.usage.costUsd / c.mergedAi) * 100) / 100 : null,
833
+ };
834
+ }
835
+
836
+ function emptyUserProductivity(user: string): UserProductivity {
837
+ return {
838
+ user,
839
+ usage: { inputTokens: 0, outputTokens: 0, costUsd: 0, calls: 0, byTool: {}, byModel: {} },
840
+ commits: { total: 0, ai: 0, human: 0, mergedAi: 0, revertedAi: 0 },
841
+ ratios: { aiSharePct: null, mergeRatePct: null, defectRatePct: null, costPerAiCommit: null, costPerShippedCommit: null },
842
+ };
843
+ }
844
+
845
+ /**
846
+ * GET /v1/productivity?user=<email|all>&from=YYYY-MM-DD&to=YYYY-MM-DD
847
+ *
848
+ * Aggregates usage (from OTEL#DAY rollups) and commit outcomes (attribution
849
+ * items, AI-origin via traceId join) per developer, with fleet totals.
850
+ * Defaults: user=all, last 30 days. Full history at real commit timestamps —
851
+ * unlike the CloudWatch metrics, this path has no 2-week ingestion clamp.
852
+ */
853
+ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
854
+ const caller = resolveIdentity(event);
855
+ if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
856
+
857
+ const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
858
+ const userParam = url.searchParams.get('user') ?? 'all';
859
+ const now = new Date();
860
+ const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
861
+ const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
862
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
863
+ return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
864
+ }
865
+
866
+ const users = new Map<string, UserProductivity>();
867
+ const getUser = (email: string): UserProductivity => {
868
+ let u = users.get(email);
869
+ if (!u) { u = emptyUserProductivity(email); users.set(email, u); }
870
+ return u;
871
+ };
872
+
873
+ // --- Usage from OTEL#DAY rollups ---
874
+ if (userParam !== 'all') {
875
+ // Single user: direct key-range query.
876
+ let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
877
+ do {
878
+ const result = await dynamoClient.send(new QueryCommand({
879
+ TableName: AI_USAGE_TABLE,
880
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :lo AND :hi',
881
+ ExpressionAttributeValues: {
882
+ ':pk': { S: `USER#${userParam}` },
883
+ ':lo': { S: `OTEL#DAY#${from}` },
884
+ ':hi': { S: `OTEL#DAY#${to}\uffff` },
885
+ },
886
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
887
+ }));
888
+ for (const item of result.Items ?? []) accumulateUsage(getUser(userParam), item);
889
+ lastKey = result.LastEvaluatedKey;
890
+ } while (lastKey);
891
+ } else {
892
+ // Fleet: paginated scan over the daily rollups. Item count is
893
+ // users × days × tools × models — small at team scale; revisit with a
894
+ // registry item or GSI if the fleet grows past a few hundred users.
895
+ let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
896
+ do {
897
+ const result = await dynamoClient.send(new ScanCommand({
898
+ TableName: AI_USAGE_TABLE,
899
+ FilterExpression: 'record_type = :rt AND #day BETWEEN :lo AND :hi',
900
+ ExpressionAttributeNames: { '#day': 'day' },
901
+ ExpressionAttributeValues: {
902
+ ':rt': { S: 'OTEL_DAY' },
903
+ ':lo': { S: from },
904
+ ':hi': { S: to },
905
+ },
906
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
907
+ }));
908
+ for (const item of result.Items ?? []) {
909
+ const email = item.pk?.S?.replace('USER#', '') ?? '';
910
+ if (email) accumulateUsage(getUser(email), item);
911
+ }
912
+ lastKey = result.LastEvaluatedKey;
913
+ } while (lastKey);
914
+ }
915
+
916
+ // --- Commits ---
917
+ type RawCommit = { user: string; traceId: string; inMain: boolean; wasReverted: boolean };
918
+ const rawCommits: RawCommit[] = [];
919
+ const commitFrom = `COMMIT#${from}`;
920
+ const commitTo = `COMMIT#${to}\uffff`;
921
+ if (userParam !== 'all') {
922
+ let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
923
+ do {
924
+ const result = await dynamoClient.send(new QueryCommand({
925
+ TableName: AI_USAGE_TABLE,
926
+ IndexName: 'by-user',
927
+ KeyConditionExpression: 'gsi_user = :pk AND gsi_user_sk BETWEEN :lo AND :hi',
928
+ ExpressionAttributeValues: {
929
+ ':pk': { S: `USER#${userParam}` },
930
+ ':lo': { S: commitFrom },
931
+ ':hi': { S: commitTo },
932
+ },
933
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
934
+ }));
935
+ for (const item of result.Items ?? []) {
936
+ rawCommits.push({
937
+ user: userParam,
938
+ traceId: item.trace_id?.S ?? '',
939
+ inMain: item.in_main?.BOOL ?? false,
940
+ wasReverted: item.was_reverted?.BOOL ?? false,
941
+ });
942
+ }
943
+ lastKey = result.LastEvaluatedKey;
944
+ } while (lastKey);
945
+ } else {
946
+ let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
947
+ do {
948
+ const result = await dynamoClient.send(new ScanCommand({
949
+ TableName: AI_USAGE_TABLE,
950
+ FilterExpression: 'record_type = :rt AND #ts BETWEEN :lo AND :hi',
951
+ ExpressionAttributeNames: { '#ts': 'timestamp' },
952
+ ExpressionAttributeValues: {
953
+ ':rt': { S: 'OTEL_ATTR_COMMIT' },
954
+ ':lo': { S: from },
955
+ ':hi': { S: `${to}\uffff` },
956
+ },
957
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
958
+ }));
959
+ for (const item of result.Items ?? []) {
960
+ rawCommits.push({
961
+ user: item.user?.S ?? '',
962
+ traceId: item.trace_id?.S ?? '',
963
+ inMain: item.in_main?.BOOL ?? false,
964
+ wasReverted: item.was_reverted?.BOOL ?? false,
965
+ });
966
+ }
967
+ lastKey = result.LastEvaluatedKey;
968
+ } while (lastKey);
969
+ }
970
+
971
+ // Classify commits via one trace map per user (AI-origin = correlated
972
+ // usage spans exist for the commit's traceId).
973
+ const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
974
+ for (const c of rawCommits) {
975
+ if (!c.user) continue;
976
+ const u = getUser(c.user);
977
+ u.commits.total++;
978
+ let traceMap = traceMaps.get(c.user);
979
+ if (!traceMap) {
980
+ traceMap = await buildUserTraceMap(c.user);
981
+ traceMaps.set(c.user, traceMap);
982
+ }
983
+ const isAi = c.traceId !== '' && traceMap.has(c.traceId);
984
+ if (isAi) {
985
+ u.commits.ai++;
986
+ if (c.inMain) u.commits.mergedAi++;
987
+ if (c.wasReverted) u.commits.revertedAi++;
988
+ } else {
989
+ u.commits.human++;
990
+ }
991
+ }
992
+
993
+ // --- Ratios + fleet totals ---
994
+ const totals = emptyUserProductivity('all');
995
+ for (const u of users.values()) {
996
+ computeRatios(u);
997
+ totals.usage.inputTokens += u.usage.inputTokens;
998
+ totals.usage.outputTokens += u.usage.outputTokens;
999
+ totals.usage.costUsd += u.usage.costUsd;
1000
+ totals.usage.calls += u.usage.calls;
1001
+ for (const [tool, t] of Object.entries(u.usage.byTool)) {
1002
+ const agg = totals.usage.byTool[tool] ?? { costUsd: 0, calls: 0 };
1003
+ agg.costUsd += t.costUsd;
1004
+ agg.calls += t.calls;
1005
+ totals.usage.byTool[tool] = agg;
1006
+ }
1007
+ for (const [model, m] of Object.entries(u.usage.byModel)) {
1008
+ const agg = totals.usage.byModel[model] ?? { costUsd: 0, calls: 0 };
1009
+ agg.costUsd += m.costUsd;
1010
+ agg.calls += m.calls;
1011
+ totals.usage.byModel[model] = agg;
1012
+ }
1013
+ totals.commits.total += u.commits.total;
1014
+ totals.commits.ai += u.commits.ai;
1015
+ totals.commits.human += u.commits.human;
1016
+ totals.commits.mergedAi += u.commits.mergedAi;
1017
+ totals.commits.revertedAi += u.commits.revertedAi;
1018
+ }
1019
+ computeRatios(totals);
1020
+ totals.usage.costUsd = Math.round(totals.usage.costUsd * 100) / 100;
1021
+
1022
+ return jsonResponse(200, {
1023
+ range: { from, to },
1024
+ scope: userParam === 'all' ? 'org' : 'user',
1025
+ generatedAt: new Date().toISOString(),
1026
+ users: [...users.values()].sort((a, b) => b.usage.costUsd - a.usage.costUsd),
1027
+ totals,
1028
+ });
1029
+ }
1030
+
1031
+ function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string; N?: string }>): void {
1032
+ const tool = item.tool?.S ?? 'unknown';
1033
+ const cost = Number(item.cost_usd?.N ?? 0);
1034
+ const calls = Number(item.call_count?.N ?? 0);
1035
+ u.usage.inputTokens += Number(item.input_tokens?.N ?? 0);
1036
+ u.usage.outputTokens += Number(item.output_tokens?.N ?? 0);
1037
+ u.usage.costUsd = Math.round((u.usage.costUsd + cost) * 10000) / 10000;
1038
+ u.usage.calls += calls;
1039
+ const t = u.usage.byTool[tool] ?? { costUsd: 0, calls: 0 };
1040
+ t.costUsd = Math.round((t.costUsd + cost) * 10000) / 10000;
1041
+ t.calls += calls;
1042
+ u.usage.byTool[tool] = t;
1043
+ const model = item.model?.S ?? 'unknown';
1044
+ const m = u.usage.byModel[model] ?? { costUsd: 0, calls: 0 };
1045
+ m.costUsd = Math.round((m.costUsd + cost) * 10000) / 10000;
1046
+ m.calls += calls;
1047
+ u.usage.byModel[model] = m;
1048
+ }
1049
+
359
1050
  // ---- Handler ----
360
1051
 
361
1052
  export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
@@ -368,5 +1059,11 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
368
1059
  if (method === 'POST' && path === '/v1/traces') {
369
1060
  return handleTraces(event);
370
1061
  }
1062
+ if (method === 'GET' && path === '/v1/attribution') {
1063
+ return handleAttributionQuery(event);
1064
+ }
1065
+ if (method === 'GET' && path === '/v1/productivity') {
1066
+ return handleProductivityQuery(event);
1067
+ }
371
1068
  return jsonResponse(404, { message: 'Not found' });
372
1069
  }