@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.
- package/dist/assets/github-workflows/prism-ai-metrics.yml +172 -1
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +216 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +47 -45
- package/dist/assets/infra/lib/lambda/event-schema.ts +11 -1
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +10 -5
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +46 -4
- package/dist/assets/infra/lib/lambda/pre-token-generation.ts +79 -0
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +299 -10
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +14 -0
- package/dist/assets/infra/package.json +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
new cloudwatch.CustomWidget({
|
|
219
|
+
functionArn: props?.velocityWidgetArn ?? '',
|
|
220
|
+
title: 'AI Spend Trend',
|
|
221
221
|
width: 8,
|
|
222
222
|
height: 6,
|
|
223
|
-
|
|
223
|
+
params: { view: 'ai-spend-trend' },
|
|
224
|
+
updateOnRefresh: true,
|
|
225
|
+
updateOnResize: false,
|
|
226
|
+
updateOnTimeRangeChange: true,
|
|
224
227
|
}),
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
896
|
-
|
|
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
|
|
900
|
-
const 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[] = (
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
// ---------------------------------------------------
|