@prism-d1/cli 1.0.98 → 1.1.2

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.
@@ -32,6 +32,8 @@ on:
32
32
  pull_request:
33
33
  types: [closed]
34
34
  branches: [main, master]
35
+ push:
36
+ branches: [main, master]
35
37
 
36
38
 
37
39
  env:
@@ -45,7 +47,7 @@ permissions:
45
47
 
46
48
  jobs:
47
49
  emit-metrics:
48
- if: github.event.pull_request.merged == true
50
+ if: github.event_name == 'pull_request' && github.event.pull_request.merged == true
49
51
  runs-on: ubuntu-latest
50
52
  steps:
51
53
  - name: Checkout
@@ -245,3 +247,172 @@ jobs:
245
247
  \"EventBusName\": \"prism-d1-metrics\",
246
248
  \"Detail\": $(echo "${DEPLOY_EVENT}" | jq -c '.' | jq -Rs '.')
247
249
  }]"
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Census-only path: commits that reached the default branch WITHOUT a PR.
253
+ #
254
+ # WHY THIS EXISTS. Attribution Coverage is attributed_commits / ci_commits,
255
+ # and ci_commits was sourced solely from merged-PR events. A direct push to
256
+ # main therefore shrank the denominator while codeburn still attributed the
257
+ # commits, so coverage rose toward a confident 100% precisely as the census
258
+ # became less complete -- the one number meant to flag untrustworthy AI
259
+ # metrics failed in the reassuring direction. See velocity-widget.ts →
260
+ # attributionCoverage().
261
+ #
262
+ # WHAT IT DELIBERATELY DOES NOT DO. It emits no `dora` block and no
263
+ # prism.d1.deploy event, and it uses its own detail type rather than
264
+ # prism.d1.pr. Three reasons, each load-bearing:
265
+ # - Change Failure Rate is failure_fixes / prs.length. Pushing census
266
+ # events into prism.d1.pr would inflate that denominator and make the
267
+ # revert rate look better, reintroducing the same class of lie.
268
+ # - A push has no created_at → merged_at pair, so any lead time would be
269
+ # fabricated.
270
+ # - A push has no title convention to test for revert/hotfix/rollback.
271
+ # Deployment Frequency therefore still counts merges only. That is the
272
+ # accepted trade: this job fixes the census, it does not invent DORA data.
273
+ #
274
+ # NOT prism.d1.commit, which already exists: that type is per-commit and
275
+ # carries ai_context.{tool,model,origin} from the deprecated git hook. Its
276
+ # two remaining consumers (api-handler.ts, security-agent-processor.ts)
277
+ # expect that shape, and AI origin is deliberately resolved at render time
278
+ # here rather than at CI time.
279
+ emit-push-census:
280
+ # A PR merge fires BOTH pull_request:closed AND push, so this job must not
281
+ # double-count it. The head-commit-has-no-PR check below is the guard; this
282
+ # condition only keeps push-triggered runs.
283
+ if: github.event_name == 'push'
284
+ runs-on: ubuntu-latest
285
+ steps:
286
+ - name: Checkout
287
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
288
+ with:
289
+ # Full history: `git rev-list before..after` needs both endpoints.
290
+ fetch-depth: 0
291
+
292
+ - name: Skip if this push came from a PR merge
293
+ id: gate
294
+ env:
295
+ GH_TOKEN: ${{ github.token }}
296
+ run: |
297
+ set -uo pipefail
298
+ SHA="${{ github.event.after }}"
299
+ REPO="${{ github.repository }}"
300
+
301
+ # Covers all three merge strategies in one check. A merge commit, a
302
+ # squashed commit, and a rebased commit are all associated with their
303
+ # PR by this endpoint, whereas a hand-pushed commit is associated with
304
+ # none. Testing parent count instead would only catch merge commits
305
+ # and would double-count every squash merge.
306
+ PR_COUNT=$(gh api "repos/${REPO}/commits/${SHA}/pulls" --jq 'length' 2>/dev/null || echo "unknown")
307
+
308
+ if [[ "${PR_COUNT}" == "unknown" ]]; then
309
+ # Fail closed: emitting on an inconclusive check risks double-counting
310
+ # the census, which corrupts coverage in the direction this job exists
311
+ # to prevent. Under-counting is the safer error.
312
+ echo "Could not determine PR association for ${SHA}; skipping to avoid double-count."
313
+ echo "emit=false" >> "$GITHUB_OUTPUT"
314
+ elif [[ "${PR_COUNT}" != "0" ]]; then
315
+ echo "${SHA} belongs to ${PR_COUNT} PR(s); the emit-metrics job owns this merge."
316
+ echo "emit=false" >> "$GITHUB_OUTPUT"
317
+ else
318
+ echo "${SHA} has no associated PR — direct push, emitting census."
319
+ echo "emit=true" >> "$GITHUB_OUTPUT"
320
+ fi
321
+
322
+ - name: Configure AWS credentials (OIDC)
323
+ if: steps.gate.outputs.emit == 'true'
324
+ uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
325
+ with:
326
+ role-to-assume: ${{ secrets.PRISM_METRICS_ROLE_ARN }}
327
+ aws-region: ${{ env.PRISM_AWS_REGION }}
328
+
329
+ - name: Read team ID from .prism/config.json
330
+ if: steps.gate.outputs.emit == 'true'
331
+ id: prism-config
332
+ run: |
333
+ if [[ -f .prism/config.json ]]; then
334
+ TEAM_ID=$(jq -r '.team_id // ""' .prism/config.json 2>/dev/null || echo "")
335
+ else
336
+ TEAM_ID=""
337
+ fi
338
+ if [[ -z "${TEAM_ID}" ]]; then
339
+ TEAM_ID="no_team"
340
+ fi
341
+ echo "team_id=${TEAM_ID}" >> "$GITHUB_OUTPUT"
342
+
343
+ - name: Collect commit census
344
+ if: steps.gate.outputs.emit == 'true'
345
+ id: census
346
+ run: |
347
+ set -uo pipefail
348
+ BEFORE="${{ github.event.before }}"
349
+ AFTER="${{ github.event.after }}"
350
+
351
+ # A new branch (or the first push) reports an all-zero `before`, and
352
+ # a force-push reports a SHA that is no longer an ancestor. Both make
353
+ # `before..after` invalid or unboundedly large, so fall back to the
354
+ # single head commit rather than emitting a bogus count.
355
+ if [[ "${BEFORE}" =~ ^0+$ ]] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
356
+ echo "Unusable before-SHA (${BEFORE}); counting the head commit only."
357
+ RANGE="${AFTER} -1"
358
+ else
359
+ RANGE="${BEFORE}..${AFTER}"
360
+ fi
361
+
362
+ # --no-merges and the 50-item cap match the PR path exactly, so both
363
+ # sides of the coverage ratio are counted on the same basis. A merge
364
+ # commit introduces no authored lines and can never appear in the
365
+ # attribution store, so counting one would put a structural ceiling
366
+ # on coverage.
367
+ COMMIT_SHAS=$(git rev-list --no-merges ${RANGE} 2>/dev/null | head -50 | jq -R . | jq -sc . || echo '[]')
368
+ TOTAL_COMMITS=$(git rev-list --no-merges --count ${RANGE} 2>/dev/null || echo 0)
369
+ COMMIT_AUTHORS=$(git rev-list --no-merges ${RANGE} 2>/dev/null | head -50 | while read sha; do git log -1 --format='%ae' "$sha"; done | jq -R . | jq -sc . || echo '[]')
370
+
371
+ echo "commit_shas=${COMMIT_SHAS}" >> "$GITHUB_OUTPUT"
372
+ echo "commit_authors=${COMMIT_AUTHORS}" >> "$GITHUB_OUTPUT"
373
+ echo "total_commits=${TOTAL_COMMITS}" >> "$GITHUB_OUTPUT"
374
+
375
+ - name: Emit prism.d1.push event
376
+ if: steps.gate.outputs.emit == 'true' && steps.census.outputs.total_commits != '0'
377
+ run: |
378
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
379
+
380
+ PUSH_EVENT=$(jq -n \
381
+ --arg team_id "${{ steps.prism-config.outputs.team_id }}" \
382
+ --arg repo "${PRISM_REPO}" \
383
+ --arg timestamp "${TIMESTAMP}" \
384
+ --arg sha "${{ github.event.after }}" \
385
+ --arg pusher "${{ github.event.pusher.name }}" \
386
+ --argjson total_commits "${{ steps.census.outputs.total_commits }}" \
387
+ --argjson commit_shas '${{ steps.census.outputs.commit_shas }}' \
388
+ --argjson commit_authors '${{ steps.census.outputs.commit_authors }}' \
389
+ '{
390
+ "team_id": $team_id,
391
+ "repo": $repo,
392
+ "timestamp": $timestamp,
393
+ "prism_level": 2,
394
+ "metric": {
395
+ "name": "push_commits",
396
+ "value": $total_commits,
397
+ "unit": "count"
398
+ },
399
+ "push": {
400
+ "sha": $sha,
401
+ "pusher": $pusher,
402
+ "total_commits": $total_commits,
403
+ "commit_shas": $commit_shas,
404
+ "commit_authors": $commit_authors
405
+ }
406
+ }')
407
+
408
+ aws events put-events \
409
+ --region $PRISM_AWS_REGION \
410
+ --entries "[{
411
+ \"Source\": \"prism.d1.velocity\",
412
+ \"DetailType\": \"prism.d1.push\",
413
+ \"EventBusName\": \"prism-d1-metrics\",
414
+ \"Detail\": $(echo "${PUSH_EVENT}" | jq -c '.' | jq -Rs '.')
415
+ }]"
416
+
417
+ echo "Emitted prism.d1.push for ${{ github.event.after }}"
418
+ echo " commits in census: ${{ steps.census.outputs.total_commits }}"
@@ -49,6 +49,57 @@ export interface OtelCollectorConstructProps {
49
49
  externalClientId?: string;
50
50
  /** JWT claim used as the user identity key (default: username for Cognito, sub for BYO). */
51
51
  identityClaim?: string;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Federated IdP on the Cognito pool (Okta, Entra ID, any OIDC provider).
55
+ //
56
+ // Distinct from the BYO-IdP props above. Those REPLACE Cognito as the token
57
+ // issuer and drop the machine-to-machine client with it. These keep Cognito as
58
+ // the issuer and add an upstream provider behind it, so the coding agent's
59
+ // client-credentials path survives.
60
+ //
61
+ // All five must be supplied together; a partial set throws rather than
62
+ // half-configuring an IdP that fails at first login.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** Provider name, e.g. 'CorpIdP'. Becomes the `<name>_` username prefix. */
66
+ idpName?: string;
67
+ /** OIDC issuer URL, e.g. https://idp.example.com */
68
+ idpIssuer?: string;
69
+ /** OAuth client id registered with the IdP for this user pool. */
70
+ idpClientId?: string;
71
+ /**
72
+ * ARN or name of a Secrets Manager secret holding the IdP client secret.
73
+ *
74
+ * A REFERENCE, never the value: the secret is resolved by a CloudFormation
75
+ * dynamic reference at deploy time, so the plaintext is never written to the
76
+ * template, CloudFormation state, or CI logs. Passing the secret itself
77
+ * through CDK context would put it in `cdk.context.json` and the synthesized
78
+ * template, which is the antipattern the reference exists to avoid.
79
+ *
80
+ * Provide either a plain-text secret whose whole value is the client secret,
81
+ * or a JSON secret with a `clientSecret` key (set idpClientSecretJsonKey).
82
+ *
83
+ * CAVEAT: CloudFormation resolves a dynamic reference only when the resource
84
+ * containing it is created or updated. Rotating the secret in Secrets Manager
85
+ * does NOT reach Cognito on its own — the IdP resource must change too.
86
+ */
87
+ idpClientSecretArn?: string;
88
+ /** JSON key inside the secret holding the client secret. Omit for a plain-text secret. */
89
+ idpClientSecretJsonKey?: string;
90
+ /**
91
+ * Scopes Cognito requests FROM the IdP. Defaults to 'openid email'.
92
+ *
93
+ * `email` is load-bearing and the default exists because of it: without it the
94
+ * IdP returns no email claim, Cognito stores `email: null` on every federated
95
+ * user, and the identity falls back to the `<provider>_<alias>` username —
96
+ * which never matches the git author emails the CI census emits, silently
97
+ * splitting each developer into two identities on the dashboards.
98
+ *
99
+ * NOT the same as the app client's AllowedOAuthScopes, which governs
100
+ * app→Cognito rather than Cognito→IdP.
101
+ */
102
+ idpScopes?: string;
52
103
  }
