From 58312acfd8bc7333c5de726d77bc60e4c95600d4 Mon Sep 17 00:00:00 2001 From: Galib Sarayev Date: Thu, 3 Sep 2026 13:16:09 +0000 Subject: [PATCH 1/4] fix(hosting): place CloudFront 5xx alarm in us-east-1 (#481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS/CloudFront metrics are published only in us-east-1, and a CloudWatch alarm can only evaluate a metric in its own region (confirmed by the CloudWatch docs "Cross-Region functionality is not supported for alarms" and rejected by aws-cdk-lib at synth). For any hosting stack outside us-east-1 the CloudFront5xxRate alarm never received a datapoint and, with treatMissingData: NOT_BREACHING, sat at OK forever instead of alarming — a monitoring alarm that silently never fires. Fix (two-topic design): off-region, defer the CloudFront alarm to a dedicated hosting-owned us-east-1 support stack that owns its own SNS topic. Its topic ARN is surfaced as MonitoringTopicArnUsEast1 (an output of the support stack) for the operator to subscribe to, alongside the regional MonitoringTopicArn. The regional alarms (SSR/image/DLQ) are unchanged — their metrics are regional. In-region (us-east-1) behavior is unchanged: single stack, alarm created locally. New prop monitoring.cloudFrontAlarm: 'usEast1Stack' (default) | 'skip'. The default requires env:{account,region} off-region (a cross-region stack needs a concrete account); env-agnostic off-region synth throws MonitoringErrorRequiredError with guidance. 'skip' emits a synth warning and creates no second stack. Notes: - The us-east-1 alarm references the regional distribution id, which CDK bridges with its standard cross-region export reader/writer custom resources (added to both stacks automatically) — not a runtime forwarder. - Breaking-ish: the default now synthesizes a second CloudFormation stack off-region and requires env:{account,region}. Tests: MonitoringConstruct deferral unit tests; new hosting_construct.cf_alarm_region.test.ts covering off-region two-stack synth, env-required throw, skip warning, and in-region single-stack. All 853 hosting tests pass. --- .../hosting_construct.cf_alarm_region.test.ts | 145 ++++++++++++++++++ .../src/constructs/hosting_construct.ts | 78 ++++++++++ .../constructs/monitoring_construct.test.ts | 43 ++++++ .../src/constructs/monitoring_construct.ts | 28 +++- .../constructs/us_east_1_monitoring_stack.ts | 113 ++++++++++++++ packages/hosting/src/types.ts | 23 +++ 6 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts create mode 100644 packages/hosting/src/constructs/us_east_1_monitoring_stack.ts diff --git a/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts b/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts new file mode 100644 index 000000000..324a946c8 --- /dev/null +++ b/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts @@ -0,0 +1,145 @@ +/** + * HostingConstruct — CloudFront alarm region wiring (issue #481). + * + * AWS/CloudFront metrics only publish in us-east-1 and a CloudWatch + * alarm can only evaluate a metric in its own region. These tests cover + * the two-topic fix: off-region, the CloudFront 5xx alarm is placed in a + * hosting-owned us-east-1 support stack with its own SNS topic; in + * us-east-1 the behavior is unchanged (single stack, alarm local). + */ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { App, Stack } from 'aws-cdk-lib'; +import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; +import { HostingConstruct } from './hosting_construct.js'; +import { DeployManifest } from '../manifest/types.js'; +import { HostingError } from '../hosting_error.js'; + +let tmpDir: string; + +const createStaticDir = (): string => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hosting-cfregion-')); + fs.writeFileSync(path.join(tmpDir, 'index.html'), ''); + return tmpDir; +}; + +const spaManifest = (staticDir: string): DeployManifest => ({ + version: 1, + compute: {}, + staticAssets: { directory: staticDir }, + routes: [{ pattern: '/*', target: 'static' }], + buildId: 'cfregion-test-1', +}); + +const CF_ALARM = Match.objectLike({ Namespace: 'AWS/CloudFront' }); + +void describe('HostingConstruct — CloudFront alarm region (#481)', () => { + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ---- (iv) In-region: unchanged single-stack behavior ---- + void it('creates the CloudFront alarm locally in a single stack when region is us-east-1', () => { + const staticDir = createStaticDir(); + const app = new App(); + const stack = new Stack(app, 'TestStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + new HostingConstruct(stack, 'Hosting', { manifest: spaManifest(staticDir) }); + + const template = Template.fromStack(stack); + // Alarm lives in this (us-east-1) stack. + template.resourcePropertiesCountIs('AWS::CloudWatch::Alarm', CF_ALARM, 1); + // No second stack was synthesized. + assert.strictEqual( + app.node.tryFindChild('TestStack-CfMonitoring'), + undefined, + ); + }); + + // ---- (i) Off-region: two stacks, CF alarm in us-east-1 stack ---- + void it('places the CloudFront alarm in a us-east-1 support stack when off-region', () => { + const staticDir = createStaticDir(); + const app = new App(); + const stack = new Stack(app, 'TestStack', { + env: { account: '123456789012', region: 'ap-northeast-1' }, + }); + new HostingConstruct(stack, 'Hosting', { manifest: spaManifest(staticDir) }); + + // The regional stack has NO CloudFront alarm... + const regional = Template.fromStack(stack); + regional.resourcePropertiesCountIs('AWS::CloudWatch::Alarm', CF_ALARM, 0); + + // A sibling us-east-1 support stack exists and holds the alarm. + const support = app.node.tryFindChild('TestStack-CfMonitoring') as Stack; + assert.ok(support, 'expected a TestStack-CfMonitoring support stack'); + assert.strictEqual(support.region, 'us-east-1'); + + const supportTemplate = Template.fromStack(support); + supportTemplate.resourcePropertiesCountIs( + 'AWS::CloudWatch::Alarm', + Match.objectLike({ + Namespace: 'AWS/CloudFront', + MetricName: '5xxErrorRate', + Threshold: 5, + TreatMissingData: 'notBreaching', + }), + 1, + ); + // Its own SNS topic (two-topic design). No forwarder subscription — + // consolidation is via two topics, not runtime message forwarding. + supportTemplate.resourceCountIs('AWS::SNS::Topic', 1); + supportTemplate.resourceCountIs('AWS::SNS::Subscription', 0); + // The us-east-1 topic ARN is surfaced as an output of the support stack. + supportTemplate.hasOutput( + '*', + Match.objectLike({ Description: Match.stringLikeRegexp('us-east-1') }), + ); + }); + + // ---- (iii) 'skip' mode: warning, no second stack ---- + void it("emits a warning and creates no support stack when cloudFrontAlarm is 'skip'", () => { + const staticDir = createStaticDir(); + const app = new App(); + const stack = new Stack(app, 'TestStack', { + env: { account: '123456789012', region: 'ap-northeast-1' }, + }); + new HostingConstruct(stack, 'Hosting', { + manifest: spaManifest(staticDir), + monitoring: { cloudFrontAlarm: 'skip' }, + }); + + const template = Template.fromStack(stack); + template.resourcePropertiesCountIs('AWS::CloudWatch::Alarm', CF_ALARM, 0); + assert.strictEqual( + app.node.tryFindChild('TestStack-CfMonitoring'), + undefined, + ); + Annotations.fromStack(stack).hasWarning( + '*', + Match.stringLikeRegexp('CloudFront 5xx alarm skipped'), + ); + }); + + // ---- (ii) Off-region without a concrete account → throws ---- + void it('throws MonitoringEnvRequiredError off-region when the account is unresolved', () => { + const staticDir = createStaticDir(); + const app = new App(); + // region resolved (off-region) but account left unresolved. + const stack = new Stack(app, 'TestStack', { + env: { region: 'ap-northeast-1' }, + }); + assert.throws( + () => + new HostingConstruct(stack, 'Hosting', { + manifest: spaManifest(staticDir), + }), + (err: unknown) => + err instanceof HostingError && + err.code === 'MonitoringEnvRequiredError', + ); + }); +}); diff --git a/packages/hosting/src/constructs/hosting_construct.ts b/packages/hosting/src/constructs/hosting_construct.ts index f54670863..88258db0e 100644 --- a/packages/hosting/src/constructs/hosting_construct.ts +++ b/packages/hosting/src/constructs/hosting_construct.ts @@ -10,6 +10,8 @@ import { RemovalPolicy, Size, Stack, + Stage, + Token, } from 'aws-cdk-lib'; import type { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; import { @@ -52,6 +54,7 @@ import { CdnConstruct } from './cdn_construct.js'; import { ComputeConstruct } from './compute_construct.js'; import { DnsConstruct } from './dns_construct.js'; import { MonitoringConstruct } from './monitoring_construct.js'; +import { UsEast1MonitoringStack } from './us_east_1_monitoring_stack.js'; import { DEFAULT_NODE_RUNTIME } from './node_runtime.js'; import type { QuotaOverrides } from './quota_budget.js'; import { createSecurityHeadersPolicy } from './security_headers.js'; @@ -320,6 +323,17 @@ export type HostingConstructProps = { /** @default true */ enabled?: boolean; snsTopicArn?: string; + /** + * How to handle the CloudFront 5xx alarm when this stack is not in + * us-east-1. CloudFront metrics only exist in us-east-1 and an alarm + * can't watch a metric cross-region (issue #481). + * - 'usEast1Stack' (default): place the alarm in a hosting-owned + * us-east-1 support stack (requires env: { account, region }). + * - 'skip': omit the CloudFront alarm off-region (emit a warning). + * Ignored when the stack is already in us-east-1. + * @default 'usEast1Stack' + */ + cloudFrontAlarm?: 'usEast1Stack' | 'skip'; }; /** * Cookie-based skew protection. @@ -1150,6 +1164,15 @@ export class HostingConstruct extends Construct { : undefined; const ssrFn = ssrComputeName ? this.computeFunctions.get(ssrComputeName) : undefined; const imgFn = this.computeFunctions.get('image-optimization'); + + // CloudFront metrics only exist in us-east-1 and an alarm can't + // watch a metric cross-region (issue #481). Off-region, defer the + // CloudFront alarm to a dedicated us-east-1 support stack. + const hostingStack = Stack.of(this); + const region = hostingStack.region; + const regionResolved = !Token.isUnresolved(region); + const offRegion = regionResolved && region !== 'us-east-1'; + const monitoring = new MonitoringConstruct(this, 'Monitoring', { enabled: true, snsTopic: userTopic, @@ -1159,6 +1182,7 @@ export class HostingConstruct extends Construct { ssrFunction: ssrFn instanceof LambdaFunction ? ssrFn : undefined, imageFunction: imgFn instanceof LambdaFunction ? imgFn : undefined, revalidationDlq: this.revalidationDlq, + createCloudFrontAlarmLocally: !offRegion, }); this.monitoringTopic = monitoring.topic; if (monitoring.topic) { @@ -1167,6 +1191,60 @@ export class HostingConstruct extends Construct { description: 'SNS topic for hosting alarms. Subscribe an email/Slack/PagerDuty endpoint here.', }); } + + // Off-region CloudFront alarm handling. + if (this.distribution && monitoring.cloudFrontAlarmDeferred) { + const cfAlarmMode = props.monitoring?.cloudFrontAlarm ?? 'usEast1Stack'; + if (cfAlarmMode === 'skip') { + Annotations.of(this).addWarningV2( + '@aws-blocks/hosting:CloudFrontAlarmSkipped', + `CloudFront 5xx alarm skipped: this stack is in ${region}, but ` + + `AWS/CloudFront metrics only exist in us-east-1 and an alarm ` + + `cannot watch a metric cross-region. Set ` + + `monitoring.cloudFrontAlarm: 'usEast1Stack' (requires ` + + `env: { account, region }) for real CloudFront 5xx coverage.`, + ); + } else if (Token.isUnresolved(hostingStack.account)) { + // A cross-region stack needs a concrete account. Fail loud + // rather than silently drop CloudFront monitoring. + throw new HostingError('MonitoringEnvRequiredError', { + message: + `monitoring.cloudFrontAlarm: 'usEast1Stack' requires an explicit ` + + `env: { account, region } on the stack (region '${region}'), ` + + `because the CloudFront alarm must be placed in a separate ` + + `us-east-1 stack.`, + resolution: + `Add env: { account, region } to the Stack, or set ` + + `monitoring.cloudFrontAlarm: 'skip' to omit the CloudFront alarm.`, + }); + } else { + const stage = Stage.of(this); + if (!stage) { + throw new HostingError('MonitoringEnvRequiredError', { + message: + `Cannot create the us-east-1 CloudFront monitoring stack: no ` + + `enclosing App/Stage was found for this construct.`, + resolution: + `Instantiate hosting within a CDK App (or Stage), or set ` + + `monitoring.cloudFrontAlarm: 'skip'.`, + }); + } + // The us-east-1 CloudFront alarm must reference the regional + // distribution's id; CDK bridges that with its standard + // cross-region export reader/writer custom resources (added to + // both stacks automatically). The topic ARN is surfaced as an + // output OF the support stack (MonitoringTopicArnUsEast1) — not + // re-output here, to keep the wiring one-directional. + new UsEast1MonitoringStack( + stage, + `${hostingStack.stackName}-CfMonitoring`, + { + env: { account: hostingStack.account, region: 'us-east-1' }, + distributionId: this.distribution.distributionId, + }, + ); + } + } } // ---- 9a. OPEN_NEXT_ORIGIN env var for URL construction ---- diff --git a/packages/hosting/src/constructs/monitoring_construct.test.ts b/packages/hosting/src/constructs/monitoring_construct.test.ts index 8c03bb224..68d9c4f11 100644 --- a/packages/hosting/src/constructs/monitoring_construct.test.ts +++ b/packages/hosting/src/constructs/monitoring_construct.test.ts @@ -165,6 +165,49 @@ void describe('MonitoringConstruct', () => { // No alarm at all because no distribution / Lambda / DLQ given. template.resourceCountIs('AWS::CloudWatch::Alarm', 0); }); + + // Issue #481: off-region, the CloudFront alarm is deferred to a + // us-east-1 support stack by the parent instead of created here. + void it('is deferred (not created locally) when createCloudFrontAlarmLocally is false', () => { + const stack = createStack(); + const m = new MonitoringConstruct(stack, 'Monitoring', { + enabled: true, + distribution: newDistribution(stack), + createCloudFrontAlarmLocally: false, + }); + const template = Template.fromStack(stack); + + assert.strictEqual(m.cloudFrontAlarmDeferred, true); + // No AWS/CloudFront alarm was created in this (regional) stack. + template.resourcePropertiesCountIs( + 'AWS::CloudWatch::Alarm', + Match.objectLike({ Namespace: 'AWS/CloudFront' }), + 0, + ); + }); + + void it('still creates the regional alarms (SSR/image/DLQ) when the CF alarm is deferred', () => { + const stack = createStack(); + const m = new MonitoringConstruct(stack, 'Monitoring', { + enabled: true, + distribution: newDistribution(stack), + ssrFunction: newLambda(stack, 'Ssr'), + imageFunction: newLambda(stack, 'Img'), + revalidationDlq: new Queue(stack, 'Dlq'), + createCloudFrontAlarmLocally: false, + }); + const template = Template.fromStack(stack); + + assert.strictEqual(m.cloudFrontAlarmDeferred, true); + // 4 regional alarms (2 SSR + 1 image + 1 DLQ); the CF alarm is deferred. + template.resourceCountIs('AWS::CloudWatch::Alarm', 4); + assert.strictEqual(m.alarms.length, 4); + template.resourcePropertiesCountIs( + 'AWS::CloudWatch::Alarm', + Match.objectLike({ Namespace: 'AWS/CloudFront' }), + 0, + ); + }); }); void describe('SSR Lambda alarms', () => { diff --git a/packages/hosting/src/constructs/monitoring_construct.ts b/packages/hosting/src/constructs/monitoring_construct.ts index 9698afc47..e382c7fe8 100644 --- a/packages/hosting/src/constructs/monitoring_construct.ts +++ b/packages/hosting/src/constructs/monitoring_construct.ts @@ -71,6 +71,18 @@ export type MonitoringConstructProps = { * @default 1 (alarm when >=1% of invocations error) */ ssrErrorRatePercent?: number; + /** + * When `true` (default) the CloudFront 5xx alarm is created in THIS + * (regional) construct. `AWS/CloudFront` metrics only publish in + * us-east-1 and a CloudWatch alarm can only evaluate a metric in its + * own region, so for an off-region hosting stack the parent sets this + * to `false` and instead places the alarm in a dedicated us-east-1 + * support stack (issue #481). When `false` and a `distribution` is + * supplied, the CF alarm is skipped here and `cloudFrontAlarmDeferred` + * is set to `true`. The regional alarms (SSR/image/DLQ) are unaffected. + * @default true + */ + createCloudFrontAlarmLocally?: boolean; }; /** @@ -94,7 +106,7 @@ export type MonitoringConstructProps = { * service-principal calls, and a hard `StringEquals` would * reintroduce the exact silent-deny this grant exists to prevent. */ -const createAlarmTopicKey = (scope: Construct): Key => { +export const createAlarmTopicKey = (scope: Construct): Key => { const key = new Key(scope, 'AlarmTopicKey', { description: 'Encrypts CloudWatch alarm notifications published to the hosting alarm topic.', @@ -137,6 +149,13 @@ export class MonitoringConstruct extends Construct { readonly encryptionKey?: IKey; /** All CloudWatch alarms created by this construct. */ readonly alarms: Alarm[] = []; + /** + * True when a `distribution` was supplied but the CloudFront 5xx + * alarm was NOT created here because `createCloudFrontAlarmLocally` + * was `false` (off-region). The parent must place the alarm in a + * us-east-1 support stack. See issue #481. + */ + readonly cloudFrontAlarmDeferred: boolean = false; /** * Wire the default alarm set to the user-supplied or auto-created @@ -159,7 +178,7 @@ export class MonitoringConstruct extends Construct { } const action = new SnsAction(this.topic); - if (props.distribution) { + if (props.distribution && (props.createCloudFrontAlarmLocally ?? true)) { const cf5xx = new Alarm(this, 'CloudFront5xxRate', { metric: new Metric({ namespace: 'AWS/CloudFront', @@ -182,6 +201,11 @@ export class MonitoringConstruct extends Construct { }); cf5xx.addAlarmAction(action); this.alarms.push(cf5xx); + } else if (props.distribution) { + // Off-region: the CloudFront metric only exists in us-east-1 and an + // alarm can't watch a metric cross-region, so defer the alarm to a + // us-east-1 support stack owned by the parent. See issue #481. + this.cloudFrontAlarmDeferred = true; } if (props.ssrFunction) { diff --git a/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts b/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts new file mode 100644 index 000000000..e047c6a4c --- /dev/null +++ b/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts @@ -0,0 +1,113 @@ +import { Construct } from 'constructs'; +import { CfnOutput, Duration, Stack, StackProps } from 'aws-cdk-lib'; +import { + Alarm, + ComparisonOperator, + Metric, + TreatMissingData, +} from 'aws-cdk-lib/aws-cloudwatch'; +import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions'; +import { IKey } from 'aws-cdk-lib/aws-kms'; +import { ITopic, Topic } from 'aws-cdk-lib/aws-sns'; +import { createAlarmTopicKey } from './monitoring_construct.js'; + +/** + * Props for {@link UsEast1MonitoringStack}. + */ +export type UsEast1MonitoringStackProps = StackProps & { + /** + * CloudFront distribution id to alarm on. Passed as a plain string so + * this us-east-1 stack takes no cross-region CDK reference on the + * regional hosting stack. + */ + distributionId: string; + /** + * BYO SNS topic for the alarm action. When omitted an encrypted topic + * is created in THIS (us-east-1) stack and exposed via `topic` for the + * operator to subscribe to (surfaced by the parent as the + * `MonitoringTopicArnUsEast1` output). Supply this only if you already + * have a us-east-1 topic — a regional topic cannot be used because a + * CloudWatch alarm's SNS action must target a topic in the alarm's own + * region. + */ + snsTopic?: ITopic; +}; + +/** + * Hosting-owned **us-east-1** support stack that holds the CloudFront + * 5xx alarm (issue #481). + * + * `AWS/CloudFront` metrics are published only in us-east-1, and a + * CloudWatch alarm can only evaluate a metric in its own region + * (confirmed by the CloudWatch docs — "Cross-Region functionality is + * not supported for alarms" — and rejected by aws-cdk-lib at synth). An + * off-region hosting stack therefore cannot host a working CloudFront + * alarm; the parent creates this stack next to it (same account, + * region pinned to us-east-1) so the alarm actually evaluates. + * + * Two-topic design: this stack owns its OWN us-east-1 alarm topic (no + * cross-region SNS plumbing, no forwarder Lambda). The operator + * subscribes to this topic's ARN — surfaced by the parent as + * `MonitoringTopicArnUsEast1` — in addition to the regional + * `MonitoringTopicArn`. + */ +export class UsEast1MonitoringStack extends Stack { + /** The us-east-1 topic the CloudFront alarm publishes to. */ + readonly topic: ITopic; + /** KMS key encrypting the auto-created topic (undefined for a BYO topic). */ + readonly encryptionKey?: IKey; + /** The CloudFront 5xx alarm. */ + readonly alarm: Alarm; + + constructor( + scope: Construct, + id: string, + props: UsEast1MonitoringStackProps, + ) { + super(scope, id, props); + + if (props.snsTopic) { + this.topic = props.snsTopic; + } else { + this.encryptionKey = createAlarmTopicKey(this); + this.topic = new Topic(this, 'AlarmTopic', { + masterKey: this.encryptionKey, + }); + } + + // Identical alarm config to the regional construct's original — just + // re-homed to us-east-1 where the metric actually exists. + this.alarm = new Alarm(this, 'CloudFront5xxRate', { + metric: new Metric({ + namespace: 'AWS/CloudFront', + metricName: '5xxErrorRate', + dimensionsMap: { + DistributionId: props.distributionId, + // CloudFront metrics live in us-east-1 regardless of stack. + Region: 'Global', + }, + period: Duration.minutes(5), + statistic: 'Average', + }), + threshold: 5, // percent + evaluationPeriods: 1, + comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treatMissingData: TreatMissingData.NOT_BREACHING, + alarmDescription: + 'CloudFront is returning 5xx for >=5% of requests over 5 minutes.', + }); + this.alarm.addAlarmAction(new SnsAction(this.topic)); + + // Surface the us-east-1 topic ARN as an output OF THIS stack (not the + // regional one) so there is no cross-region reference — the operator + // subscribes to this in addition to the regional MonitoringTopicArn. + new CfnOutput(this, 'MonitoringTopicArnUsEast1', { + value: this.topic.topicArn, + description: + 'SNS topic (us-east-1) for the CloudFront 5xx alarm. Subscribe an ' + + 'endpoint here in addition to the regional MonitoringTopicArn — the ' + + 'CloudFront alarm lives in this us-east-1 stack because AWS/CloudFront ' + + 'metrics only exist in us-east-1.', + }); + } +} diff --git a/packages/hosting/src/types.ts b/packages/hosting/src/types.ts index 258433f93..37f235686 100644 --- a/packages/hosting/src/types.ts +++ b/packages/hosting/src/types.ts @@ -351,6 +351,29 @@ export type HostingProps = { * is created. */ snsTopicArn?: string; + /** + * How to handle the CloudFront 5xx alarm when the hosting stack is + * NOT in us-east-1. + * + * `AWS/CloudFront` metrics are published only in us-east-1, and a + * CloudWatch alarm can only evaluate a metric in its own region, so + * an off-region stack cannot host a working CloudFront alarm. + * + * - `'usEast1Stack'` (default): synthesize a small hosting-owned + * us-east-1 support stack that holds the alarm and its own SNS + * topic. The topic ARN is surfaced as the `MonitoringTopicArnUsEast1` + * CloudFormation output for the operator to subscribe to (in + * addition to the regional `MonitoringTopicArn`). This REQUIRES an + * explicit `env: { account, region }` on the stack — an + * env-agnostic stack cannot create a second-region stack. + * - `'skip'`: do not create the CloudFront alarm off-region; a synth + * warning is emitted instead. No second stack; works env-agnostic. + * + * Ignored when the stack is already in us-east-1 (the alarm is created + * locally in the single regional stack, unchanged). + * @default 'usEast1Stack' + */ + cloudFrontAlarm?: 'usEast1Stack' | 'skip'; }; /** From e8a3199eec11e3a084139585361f7712918bd96e Mon Sep 17 00:00:00 2001 From: Galib Sarayev Date: Fri, 4 Sep 2026 08:31:05 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(hosting,core):=20finish=20#481=20review?= =?UTF-8?q?=20=E2=80=94=20off-region=20us-east-1=20CF=20alarm=20handling,?= =?UTF-8?q?=20core=20cloudFrontAlarm=20prop,=20split=20MonitoringStageRequ?= =?UTF-8?q?iredError,=20lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/hosting.ts | 25 +++++++++- .../hosting_construct.cf_alarm_region.test.ts | 8 ++-- .../src/constructs/hosting_construct.ts | 2 +- .../src/constructs/monitoring_construct.ts | 8 ++-- .../us_east_1_monitoring_stack.test.ts | 46 +++++++++++++++++++ .../constructs/us_east_1_monitoring_stack.ts | 8 ++-- 6 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 packages/hosting/src/constructs/us_east_1_monitoring_stack.test.ts diff --git a/packages/core/src/hosting.ts b/packages/core/src/hosting.ts index f76302f25..8cb8f0c95 100644 --- a/packages/core/src/hosting.ts +++ b/packages/core/src/hosting.ts @@ -382,6 +382,21 @@ export interface HostingProps { enabled?: boolean; /** ARN of an existing SNS topic to send alarm actions to. */ snsTopicArn?: string; + /** + * How to handle the CloudFront 5xx alarm when the app is deployed + * outside us-east-1. AWS/CloudFront metrics only exist in us-east-1 + * and a CloudWatch alarm can't watch a metric cross-region, so the + * alarm cannot live in an off-region stack. + * - `'usEast1Stack'`: place the alarm in a dedicated us-east-1 + * support stack (`-CfMonitoring`) with its own SNS + * topic, surfaced as the `MonitoringTopicArnUsEast1` output. + * Requires `env: { account, region }` on the stack. + * - `'skip'`: omit the CloudFront alarm off-region (emit a warning). + * Ignored when the stack is already in us-east-1. + * + * When omitted, inherits the underlying default (`'usEast1Stack'`). + */ + cloudFrontAlarm?: 'usEast1Stack' | 'skip'; }; /** @@ -684,7 +699,15 @@ export class Hosting extends Construct { logging: props.logging, buildCache: props.buildCache, errorPages: skipPropsErrorPages ? undefined : props.errorPages, - monitoring: props.monitoring, + monitoring: props.monitoring + ? { + ...props.monitoring, + // Forward cloudFrontAlarm through as-is: undefined inherits the + // L3 default ('usEast1Stack'). To change the Blocks-app default + // to 'skip' later, replace the fallback on the next line. + cloudFrontAlarm: props.monitoring.cloudFrontAlarm ?? undefined, + } + : undefined, skewProtection: props.skewProtection, }; diff --git a/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts b/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts index 324a946c8..81ee6ac65 100644 --- a/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts +++ b/packages/hosting/src/constructs/hosting_construct.cf_alarm_region.test.ts @@ -9,13 +9,13 @@ */ import { afterEach, describe, it } from 'node:test'; import assert from 'node:assert'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; import { App, Stack } from 'aws-cdk-lib'; import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import { HostingConstruct } from './hosting_construct.js'; -import { DeployManifest } from '../manifest/types.js'; +import type { DeployManifest } from '../manifest/types.js'; import { HostingError } from '../hosting_error.js'; let tmpDir: string; diff --git a/packages/hosting/src/constructs/hosting_construct.ts b/packages/hosting/src/constructs/hosting_construct.ts index 88258db0e..792dfff1c 100644 --- a/packages/hosting/src/constructs/hosting_construct.ts +++ b/packages/hosting/src/constructs/hosting_construct.ts @@ -1220,7 +1220,7 @@ export class HostingConstruct extends Construct { } else { const stage = Stage.of(this); if (!stage) { - throw new HostingError('MonitoringEnvRequiredError', { + throw new HostingError('MonitoringStageRequiredError', { message: `Cannot create the us-east-1 CloudFront monitoring stack: no ` + `enclosing App/Stage was found for this construct.`, diff --git a/packages/hosting/src/constructs/monitoring_construct.ts b/packages/hosting/src/constructs/monitoring_construct.ts index e382c7fe8..b41646037 100644 --- a/packages/hosting/src/constructs/monitoring_construct.ts +++ b/packages/hosting/src/constructs/monitoring_construct.ts @@ -8,12 +8,12 @@ import { TreatMissingData, } from 'aws-cdk-lib/aws-cloudwatch'; import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions'; -import { Distribution } from 'aws-cdk-lib/aws-cloudfront'; +import type { Distribution } from 'aws-cdk-lib/aws-cloudfront'; import * as iam from 'aws-cdk-lib/aws-iam'; import { type IKey, Key } from 'aws-cdk-lib/aws-kms'; -import { Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda'; -import { Queue } from 'aws-cdk-lib/aws-sqs'; -import { ITopic, Topic } from 'aws-cdk-lib/aws-sns'; +import type { Function as LambdaFunction } from 'aws-cdk-lib/aws-lambda'; +import type { Queue } from 'aws-cdk-lib/aws-sqs'; +import { type ITopic, Topic } from 'aws-cdk-lib/aws-sns'; /** * Default CloudWatch alarm wiring (P3.1 + P3.2). diff --git a/packages/hosting/src/constructs/us_east_1_monitoring_stack.test.ts b/packages/hosting/src/constructs/us_east_1_monitoring_stack.test.ts new file mode 100644 index 000000000..2cf7d35ec --- /dev/null +++ b/packages/hosting/src/constructs/us_east_1_monitoring_stack.test.ts @@ -0,0 +1,46 @@ +/** + * UsEast1MonitoringStack — BYO-topic behavior (issue #481). + * + * The support stack normally creates its own encrypted us-east-1 topic, + * but a caller may supply an existing us-east-1 topic. When they do, no + * new topic/key is created and the alarm action targets the supplied + * topic. + */ +import { describe, it } from 'node:test'; +import { App, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { Topic } from 'aws-cdk-lib/aws-sns'; +import { UsEast1MonitoringStack } from './us_east_1_monitoring_stack.js'; + +void describe('UsEast1MonitoringStack — BYO topic', () => { + void it('reuses a supplied topic: no new topic or KMS key, alarm targets it', () => { + const app = new App(); + // An imported us-east-1 topic (BYO by ARN — no cross-stack export). + const arnStack = new Stack(app, 'ArnStack', { + env: { account: '123456789012', region: 'us-east-1' }, + }); + const topicArn = 'arn:aws:sns:us-east-1:123456789012:byo-alarm-topic'; + const userTopic = Topic.fromTopicArn(arnStack, 'UserTopic', topicArn); + + const stack = new UsEast1MonitoringStack(app, 'CfMon', { + env: { account: '123456789012', region: 'us-east-1' }, + distributionId: 'E1234567890ABC', + snsTopic: userTopic, + }); + const template = Template.fromStack(stack); + + // The support stack created neither a topic nor an encryption key. + template.resourceCountIs('AWS::SNS::Topic', 0); + template.resourceCountIs('AWS::KMS::Key', 0); + + // The alarm exists and its action is the supplied (imported) topic ARN. + template.hasResourceProperties( + 'AWS::CloudWatch::Alarm', + Match.objectLike({ + Namespace: 'AWS/CloudFront', + MetricName: '5xxErrorRate', + AlarmActions: [topicArn], + }), + ); + }); +}); diff --git a/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts b/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts index e047c6a4c..a72a267d4 100644 --- a/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts +++ b/packages/hosting/src/constructs/us_east_1_monitoring_stack.ts @@ -1,5 +1,5 @@ -import { Construct } from 'constructs'; -import { CfnOutput, Duration, Stack, StackProps } from 'aws-cdk-lib'; +import type { Construct } from 'constructs'; +import { CfnOutput, Duration, Stack, type StackProps } from 'aws-cdk-lib'; import { Alarm, ComparisonOperator, @@ -7,8 +7,8 @@ import { TreatMissingData, } from 'aws-cdk-lib/aws-cloudwatch'; import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions'; -import { IKey } from 'aws-cdk-lib/aws-kms'; -import { ITopic, Topic } from 'aws-cdk-lib/aws-sns'; +import type { IKey } from 'aws-cdk-lib/aws-kms'; +import { type ITopic, Topic } from 'aws-cdk-lib/aws-sns'; import { createAlarmTopicKey } from './monitoring_construct.js'; /** From 32b695e899f7b600d5804fe832c29eb5e6af3cdb Mon Sep 17 00:00:00 2001 From: Galib Sarayev Date: Fri, 4 Sep 2026 08:32:10 +0000 Subject: [PATCH 3/4] docs(hosting): document off-region CloudFront alarm parity; add changeset --- .changeset/cf-alarm-us-east-1-off-region.md | 23 +++++++++++++ packages/hosting/README.md | 38 +++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 .changeset/cf-alarm-us-east-1-off-region.md diff --git a/.changeset/cf-alarm-us-east-1-off-region.md b/.changeset/cf-alarm-us-east-1-off-region.md new file mode 100644 index 000000000..c7d7c7695 --- /dev/null +++ b/.changeset/cf-alarm-us-east-1-off-region.md @@ -0,0 +1,23 @@ +--- +"@aws-blocks/hosting": minor +"@aws-blocks/core": patch +--- + +Fix off-region CloudFront alarm placement (#481). + +`HostingConstruct` now correctly handles the AWS constraint that +CloudFront metrics only publish in `us-east-1`: when the hosting stack +is deployed to a non-`us-east-1` region the CloudFront 5xx alarm is +placed in a synthesized `-CfMonitoring` us-east-1 stack that +owns its own encrypted SNS topic and exposes a `MonitoringTopicArnUsEast1` +CloudFormation output for operator subscriptions. + +Off-region deployments now defer the CloudFront 5xx alarm to a synthesized +`-CfMonitoring` us-east-1 stack (own SNS topic + +MonitoringTopicArnUsEast1 output); this requires env:{account,region} to +be set off-region. Set monitoring.cloudFrontAlarm:'skip' to opt out (emits +a warning). + +`@aws-blocks/core` gains the `cloudFrontAlarm` property on +`HostingMonitoringOptions` to surface the new `'skip'` | `'usEast1Stack'` +choice at the L3 config layer. diff --git a/packages/hosting/README.md b/packages/hosting/README.md index b990b7fea..dc61fb16c 100644 --- a/packages/hosting/README.md +++ b/packages/hosting/README.md @@ -335,6 +335,44 @@ phases around the deploy: (`app.example.com`) to the `DistributionDomainName` value. For an apex domain, use an ALIAS or ANAME record if your provider supports it. +## Off-region CloudFront alarm parity + +AWS CloudFront metrics only publish in `us-east-1`. A CloudWatch alarm +can only evaluate a metric in its own region, so the CloudFront 5xx +alarm cannot be placed in a non-`us-east-1` hosting stack. + +When `HostingConstruct` detects that the stack's region is **not** +`us-east-1` it automatically synthesizes a companion stack named +`-CfMonitoring` pinned to `us-east-1`. That stack: + +- creates its own encrypted SNS topic (the alarm action target), +- wires the CloudFront 5xx rate alarm to that topic, and +- emits a `MonitoringTopicArnUsEast1` CloudFormation output so operators + can subscribe their on-call pipeline to it. + +> **Requirement:** because the companion stack is region-pinned you must +> set `env: { account, region }` on the parent hosting stack when +> deploying outside `us-east-1`, exactly as you would for a WAF stack +> (see `waf_construct.ts`). An unresolved (`Token.isUnresolved`) region +> is left to the CDK to resolve at deploy time. + +To opt out — for example in a region where the CloudFront distribution +is managed separately — set `monitoring.cloudFrontAlarm: 'skip'`. The +construct emits a CDK warning and omits the alarm entirely. + +```ts +new HostingConstruct(this, 'Hosting', { + // ... + monitoring: { + cloudFrontAlarm: 'skip', // suppress the us-east-1 companion stack + }, +}); +``` + +The default value (`'usEast1Stack'`) creates the companion stack. In +`us-east-1` no companion stack is created — the alarm stays local. + + ## Development ```bash From c0b3ab8a97c4327ea11440822a61da350d69eadc Mon Sep 17 00:00:00 2001 From: Galib Sarayev Date: Fri, 4 Sep 2026 12:00:45 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(hosting,core):=20address=20#481=20rev?= =?UTF-8?q?iew=20nits=20=E2=80=94=20drop=20no-op=20cloudFrontAlarm=20passt?= =?UTF-8?q?hrough,=20document=20defensive=20stage=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/hosting.ts | 7 +++---- packages/hosting/src/constructs/hosting_construct.ts | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/hosting.ts b/packages/core/src/hosting.ts index 8cb8f0c95..8ee896072 100644 --- a/packages/core/src/hosting.ts +++ b/packages/core/src/hosting.ts @@ -702,10 +702,9 @@ export class Hosting extends Construct { monitoring: props.monitoring ? { ...props.monitoring, - // Forward cloudFrontAlarm through as-is: undefined inherits the - // L3 default ('usEast1Stack'). To change the Blocks-app default - // to 'skip' later, replace the fallback on the next line. - cloudFrontAlarm: props.monitoring.cloudFrontAlarm ?? undefined, + // cloudFrontAlarm flows through the spread as-is: undefined inherits + // the L3 default ('usEast1Stack'). To change the Blocks-app default + // to 'skip' later, set it explicitly here. } : undefined, skewProtection: props.skewProtection, diff --git a/packages/hosting/src/constructs/hosting_construct.ts b/packages/hosting/src/constructs/hosting_construct.ts index 792dfff1c..1217189d5 100644 --- a/packages/hosting/src/constructs/hosting_construct.ts +++ b/packages/hosting/src/constructs/hosting_construct.ts @@ -1218,6 +1218,9 @@ export class HostingConstruct extends Construct { `monitoring.cloudFrontAlarm: 'skip' to omit the CloudFront alarm.`, }); } else { + // Belt-and-suspenders: only reachable for a Hosting construct with no enclosing App/Stage + // (CDK's App extends Stage, so Stage.of(this) resolves in normal use). Fail loud rather + // than synthesize a mis-scoped us-east-1 support stack. const stage = Stage.of(this); if (!stage) { throw new HostingError('MonitoringStageRequiredError', {