@prism-d1/cli 1.0.99 → 1.2.0
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-agent-eval.yml +2 -1
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +11 -1
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +8 -0
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +312 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +74 -45
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +324 -12
- package/dist/assets/infra/lib/lambda/pre-token-generation.ts +79 -0
- package/dist/assets/infra/lib/lambda/pricing/litellm-snapshot.json +3769 -0
- package/dist/assets/infra/lib/lambda/pricing/price-source.ts +56 -0
- package/dist/assets/infra/lib/lambda/pricing/price-table.ts +240 -0
- package/dist/assets/infra/lib/lambda/pricing/prices-key.ts +6 -0
- package/dist/assets/infra/lib/lambda/pricing/reprice.ts +108 -0
- package/dist/assets/infra/lib/lambda/pricing-refresh.ts +352 -0
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +397 -1
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +13 -0
- package/dist/assets/infra/package.json +5 -2
- package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
- package/dist/src/codewatch/config.d.ts +17 -0
- package/dist/src/codewatch/config.d.ts.map +1 -0
- package/dist/src/codewatch/config.js +40 -0
- package/dist/src/codewatch/config.js.map +1 -0
- package/dist/src/codewatch/gitleaks.d.ts +49 -0
- package/dist/src/codewatch/gitleaks.d.ts.map +1 -0
- package/dist/src/codewatch/gitleaks.js +160 -0
- package/dist/src/codewatch/gitleaks.js.map +1 -0
- package/dist/src/codewatch/globalHooks.d.ts +52 -0
- package/dist/src/codewatch/globalHooks.d.ts.map +1 -0
- package/dist/src/codewatch/globalHooks.js +235 -0
- package/dist/src/codewatch/globalHooks.js.map +1 -0
- package/dist/src/codewatch/hooks.d.ts +38 -0
- package/dist/src/codewatch/hooks.d.ts.map +1 -0
- package/dist/src/codewatch/hooks.js +176 -0
- package/dist/src/codewatch/hooks.js.map +1 -0
- package/dist/src/codewatch/prepush.d.ts +37 -0
- package/dist/src/codewatch/prepush.d.ts.map +1 -0
- package/dist/src/codewatch/prepush.js +231 -0
- package/dist/src/codewatch/prepush.js.map +1 -0
- package/dist/src/codewatch/results.d.ts +90 -0
- package/dist/src/codewatch/results.d.ts.map +1 -0
- package/dist/src/codewatch/results.js +121 -0
- package/dist/src/codewatch/results.js.map +1 -0
- package/dist/src/codewatch/reviewers/claude.d.ts +9 -0
- package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/claude.js +48 -0
- package/dist/src/codewatch/reviewers/claude.js.map +1 -0
- package/dist/src/codewatch/reviewers/index.d.ts +39 -0
- package/dist/src/codewatch/reviewers/index.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/index.js +97 -0
- package/dist/src/codewatch/reviewers/index.js.map +1 -0
- package/dist/src/codewatch/reviewers/kiro.d.ts +14 -0
- package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/kiro.js +32 -0
- package/dist/src/codewatch/reviewers/kiro.js.map +1 -0
- package/dist/src/codewatch/reviewers/types.d.ts +56 -0
- package/dist/src/codewatch/reviewers/types.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/types.js +91 -0
- package/dist/src/codewatch/reviewers/types.js.map +1 -0
- package/dist/src/codewatch/rules.d.ts +12 -0
- package/dist/src/codewatch/rules.d.ts.map +1 -0
- package/dist/src/codewatch/rules.js +27 -0
- package/dist/src/codewatch/rules.js.map +1 -0
- package/dist/src/commands/_top/dashboard.d.ts +17 -0
- package/dist/src/commands/_top/dashboard.d.ts.map +1 -0
- package/dist/src/commands/_top/dashboard.js +76 -0
- package/dist/src/commands/_top/dashboard.js.map +1 -0
- package/dist/src/commands/bedrock-protection/scan-account.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-account.js +2 -1
- package/dist/src/commands/bedrock-protection/scan-account.js.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-org.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-org.js +16 -4
- package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.js +15 -48
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +13 -0
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.js +28 -147
- package/dist/src/commands/bootstrapper/install-git-hooks.js.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.js +5 -39
- package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
- package/dist/src/commands/codewatch/pre-commit.d.ts +7 -0
- package/dist/src/commands/codewatch/pre-commit.d.ts.map +1 -0
- package/dist/src/commands/codewatch/pre-commit.js +30 -0
- package/dist/src/commands/codewatch/pre-commit.js.map +1 -0
- package/dist/src/commands/codewatch/pre-push.d.ts +16 -0
- package/dist/src/commands/codewatch/pre-push.d.ts.map +1 -0
- package/dist/src/commands/codewatch/pre-push.js +32 -0
- package/dist/src/commands/codewatch/pre-push.js.map +1 -0
- package/dist/src/commands/codewatch/report.d.ts +22 -0
- package/dist/src/commands/codewatch/report.d.ts.map +1 -0
- package/dist/src/commands/codewatch/report.js +64 -0
- package/dist/src/commands/codewatch/report.js.map +1 -0
- package/dist/src/commands/enable/codewatch.d.ts +15 -0
- package/dist/src/commands/enable/codewatch.d.ts.map +1 -0
- package/dist/src/commands/enable/codewatch.js +186 -0
- package/dist/src/commands/enable/codewatch.js.map +1 -0
- package/dist/src/commands/git/claude-session-context.d.ts.map +1 -1
- package/dist/src/commands/git/claude-session-context.js +2 -1
- package/dist/src/commands/git/claude-session-context.js.map +1 -1
- package/dist/src/commands/git/commit-trailers.js +1 -1
- package/dist/src/commands/git/commit-trailers.js.map +1 -1
- package/dist/src/commands/workshop/verify-setup.d.ts.map +1 -1
- package/dist/src/commands/workshop/verify-setup.js +5 -3
- package/dist/src/commands/workshop/verify-setup.js.map +1 -1
- package/dist/src/dashboard/codewatch.d.ts +106 -0
- package/dist/src/dashboard/codewatch.d.ts.map +1 -0
- package/dist/src/dashboard/codewatch.js +119 -0
- package/dist/src/dashboard/codewatch.js.map +1 -0
- package/dist/src/dashboard/commits.d.ts +96 -0
- package/dist/src/dashboard/commits.d.ts.map +1 -0
- package/dist/src/dashboard/commits.js +207 -0
- package/dist/src/dashboard/commits.js.map +1 -0
- package/dist/src/dashboard/page.d.ts +10 -0
- package/dist/src/dashboard/page.d.ts.map +1 -0
- package/dist/src/dashboard/page.js +315 -0
- package/dist/src/dashboard/page.js.map +1 -0
- package/dist/src/dashboard/payload.d.ts +111 -0
- package/dist/src/dashboard/payload.d.ts.map +1 -0
- package/dist/src/dashboard/payload.js +93 -0
- package/dist/src/dashboard/payload.js.map +1 -0
- package/dist/src/dashboard/report.d.ts +99 -0
- package/dist/src/dashboard/report.d.ts.map +1 -0
- package/dist/src/dashboard/report.js +300 -0
- package/dist/src/dashboard/report.js.map +1 -0
- package/dist/src/dashboard/repos.d.ts +58 -0
- package/dist/src/dashboard/repos.d.ts.map +1 -0
- package/dist/src/dashboard/repos.js +111 -0
- package/dist/src/dashboard/repos.js.map +1 -0
- package/dist/src/dashboard/server.d.ts +52 -0
- package/dist/src/dashboard/server.d.ts.map +1 -0
- package/dist/src/dashboard/server.js +163 -0
- package/dist/src/dashboard/server.js.map +1 -0
- package/dist/src/dashboard/setup.d.ts +52 -0
- package/dist/src/dashboard/setup.d.ts.map +1 -0
- package/dist/src/dashboard/setup.js +129 -0
- package/dist/src/dashboard/setup.js.map +1 -0
- package/dist/src/dashboard/usage.d.ts +43 -0
- package/dist/src/dashboard/usage.d.ts.map +1 -0
- package/dist/src/dashboard/usage.js +88 -0
- package/dist/src/dashboard/usage.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +40 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/utils/audit-bedrock.d.ts +1 -0
- package/dist/src/utils/audit-bedrock.d.ts.map +1 -1
- package/dist/src/utils/audit-bedrock.js +133 -0
- package/dist/src/utils/audit-bedrock.js.map +1 -1
- package/dist/src/utils/audit-org.d.ts +1 -0
- package/dist/src/utils/audit-org.d.ts.map +1 -1
- package/dist/src/utils/audit-org.js +155 -0
- package/dist/src/utils/audit-org.js.map +1 -1
- package/dist/src/utils/otel-sync-schedule.d.ts +22 -0
- package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
- package/dist/src/utils/otel-sync-schedule.js +68 -0
- package/dist/src/utils/otel-sync-schedule.js.map +1 -0
- package/dist/src/utils/tool-check.d.ts +14 -0
- package/dist/src/utils/tool-check.d.ts.map +1 -0
- package/dist/src/utils/tool-check.js +42 -0
- package/dist/src/utils/tool-check.js.map +1 -0
- package/package.json +3 -2
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
# - OIDC provider configured for GitHub Actions in your AWS account
|
|
7
7
|
# - IAM role with bedrock:InvokeModel and events:PutEvents permissions
|
|
8
8
|
# - Repository secret PRISM_METRICS_ROLE_ARN set to the OIDC role ARN
|
|
9
|
-
# - .prism/config.json committed to the repo
|
|
9
|
+
# - .prism/config.json committed to the repo with a "team_id" key; add it by hand, e.g.
|
|
10
|
+
# {"team_id": "team-payments"} (prism-cli enable codewatch keeps existing keys in this file)
|
|
10
11
|
# - .prism/eval-harness/ installed (run: bash prism-cli bootstrapper install-eval-harness)
|
|
11
12
|
# - (Optional) Agent entry point supports --mock flag for dry-run execution
|
|
12
13
|
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
#
|
|
5
5
|
# Required setup:
|
|
6
6
|
# - Kiro API key stored as repository secret: KIRO_API_KEY
|
|
7
|
-
# - .
|
|
7
|
+
# - .prism/review/rules.md (shared with the local pre-push review) or
|
|
8
|
+
# .kiro/steering/code-review.md in your repo (review rules)
|
|
8
9
|
# - (Optional) PRISM_METRICS_ROLE_ARN for EventBridge metrics emission
|
|
9
10
|
# - (Optional) AWS Continuum configured for security scanning
|
|
10
11
|
#
|
|
@@ -236,6 +237,15 @@ jobs:
|
|
|
236
237
|
exit 0
|
|
237
238
|
fi
|
|
238
239
|
|
|
240
|
+
# Shared rule set: .prism/review/rules.md is the one source for this
|
|
241
|
+
# gate and the local pre-push review (prism-cli enable security).
|
|
242
|
+
# kiro-cli loads rules from .kiro/steering/, so sync it in for this run.
|
|
243
|
+
if [ -f .prism/review/rules.md ]; then
|
|
244
|
+
mkdir -p .kiro/steering
|
|
245
|
+
cp .prism/review/rules.md .kiro/steering/code-review.md
|
|
246
|
+
echo "Review rules: .prism/review/rules.md"
|
|
247
|
+
fi
|
|
248
|
+
|
|
239
249
|
# Get all changed source files in this PR
|
|
240
250
|
FILES=$(git diff --name-only "${BASE_SHA}..${HEAD_SHA}" \
|
|
241
251
|
| grep -E '\.(ts|js|py|go|java|rs|tsx|jsx)$' \
|
|
@@ -186,6 +186,14 @@ prism-eval-gate:
|
|
|
186
186
|
elif [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
|
|
187
187
|
echo "⚠️ Missing base/head SHA — review skipped."
|
|
188
188
|
else
|
|
189
|
+
# Shared rule set (also used by the local pre-push review). kiro-cli
|
|
190
|
+
# loads rules from .kiro/steering/, so sync it in for this run.
|
|
191
|
+
if [ -f .prism/review/rules.md ]; then
|
|
192
|
+
mkdir -p .kiro/steering
|
|
193
|
+
cp .prism/review/rules.md .kiro/steering/code-review.md
|
|
194
|
+
echo "Review rules: .prism/review/rules.md"
|
|
195
|
+
fi
|
|
196
|
+
|
|
189
197
|
FILES=$(git diff --name-only "${BASE_SHA}..${HEAD_SHA}" \
|
|
190
198
|
| grep -E '\.(ts|js|py|go|java|rs|tsx|jsx)$' \
|
|
191
199
|
| grep -vE '\.test\.|\.spec\.|__test__|_test\.' \
|
|
@@ -24,6 +24,9 @@ import * as cognito from 'aws-cdk-lib/aws-cognito';
|
|
|
24
24
|
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
25
25
|
import * as kms from 'aws-cdk-lib/aws-kms';
|
|
26
26
|
import * as logs from 'aws-cdk-lib/aws-logs';
|
|
27
|
+
import * as events from 'aws-cdk-lib/aws-events';
|
|
28
|
+
import * as targets from 'aws-cdk-lib/aws-events-targets';
|
|
29
|
+
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
27
30
|
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
|
28
31
|
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
29
32
|
import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
|
|
@@ -32,6 +35,7 @@ import * as ssm from 'aws-cdk-lib/aws-ssm';
|
|
|
32
35
|
import * as path from 'path';
|
|
33
36
|
import { Construct } from 'constructs';
|
|
34
37
|
import { NagSuppressions } from 'cdk-nag';
|
|
38
|
+
import { PRICES_KEY } from '../lambda/pricing/prices-key';
|
|
35
39
|
|
|
36
40
|
/** codeburn sync fixed loopback callback ports (see codeburn src/sync/auth.ts). */
|
|
37
41
|
const CODEBURN_CALLBACK_PORTS = [19876, 19877, 19878];
|
|
@@ -49,6 +53,57 @@ export interface OtelCollectorConstructProps {
|
|
|
49
53
|
externalClientId?: string;
|
|
50
54
|
/** JWT claim used as the user identity key (default: username for Cognito, sub for BYO). */
|
|
51
55
|
identityClaim?: string;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Federated IdP on the Cognito pool (Okta, Entra ID, any OIDC provider).
|
|
59
|
+
//
|
|
60
|
+
// Distinct from the BYO-IdP props above. Those REPLACE Cognito as the token
|
|
61
|
+
// issuer and drop the machine-to-machine client with it. These keep Cognito as
|
|
62
|
+
// the issuer and add an upstream provider behind it, so the coding agent's
|
|
63
|
+
// client-credentials path survives.
|
|
64
|
+
//
|
|
65
|
+
// All five must be supplied together; a partial set throws rather than
|
|
66
|
+
// half-configuring an IdP that fails at first login.
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** Provider name, e.g. 'CorpIdP'. Becomes the `<name>_` username prefix. */
|
|
70
|
+
idpName?: string;
|
|
71
|
+
/** OIDC issuer URL, e.g. https://idp.example.com */
|
|
72
|
+
idpIssuer?: string;
|
|
73
|
+
/** OAuth client id registered with the IdP for this user pool. */
|
|
74
|
+
idpClientId?: string;
|
|
75
|
+
/**
|
|
76
|
+
* ARN or name of a Secrets Manager secret holding the IdP client secret.
|
|
77
|
+
*
|
|
78
|
+
* A REFERENCE, never the value: the secret is resolved by a CloudFormation
|
|
79
|
+
* dynamic reference at deploy time, so the plaintext is never written to the
|
|
80
|
+
* template, CloudFormation state, or CI logs. Passing the secret itself
|
|
81
|
+
* through CDK context would put it in `cdk.context.json` and the synthesized
|
|
82
|
+
* template, which is the antipattern the reference exists to avoid.
|
|
83
|
+
*
|
|
84
|
+
* Provide either a plain-text secret whose whole value is the client secret,
|
|
85
|
+
* or a JSON secret with a `clientSecret` key (set idpClientSecretJsonKey).
|
|
86
|
+
*
|
|
87
|
+
* CAVEAT: CloudFormation resolves a dynamic reference only when the resource
|
|
88
|
+
* containing it is created or updated. Rotating the secret in Secrets Manager
|
|
89
|
+
* does NOT reach Cognito on its own — the IdP resource must change too.
|
|
90
|
+
*/
|
|
91
|
+
idpClientSecretArn?: string;
|
|
92
|
+
/** JSON key inside the secret holding the client secret. Omit for a plain-text secret. */
|
|
93
|
+
idpClientSecretJsonKey?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Scopes Cognito requests FROM the IdP. Defaults to 'openid email'.
|
|
96
|
+
*
|
|
97
|
+
* `email` is load-bearing and the default exists because of it: without it the
|
|
98
|
+
* IdP returns no email claim, Cognito stores `email: null` on every federated
|
|
99
|
+
* user, and the identity falls back to the `<provider>_<alias>` username —
|
|
100
|
+
* which never matches the git author emails the CI census emits, silently
|
|
101
|
+
* splitting each developer into two identities on the dashboards.
|
|
102
|
+
*
|
|
103
|
+
* NOT the same as the app client's AllowedOAuthScopes, which governs
|
|
104
|
+
* app→Cognito rather than Cognito→IdP.
|
|
105
|
+
*/
|
|
106
|
+
idpScopes?: string;
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
export class OtelCollectorConstruct extends Construct {
|
|
@@ -117,6 +172,84 @@ export class OtelCollectorConstruct extends Construct {
|
|
|
117
172
|
cognitoDomain: { domainPrefix },
|
|
118
173
|
});
|
|
119
174
|
|
|
175
|
+
// -----------------------------------------------------------------
|
|
176
|
+
// Optional federated IdP in front of the pool.
|
|
177
|
+
//
|
|
178
|
+
// Created BEFORE the app client so the client's
|
|
179
|
+
// supportedIdentityProviders can reference it; CloudFormation rejects a
|
|
180
|
+
// client naming a provider that does not exist yet, and addDependency
|
|
181
|
+
// below makes that ordering explicit rather than incidental.
|
|
182
|
+
// -----------------------------------------------------------------
|
|
183
|
+
const idpFields = [props.idpName, props.idpIssuer, props.idpClientId, props.idpClientSecretArn];
|
|
184
|
+
const idpConfigured = idpFields.some((f) => f !== undefined);
|
|
185
|
+
if (idpConfigured && idpFields.some((f) => f === undefined)) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'Federated IdP requires idpName, idpIssuer, idpClientId and idpClientSecretArn together '
|
|
188
|
+
+ '(context: -c otelIdpName=... -c otelIdpIssuer=... -c otelIdpClientId=... -c otelIdpClientSecretArn=...)',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let federatedIdp: cognito.CfnUserPoolIdentityProvider | undefined;
|
|
193
|
+
if (idpConfigured) {
|
|
194
|
+
// Build the dynamic reference as a LITERAL string.
|
|
195
|
+
//
|
|
196
|
+
// The obvious CDK spelling — Secret.fromSecretNameV2(...).secretValueFromJson(k)
|
|
197
|
+
// .unsafeUnwrap() — composes the ARN with `Ref: AWS::Partition`, so the
|
|
198
|
+
// property renders as an Fn::Join whose OUTPUT happens to be a
|
|
199
|
+
// {{resolve:...}} string. CloudFormation resolves dynamic references
|
|
200
|
+
// during template processing and does not reliably resolve one produced
|
|
201
|
+
// by an intrinsic; the risk is Cognito storing the literal
|
|
202
|
+
// "{{resolve:secretsmanager:...}}" as the client secret, which fails at
|
|
203
|
+
// first federated login with an upstream "invalid_client" and no failed
|
|
204
|
+
// resource to point at. Verified by synthesizing both forms.
|
|
205
|
+
//
|
|
206
|
+
// The secret id comes from CDK context, so it is a plain string and
|
|
207
|
+
// interpolation yields a literal with no intrinsic. Secrets Manager
|
|
208
|
+
// accepts either a friendly name (same account and region) or a full ARN.
|
|
209
|
+
const secretId = props.idpClientSecretArn!;
|
|
210
|
+
if (!/^(arn:[a-z0-9-]+:secretsmanager:[a-z0-9-]+:\d{12}:secret:[A-Za-z0-9/_+=.@-]+|[A-Za-z0-9/_+=.@-]+)$/.test(secretId)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`idpClientSecretArn "${secretId}" is not a valid Secrets Manager secret name or ARN. `
|
|
213
|
+
+ 'Expected a friendly name or a full arn:...:secretsmanager:...:secret:... ARN.',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const jsonKey = props.idpClientSecretJsonKey ?? '';
|
|
217
|
+
if (jsonKey && !/^[A-Za-z0-9_.-]+$/.test(jsonKey)) {
|
|
218
|
+
throw new Error(`idpClientSecretJsonKey "${jsonKey}" must be alphanumeric with . _ - only.`);
|
|
219
|
+
}
|
|
220
|
+
// Trailing `::` selects the current version stage (AWSCURRENT), so the
|
|
221
|
+
// reference tracks rotation without pinning a version id.
|
|
222
|
+
const clientSecretRef = `{{resolve:secretsmanager:${secretId}:SecretString:${jsonKey}::}}`;
|
|
223
|
+
|
|
224
|
+
federatedIdp = new cognito.CfnUserPoolIdentityProvider(this, 'FederatedIdp', {
|
|
225
|
+
userPoolId: this.userPool.userPoolId,
|
|
226
|
+
providerName: props.idpName!,
|
|
227
|
+
providerType: 'OIDC',
|
|
228
|
+
// A SUPERSET of what the console-created provider had, never a
|
|
229
|
+
// replacement. `username: sub` is what Cognito uses to derive the
|
|
230
|
+
// native federated username (`<ProviderName>_<sub>`); dropping it
|
|
231
|
+
// changes the username shape, which mints a second set of user
|
|
232
|
+
// records and splits every developer's history a second time. Only
|
|
233
|
+
// `email` and the name claims are additions.
|
|
234
|
+
//
|
|
235
|
+
// `email` is the load-bearing one: it is what lets the identity match
|
|
236
|
+
// the git author emails the CI census emits.
|
|
237
|
+
attributeMapping: {
|
|
238
|
+
username: 'sub',
|
|
239
|
+
email: 'EMAIL',
|
|
240
|
+
given_name: 'GIVEN_NAME',
|
|
241
|
+
family_name: 'FAMILY_NAME',
|
|
242
|
+
},
|
|
243
|
+
providerDetails: {
|
|
244
|
+
oidc_issuer: props.idpIssuer!,
|
|
245
|
+
client_id: props.idpClientId!,
|
|
246
|
+
client_secret: clientSecretRef,
|
|
247
|
+
authorize_scopes: props.idpScopes ?? 'openid email',
|
|
248
|
+
attributes_request_method: 'GET',
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
120
253
|
// Public client (no secret) — codeburn does Authorization Code + PKCE
|
|
121
254
|
// with loopback redirects. Cognito allows http:// for localhost, 127.0.0.1,
|
|
122
255
|
// and [::1] (per CreateUserPoolClient API docs). codeburn uses 127.0.0.1.
|
|
@@ -132,10 +265,97 @@ export class OtelCollectorConstruct extends Construct {
|
|
|
132
265
|
scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE],
|
|
133
266
|
callbackUrls,
|
|
134
267
|
},
|
|
268
|
+
// Declared here so it cannot drift. Setting this in the console instead
|
|
269
|
+
// leaves CloudFormation believing the client is Cognito-only, and the
|
|
270
|
+
// next deploy of this stack silently reverts it — breaking federated
|
|
271
|
+
// login with no failed resource to point at.
|
|
272
|
+
supportedIdentityProviders: idpConfigured
|
|
273
|
+
? [
|
|
274
|
+
cognito.UserPoolClientIdentityProvider.COGNITO,
|
|
275
|
+
cognito.UserPoolClientIdentityProvider.custom(props.idpName!),
|
|
276
|
+
]
|
|
277
|
+
: [cognito.UserPoolClientIdentityProvider.COGNITO],
|
|
135
278
|
preventUserExistenceErrors: true,
|
|
136
279
|
accessTokenValidity: cdk.Duration.hours(1),
|
|
137
280
|
refreshTokenValidity: cdk.Duration.days(30),
|
|
138
281
|
});
|
|
282
|
+
if (federatedIdp) {
|
|
283
|
+
this.userPoolClient.node.defaultChild?.node.addDependency(federatedIdp);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// -----------------------------------------------------------------
|
|
287
|
+
// Pre token generation (V2_0) — puts `email` in the ACCESS token.
|
|
288
|
+
//
|
|
289
|
+
// Attached only when a federated IdP is configured: for admin-created
|
|
290
|
+
// users the username IS the email, so the trigger would be a no-op that
|
|
291
|
+
// still billed an invocation on every token issuance.
|
|
292
|
+
//
|
|
293
|
+
// Inert without `identityClaim: 'email'` — see the note in
|
|
294
|
+
// pre-token-generation.ts. The warning below fires on the one
|
|
295
|
+
// half-configured combination that looks like it should work.
|
|
296
|
+
// -----------------------------------------------------------------
|
|
297
|
+
if (idpConfigured) {
|
|
298
|
+
// props.identityClaim is not yet folded into `identityClaim` at this
|
|
299
|
+
// point in the branch, so resolve the same default inline.
|
|
300
|
+
const claimForWarning = props.identityClaim ?? 'username';
|
|
301
|
+
if (claimForWarning !== 'email') {
|
|
302
|
+
cdk.Annotations.of(this).addWarning(
|
|
303
|
+
'A federated IdP is configured and the pre-token-generation trigger will add an `email` '
|
|
304
|
+
+ 'claim to access tokens, but identityClaim is '
|
|
305
|
+
+ `"${claimForWarning}" — the receiver checks that claim (and then \`username\`, which is `
|
|
306
|
+
+ 'always present) before `email`, so the trigger has no effect. Deploy with '
|
|
307
|
+
+ '-c otelIdentityClaim=email to key identities on email.',
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const preTokenGen = new lambda.Function(this, 'PreTokenGeneration', {
|
|
312
|
+
functionName: 'prism-d1-pre-token-generation',
|
|
313
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
314
|
+
handler: 'pre-token-generation.handler',
|
|
315
|
+
// Deliberately NOT in the VPC: Cognito invokes this synchronously on
|
|
316
|
+
// the token-issuance path, and it makes no network calls, so a VPC
|
|
317
|
+
// attachment would add cold-start ENI latency to every sign-in for
|
|
318
|
+
// nothing.
|
|
319
|
+
code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda'), {
|
|
320
|
+
bundling: {
|
|
321
|
+
image: lambda.Runtime.NODEJS_22_X.bundlingImage,
|
|
322
|
+
command: [
|
|
323
|
+
'bash', '-c',
|
|
324
|
+
[
|
|
325
|
+
'npm init -y > /dev/null 2>&1',
|
|
326
|
+
'npm install --save esbuild > /dev/null 2>&1',
|
|
327
|
+
'npx esbuild pre-token-generation.ts --bundle --platform=node --target=node22 --outfile=/asset-output/pre-token-generation.js --external:@aws-sdk/*',
|
|
328
|
+
].join(' && '),
|
|
329
|
+
],
|
|
330
|
+
local: {
|
|
331
|
+
tryBundle(outputDir: string): boolean {
|
|
332
|
+
try {
|
|
333
|
+
const { execSync } = require('child_process');
|
|
334
|
+
execSync(
|
|
335
|
+
`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/*`,
|
|
336
|
+
{ stdio: 'pipe' },
|
|
337
|
+
);
|
|
338
|
+
return true;
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
}),
|
|
346
|
+
// Token issuance blocks on this call, so keep the budget tight: the
|
|
347
|
+
// function reads one attribute off the event and returns.
|
|
348
|
+
timeout: cdk.Duration.seconds(5),
|
|
349
|
+
memorySize: 128,
|
|
350
|
+
logRetention: logs.RetentionDays.ONE_MONTH,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
this.userPool.addTrigger(
|
|
354
|
+
cognito.UserPoolOperation.PRE_TOKEN_GENERATION_CONFIG,
|
|
355
|
+
preTokenGen,
|
|
356
|
+
cognito.LambdaVersion.V2_0,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
139
359
|
|
|
140
360
|
issuer = `https://cognito-idp.${cdk.Aws.REGION}.amazonaws.com/${this.userPool.userPoolId}`;
|
|
141
361
|
clientId = this.userPoolClient.userPoolClientId;
|
|
@@ -293,9 +513,101 @@ export class OtelCollectorConstruct extends Construct {
|
|
|
293
513
|
|
|
294
514
|
props.aiUsageTable.grantReadWriteData(receiver);
|
|
295
515
|
this.archiveBucket.grantPut(receiver);
|
|
516
|
+
// The LiteLLM price table the refresh Lambda maintains (pricing/price-source.ts).
|
|
517
|
+
// Exact key, read only. KMS decrypt comes from grantEncryptDecrypt below.
|
|
518
|
+
const pricesObjectArn = this.archiveBucket.arnForObjects(PRICES_KEY);
|
|
519
|
+
receiver.addToRolePolicy(new iam.PolicyStatement({
|
|
520
|
+
actions: ['s3:GetObject'],
|
|
521
|
+
resources: [pricesObjectArn],
|
|
522
|
+
}));
|
|
296
523
|
props.kmsKey.grantEncryptDecrypt(receiver);
|
|
297
524
|
this.receiverFunction = receiver;
|
|
298
525
|
|
|
526
|
+
// -------------------------------------------------------
|
|
527
|
+
// Price refresh + reprice — daily
|
|
528
|
+
// Keeps the receiver's LiteLLM fallback table current in S3, then
|
|
529
|
+
// corrects spans stored at $0 whose model has since been priced.
|
|
530
|
+
// See lib/lambda/pricing-refresh.ts.
|
|
531
|
+
//
|
|
532
|
+
// Deliberately NOT in the VPC: the PRISM VPC has no NAT gateway, and this
|
|
533
|
+
// function's one job that needs a network path is an HTTPS GET to
|
|
534
|
+
// raw.githubusercontent.com. It accepts no inbound traffic, and reaches
|
|
535
|
+
// only S3 and DynamoDB besides, both over their public endpoints with IAM.
|
|
536
|
+
// -------------------------------------------------------
|
|
537
|
+
const pricingRefresh = new lambda.Function(this, 'PricingRefresh', {
|
|
538
|
+
functionName: 'prism-d1-pricing-refresh',
|
|
539
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
540
|
+
handler: 'pricing-refresh.handler',
|
|
541
|
+
code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda'), {
|
|
542
|
+
bundling: {
|
|
543
|
+
image: lambda.Runtime.NODEJS_22_X.bundlingImage,
|
|
544
|
+
command: [
|
|
545
|
+
'bash', '-c',
|
|
546
|
+
[
|
|
547
|
+
'npm init -y > /dev/null 2>&1',
|
|
548
|
+
'npm install --save @aws-sdk/client-dynamodb @aws-sdk/client-s3 esbuild > /dev/null 2>&1',
|
|
549
|
+
'npx esbuild pricing-refresh.ts --bundle --platform=node --target=node22 --outfile=/asset-output/pricing-refresh.js --external:@aws-sdk/*',
|
|
550
|
+
].join(' && '),
|
|
551
|
+
],
|
|
552
|
+
local: {
|
|
553
|
+
tryBundle(outputDir: string): boolean {
|
|
554
|
+
try {
|
|
555
|
+
const { execSync } = require('child_process');
|
|
556
|
+
execSync(
|
|
557
|
+
`npx esbuild ${path.join(__dirname, '..', 'lambda', 'pricing-refresh.ts')} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, 'pricing-refresh.js')} --external:@aws-sdk/*`,
|
|
558
|
+
{ stdio: 'pipe' },
|
|
559
|
+
);
|
|
560
|
+
return true;
|
|
561
|
+
} catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
}),
|
|
568
|
+
// A full-table scan plus, on the first run, a read of the raw archive to
|
|
569
|
+
// recover cache-token counts for spans stored before they were kept.
|
|
570
|
+
timeout: cdk.Duration.minutes(15),
|
|
571
|
+
memorySize: 1024,
|
|
572
|
+
environment: {
|
|
573
|
+
AI_USAGE_TABLE: props.aiUsageTable.tableName,
|
|
574
|
+
ARCHIVE_BUCKET: this.archiveBucket.bucketName,
|
|
575
|
+
},
|
|
576
|
+
logRetention: logs.RetentionDays.ONE_MONTH,
|
|
577
|
+
description: 'Daily: refresh the LiteLLM price table in S3, then reprice usage spans stored at $0',
|
|
578
|
+
});
|
|
579
|
+
props.aiUsageTable.grantReadWriteData(pricingRefresh);
|
|
580
|
+
// Read the raw archive (to recover cache-token counts), and read/write the
|
|
581
|
+
// one price-table object. No delete: an old table is only ever overwritten.
|
|
582
|
+
this.archiveBucket.grantRead(pricingRefresh, 'otlp/*');
|
|
583
|
+
pricingRefresh.addToRolePolicy(new iam.PolicyStatement({
|
|
584
|
+
actions: ['s3:GetObject', 's3:PutObject'],
|
|
585
|
+
resources: [pricesObjectArn],
|
|
586
|
+
}));
|
|
587
|
+
props.kmsKey.grantEncryptDecrypt(pricingRefresh);
|
|
588
|
+
|
|
589
|
+
new events.Rule(this, 'PricingRefreshSchedule', {
|
|
590
|
+
ruleName: 'prism-d1-pricing-refresh-daily',
|
|
591
|
+
description: 'Refresh LiteLLM prices and reprice $0 spans',
|
|
592
|
+
// 06:17 UTC: off the hour so it is not queued behind other schedules.
|
|
593
|
+
schedule: events.Schedule.cron({ minute: '17', hour: '6' }),
|
|
594
|
+
// No retry: a failed run already repriced what it could and the next day's
|
|
595
|
+
// run starts from the same state. Errors surface on the Lambda metric.
|
|
596
|
+
targets: [new targets.LambdaFunction(pricingRefresh, { retryAttempts: 0 })],
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
NagSuppressions.addResourceSuppressions(pricingRefresh, [
|
|
600
|
+
{
|
|
601
|
+
id: 'AwsSolutions-IAM5',
|
|
602
|
+
reason: 'grantRead scopes S3 reads to the otlp/* prefix of the single archive bucket (raw OTLP batches, read to recover cache-token counts); grantReadWriteData adds the AI-usage table GSI index wildcard.',
|
|
603
|
+
appliesTo: [
|
|
604
|
+
'Action::s3:GetObject*', 'Action::s3:GetBucket*', 'Action::s3:List*',
|
|
605
|
+
{ regex: '/^Resource::<OtelCollectorArchiveBucket.*\\.Arn>\\/otlp\\/\\*$/g' },
|
|
606
|
+
{ regex: '/^Resource::<AiUsageTable.*\\.Arn>\\/index\\/\\*$/g' },
|
|
607
|
+
],
|
|
608
|
+
},
|
|
609
|
+
], true);
|
|
610
|
+
|
|
299
611
|
// -------------------------------------------------------
|
|
300
612
|
// CloudWatch Custom Widget: Developer Productivity
|
|
301
613
|
// Invoked by CloudWatch dashboards with the VIEWING user's IAM creds
|
|
@@ -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({
|
|
@@ -806,6 +808,33 @@ export class DashboardStack extends cdk.Stack {
|
|
|
806
808
|
}),
|
|
807
809
|
);
|
|
808
810
|
|
|
811
|
+
// --- Row 5: Tools the model invoked (codeburn ai.tools) ---
|
|
812
|
+
// Store-backed like the spend tiles: OTEL#TOOLDAY rollups are bucketed by
|
|
813
|
+
// the call's own day and keep full history, so a backfilled sync push is
|
|
814
|
+
// not cut off at CloudWatch's two-week window. Follows the Developer filter.
|
|
815
|
+
devDashboard.addWidgets(
|
|
816
|
+
new cloudwatch.CustomWidget({
|
|
817
|
+
functionArn: props.velocityWidgetArn ?? '',
|
|
818
|
+
title: 'Tool Usage: Top Tools & MCP Servers',
|
|
819
|
+
width: 16,
|
|
820
|
+
height: 9,
|
|
821
|
+
params: { view: 'tool-usage-top', user: USER_TOKEN },
|
|
822
|
+
updateOnRefresh: true,
|
|
823
|
+
updateOnResize: false,
|
|
824
|
+
updateOnTimeRangeChange: true,
|
|
825
|
+
}),
|
|
826
|
+
new cloudwatch.CustomWidget({
|
|
827
|
+
functionArn: props.velocityWidgetArn ?? '',
|
|
828
|
+
title: 'Tool Calls / Day',
|
|
829
|
+
width: 8,
|
|
830
|
+
height: 9,
|
|
831
|
+
params: { view: 'tool-usage-trend', user: USER_TOKEN },
|
|
832
|
+
updateOnRefresh: true,
|
|
833
|
+
updateOnResize: false,
|
|
834
|
+
updateOnTimeRangeChange: true,
|
|
835
|
+
}),
|
|
836
|
+
);
|
|
837
|
+
|
|
809
838
|
new cdk.CfnOutput(this, 'DevProductivityDashboardUrl', {
|
|
810
839
|
value: `https://${this.region}.console.aws.amazon.com/cloudwatch/home?region=${this.region}#dashboards:name=PRISM-D1-Developer-Productivity`,
|
|
811
840
|
description: 'Developer Productivity Dashboard URL',
|