53
104
 
54
105
  export class OtelCollectorConstruct extends Construct {
@@ -117,6 +168,84 @@ export class OtelCollectorConstruct extends Construct {
117
168
  cognitoDomain: { domainPrefix },
118
169
  });
119
170
 
171
+ // -----------------------------------------------------------------
172
+ // Optional federated IdP in front of the pool.
173
+ //
174
+ // Created BEFORE the app client so the client's
175
+ // supportedIdentityProviders can reference it; CloudFormation rejects a
176
+ // client naming a provider that does not exist yet, and addDependency
177
+ // below makes that ordering explicit rather than incidental.
178
+ // -----------------------------------------------------------------
179
+ const idpFields = [props.idpName, props.idpIssuer, props.idpClientId, props.idpClientSecretArn];
180
+ const idpConfigured = idpFields.some((f) => f !== undefined);
181
+ if (idpConfigured && idpFields.some((f) => f === undefined)) {
182
+ throw new Error(
183
+ 'Federated IdP requires idpName, idpIssuer, idpClientId and idpClientSecretArn together '
184
+ + '(context: -c otelIdpName=... -c otelIdpIssuer=... -c otelIdpClientId=... -c otelIdpClientSecretArn=...)',
185
+ );
186
+ }
187
+
188
+ let federatedIdp: cognito.CfnUserPoolIdentityProvider | undefined;
189
+ if (idpConfigured) {
190
+ // Build the dynamic reference as a LITERAL string.
191
+ //
192
+ // The obvious CDK spelling — Secret.fromSecretNameV2(...).secretValueFromJson(k)
193
+ // .unsafeUnwrap() — composes the ARN with `Ref: AWS::Partition`, so the
194
+ // property renders as an Fn::Join whose OUTPUT happens to be a
195
+ // {{resolve:...}} string. CloudFormation resolves dynamic references
196
+ // during template processing and does not reliably resolve one produced
197
+ // by an intrinsic; the risk is Cognito storing the literal
198
+ // "{{resolve:secretsmanager:...}}" as the client secret, which fails at
199
+ // first federated login with an upstream "invalid_client" and no failed
200
+ // resource to point at. Verified by synthesizing both forms.
201
+ //
202
+ // The secret id comes from CDK context, so it is a plain string and
203
+ // interpolation yields a literal with no intrinsic. Secrets Manager
204
+ // accepts either a friendly name (same account and region) or a full ARN.
205
+ const secretId = props.idpClientSecretArn!;
206
+ if (!/^(arn:[a-z0-9-]+:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+|[A-Za-z0-9/_+=.@-]+)$/.test(secretId)) {
207
+ throw new Error(
208
+ `idpClientSecretArn "${secretId}" is not a valid Secrets Manager secret name or ARN. `
209
+ + 'Expected a friendly name or a full arn:...:secretsmanager:...:secret:... ARN.',
210
+ );
211
+ }
212
+ const jsonKey = props.idpClientSecretJsonKey ?? '';
213
+ if (jsonKey && !/^[A-Za-z0-9_.-]+$/.test(jsonKey)) {
214
+ throw new Error(`idpClientSecretJsonKey "${jsonKey}" must be alphanumeric with . _ - only.`);
215
+ }
216
+ // Trailing `::` selects the current version stage (AWSCURRENT), so the
217
+ // reference tracks rotation without pinning a version id.
218
+ const clientSecretRef = `{{resolve:secretsmanager:${secretId}:SecretString:${jsonKey}::}}`;
219
+
220
+ federatedIdp = new cognito.CfnUserPoolIdentityProvider(this, 'FederatedIdp', {
221
+ userPoolId: this.userPool.userPoolId,
222
+ providerName: props.idpName!,
223
+ providerType: 'OIDC',
224
+ // A SUPERSET of what the console-created provider had, never a
225
+ // replacement. `username: sub` is what Cognito uses to derive the
226
+ // native federated username (`<ProviderName>_<sub>`); dropping it
227
+ // changes the username shape, which mints a second set of user
228
+ // records and splits every developer's history a second time. Only
229
+ // `email` and the name claims are additions.
230
+ //
231
+ // `email` is the load-bearing one: it is what lets the identity match
232
+ // the git author emails the CI census emits.
233
+ attributeMapping: {
234
+ username: 'sub',
235
+ email: 'EMAIL',
236
+ given_name: 'GIVEN_NAME',
237
+ family_name: 'FAMILY_NAME',
238
+ },
239
+ providerDetails: {
240
+ oidc_issuer: props.idpIssuer!,
241
+ client_id: props.idpClientId!,
242
+ client_secret: clientSecretRef,
243
+ authorize_scopes: props.idpScopes ?? 'openid email',
244
+ attributes_request_method: 'GET',
245
+ },
246
+ });
247
+ }
248
+
120
249
  // Public client (no secret) — codeburn does Authorization Code + PKCE
