@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.
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +2 -2
- package/dist/assets/infra/bin/app.ts +1 -0
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +58 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +259 -5
- package/dist/assets/infra/lib/lambda/attribution-metrics-publisher.ts +248 -0
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +713 -16
- package/dist/assets/infra/lib/lambda/productivity-widget.ts +203 -0
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +81 -0
- package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.js +3 -2
- package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
161
|
-
export function parseOtlpSpans(payload: OtlpPayload): {
|
|
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
|
-
|
|
177
|
-
|
|
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 (
|
|
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
|
-
|
|
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}
|
|
347
|
-
`new=${
|
|
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
|
|
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
|
}
|