@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.
- package/dist/assets/github-workflows/prism-ai-metrics.yml +83 -122
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +66 -0
- package/dist/assets/github-workflows/prism-eval-gate.yml +1 -1
- package/dist/assets/gitlab-workflows/.gitlab-ci.yml +0 -3
- package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +53 -56
- package/dist/assets/infra/bin/app.ts +2 -0
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +62 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +541 -1144
- package/dist/assets/infra/lib/lambda/attribution-metrics-publisher.ts +248 -0
- package/dist/assets/infra/lib/lambda/event-schema.ts +144 -0
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +124 -36
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +927 -16
- package/dist/assets/infra/lib/lambda/productivity-widget.ts +203 -0
- package/dist/assets/infra/lib/lambda/security-remediation-tracker.ts +17 -2
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +1483 -0
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +191 -119
- package/dist/assets/infra/package.json +2 -1
- package/dist/assets/infra/tsconfig.json +9 -2
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts +8 -2
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.js +32 -32
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-github-workflows.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-github-workflows.js +7 -2
- package/dist/src/commands/bootstrapper/install-github-workflows.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.js +7 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.js.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.js +3 -2
- package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
- package/dist/src/utils/region.d.ts +50 -0
- package/dist/src/utils/region.d.ts.map +1 -0
- package/dist/src/utils/region.js +62 -0
- package/dist/src/utils/region.js.map +1 -0
- package/package.json +2 -2
- package/dist/assets/eval-harness/README.md +0 -144
- package/dist/assets/github-workflows/README.md +0 -110
- package/dist/assets/github-workflows/prism-dora-weekly.yml +0 -334
- package/dist/assets/gitlab-workflows/prism-dora-weekly.yml +0 -181
- package/dist/assets/infra/lib/lambda/defect-correlator.ts +0 -142
- package/dist/assets/infra/lib/lambda/spec-to-code-calculator.ts +0 -123
- 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
|
|
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,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 (
|
|
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
|
-
|
|
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}
|
|
347
|
-
`new=${
|
|
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
|
|
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
|
}
|