121
250
  // with loopback redirects. Cognito allows http:// for localhost, 127.0.0.1,
122
251
  // and [::1] (per CreateUserPoolClient API docs). codeburn uses 127.0.0.1.
@@ -132,10 +261,97 @@ export class OtelCollectorConstruct extends Construct {
132
261
  scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE],
133
262
  callbackUrls,
134
263
  },
264
+ // Declared here so it cannot drift. Setting this in the console instead
265
+ // leaves CloudFormation believing the client is Cognito-only, and the
266
+ // next deploy of this stack silently reverts it — breaking federated
267
+ // login with no failed resource to point at.
268
+ supportedIdentityProviders: idpConfigured
269
+ ? [
270
+ cognito.UserPoolClientIdentityProvider.COGNITO,
271
+ cognito.UserPoolClientIdentityProvider.custom(props.idpName!),
272
+ ]
273
+ : [cognito.UserPoolClientIdentityProvider.COGNITO],
135
274
  preventUserExistenceErrors: true,
136
275
  accessTokenValidity: cdk.Duration.hours(1),
137
276
  refreshTokenValidity: cdk.Duration.days(30),
138
277
  });
278
+ if (federatedIdp) {
279
+ this.userPoolClient.node.defaultChild?.node.addDependency(federatedIdp);
280
+ }
281
+
282
+ // -----------------------------------------------------------------
283
+ // Pre token generation (V2_0) — puts `email` in the ACCESS token.
284
+ //
285
+ // Attached only when a federated IdP is configured: for admin-created
286
+ // users the username IS the email, so the trigger would be a no-op that
287
+ // still billed an invocation on every token issuance.
288
+ //
289
+ // Inert without `identityClaim: 'email'` — see the note in
290
+ // pre-token-generation.ts. The warning below fires on the one
291
+ // half-configured combination that looks like it should work.
292
+ // -----------------------------------------------------------------
293
+ if (idpConfigured) {
294
+ // props.identityClaim is not yet folded into `identityClaim` at this
295
+ // point in the branch, so resolve the same default inline.
296
+ const claimForWarning = props.identityClaim ?? 'username';
297
+ if (claimForWarning !== 'email') {
298
+ cdk.Annotations.of(this).addWarning(
299
+ 'A federated IdP is configured and the pre-token-generation trigger will add an `email` '
300
+ + 'claim to access tokens, but identityClaim is '
301
+ + `"${claimForWarning}" — the receiver checks that claim (and then \`username\`, which is `
302
+ + 'always present) before `email`, so the trigger has no effect. Deploy with '
303
+ + '-c otelIdentityClaim=email to key identities on email.',
304
+ );
305
+ }
306
+
307
+ const preTokenGen = new lambda.Function(this, 'PreTokenGeneration', {
308
+ functionName: 'prism-d1-pre-token-generation',
309
+ runtime: lambda.Runtime.NODEJS_22_X,
310
+ handler: 'pre-token-generation.handler',
311
+ // Deliberately NOT in the VPC: Cognito invokes this synchronously on
312
+ // the token-issuance path, and it makes no network calls, so a VPC
313
+ // attachment would add cold-start ENI latency to every sign-in for
314
+ // nothing.
315
+ code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda'), {
316
+ bundling: {
317
+ image: lambda.Runtime.NODEJS_22_X.bundlingImage,
318
+ command: [
319
+ 'bash', '-c',
320
+ [
321
+ 'npm init -y > /dev/null 2>&1',
322
+ 'npm install --save esbuild > /dev/null 2>&1',
323
+ 'npx esbuild pre-token-generation.ts --bundle --platform=node --target=node22 --outfile=/asset-output/pre-token-generation.js --external:@aws-sdk/*',
324
+ ].join(' && '),
325
+ ],
326
+ local: {
327
+ tryBundle(outputDir: string): boolean {
328
+ try {
329
+ const { execSync } = require('child_process');
330
+ execSync(
331
+ `npx esbuild ${path.join(__dirname, '..', 'lambda', 'pre-token-generation.ts')} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, 'pre-token-generation.js')} --external:@aws-sdk/*`,
332
+ { stdio: 'pipe' },
333
+ );
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ },
339
+ },
340
+ },
341
+ }),
342
+ // Token issuance blocks on this call, so keep the budget tight: the
343
+ // function reads one attribute off the event and returns.
344
+ timeout: cdk.Duration.seconds(5),
345
+ memorySize: 128,
346
+ logRetention: logs.RetentionDays.ONE_MONTH,
347
+ });
348
+
349
+ this.userPool.addTrigger(
350
+ cognito.UserPoolOperation.PRE_TOKEN_GENERATION_CONFIG,
351
+ preTokenGen,
352
+ cognito.LambdaVersion.V2_0,
353
+ );
354
+ }
139
355
 
140
356
  issuer = `https://cognito-idp.${cdk.Aws.REGION}.amazonaws.com/${this.userPool.userPoolId}`;
141
357
  clientId = this.userPoolClient.userPoolClientId;
@@ -215,31 +215,31 @@ export class DashboardStack extends cdk.Stack {
215
215
  updateOnResize: false,
216
216
  updateOnTimeRangeChange: true,
217
217
  })] : []),
