@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.
@@ -339,13 +339,13 @@ jobs:
339
339
  echo "Continuum job started: ${JOB_ID}"
340
340
 
341
341
  STATUS="UNKNOWN"
342
- for i in $(seq 1 30); do
342
+ for i in $(seq 1 60); do
343
343
  sleep 30
344
344
  STATUS=$(aws securityagent batch-get-code-review-jobs \
345
345
  --agent-space-id "${AGENT_SPACE_ID}" \
346
346
  --code-review-job-ids "${JOB_ID}" \
347
347
  --query 'codeReviewJobs[0].status' --output text 2>/dev/null || echo "UNKNOWN")
348
- echo " Poll ${i}/30: ${STATUS}"
348
+ echo " Poll ${i}/60: ${STATUS}"
349
349
  case "$STATUS" in COMPLETED|FAILED|STOPPED) break ;; esac
350
350
  done
351
351
 
@@ -41,6 +41,7 @@ const apiStack = new ApiStack(app, 'PrismD1Api', {
41
41
  const dashboardStack = new DashboardStack(app, 'PrismD1Dashboard', {
42
42
  env,
43
43
  description: 'PRISM D1 Velocity - CloudWatch dashboards and alarms',
44
+ productivityWidgetArn: pipelineStack.otelCollector?.productivityWidgetArn,
44
45
  tags: {
45
46
  'prism:project': 'PRISM',
46
47
  'prism:domain': 'D1-Velocity',
@@ -54,6 +54,8 @@ export class OtelCollectorConstruct extends Construct {
54
54
  public readonly archiveBucket: s3.Bucket;
55
55
  public readonly userPool?: cognito.UserPool;
56
56
  public readonly userPoolClient?: cognito.UserPoolClient;
57
+ /** ARN of the CloudWatch custom-widget Lambda (Developer Productivity table). */
58
+ public productivityWidgetArn?: string;
57
59
 
58
60
  constructor(scope: Construct, id: string, props: OtelCollectorConstructProps) {
59
61
  super(scope, id);
@@ -197,6 +199,62 @@ export class OtelCollectorConstruct extends Construct {
197
199
  this.archiveBucket.grantPut(receiver);
198
200
  props.kmsKey.grantEncryptDecrypt(receiver);
199
201
 
202
+ // -------------------------------------------------------
203
+ // CloudWatch Custom Widget: Developer Productivity
204
+ // Invoked by CloudWatch dashboards with the VIEWING user's IAM creds
205
+ // (access = lambda:InvokeFunction on this function). Delegates
206
+ // aggregation to the receiver's /v1/productivity handler via direct
207
+ // invoke so there is a single aggregation implementation.
208
+ // -------------------------------------------------------
209
+ const productivityWidget = new lambda.Function(this, 'ProductivityWidget', {
210
+ functionName: 'prism-d1-productivity-widget',
211
+ runtime: lambda.Runtime.NODEJS_22_X,
212
+ handler: 'productivity-widget.handler',
213
+ code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda'), {
214
+ bundling: {
215
+ image: lambda.Runtime.NODEJS_22_X.bundlingImage,
216
+ command: [
217
+ 'bash', '-c',
218
+ [
219
+ 'npm init -y > /dev/null 2>&1',
220
+ 'npm install --save @aws-sdk/client-lambda esbuild > /dev/null 2>&1',
221
+ 'npx esbuild productivity-widget.ts --bundle --platform=node --target=node22 --outfile=/asset-output/productivity-widget.js --external:@aws-sdk/*',
222
+ ].join(' && '),
223
+ ],
224
+ local: {
225
+ tryBundle(outputDir: string): boolean {
226
+ try {
227
+ const { execSync } = require('child_process');
228
+ execSync(
229
+ `npx esbuild ${path.join(__dirname, '..', 'lambda', 'productivity-widget.ts')} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, 'productivity-widget.js')} --external:@aws-sdk/*`,
230
+ { stdio: 'pipe' },
231
+ );
232
+ return true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ },
237
+ },
238
+ },
239
+ }),
240
+ timeout: cdk.Duration.seconds(30),
241
+ memorySize: 256,
242
+ environment: {
243
+ RECEIVER_FUNCTION: receiver.functionName,
244
+ },
245
+ logRetention: logs.RetentionDays.ONE_MONTH,
246
+ description: 'CloudWatch custom widget: per-developer productivity table from the attribution store',
247
+ });
248
+ receiver.grantInvoke(productivityWidget);
249
+ NagSuppressions.addResourceSuppressions(productivityWidget, [
250
+ {
251
+ id: 'AwsSolutions-IAM5',
252
+ reason: 'lambda.grantInvoke scopes to the receiver function ARN plus its versions/aliases (<fn-arn>:*) — a single-function grant, not a broad wildcard.',
253
+ appliesTo: [{ regex: '/^Resource::<OtelCollectorReceiver.*\\.Arn>:\\*$/' }],
254
+ },
255
+ ], true);
256
+ this.productivityWidgetArn = productivityWidget.functionArn;
257
+
200
258
  // -------------------------------------------------------
201
259
  // HTTP API — discovery (public) + traces (JWT)
202
260
  // -------------------------------------------------------
@@ -6,8 +6,13 @@ import { NagSuppressions } from 'cdk-nag';
6
6
  const METRIC_NAMESPACE = 'PRISM/D1/Velocity';
7
7
  const DEFAULT_PERIOD = cdk.Duration.hours(1);
8
8
 
9
+ export interface DashboardStackProps extends cdk.StackProps {
10
+ /** ARN of the productivity custom-widget Lambda (from the OTEL collector construct). */
11
+ readonly productivityWidgetArn?: string;
12
+ }
13
+
9
14
  export class DashboardStack extends cdk.Stack {
10
- constructor(scope: Construct, id: string, props?: cdk.StackProps) {
15
+ constructor(scope: Construct, id: string, props?: DashboardStackProps) {
11
16
  super(scope, id, props);
12
17
 
13
18
  // cdk-nag: CloudWatch alarms in this stack are for observability dashboards.
@@ -159,18 +164,77 @@ export class DashboardStack extends cdk.Stack {
159
164
  new cloudwatch.GraphWidget({
160
165
  title: 'AI to Merge Ratio',
161
166
  left: [
167
+ // Line 1: CI-fed metric (git trailers, computed per PR at merge time
168
+ // by prism-ai-metrics.yml). Requires an instrumented repo.
162
169
  new cloudwatch.Metric({
163
170
  namespace: METRIC_NAMESPACE,
164
171
  metricName: 'AIToMergeRatio',
165
172
  statistic: 'Average',
166
173
  period: DEFAULT_PERIOD,
167
- label: 'AI-to-Merge Ratio (%)',
174
+ label: 'CI (git trailers, per-PR)',
175
+ }),
176
+ // Line 2: attribution-derived (codeburn spans, fleet aggregate).
177
+ // Populates for any user running `codeburn sync --attribution`.
178
+ // Divergence from line 1 indicates attribution coverage gaps.
179
+ // Inner FILL/IF: avoid NaN on empty buckets (gap, not 0). Outer
180
+ // FILL(..., REPEAT): carry the last ratio forward through gap days —
181
+ // commit data is sparse, and CloudWatch renders an isolated
182
+ // datapoint between gaps as an invisible dot, not a line.
183
+ new cloudwatch.MathExpression({
184
+ expression: 'FILL(IF(FILL(aiCommits, 0) > 0, 100 * FILL(mergedAi, 0) / FILL(aiCommits, 0)), REPEAT)',
185
+ usingMetrics: {
186
+ mergedAi: new cloudwatch.Metric({
187
+ namespace: METRIC_NAMESPACE,
188
+ metricName: 'MergedAICommits',
189
+ statistic: 'Sum',
190
+ period: cdk.Duration.days(1),
191
+ }),
192
+ aiCommits: new cloudwatch.Metric({
193
+ namespace: METRIC_NAMESPACE,
194
+ metricName: 'AICommits',
195
+ statistic: 'Sum',
196
+ period: cdk.Duration.days(1),
197
+ }),
198
+ },
199
+ label: 'Attribution (codeburn spans)',
200
+ period: cdk.Duration.days(1),
168
201
  }),
169
202
  ],
170
- width: 8,
203
+ width: 6,
171
204
  height: 6,
172
205
  leftYAxis: { min: 0, max: 100, label: 'Percent' },
173
206
  }),
207
+ // KPI card: the aggregate ratio over the whole displayed time range.
208
+ // Always shows a number regardless of how sparse the daily series is —
209
+ // the graph next door can render near-empty when the range holds only
210
+ // isolated datapoints.
211
+ new cloudwatch.SingleValueWidget({
212
+ title: 'AI Merge Rate (range)',
213
+ metrics: [
214
+ new cloudwatch.MathExpression({
215
+ expression: 'IF(FILL(aiCommitsKpi, 0) > 0, 100 * FILL(mergedAiKpi, 0) / FILL(aiCommitsKpi, 0))',
216
+ usingMetrics: {
217
+ mergedAiKpi: new cloudwatch.Metric({
218
+ namespace: METRIC_NAMESPACE,
219
+ metricName: 'MergedAICommits',
220
+ statistic: 'Sum',
221
+ period: cdk.Duration.days(1),
222
+ }),
223
+ aiCommitsKpi: new cloudwatch.Metric({
224
+ namespace: METRIC_NAMESPACE,
225
+ metricName: 'AICommits',
226
+ statistic: 'Sum',
227
+ period: cdk.Duration.days(1),
228
+ }),
229
+ },
230
+ label: 'Merged AI / AI commits (%)',
231
+ period: cdk.Duration.days(1),
232
+ }),
233
+ ],
234
+ setPeriodToTimeRange: true,
235
+ width: 2,
236
+ height: 6,
237
+ }),
174
238
  );
175
239
 
176
240
  // --- Agent Metrics Row ---
@@ -439,7 +503,26 @@ export class DashboardStack extends cdk.Stack {
439
503
  metricName: 'AIToMergeRatio',
440
504
  statistic: 'Average',
441
505
  period: cdk.Duration.days(1),
442
- label: 'AI-to-Merge Ratio (%)',
506
+ label: 'AI-to-Merge Ratio (CI, %)',
507
+ }),
508
+ new cloudwatch.MathExpression({
509
+ expression: 'IF(FILL(execAiCommits, 0) > 0, 100 * FILL(execMergedAi, 0) / FILL(execAiCommits, 0))',
510
+ usingMetrics: {
511
+ execMergedAi: new cloudwatch.Metric({
512
+ namespace: METRIC_NAMESPACE,
513
+ metricName: 'MergedAICommits',
514
+ statistic: 'Sum',
515
+ period: cdk.Duration.days(1),
516
+ }),
517
+ execAiCommits: new cloudwatch.Metric({
518
+ namespace: METRIC_NAMESPACE,
519
+ metricName: 'AICommits',
520
+ statistic: 'Sum',
521
+ period: cdk.Duration.days(1),
522
+ }),
523
+ },
524
+ label: 'AI-to-Merge Ratio (Attribution, %)',
525
+ period: cdk.Duration.days(1),
443
526
  }),
444
527
  new cloudwatch.Metric({
445
528
  namespace: METRIC_NAMESPACE,
@@ -496,12 +579,37 @@ export class DashboardStack extends cdk.Stack {
496
579
  new cloudwatch.GraphWidget({
497
580
  title: 'Post-Merge Defect Rate',
498
581
  left: [
582
+ // CI-fed (git trailers + revert scan in prism-ai-metrics.yml)
499
583
  new cloudwatch.Metric({
500
584
  namespace: METRIC_NAMESPACE,
501
585
  metricName: 'PostMergeDefectRate',
502
586
  statistic: 'Average',
503
587
  period: cdk.Duration.days(1),
504
- label: 'Defect Rate (%)',
588
+ label: 'CI (per-PR)',
589
+ }),
590
+ // Attribution-derived: reverted AI commits over merged AI commits.
591
+ // FILL guards both series: RevertedAICommits may not exist AT ALL
592
+ // until the first revert is published (a metric with zero datums is
593
+ // missing, not zero — unguarded math renders NaN). The IF guard
594
+ // gaps buckets with no merged commits instead of dividing by zero.
595
+ new cloudwatch.MathExpression({
596
+ expression: 'IF(FILL(mergedAi, 0) > 0, 100 * FILL(revertedAi, 0) / FILL(mergedAi, 0))',
597
+ usingMetrics: {
598
+ revertedAi: new cloudwatch.Metric({
599
+ namespace: METRIC_NAMESPACE,
600
+ metricName: 'RevertedAICommits',
601
+ statistic: 'Sum',
602
+ period: cdk.Duration.days(1),
603
+ }),
604
+ mergedAi: new cloudwatch.Metric({
605
+ namespace: METRIC_NAMESPACE,
606
+ metricName: 'MergedAICommits',
607
+ statistic: 'Sum',
608
+ period: cdk.Duration.days(1),
609
+ }),
610
+ },
611
+ label: 'Attribution (codeburn spans)',
612
+ period: cdk.Duration.days(1),
505
613
  }),
506
614
  ],
507
615
  width: 6,
@@ -1430,5 +1538,151 @@ export class DashboardStack extends cdk.Stack {
1430
1538
  value: `https://${this.region}.console.aws.amazon.com/cloudwatch/home?region=${this.region}#dashboards:name=PRISM-D1-Executive-Readout`,
1431
1539
  description: 'Executive Readout Dashboard URL',
1432
1540
  });
1541
+
1542
+ // =======================================================
1543
+ // Dashboard 3: Developer Productivity (attribution store)
1544
+ // Only created when the OTEL collector (and its custom-widget
1545
+ // Lambda) is deployed.
1546
+ // =======================================================
1547
+ if (props?.productivityWidgetArn) {
1548
+ // Type-in filter for the detail row. PATTERN variables literal-replace
1549
+ // the token below anywhere in the dashboard JSON, so the token must be
1550
+ // unique. Default 'all' = organization scope.
1551
+ const USER_TOKEN = 'PRISM_USER_FILTER_TOKEN';
1552
+ const devDashboard = new cloudwatch.Dashboard(this, 'DeveloperProductivityDashboard', {
1553
+ dashboardName: 'PRISM-D1-Developer-Productivity',
1554
+ defaultInterval: cdk.Duration.days(30),
1555
+ variables: [
1556
+ new cloudwatch.DashboardVariable({
1557
+ id: 'devFilter',
1558
+ label: 'Developer',
1559
+ type: cloudwatch.VariableType.PATTERN,
1560
+ value: USER_TOKEN,
1561
+ inputType: cloudwatch.VariableInputType.INPUT,
1562
+ defaultValue: cloudwatch.DefaultValue.value('all'),
1563
+ visible: true,
1564
+ }),
1565
+ ],
1566
+ });
1567
+
1568
+ devDashboard.addWidgets(
1569
+ new cloudwatch.TextWidget({
1570
+ markdown:
1571
+ '# PRISM D1 - Developer Productivity\n' +
1572
+ 'Rows 1-2: org metrics (CloudWatch, 2-week ingestion window applies to backfills). ' +
1573
+ 'Rows 3-4: attribution store — full history, real commit timestamps. ' +
1574
+ 'Type a developer email into the **Developer** variable (default `all`) to scope the detail row.',
1575
+ width: 24,
1576
+ height: 2,
1577
+ }),
1578
+ );
1579
+
1580
+ // --- Row 1: Org KPIs (NaN-guarded metric math, follow time range) ---
1581
+ const dailyMetric = (metricName: string, id: string): cloudwatch.Metric =>
1582
+ new cloudwatch.Metric({
1583
+ namespace: METRIC_NAMESPACE,
1584
+ metricName,
1585
+ statistic: 'Sum',
1586
+ period: cdk.Duration.days(1),
1587
+ label: id,
1588
+ });
1589
+ const ratioKpi = (title: string, expression: string, using: Record<string, cloudwatch.IMetric>): cloudwatch.SingleValueWidget =>
1590
+ new cloudwatch.SingleValueWidget({
1591
+ title,
1592
+ metrics: [new cloudwatch.MathExpression({ expression, usingMetrics: using, label: title, period: cdk.Duration.days(1) })],
1593
+ setPeriodToTimeRange: true,
1594
+ width: 6,
1595
+ height: 4,
1596
+ });
1597
+
1598
+ devDashboard.addWidgets(
1599
+ ratioKpi('AI Share of Commits (%)', 'IF(FILL(kTotal, 0) > 0, 100 * FILL(kAi, 0) / FILL(kTotal, 0))', {
1600
+ kAi: dailyMetric('AICommits', 'kAi'), kTotal: dailyMetric('CommitsTotal', 'kTotal'),
1601
+ }),
1602
+ ratioKpi('AI Merge Rate (%)', 'IF(FILL(kAi2, 0) > 0, 100 * FILL(kMerged, 0) / FILL(kAi2, 0))', {
1603
+ kMerged: dailyMetric('MergedAICommits', 'kMerged'), kAi2: dailyMetric('AICommits', 'kAi2'),
1604
+ }),
1605
+ ratioKpi('Cost per Shipped Commit ($)', 'IF(FILL(kMerged2, 0) > 0, FILL(kCost, 0) / FILL(kMerged2, 0))', {
1606
+ kCost: dailyMetric('AICostUSD', 'kCost'), kMerged2: dailyMetric('MergedAICommits', 'kMerged2'),
1607
+ }),
1608
+ new cloudwatch.SingleValueWidget({
1609
+ title: 'AI Spend (range, $)',
1610
+ metrics: [dailyMetric('AICostUSD', 'AI Spend')],
1611
+ setPeriodToTimeRange: true,
1612
+ width: 6,
1613
+ height: 4,
1614
+ }),
1615
+ );
1616
+
1617
+ // --- Row 2: Org trends (native interactive charts) ---
1618
+ devDashboard.addWidgets(
1619
+ new cloudwatch.GraphWidget({
1620
+ title: 'Commits / Day (AI vs Human)',
1621
+ left: [
1622
+ dailyMetric('AICommits', 'AI commits'),
1623
+ dailyMetric('HumanCommits', 'Human commits'),
1624
+ ],
1625
+ view: cloudwatch.GraphWidgetView.BAR,
1626
+ stacked: true,
1627
+ width: 8,
1628
+ height: 6,
1629
+ leftYAxis: { min: 0, label: 'Commits' },
1630
+ }),
1631
+ new cloudwatch.GraphWidget({
1632
+ title: 'AI Spend / Day',
1633
+ left: [dailyMetric('AICostUSD', 'Spend ($)')],
1634
+ width: 8,
1635
+ height: 6,
1636
+ leftYAxis: { min: 0, label: 'USD' },
1637
+ }),
1638
+ new cloudwatch.GraphWidget({
1639
+ title: 'AI Merge Ratio Trend',
1640
+ left: [
1641
+ new cloudwatch.MathExpression({
1642
+ expression: 'FILL(IF(FILL(tAi, 0) > 0, 100 * FILL(tMerged, 0) / FILL(tAi, 0)), REPEAT)',
1643
+ usingMetrics: { tMerged: dailyMetric('MergedAICommits', 'tMerged'), tAi: dailyMetric('AICommits', 'tAi') },
1644
+ label: 'Merge ratio (%)',
1645
+ period: cdk.Duration.days(1),
1646
+ }),
1647
+ ],
1648
+ width: 8,
1649
+ height: 6,
1650
+ leftYAxis: { min: 0, max: 100, label: 'Percent' },
1651
+ }),
1652
+ );
1653
+
1654
+ // --- Row 3: Team comparison table (custom widget, view=table) ---
1655
+ devDashboard.addWidgets(
1656
+ new cloudwatch.CustomWidget({
1657
+ functionArn: props.productivityWidgetArn,
1658
+ title: 'Team Comparison (full history)',
1659
+ width: 24,
1660
+ height: 8,
1661
+ params: { view: 'table' },
1662
+ updateOnRefresh: true,
1663
+ updateOnResize: false,
1664
+ updateOnTimeRangeChange: true,
1665
+ }),
1666
+ );
1667
+
1668
+ // --- Row 4: Detail panel (custom widget, view=detail, follows filter) ---
1669
+ devDashboard.addWidgets(
1670
+ new cloudwatch.CustomWidget({
1671
+ functionArn: props.productivityWidgetArn,
1672
+ title: 'Detail: By Tool / By Model / Ratios',
1673
+ width: 24,
1674
+ height: 8,
1675
+ params: { view: 'detail', user: USER_TOKEN },
1676
+ updateOnRefresh: true,
1677
+ updateOnResize: false,
1678
+ updateOnTimeRangeChange: true,
1679
+ }),
1680
+ );
1681
+
1682
+ new cdk.CfnOutput(this, 'DevProductivityDashboardUrl', {
1683
+ value: `https://${this.region}.console.aws.amazon.com/cloudwatch/home?region=${this.region}#dashboards:name=PRISM-D1-Developer-Productivity`,
1684
+ description: 'Developer Productivity Dashboard URL',
1685
+ });
1686
+ }
1433
1687
  }
1434
1688
  }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * PRISM D1 — Attribution metrics publisher.
3
+ *
4
+ * Consumes the prism-d1-ai-usage DynamoDB stream (filtered to COMMIT# items
5
+ * under REPO# pk) and publishes commit attribution metrics to CloudWatch.
6
+ *
7
+ * AI-Origin inference: joins commit → usage spans via traceId.
8
+ * - Has correlated usage spans → ai-generated, tool from span
9
+ * - No correlated usage spans → human, tool=none
10
+ *
11
+ * Metrics published (namespace: PRISM/D1/Velocity):
12
+ * AICommits — count of ai-generated commits (dimensionless + by Tool)
13
+ * HumanCommits — count of human commits (dimensionless only)
14
+ * RevertedAICommits — ai-generated commits that were later reverted
15
+ * CommitsTotal — all tracked commits regardless of origin
16
+ */
17
+
18
+ import {
19
+ CloudWatchClient,
20
+ PutMetricDataCommand,
21
+ MetricDatum,
22
+ StandardUnit,
23
+ } from '@aws-sdk/client-cloudwatch';
24
+ import {
25
+ DynamoDBClient,
26
+ QueryCommand,
27
+ } from '@aws-sdk/client-dynamodb';
28
+
29
+ const cloudwatch = new CloudWatchClient({});
30
+ const dynamoClient = new DynamoDBClient({});
31
+
32
+ const METRIC_NAMESPACE = process.env.METRIC_NAMESPACE || 'PRISM/D1/Velocity';
33
+ const AI_USAGE_TABLE = process.env.AI_USAGE_TABLE || 'prism-d1-ai-usage';
34
+ const PUT_METRIC_BATCH_SIZE = 1000;
35
+
36
+ // ---- Types ----
37
+
38
+ interface AttributeValue {
39
+ S?: string;
40
+ N?: string;
41
+ BOOL?: boolean;
42
+ }
43
+
44
+ interface StreamImage {
45
+ pk?: AttributeValue;
46
+ sk?: AttributeValue;
47
+ trace_id?: AttributeValue;
48
+ user?: AttributeValue;
49
+ tool?: AttributeValue;
50
+ model?: AttributeValue;
51
+ in_main?: AttributeValue;
52
+ was_reverted?: AttributeValue;
53
+ timestamp?: AttributeValue;
54
+ record_type?: AttributeValue;
55
+ }
56
+
57
+ interface StreamRecord {
58
+ eventName?: string;
59
+ dynamodb?: {
60
+ Keys?: { pk?: AttributeValue; sk?: AttributeValue };
61
+ NewImage?: StreamImage;
62
+ OldImage?: StreamImage;
63
+ };
64
+ }
65
+
66
+ interface StreamEvent {
67
+ Records?: StreamRecord[];
68
+ }
69
+
70
+ // ---- Helpers ----
71
+
72
+ /** Resolve the AI tool for a commit by joining with usage spans via traceId. */
73
+ async function resolveAiTool(user: string, traceId: string): Promise<{ tool: string; isAi: boolean }> {
74
+ if (!user || !traceId) return { tool: 'none', isAi: false };
75
+
76
+ try {
77
+ // NOTE: DynamoDB applies `Limit` BEFORE `FilterExpression` — `Limit: 1`
78
+ // would evaluate exactly one span and almost always miss the match,
79
+ // resolving every commit as human. Paginate the user's spans and stop at
80
+ // the first trace_id hit. Page size bounds per-call read cost; the loop
81
+ // bounds total pages defensively (a user with >50k spans and no match
82
+ // resolves as human rather than scanning forever).
83
+ let lastKey: import('@aws-sdk/client-dynamodb').QueryCommandOutput['LastEvaluatedKey'];
84
+ for (let page = 0; page < 50; page++) {
85
+ const result = await dynamoClient.send(new QueryCommand({
86
+ TableName: AI_USAGE_TABLE,
87
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
88
+ FilterExpression: 'trace_id = :tid',
89
+ ExpressionAttributeValues: {
90
+ ':pk': { S: `USER#${user}` },
91
+ ':prefix': { S: 'SPAN#' },
92
+ ':tid': { S: traceId },
93
+ },
94
+ Limit: 1000,
95
+ ...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
96
+ }));
97
+
98
+ if ((result.Items?.length ?? 0) > 0) {
99
+ const tool = result.Items![0].tool?.S ?? 'unknown';
100
+ return { tool, isAi: true };
101
+ }
102
+ lastKey = result.LastEvaluatedKey;
103
+ if (!lastKey) break;
104
+ }
105
+ } catch (e) {
106
+ console.warn(`[attribution-metrics] Failed to resolve tool for trace=${traceId}: ${e}`);
107
+ }
108
+
109
+ return { tool: 'none', isAi: false };
110
+ }
111
+
112
+ /** Parse a stream record into commit metadata. Only processes COMMIT# items. */
113
+ function parseCommitRecord(record: StreamRecord): {
114
+ user: string;
115
+ traceId: string;
116
+ wasReverted: boolean;
117
+ inMain: boolean;
118
+ timestamp: string;
119
+ /** Which state transitions this event represents. INSERT = 'new'. A MODIFY
120
+ * can flip in_main and/or was_reverted (receiver writes only when one of
121
+ * them changed). */
122
+ isNew: boolean;
123
+ mergedNow: boolean; // in_main flipped false → true
124
+ revertedNow: boolean; // was_reverted flipped false → true
125
+ } | null {
126
+ if (record.eventName !== 'INSERT' && record.eventName !== 'MODIFY') return null;
127
+
128
+ const newImage = record.dynamodb?.NewImage;
129
+ if (!newImage) return null;
130
+
131
+ const recordType = newImage.record_type?.S;
132
+ if (recordType !== 'OTEL_ATTR_COMMIT') return null;
133
+
134
+ const inMain = newImage.in_main?.BOOL ?? false;
135
+ const wasReverted = newImage.was_reverted?.BOOL ?? false;
136
+
137
+ let isNew = record.eventName === 'INSERT';
138
+ let mergedNow = false;
139
+ let revertedNow = false;
140
+
141
+ if (record.eventName === 'MODIFY') {
142
+ const oldImage = record.dynamodb?.OldImage;
143
+ const oldInMain = oldImage?.in_main?.BOOL ?? false;
144
+ const oldReverted = oldImage?.was_reverted?.BOOL ?? false;
145
+ mergedNow = inMain && !oldInMain;
146
+ revertedNow = wasReverted && !oldReverted;
147
+ if (!mergedNow && !revertedNow) return null; // no meaningful transition
148
+ }
149
+
150
+ return {
151
+ user: newImage.user?.S ?? '',
152
+ traceId: newImage.trace_id?.S ?? '',
153
+ wasReverted,
154
+ inMain,
155
+ timestamp: newImage.timestamp?.S ?? new Date().toISOString(),
156
+ isNew,
157
+ mergedNow,
158
+ revertedNow,
159
+ };
160
+ }
161
+
162
+ // ---- Handler ----
163
+
164
+ export async function handler(event: StreamEvent): Promise<void> {
165
+ const now = new Date();
166
+ const datums: MetricDatum[] = [];
167
+ let aiCommits = 0;
168
+ let humanCommits = 0;
169
+ let mergedAi = 0;
170
+ let revertedAi = 0;
171
+
172
+ /** Push a Count=1 datum, dimensionless plus an optional Tool-dimensioned twin. */
173
+ const emit = (name: string, ts: Date, tool?: string): void => {
174
+ datums.push({ MetricName: name, Value: 1, Unit: StandardUnit.Count, Timestamp: ts, Dimensions: [] });
175
+ if (tool) {
176
+ datums.push({ MetricName: name, Value: 1, Unit: StandardUnit.Count, Timestamp: ts, Dimensions: [{ Name: 'Tool', Value: tool }] });
177
+ }
178
+ };
179
+
180
+ for (const record of event.Records ?? []) {
181
+ const commit = parseCommitRecord(record);
182
+ if (!commit) continue;
183
+
184
+ const { tool, isAi } = await resolveAiTool(commit.user, commit.traceId);
185
+ const timestamp = new Date(commit.timestamp);
186
+ // CloudWatch rejects PutMetricData timestamps older than 2 weeks (and one
187
+ // bad member rejects the whole batch, wedging the stream in a retry loop).
188
+ // Use the commit's own time when it's inside the window so live traffic
189
+ // charts at commit time; clamp backfilled history to ingest time.
190
+ const OLDEST_ALLOWED_MS = 13 * 24 * 60 * 60 * 1000; // 13d — safety margin inside CW's 14d limit
191
+ const metricTimestamp =
192
+ isNaN(timestamp.getTime()) || now.getTime() - timestamp.getTime() > OLDEST_ALLOWED_MS
193
+ ? now
194
+ : timestamp;
195
+
196
+ if (commit.isNew) {
197
+ // New commit: CommitsTotal always, then AI/Human split.
198
+ emit('CommitsTotal', metricTimestamp);
199
+ if (isAi) {
200
+ aiCommits++;
201
+ emit('AICommits', metricTimestamp, tool);
202
+ // States already true at INSERT time (backfill of an old commit whose
203
+ // merge/revert happened before codeburn first synced it).
204
+ if (commit.inMain) {
205
+ mergedAi++;
206
+ emit('MergedAICommits', metricTimestamp, tool);
207
+ }
208
+ if (commit.wasReverted) {
209
+ revertedAi++;
210
+ emit('RevertedAICommits', metricTimestamp);
211
+ }
212
+ } else {
213
+ humanCommits++;
214
+ emit('HumanCommits', metricTimestamp);
215
+ if (commit.inMain) emit('MergedHumanCommits', metricTimestamp);
216
+ }
217
+ continue;
218
+ }
219
+
220
+ // MODIFY — state transition(s). The receiver writes only when in_main or
221
+ // was_reverted actually changed, and a single MODIFY can carry both flips.
222
+ if (commit.mergedNow) {
223
+ if (isAi) {
224
+ mergedAi++;
225
+ emit('MergedAICommits', metricTimestamp, tool);
226
+ } else {
227
+ emit('MergedHumanCommits', metricTimestamp);
228
+ }
229
+ }
230
+ if (commit.revertedNow && isAi) {
231
+ revertedAi++;
232
+ emit('RevertedAICommits', metricTimestamp, tool);
233
+ }
234
+ }
235
+
236
+ // Publish in batches
237
+ for (let i = 0; i < datums.length; i += PUT_METRIC_BATCH_SIZE) {
238
+ await cloudwatch.send(new PutMetricDataCommand({
239
+ Namespace: METRIC_NAMESPACE,
240
+ MetricData: datums.slice(i, i + PUT_METRIC_BATCH_SIZE),
241
+ }));
242
+ }
243
+
244
+ console.log(
245
+ `[attribution-metrics-publisher] records=${event.Records?.length ?? 0} ` +
246
+ `ai=${aiCommits} human=${humanCommits} merged=${mergedAi} reverted=${revertedAi} datums=${datums.length}`,
247
+ );
248
+ }