@prism-d1/cli 1.0.77 → 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/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
|
@@ -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?:
|
|
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: '
|
|
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:
|
|
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: '
|
|
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
|
+
}
|