218
- new cloudwatch.GraphWidget({
219
- title: 'AI Spend Trend (Weekly)',
220
- left: [weeklySum('AICostUSD', 'Spend ($)')],
218
+ new cloudwatch.CustomWidget({
219
+ functionArn: props?.velocityWidgetArn ?? '',
220
+ title: 'AI Spend Trend',
221
221
  width: 8,
222
222
  height: 6,
223
- leftYAxis: { min: 0, label: 'USD' },
223
+ params: { view: 'ai-spend-trend' },
224
+ updateOnRefresh: true,
225
+ updateOnResize: false,
226
+ updateOnTimeRangeChange: true,
224
227
  }),
225
- new cloudwatch.GraphWidget({
226
- title: 'Cost per Shipped Commit (Weekly)',
227
- left: [
228
- // The ROI narrative: spend can rise while this falls, which means AI
229
- // is getting more efficient per unit of shipped work.
230
- new cloudwatch.MathExpression({
231
- expression: 'FILL(IF(FILL(execMerged, 0) > 0, FILL(execCost, 0) / FILL(execMerged, 0)), REPEAT)',
232
- usingMetrics: {
233
- execCost: weeklySum('AICostUSD'),
234
- execMerged: weeklySum('MergedAICommits'),
235
- },
236
- label: '$ / shipped AI commit',
237
- period: cdk.Duration.days(7),
238
- }),
239
- ],
228
+ // The ROI narrative: spend can rise while this falls, which means AI is
229
+ // getting more efficient per unit of shipped work. That reading only holds
230
+ // if both terms are bucketed by when the work happened — the CloudWatch
231
+ // version this replaces divided two ingest-timestamped metrics, so a
232
+ // backfill collapsed the ratio and read as a 94% efficiency gain that
233
+ // never occurred. See renderCostPerCommitTrend.
234
+ new cloudwatch.CustomWidget({
235
+ functionArn: props?.velocityWidgetArn ?? '',
236
+ title: 'Cost per Shipped Commit',
240
237
  width: 8,
241
238
  height: 6,
242
- leftYAxis: { min: 0, label: 'USD' },
239
+ params: { view: 'cost-per-commit-trend' },
240
+ updateOnRefresh: true,
241
+ updateOnResize: false,
242
+ updateOnTimeRangeChange: true,
243
243
  }),
244
244
  );
245
245
 
@@ -676,23 +676,6 @@ export class DashboardStack extends cdk.Stack {
676
676
  );
677
677
 
678
678
  // --- Row 1: Org KPIs (NaN-guarded metric math, follow time range) ---
679
- const dailyMetric = (metricName: string, id: string): cloudwatch.Metric =>
680
- new cloudwatch.Metric({
681
- namespace: METRIC_NAMESPACE,
682
- metricName,
683
- statistic: 'Sum',
684
- period: cdk.Duration.days(1),
685
- label: id,
686
- });
687
- const ratioKpi = (title: string, expression: string, using: Record<string, cloudwatch.IMetric>): cloudwatch.SingleValueWidget =>
688
- new cloudwatch.SingleValueWidget({
689
- title,
690
- metrics: [new cloudwatch.MathExpression({ expression, usingMetrics: using, label: title, period: cdk.Duration.days(1) })],
691
- setPeriodToTimeRange: true,
692
- width: 6,
693
- height: 4,
694
- });
695
-
696
679
  devDashboard.addWidgets(
697
680
  // AI Share and AI Merge Rate are DDB-backed (accurate, no CloudWatch delay)
698
681
  new cloudwatch.CustomWidget({
@@ -715,15 +698,31 @@ export class DashboardStack extends cdk.Stack {
715
698
  updateOnResize: false,
716
699
  updateOnTimeRangeChange: true,
717
700
  }),
718
- ratioKpi('Cost per Shipped Commit ($)', 'IF(FILL(kMerged2, 0) > 0, FILL(kCost, 0) / FILL(kMerged2, 0))', {
719
- kCost: dailyMetric('AICostUSD', 'kCost'), kMerged2: dailyMetric('MergedAICommits', 'kMerged2'),
701
+ // Both of these read the attribution store rather than CloudWatch metric
702
+ // math. The metric versions divided/summed AICostUSD and MergedAICommits,
703
+ // which are ingest-timestamped and subject to CloudWatch's two-week
704
+ // datapoint cutoff, and carry no user dimension — so they disagreed with
705
+ // the store-backed figures in row 3 on both terms AND ignored the
706
+ // Developer variable. See renderCostPerCommitKpi for the measured gap.
707
+ new cloudwatch.CustomWidget({
708
+ functionArn: props.velocityWidgetArn ?? '',
709
+ title: 'Cost per Shipped Commit ($)',
710
+ width: 6,
711
+ height: 4,
712
+ params: { view: 'cost-per-commit-kpi' },
713
+ updateOnRefresh: true,
714
+ updateOnResize: false,
715
+ updateOnTimeRangeChange: true,
720
716
  }),
721
- new cloudwatch.SingleValueWidget({
717
+ new cloudwatch.CustomWidget({
718
+ functionArn: props.velocityWidgetArn ?? '',
722
719
  title: 'AI Spend (range, $)',
723
- metrics: [dailyMetric('AICostUSD', 'AI Spend')],
724
- setPeriodToTimeRange: true,
725
720
  width: 6,
726
721
  height: 4,
722
+ params: { view: 'ai-spend-kpi' },
723
+ updateOnRefresh: true,
724
+ updateOnResize: false,
725
+ updateOnTimeRangeChange: true,
727
726
  }),
728
727
  );
729
728
 
@@ -740,12 +739,15 @@ export class DashboardStack extends cdk.Stack {
740
739
  updateOnResize: false,
741
740
  updateOnTimeRangeChange: true,
742
741
  }),
743
- new cloudwatch.GraphWidget({
742
+ new cloudwatch.CustomWidget({
743
+ functionArn: props?.velocityWidgetArn ?? '',
744
744
  title: 'AI Spend / Day',
745
- left: [dailyMetric('AICostUSD', 'Spend ($)')],
746
745
  width: 8,
747
746
  height: 6,
748
- leftYAxis: { min: 0, label: 'USD' },
747
+ params: { view: 'ai-spend-trend' },
748
+ updateOnRefresh: true,
749
+ updateOnResize: false,
750
+ updateOnTimeRangeChange: true,
749
751
  }),
750
752
  // Merge Rate Trend — DDB-backed line chart replacing CloudWatch AI Merge Ratio Trend
751
753
  new cloudwatch.CustomWidget({
@@ -36,6 +36,7 @@ export const PERSISTED_SECTIONS = [
36
36
  'security_agent_finding',
37
37
  'security_remediation',
38
38
  'pr',
39
+ 'push',
39
40
  ] as const;
40
41
 
41
42
  export type PersistedSection = (typeof PERSISTED_SECTIONS)[number];
@@ -76,7 +77,16 @@ export const SECTION_FIELDS: Record<PersistedSection, readonly string[]> = {
76
77
  ],
77
78
  pr: [
78
79
  'number', 'author', 'reviews_approved', 'reviews_changes_requested',
79
- 'total_commits', 'commit_shas',
80
+ 'total_commits', 'commit_shas', 'commit_authors',
81
+ ],
82
+ // Census-only counterpart to `pr`, emitted by prism-ai-metrics.yml when
83
+ // commits reach the default branch without a PR. Deliberately carries no
84
+ // lead-time or failure-fix field: a push has neither a created_at→merged_at
85
+ // pair nor a title convention, so any such value would be fabricated. Kept a
86
+ // separate section (rather than reusing `pr`) because Change Failure Rate
87
+ // divides by the count of prism.d1.pr events.
88
+ push: [
89
+ 'sha', 'pusher', 'total_commits', 'commit_shas', 'commit_authors',
80
90
  ],
81
91
  };
82
92
 
@@ -892,15 +892,20 @@ function mapUnit(unit: string): StandardUnit {
892
892
  * processor already handles both forms for point lookups.
893
893
  */
894
894
  async function seedCommitAttribution(detailType: string, detail: any): Promise<void> {
895
- // Only process prism.d1.pr events that carry commit SHAs
896
- if (detailType !== 'prism.d1.pr') return;
895
+ // prism.d1.pr and prism.d1.push both report commits that reached the default
896
+ // branch, so both seed in_main=true. A direct push is as much "shipped" as a
897
+ // merge; excluding it would undercount shipped commits and inflate the
898
+ // cost-per-shipped-commit KPI, while still charging the tokens that produced
899
+ // them. The census fields are identically shaped on the two events, only
900
+ // nested under a different key.
901
+ if (detailType !== 'prism.d1.pr' && detailType !== 'prism.d1.push') return;
897
902
  if (!AI_USAGE_TABLE) return;
898
903
 
899
- const pr = detail.pr as PrDetail | undefined;
900
- const shas = pr?.commit_shas;
904
+ const census = (detailType === 'prism.d1.pr' ? detail.pr : detail.push) as PrDetail | undefined;
905
+ const shas = census?.commit_shas;
901
906
  if (!shas || shas.length === 0) return;
902
907
 
903
- const authors: string[] = (pr as any)?.commit_authors ?? [];
908
+ const authors: string[] = (census as any)?.commit_authors ?? [];
904
909
  const repo = detail.repo as string;
905
910
  // CI workflows now emit fully-qualified repo names (github.com/owner/repo
906
911
  // or gitlab.com/group/project) matching codeburn's convention. No
@@ -1539,7 +1539,7 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
1539
1539
  }
1540
1540
 
1541
1541
  // Scan COMMIT# items in the date range.
1542
- const days = new Map<string, { ai: number; human: number; mergedAi: number; mergedHuman: number }>();
1542
+ const days = new Map<string, { ai: number; human: number; mergedAi: number; mergedHuman: number; costUsd: number }>();
1543
1543
  let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1544
1544
  do {
1545
1545
  const result = await dynamoClient.send(new ScanCommand({
@@ -1561,7 +1561,7 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
1561
1561
  const origin = item.ai_origin?.S ?? 'human';
1562
1562
  const inMain = item.in_main?.BOOL ?? false;
1563
1563
  let bucket = days.get(day);
1564
- if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0 }; days.set(day, bucket); }
1564
+ if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0, costUsd: 0 }; days.set(day, bucket); }
1565
1565
  if (origin === 'ai-generated') {
1566
1566
  bucket.ai++;
1567
1567
  if (inMain) bucket.mergedAi++;
@@ -1573,10 +1573,52 @@ async function handleCommitsDailyQuery(event: HttpApiEvent): Promise<HttpApiResp
1573
1573
  lastKey = result.LastEvaluatedKey;
1574
1574
  } while (lastKey);
1575
1575
 
1576
- // Sort ascending by date and return as an array.
1576
+ // Second pass: fold daily AI spend into the same buckets, so a cost-per-commit
1577
+ // trend can be computed from one source instead of dividing a CloudWatch cost
1578
+ // metric by a CloudWatch commit metric.
1579
+ //
1580
+ // The two passes key on DIFFERENT timestamps by design, and that is the whole
1581
+ // point of serving this from the store. A COMMIT# item is bucketed by its real
1582
+ // commit timestamp and an OTEL_DAY item by the day the usage happened, so both
1583
+ // sides of the ratio land on the day the work actually occurred. The CloudWatch
1584
+ // equivalent buckets by INGEST time, which is why a backfill dumps months of
1585
+ // commits into the week it was pushed and collapses that week's ratio.
1586
+ {
1587
+ let costKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
1588
+ do {
1589
+ const result = await dynamoClient.send(new ScanCommand({
1590
+ TableName: AI_USAGE_TABLE,
1591
+ FilterExpression: 'record_type = :rt AND #day BETWEEN :lo AND :hi',
1592
+ ExpressionAttributeNames: { '#day': 'day' },
1593
+ ExpressionAttributeValues: {
1594
+ ':rt': { S: 'OTEL_DAY' },
1595
+ ':lo': { S: from },
1596
+ ':hi': { S: to },
1597
+ },
1598
+ ProjectionExpression: '#day, cost_usd',
1599
+ ...(costKey ? { ExclusiveStartKey: costKey } : {}),
1600
+ }));
1601
+ for (const item of result.Items ?? []) {
1602
+ const day = item.day?.S;
1603
+ if (!day) continue;
1604
+ const cost = Number(item.cost_usd?.N ?? '0');
1605
+ if (!Number.isFinite(cost)) continue;
1606
+ let bucket = days.get(day);
1607
+ // A day with spend but no commits is a real observation — AI work that
1608
+ // shipped nothing — so it gets a bucket rather than being dropped. The
1609
+ // consumer renders its ratio as null, not as zero.
1610
+ if (!bucket) { bucket = { ai: 0, human: 0, mergedAi: 0, mergedHuman: 0, costUsd: 0 }; days.set(day, bucket); }
1611
+ bucket.costUsd += cost;
1612
+ }
1613
+ costKey = result.LastEvaluatedKey;
1614
+ } while (costKey);
1615
+ }
1616
+
1617
+ // Sort ascending by date and return as an array. Cost is rounded once, here,
1618
+ // so every consumer renders the same figure.
1577
1619
  const sorted = [...days.entries()]
1578
1620
  .sort((a, b) => a[0].localeCompare(b[0]))
1579
- .map(([date, counts]) => ({ date, ...counts }));
1621
+ .map(([date, counts]) => ({ ...counts, date, costUsd: Math.round(counts.costUsd * 100) / 100 }));
1580
1622
 
1581
1623
  return jsonResponse(200, {
1582
1624
  range: { from, to },
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Pre token generation trigger (V2_0) — copies `email` into the ACCESS token.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * Every per-developer number on these dashboards keys on the identity the
7
+ * otel-receiver derives from the caller's JWT, and the commit side of the
8
+ * pipeline keys on the git author email (`metrics-processor.ts` writes
9
+ * `gsi_user: USER#<author email>` from the CI census). For the two to join,
10
+ * the receiver's identity must also be an email.
11
+ *
12
+ * Cognito ID tokens carry `email`. Cognito ACCESS tokens do not — and API
13
+ * Gateway's HttpJwtAuthorizer validates the access token. So with a federated
14
+ * IdP the receiver falls back to `username`, which Cognito prefixes with the
15
+ * provider name (`corpidp_alice`). The same person then appears as two
16
+ * developers: one holding all the token spend and no commits, the other the
17
+ * reverse. On a pool with months of accumulated telemetry, effectively all of
18
+ * the AI-usage history lands on the prefixed identity while commit attribution
19
+ * lands on the email one.
20
+ *
21
+ * Mapping `email` on the identity provider is necessary but NOT sufficient: it
22
+ * populates the user ATTRIBUTE, which never reaches the access token on its
23
+ * own. This trigger is the piece that moves it there.
24
+ *
25
+ * PAIRED WITH `otelIdentityClaim=email`
26
+ *
27
+ * This function is inert by itself. The receiver resolves identity as
28
+ * `[IDENTITY_CLAIM, 'username', 'email', 'sub']`, and IDENTITY_CLAIM defaults
29
+ * to `username` — which is always present, so `email` is unreachable no matter
30
+ * what this trigger adds. Deploy this WITH `-c otelIdentityClaim=email`, or it
31
+ * adds a claim nothing reads.
32
+ *
33
+ * FAIL-OPEN, DELIBERATELY
34
+ *
35
+ * A federated user's `email` is populated by the IdP attribute mapping on
36
+ * sign-in, so a user who has not signed in since the mapping was added has no
37
+ * email yet. Returning the event unmodified in that case lets the receiver's
38
+ * chain fall through to `username` — the pre-existing behaviour — so those users
39
+ * keep writing to their old key until they next sign in, and the migration is
40
+ * self-completing. Throwing here would instead block token issuance and lock
41
+ * them out of sync entirely, turning a cosmetic split into an outage.
42
+ *
43
+ * Requires the Essentials or Plus feature plan; access token customization is
44
+ * unavailable on Lite. Deployed against a pool already on Plus.
45
+ */
46
+
47
+ interface PreTokenGenerationV2Event {
48
+ request?: {
49
+ userAttributes?: Record<string, string>;
50
+ };
51
+ response?: unknown;
52
+ }
53
+
54
+ export async function handler(event: PreTokenGenerationV2Event): Promise<PreTokenGenerationV2Event> {
55
+ const email = event.request?.userAttributes?.email;
56
+
57
+ // Trim before testing: a whitespace-only attribute would satisfy a bare
58
+ // truthiness check here and then be rejected by the receiver's own
59
+ // `v.trim()` guard, silently reverting to `username` with no signal.
60
+ if (typeof email !== 'string' || email.trim() === '') {
61
+ return event;
62
+ }
63
+
64
+ // Lowercased to match the receiver, which lowercases every resolved identity
65
+ // before building `USER#<identity>`. Emitting mixed case here would create a
66
+ // second partition key for the same person the first time an IdP returned a
67
+ // capitalized address.
68
+ const normalized = email.trim().toLowerCase();
69
+
70
+ event.response = {
71
+ claimsAndScopeOverrideDetails: {
72
+ accessTokenGeneration: {
73
+ claimsToAddOrOverride: { email: normalized },
74
+ },
75
+ },
76
+ };
77
+
78
+ return event;
79
+ }
@@ -193,7 +193,7 @@ async function renderDora(fromIso: string, toIso: string, days: number, p: Palet
193
193
  ]);
194
194
 
195
195
  if (deploys.length === 0 && prs.length === 0) {
196
- return emptyState('deploy / PR', 'Populated by prism-ai-metrics.yml on PR merge. Install the workflow and set PRISM_METRICS_ROLE_ARN.', p);
196
+ return emptyState('deploy / PR', 'Populated by prism-ai-metrics.yml on PR merge (and on direct pushes to the default branch, census only). Install the workflow and set PRISM_METRICS_ROLE_ARN.', p);
197
197
  }
198
198
 
199
199
  const mergeFreq = days > 0 ? deploys.length / days : deploys.length;
@@ -246,18 +246,32 @@ function median(values: number[]): number | null {
246
246
  * of who authored it and reports pr.total_commits. Attribution gives us the
247
247
  * sample. The ratio is the coverage.
248
248
  *
249
+ * `pushes` carries the other half of that census. Commits pushed straight to
250
+ * the default branch produce no PR event, so before they were counted they
251
+ * shrank this denominator while codeburn still attributed them -- coverage rose
252
+ * toward 100% exactly as the census got less complete, and the Math.min below
253
+ * hid the overshoot that would otherwise have exposed it. Both event types are
254
+ * summed here; they are kept separate from `prs` everywhere else, because
255
+ * Change Failure Rate divides by prs.length and a push has no title to test.
256
+ *
257
+ * The emitter guarantees these do not overlap: emit-push-census skips any push
258
+ * whose head commit is associated with a PR, which covers merge, squash and
259
+ * rebase strategies alike.
260
+ *
249
261
  * Returns null when CI events are absent, since coverage is unknowable then —
250
262
  * deliberately not 0%, which would imply "measured and found nothing".
251
263
  */
252
- function attributionCoverage(prs: PrismEvent[], attributedCommits: number | null): {
264
+ function attributionCoverage(prs: PrismEvent[], pushes: PrismEvent[], attributedCommits: number | null): {
253
265
  pct: number | null;
254
266
  ciCommits: number;
255
267
  attributed: number | null;
256
268
  } {
257
- const ciCommits = prs.reduce((sum, e) => {
258
- const n = e.data.pr?.total_commits;
259
- return sum + (typeof n === 'number' && n > 0 ? n : 0);
260
- }, 0);
269
+ const countCommits = (events: PrismEvent[], key: 'pr' | 'push') =>
270
+ events.reduce((sum, e) => {
271
+ const n = (e.data as any)[key]?.total_commits;
272
+ return sum + (typeof n === 'number' && n > 0 ? n : 0);
273
+ }, 0);
274
+ const ciCommits = countCommits(prs, 'pr') + countCommits(pushes, 'push');
261
275
  if (ciCommits === 0 || attributedCommits === null) {
262
276
  return { pct: null, ciCommits, attributed: attributedCommits };
263
277
  }
@@ -345,17 +359,18 @@ async function renderRepos(fromIso: string, toIso: string, p: Palette): Promise<
345
359
  }
346
360
 
347
361
  async function renderAidora(fromIso: string, toIso: string, p: Palette): Promise<string> {
348
- const [report, evals, prs] = await Promise.all([
362
+ const [report, evals, prs, pushes] = await Promise.all([
349
363
  fetchProductivity(fromIso, toIso).catch(() => null),
350
364
  queryEvents('prism.d1.eval', fromIso, toIso),
351
365
  queryEvents('prism.d1.pr', fromIso, toIso),
366
+ queryEvents('prism.d1.push', fromIso, toIso),
352
367
  ]);
353
368
 
354
369
  const t = report?.totals;
355
370
  const passRate = evals.length > 0
356
371
  ? (evals.filter(e => e.data.eval?.result === 'PASS').length / evals.length) * 100
357
372
  : null;
358
- const cov = attributionCoverage(prs, t?.commits?.total ?? null);
373
+ const cov = attributionCoverage(prs, pushes, t?.commits?.total ?? null);
359
374
 
360
375
  const cells = [
361
376
  { label: 'AI Share of Commits', value: pct(t?.ratios?.aiSharePct ?? null), note: 'attribution store', color: (t?.ratios?.aiSharePct ?? 0) >= 30 ? p.ok : undefined },
@@ -458,13 +473,14 @@ function computeObservedLevel(input: {
458
473
  // ---- View: exec (business KPIs + delivery proxies + observed level) ----
459
474
 
460
475
  async function renderExec(fromIso: string, toIso: string, days: number, p: Palette): Promise<string> {
461
- const [report, evals, mcp, guardrails, deploys, prs] = await Promise.all([
476
+ const [report, evals, mcp, guardrails, deploys, prs, pushes] = await Promise.all([
462
477
  fetchProductivity(fromIso, toIso).catch(() => null),
463
478
  queryEvents('prism.d1.eval', fromIso, toIso),
464
479
  queryEvents('prism.d1.mcp.tool_call', fromIso, toIso),
465
480
  queryEvents('prism.d1.guardrail', fromIso, toIso),
466
481
  queryEvents('prism.d1.deploy', fromIso, toIso),
467
482
  queryEvents('prism.d1.pr', fromIso, toIso),
483
+ queryEvents('prism.d1.push', fromIso, toIso),
468
484
  ]);
469
485
 
470
486
  const t = report?.totals;
@@ -506,7 +522,7 @@ async function renderExec(fromIso: string, toIso: string, days: number, p: Palet
506
522
  ]), p, 1);
507
523
 
508
524
  // --- Business KPIs (attribution store) ---
509
- const cov = attributionCoverage(prs, t?.commits?.total ?? null);
525
+ const cov = attributionCoverage(prs, pushes, t?.commits?.total ?? null);
510
526
  const businessKpis = kpiRow([
511
527
  { label: 'AI Share of Commits', value: pct(r.aiSharePct ?? null), note: 'L2 >= 30%', color: (r.aiSharePct ?? 0) >= 30 ? p.ok : undefined },
512
528
  { label: 'AI Merge Rate', value: pct(r.mergeRatePct ?? null), note: 'L2 >= 20% · L4 >= 45%', color: (r.mergeRatePct ?? 0) >= 45 ? p.ok : (r.mergeRatePct ?? 0) >= 20 ? p.warn : undefined },
@@ -1632,6 +1648,275 @@ async function renderMergeRateKpi(fromIso: string, toIso: string, p: Palette): P
1632
1648
  `;
1633
1649
  }
1634
1650
 
1651
+ // ---- View: cost-per-commit-kpi (single big-number KPI for $ / shipped commit) ----
1652
+
1653
+ /**
1654
+ * Replaces a CloudWatch `AICostUSD / MergedAICommits` ratio tile.
1655
+ *
1656
+ * Three things were wrong with the metric-math version, and all three are
1657
+ * properties of CloudWatch rather than of the expression:
1658
+ *
1659
+ * 1. CloudWatch rejects datapoints older than two weeks, so a backfill lands
1660
+ * partially outside the ingestion window. Measured on a live 30-day range,
1661
+ * the metric path saw $1,387.61 / 141 commits where the store held
1662
+ * $1,496.24 / 164 — both terms short, in the same direction.
1663
+ * 2. AICostUSD and MergedAICommits carry no user dimension, so the tile stayed
1664
+ * org-wide while the attribution-backed tile beside it scoped to the
1665
+ * dashboard's Developer variable. Same screen, near-identical labels, one
1666
+ * responding to the filter and one not.
1667
+ * 3. The metrics are timestamped at ingest, not at commit time.
1668
+ *
1669
+ * Reading the store fixes all three, and makes this tile agree with the
1670
+ * `$ / Shipped Commit` cells on Team Velocity and the Executive Readout, which
1671
+ * already read the same field.
1672
+ */
1673
+ async function renderCostPerCommitKpi(fromIso: string, toIso: string, p: Palette): Promise<string> {
1674
+ let report: any;
1675
+ try {
1676
+ report = await fetchProductivity(fromIso, toIso);
1677
+ } catch {
1678
+ return emptyState('cost per shipped commit', 'Attribution store unreachable.', p);
1679
+ }
1680
+ const costPer = report?.totals?.ratios?.costPerShippedCommit ?? null;
1681
+ const cost = report?.totals?.usage?.costUsd ?? null;
1682
+ const mergedAi = report?.totals?.commits?.mergedAi ?? 0;
1683
+ // Deliberately uncoloured: unit economics has no org-independent "good"
1684
+ // threshold the way merge rate does, and a red/green here would invent one.
1685
+ const sub = mergedAi > 0 && cost !== null
1686
+ ? `$${num(Math.round(cost))} / ${num(mergedAi)} merged AI commits`
1687
+ : cost !== null
1688
+ ? `$${num(Math.round(cost))} spend · no merged AI commits attributed yet`
1689
+ : 'no attribution data in range';
1690
+ return `
1691
+ <div style="text-align:center;padding:12px">
1692
+ <div style="color:${p.mut};font-size:11px">$ / Shipped Commit</div>
1693
+ <div style="color:${p.fg};font-size:36px;font-weight:700;margin:4px 0">${costPer !== null ? `$${costPer.toFixed(2)}` : '—'}</div>
1694
+ <div style="color:${p.mut};font-size:10px">${sub}</div>
1695
+ </div>
1696
+ `;
1697
+ }
1698
+
1699
+ // ---- View: ai-spend-kpi (single big-number KPI for total AI spend in range) ----
1700
+
1701
+ /**
1702
+ * Replaces a CloudWatch `Sum(AICostUSD)` tile, for the same ingestion-window
1703
+ * reason as the ratio above: the metric sum is short by whatever part of the
1704
+ * range predates CloudWatch's two-week cutoff, so a tile labelled "range"
1705
+ * silently reports less than the range holds.
1706
+ */
1707
+ async function renderAiSpendKpi(fromIso: string, toIso: string, p: Palette): Promise<string> {
1708
+ let report: any;
1709
+ try {
1710
+ report = await fetchProductivity(fromIso, toIso);
1711
+ } catch {
1712
+ return emptyState('AI spend', 'Attribution store unreachable.', p);
1713
+ }
1714
+ const usage = report?.totals?.usage ?? null;
1715
+ const cost = usage?.costUsd ?? null;
1716
+ const calls = usage?.calls ?? 0;
1717
+ const tokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
1718
+ return `
1719
+ <div style="text-align:center;padding:12px">
1720
+ <div style="color:${p.mut};font-size:11px">AI Spend (range)</div>
1721
+ <div style="color:${p.fg};font-size:36px;font-weight:700;margin:4px 0">${cost !== null ? `$${num(Math.round(cost))}` : '—'}</div>
1722
+ <div style="color:${p.mut};font-size:10px">${cost !== null ? `${num(calls)} calls · ${num(Math.round(tokens / 1000))}k tokens` : 'no usage data in range'}</div>
1723
+ </div>
1724
+ `;
1725
+ }
1726
+
1727
+ // ---- View: ai-spend-trend (AI spend over time) ----
1728
+
1729
+ /**
1730
+ * Replaces the CloudWatch `Sum(AICostUSD)` spend graphs.
1731
+ *
1732
+ * Same reason as the KPI tiles: CloudWatch drops datapoints older than two
1733
+ * weeks, so a graph titled "spend trend" showed a flat or absent left-hand side
1734
+ * for any range longer than that — not because spend was low then, but because
1735
+ * the datapoints were refused at ingest. Reading OTEL_DAY rollups gives the real
1736
+ * curve over full history, and puts this graph on the same source as the
1737
+ * cost-per-commit trend beside it so the two can be read together.
1738
+ */
1739
+ async function renderAiSpendTrend(fromIso: string, toIso: string, days: number, p: Palette): Promise<string> {
1740
+ let data: any;
1741
+ try {
1742
+ data = await invokeReceiver('/v1/commits-daily', `from=${fromIso.slice(0, 10)}&to=${toIso.slice(0, 10)}`);
1743
+ } catch (err) {
1744
+ return emptyState('AI spend trend', `Attribution store unreachable (${(err as Error).message}).`, p);
1745
+ }
1746
+
1747
+ const dayBuckets: Array<{ date: string; costUsd: number }> = data?.days ?? [];
1748
+ if (dayBuckets.length === 0) {
1749
+ return emptyState('AI spend trend', 'No usage data in range.', p);
1750
+ }
1751
+
1752
+ const useWeekly = days > 21;
1753
+ let buckets: Array<{ label: string; costUsd: number }>;
1754
+ if (useWeekly) {
1755
+ const weeks = new Map<string, { label: string; costUsd: number }>();
1756
+ for (const d of dayBuckets) {
1757
+ const dt = new Date(d.date);
1758
+ const weekStart = new Date(dt);
1759
+ weekStart.setDate(dt.getDate() - dt.getDay());
1760
+ const key = weekStart.toISOString().slice(0, 10);
1761
+ let w = weeks.get(key);
1762
+ if (!w) { w = { label: key, costUsd: 0 }; weeks.set(key, w); }
1763
+ w.costUsd += d.costUsd ?? 0;
1764
+ }
1765
+ buckets = [...weeks.values()].sort((a, b) => a.label.localeCompare(b.label));
1766
+ } else {
1767
+ buckets = dayBuckets.map(d => ({ label: d.date, costUsd: d.costUsd ?? 0 }));
1768
+ }
1769
+
1770
+ const total = buckets.reduce((s, b) => s + b.costUsd, 0);
1771
+ const maxC = Math.max(...buckets.map(b => b.costUsd), 0);
1772
+ if (maxC === 0) {
1773
+ return emptyState('AI spend trend', 'No AI spend recorded in range.', p);
1774
+ }
1775
+
1776
+ // Bars rather than a line: spend is a per-bucket quantity, and a line implies
1777
+ // interpolation between buckets that a sum does not have.
1778
+ const chartW = 500;
1779
+ const chartH = 100;
1780
+ const slot = chartW / buckets.length;
1781
+ const barW = Math.max(2, slot * 0.7);
1782
+ const bars = buckets.map((b, i) => {
1783
+ const h = (b.costUsd / maxC) * (chartH - 10);
1784
+ return `<rect x="${i * slot + (slot - barW) / 2}" y="${chartH - h}" width="${barW}" height="${h}" fill="${p.accent}" opacity="0.85" />`;
1785
+ }).join('');
1786
+
1787
+ return `
1788
+ <div style="padding:8px 12px">
1789
+ <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px">
1790
+ <span style="color:${p.mut};font-size:11px">AI Spend (${useWeekly ? 'weekly' : 'daily'})</span>
1791
+ <span style="color:${p.fg};font-size:13px;font-weight:600">$${num(Math.round(total))} total</span>
1792
+ </div>
1793
+ <svg viewBox="0 0 ${chartW} ${chartH + 16}" style="width:100%;height:auto">
1794
+ ${bars}
1795
+ <text x="2" y="${chartH + 13}" fill="${p.mut}" font-size="9">${esc(buckets[0]!.label)}</text>
1796
+ <text x="${chartW - 2}" y="${chartH + 13}" fill="${p.mut}" font-size="9" text-anchor="end">${esc(buckets[buckets.length - 1]!.label)}</text>
1797
+ </svg>
1798
+ <div style="color:${p.mut};font-size:10px;margin-top:2px">peak $${maxC.toFixed(2)} per ${useWeekly ? 'week' : 'day'} · attribution store, full history</div>
1799
+ </div>
1800
+ `;
1801
+ }
1802
+
1803
+ // ---- View: cost-per-commit-trend (cost per shipped commit over time) ----
1804
+
1805
+ /**
1806
+ * Replaces the CloudWatch "Cost per Shipped Commit (Weekly)" graph.
1807
+ *
1808
+ * That graph divided two ingest-timestamped metrics, which made it report
1809
+ * efficiency changes that never happened. Measured on a live 30-day range the
1810
+ * day attribution was first backfilled: the two earlier weeks plotted as gaps
1811
+ * (spend, zero commits) and the current week read $0.55, because 141 commits
1812
+ * spanning months were all stamped with their ingest time and divided into that
1813
+ * one week's $77.33 of spend. The graph's premise — "spend can rise while this
1814
+ * falls, which means AI is getting more efficient" — inverts under that
1815
+ * artifact, so a reader draws exactly the wrong conclusion.
1816
+ *
1817
+ * Bucketing both terms by when the work happened is what makes the trend mean
1818
+ * what its title says. A bucket with spend but no merged commits renders as a
1819
+ * gap rather than as zero: no commits shipped is not a cost of zero per commit,
1820
+ * and FILL(..., 0) on the metric version drew it as a floor.
1821
+ */
1822
+ async function renderCostPerCommitTrend(fromIso: string, toIso: string, days: number, p: Palette): Promise<string> {
1823
+ let data: any;
1824
+ try {
1825
+ data = await invokeReceiver('/v1/commits-daily', `from=${fromIso.slice(0, 10)}&to=${toIso.slice(0, 10)}`);
1826
+ } catch (err) {
1827
+ return emptyState('cost per commit trend', `Attribution store unreachable (${(err as Error).message}).`, p);
1828
+ }
1829
+
1830
+ const dayBuckets: Array<{ date: string; mergedAi: number; costUsd: number }> = data?.days ?? [];
1831
+ if (dayBuckets.length === 0) {
1832
+ return emptyState('cost per commit trend', 'No commit or usage data in range.', p);
1833
+ }
1834
+
1835
+ // Same weekly/daily threshold as renderMergeRateTrend, so the two trends on a
1836
+ // dashboard share an x-axis granularity.
1837
+ const useWeekly = days > 21;
1838
+ type Bucket = { label: string; mergedAi: number; costUsd: number };
1839
+ let buckets: Bucket[];
1840
+ if (useWeekly) {
1841
+ const weeks = new Map<string, Bucket>();
1842
+ for (const d of dayBuckets) {
1843
+ const dt = new Date(d.date);
1844
+ const weekStart = new Date(dt);
1845
+ weekStart.setDate(dt.getDate() - dt.getDay());
1846
+ const key = weekStart.toISOString().slice(0, 10);
1847
+ let w = weeks.get(key);
1848
+ if (!w) { w = { label: key, mergedAi: 0, costUsd: 0 }; weeks.set(key, w); }
1849
+ w.mergedAi += d.mergedAi ?? 0;
1850
+ w.costUsd += d.costUsd ?? 0;
1851
+ }
1852
+ buckets = [...weeks.values()].sort((a, b) => a.label.localeCompare(b.label));
1853
+ } else {
1854
+ buckets = dayBuckets.map(d => ({ label: d.date, mergedAi: d.mergedAi ?? 0, costUsd: d.costUsd ?? 0 }));
1855
+ }
1856
+
1857
+ const ratios = buckets.map(b => (b.mergedAi > 0 ? b.costUsd / b.mergedAi : null));
1858
+ const present = ratios.filter((r): r is number => r !== null);
1859
+ if (present.length === 0) {
1860
+ const spend = buckets.reduce((s, b) => s + b.costUsd, 0);
1861
+ return emptyState(
1862
+ 'cost per commit trend',
1863
+ spend > 0
1864
+ ? `$${num(Math.round(spend))} of AI spend in range with no merged AI commits attributed — run \`codeburn sync push --attribution\`.`
1865
+ : 'No AI spend or merged commits in range.',
1866
+ p,
1867
+ );
1868
+ }
1869
+
1870
+ const chartW = 500;
1871
+ const chartH = 100;
1872
+ const step = buckets.length > 1 ? (chartW - 20) / (buckets.length - 1) : 0;
1873
+ const maxR = Math.max(...present);
1874
+ const yOf = (r: number): number => chartH - (maxR > 0 ? (r / maxR) * (chartH - 10) : 0);
1875
+
1876
+ // Gaps break the polyline into segments instead of interpolating across a
1877
+ // bucket that shipped nothing, which would draw a slope the data does not have.
1878
+ const segments: string[] = [];
1879
+ let current: string[] = [];
1880
+ ratios.forEach((r, i) => {
1881
+ if (r === null) {
1882
+ if (current.length > 1) segments.push(current.join(' '));
1883
+ current = [];
1884
+ return;
1885
+ }
1886
+ current.push(`${10 + i * step},${yOf(r)}`);
1887
+ });
1888
+ if (current.length > 1) segments.push(current.join(' '));
1889
+
1890
+ const dots = ratios.map((r, i) => r === null ? '' :
1891
+ `<circle cx="${10 + i * step}" cy="${yOf(r)}" r="2.5" fill="${p.accent}" />`).join('');
1892
+ const lines = segments.map(s =>
1893
+ `<polyline points="${s}" fill="none" stroke="${p.accent}" stroke-width="2" />`).join('');
1894
+
1895
+ const first = present[0];
1896
+ const last = present[present.length - 1];
1897
+ const delta = first > 0 ? ((last - first) / first) * 100 : null;
1898
+ const gaps = ratios.filter(r => r === null).length;
1899
+
1900
+ return `
1901
+ <div style="padding:8px 12px">
1902
+ <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px">
1903
+ <span style="color:${p.mut};font-size:11px">Cost per Shipped Commit (${useWeekly ? 'weekly' : 'daily'})</span>
1904
+ <span style="color:${p.fg};font-size:13px;font-weight:600">$${last.toFixed(2)}${
1905
+ delta !== null ? ` <span style="color:${delta <= 0 ? p.ok : p.warn};font-size:11px">${delta <= 0 ? '▼' : '▲'} ${Math.abs(delta).toFixed(0)}%</span>` : ''
1906
+ }</span>
1907
+ </div>
1908
+ <svg viewBox="0 0 ${chartW} ${chartH + 16}" style="width:100%;height:auto">
1909
+ ${lines}${dots}
1910
+ <text x="10" y="${chartH + 13}" fill="${p.mut}" font-size="9">${esc(buckets[0]!.label)}</text>
1911
+ <text x="${chartW - 10}" y="${chartH + 13}" fill="${p.mut}" font-size="9" text-anchor="end">${esc(buckets[buckets.length - 1]!.label)}</text>
1912
+ </svg>
1913
+ <div style="color:${p.mut};font-size:10px;margin-top:2px">
1914
+ peak $${maxR.toFixed(2)} · attribution store, bucketed by commit date${gaps > 0 ? ` · ${gaps} ${useWeekly ? 'week' : 'day'}(s) with spend but no merged AI commits shown as gaps` : ''}
1915
+ </div>
1916
+ </div>
1917
+ `;
1918
+ }
1919
+
1635
1920
  // ---- View: merge-rate-trend (line chart of merge rate over time) ----
1636
1921
 
1637
1922
  async function renderMergeRateTrend(fromIso: string, toIso: string, days: number, p: Palette): Promise<string> {
@@ -1738,6 +2023,10 @@ export async function handler(event: WidgetEvent): Promise<string> {
1738
2023
  case 'commits-bar': body = await renderCommitsBar(fromIso, toIso, days, p); break;
1739
2024
  case 'ai-share-kpi': body = await renderAiShareKpi(fromIso, toIso, p); break;
1740
2025
  case 'merge-rate-kpi': body = await renderMergeRateKpi(fromIso, toIso, p); break;
2026
+ case 'cost-per-commit-kpi': body = await renderCostPerCommitKpi(fromIso, toIso, p); break;
2027
+ case 'ai-spend-kpi': body = await renderAiSpendKpi(fromIso, toIso, p); break;
2028
+ case 'cost-per-commit-trend': body = await renderCostPerCommitTrend(fromIso, toIso, days, p); break;
2029
+ case 'ai-spend-trend': body = await renderAiSpendTrend(fromIso, toIso, days, p); break;
1741
2030
  case 'merge-rate-trend': body = await renderMergeRateTrend(fromIso, toIso, days, p); break;
1742
2031
  default: body = `<div style="color:${p.danger}">Unknown view: ${esc(view)}</div>`;
1743
2032
  }
@@ -288,6 +288,7 @@ export class MetricsPipelineStack extends cdk.Stack {
288
288
  const detailTypes = [
289
289
  'prism.d1.commit',
290
290
  'prism.d1.pr',
291
+ 'prism.d1.push',
291
292
  'prism.d1.deploy',
292
293
  'prism.d1.eval',
293
294
  'prism.d1.incident',
@@ -354,6 +355,13 @@ export class MetricsPipelineStack extends cdk.Stack {
354
355
  // OTEL Collector (on by default — server side of `codeburn sync`)
355
356
  // Skip with: npx cdk deploy --context skipOtelCollector=true
356
357
  // BYO IdP: -c otelIssuer=... -c otelClientId=... [-c otelIdentityClaim=email]
358
+ // Federated IdP behind Cognito (keeps the M2M agent client):
359
+ // -c otelIdpName=CorpIdP
360
+ // -c otelIdpIssuer=https://idp.example.com
361
+ // -c otelIdpClientId=...
362
+ // -c otelIdpClientSecretArn=<secretsmanager name or arn>
363
+ // [-c otelIdpClientSecretJsonKey=clientSecret]
364
+ // [-c otelIdpScopes='openid email'] (default: openid email)
357
365
  // Per-user AI usage flows directly from codeburn sync to the
358
366
  // ai-usage table (spans + daily aggregates).
359
367
  // -------------------------------------------------------
@@ -365,6 +373,12 @@ export class MetricsPipelineStack extends cdk.Stack {
365
373
  externalIssuer: this.node.tryGetContext('otelIssuer') as string | undefined,
366
374
  externalClientId: this.node.tryGetContext('otelClientId') as string | undefined,
367
375
  identityClaim: this.node.tryGetContext('otelIdentityClaim') as string | undefined,
376
+ idpName: this.node.tryGetContext('otelIdpName') as string | undefined,
377
+ idpIssuer: this.node.tryGetContext('otelIdpIssuer') as string | undefined,
378
+ idpClientId: this.node.tryGetContext('otelIdpClientId') as string | undefined,
379
+ idpClientSecretArn: this.node.tryGetContext('otelIdpClientSecretArn') as string | undefined,
380
+ idpClientSecretJsonKey: this.node.tryGetContext('otelIdpClientSecretJsonKey') as string | undefined,
381
+ idpScopes: this.node.tryGetContext('otelIdpScopes') as string | undefined,
368
382
  });
369
383
 
370
384
  // ---------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-d1-velocity-infra",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "PRISM D1 Velocity - AWS CDK Infrastructure for AI-DORA Metrics Platform",
5
5
  "scripts": {
6
6
  "build": "tsc",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prism-d1/cli",
3
- "version": "1.0.98",
3
+ "version": "1.1.2",
4
4
  "description": "PRISM D1 Velocity CLI — AI development lifecycle assessment tool",
5
5
  "author": "andklee",
6
6
  "engines": {