Introduction
This is the 10th installment of "AWS CDK 100 Drill Exercises." See here for an overview of the series.
By the time you notice, AWS costs have often already piled up. You catch the anomaly when the invoice arrives, or you open the console and think "wait, I shouldn't have spent this much this month." AWS gives you several ways to prevent this, but they're actually quite different in nature. Do you want to be told when you cross a known threshold? Do you want to catch unusual activity even while still within budget? Or do you want weekly usage automatically shared with your team? The right mechanism depends on which of these you're actually after.
This time we implement five different patterns for AWS cost alerting as five independent CDK stacks, and compare each one's detection trigger, notification path, and setup complexity with concrete code.
What you'll learn in this article
- How to express AWS Budgets threshold notifications as a data-driven
{ type, thresholdPercent }[]array instead of hardcoding them - The
frequency: IMMEDIATEconstraint required for SNS delivery in AWS Cost Anomaly Detection, and how to hand-assemblethresholdExpressionas a JSON string - How AWS Budgets, Cost Anomaly Detection, and CloudWatch alarms each require SNS publish permissions in their own distinct way (different
SourceAccount/SourceArnconditions per service principal) - The hard limit that AWS-managed Cost Anomaly Detection monitors are effectively capped at one or two per account — a constraint I only discovered by actually deploying, and the redesign needed to share one monitor across multiple stacks
- The trap where AWS Chatbot's
guardrailPoliciesdefaults toAdministratorAccesswhen left unset — and why an empty array[]doesn't prevent this (CDK synth strips empty arrays out of the template) - How to use Step Functions' new query language JSONata to format Cost Explorer API results straight into an SNS message with zero Lambda functions
- A string-escaping trap that spans three layers — JavaScript → JSON → JSONata (
\nhappens to work,\ssilently breaks) - Six reusable constructs factored out into
common/constructs/cost/for reuse across the whole repository
📁 Code repository: GitHub
Architecture Overview
| Pattern | Approach | Stack |
|---|---|---|
| A: Budgets | Threshold-based budget alert → SNS → Email | Budget (1 stack) |
| B: Anomaly Detection | ML-based anomaly detection → SNS → Email | Anomaly (1 stack) |
| C: Unified | Budgets + Anomaly Detection consolidated into one SNS topic, optionally Slack | Unified (1 stack) |
| D: Billing Alarm | Classic CloudWatch EstimatedCharges alarm → SNS → Email |
BillingAlarm (1 stack, pinned to us-east-1) |
| E: Cost Digest | Scheduled Step Functions → SNS → Slack/Teams | CostDigest (1 stack) |
Why five patterns
| Trait | A (Budgets) | B (Anomaly Detection) | C (Unified) | D (Billing Alarm) | E (Cost Digest) |
|---|---|---|---|---|---|
| Trigger | Reactive — known threshold | Reactive — deviation from historical trend (ML) | Both, reactive | Reactive — cumulative spend threshold | Proactive — scheduled (no threshold needed) |
| Best suited for | "Tell me when I cross $X/month" | "I want to notice unusual activity even within budget" | Consolidating everything into one channel | The oldest safety net, independent of Cost Explorer | "Share weekly spend with the team" |
| Notification channel | SNS + Email | SNS + Email | SNS + Email + optional Slack | SNS + Email | SNS + Email + optional Slack/Teams |
In short: A to stop at a known ceiling, B to catch unexpected movement, C to consolidate both into one place, D as the last-resort insurance for when you can't even trust Cost Explorer, E for a proactive recurring report.
Data Flow
Pattern A (Stack 1)
CfnBudget (account-wide + service filter)
→ SNS topic (budgets.amazonaws.com allowed to Publish)
→ Email
Pattern B (Stack 2)
CfnAnomalyMonitor (DIMENSIONAL / SERVICE)
→ CfnAnomalySubscription (frequency: IMMEDIATE)
→ SNS topic (costalerts.amazonaws.com allowed to Publish)
→ Email
Pattern C (Stack 3)
CfnBudget + CfnAnomalySubscription (attaches to Stack 2's monitor, creates no new monitor)
→ SNS topic (both service principals allowed)
→ Email + AWS Chatbot → Slack (optional)
Pattern D (Stack 4, pinned to us-east-1)
CloudWatch alarm (AWS/Billing EstimatedCharges)
→ SNS topic (cloudwatch.amazonaws.com allowed to Publish)
→ Email
Pattern E (Stack 5)
EventBridge Scheduler (cron)
→ Step Functions (JSONata): GetCostAndUsage → PublishCostDigest
→ SNS topic (Publish from its own IAM role, no service policy needed)
→ Email + AWS Chatbot → Slack / Microsoft Teams (either or both)
Key Components
| Component | Design point |
|---|---|
CostAlertTopic (shared construct) |
SNS topic + resource policy driven by allowBudgetsPublish/allowCostAnomalyDetectionPublish/allowCloudWatchAlarmPublish flags |
CostBudget (shared construct) |
A CfnBudget wrapper that dynamically builds notificationsWithSubscribers from BudgetNotificationRule[]
|
CostAnomalyDetection (shared construct) |
CfnAnomalyMonitor + CfnAnomalySubscription, encapsulating the thresholdExpression JSON assembly |
BillingAlarm (shared construct) |
AWS/Billing EstimatedCharges metric + alarm. Assumes it's pinned to us-east-1 |
SafeSlackChannelConfiguration / SafeMicrosoftTeamsChannelConfiguration (shared constructs) |
Safe Chatbot wrappers that fall back to ReadOnlyAccess when guardrailPolicies isn't specified |
| Step Functions (Stack 5) |
QueryLanguage.JSONATA, tracingEnabled: true, full execution logging to CloudWatch Logs |
Implementation Highlights
1. Data-driven budget notification rules
Rather than hardcoding "80%, 100%, forecasted 100%" into the stack, notification rules are stored as a plain array in the environment parameters.
// common/types/cost.ts
export interface BudgetNotificationRule {
readonly type: 'ACTUAL' | 'FORECASTED';
readonly thresholdPercent: number;
}
// parameters/dev-params.ts
budget: {
amount: 10,
notifications: [
{ type: 'FORECASTED', thresholdPercent: 100 },
{ type: 'ACTUAL', thresholdPercent: 80 },
{ type: 'ACTUAL', thresholdPercent: 100 },
{ type: 'ACTUAL', thresholdPercent: 200 }, // escalation for runaway costs
],
},
This lets any environment add or remove thresholds without touching the stack code. common/constructs/cost/budget.ts just .map()s over the array.
const notificationRules = props.notifications ?? defaultBudgetNotifications;
const notificationsWithSubscribers = notificationRules.map((rule) => ({
notification: {
notificationType: rule.type,
comparisonOperator: 'GREATER_THAN',
threshold: rule.thresholdPercent,
thresholdType: 'PERCENTAGE',
},
subscribers,
}));
2. SNS topic policies aren't optional — and each service does it differently
Both AWS Budgets and AWS Cost Anomaly Detection publish to SNS as service principals rather than by assuming an IAM role. CloudWatch alarm actions likewise need explicit permission. Without it, notifications fail silently — the configuration looks correct, but nothing ever arrives.
// common/constructs/cost/cost-alert-topic.ts (simplified)
if (props.allowBudgetsPublish) {
topic.addToResourcePolicy(new iam.PolicyStatement({
principals: [new iam.ServicePrincipal('budgets.amazonaws.com')],
actions: ['sns:Publish'],
conditions: {
StringEquals: { 'aws:SourceAccount': account },
ArnLike: { 'aws:SourceArn': `arn:${partition}:budgets::${account}:*` },
},
}));
}
if (props.allowCostAnomalyDetectionPublish) {
// costalerts.amazonaws.com, condition is aws:SourceAccount only (no SourceArn scoping needed)
}
if (props.allowCloudWatchAlarmPublish) {
topic.grantPublish(new iam.ServicePrincipal('cloudwatch.amazonaws.com'));
}
All three need "permission to publish to SNS," yet the strictness of the conditions is all over the place. Budgets requires both SourceAccount and SourceArn; Cost Anomaly Detection requires only SourceAccount (even AWS's own official samples don't specify SourceArn); and CloudWatch's cloudwatch-actions.SnsAction doesn't even do the kind of automatic grant other alarm actions do — you have to call grantPublish explicitly.
Don't add a customer-managed KMS key to these topics. The troubleshooting documentation for both Budgets and Cost Anomaly Detection explicitly calls out topic encryption as a classic cause of silently-failed notifications, because the service principal would also need
kms:GenerateDataKey*/kms:Decryptgranted in the key policy. Here we enforceenforceSSL: truefor transport encryption only, leaving at-rest encryption at SNS's default managed encryption.
3. Cost Anomaly Detection's thresholdExpression
CfnAnomalySubscription.thresholdExpression is typed as a plain string in aws-cdk-lib. CDK doesn't have a typed representation of Cost Explorer's Expression grammar, so you have to assemble it yourself and JSON.stringify() it.
thresholdExpression: JSON.stringify({
And: [
{
Dimensions: {
Key: 'ANOMALY_TOTAL_IMPACT_PERCENTAGE',
MatchOptions: ['GREATER_THAN_OR_EQUAL'],
Values: [String(thresholdPercentage)],
},
},
{
Dimensions: {
Key: 'ANOMALY_TOTAL_IMPACT_ABSOLUTE',
MatchOptions: ['GREATER_THAN_OR_EQUAL'],
Values: [String(thresholdAbsoluteUsd)],
},
},
],
}),
ANDing "percentage relative to expected spend" with "absolute dollar amount" filters out noise like "10x the usual cost, but the absolute amount is only $3."
SNS delivery requires
frequency: IMMEDIATE. AWS Cost Anomaly Detection only supports SNS subscribers onIMMEDIATEsubscriptions —DAILY/WEEKLYare email-only. If you also want a daily email summary, add a secondCfnAnomalySubscription(frequency:DAILY,
4. Stack 3 doesn't create its own anomaly monitor — AWS's "one per account" limit
This is a trap I only hit after actually deploying, once the writing was already done. Deploy Stack 3 with Stack 2 already deployed, and it fails reliably with this error:
CREATE_FAILED | AWS::CE::AnomalyMonitor | ...
Resource handler returned message: "null" (HandlerErrorCode: AlreadyExists)
At first I thought "the monitor names are different, so why?" — but the cause wasn't the name, it was an AWS-imposed limit. The CreateAnomalyMonitor API reference itself doesn't mention this constraint; the source is a blog post from the AWS Cloud Financial Management team.
"You can create one AWS services managed monitor plus one additional AWS managed monitor (linked account, cost allocation tag, or cost category) per management account."
"AWS managed monitors for linked accounts, cost allocation tags, and cost categories can only be created in management accounts."
— Extending AWS managed monitors in AWS Cost Anomaly Detection (AWS Cloud Financial Management blog)
Since Stack 2 has already created the account's one and only SERVICE-dimension monitor, Stack 3 trying to create a second one on the same dimension always hits AlreadyExists. Switching monitorDimension to LINKED_ACCOUNT doesn't help either — that runs into the separate constraint of only being creatable in an AWS Organizations management account, which fails in most test accounts anyway.
The fix is simple: don't have Stack 3 create a new monitor — attach a second CfnAnomalySubscription to Stack 2's existing monitor instead. Attaching multiple subscriptions to one monitor is a configuration AWS officially supports.
// common/constructs/cost/anomaly-detection.ts
if (props.existingMonitorArn) {
this.monitorArn = props.existingMonitorArn;
} else {
this.monitor = new ce.CfnAnomalyMonitor(this, 'Monitor', { /* ... */ });
this.monitorArn = this.monitor.attrMonitorArn;
}
this.subscription = new ce.CfnAnomalySubscription(this, 'Subscription', {
monitorArnList: [this.monitorArn],
// ...
});
On the Stage side, Stack 2 is instantiated before Stack 3, and anomalyStack.monitorArn is simply passed into Stack 3's props. CDK converts this into a real CloudFormation cross-stack export/import, so there's no need for a hand-written Fn::ImportValue.
const anomalyStack = new BudgetsCostAnomalyDetectionAnomalyStack(this, ..., { ... });
new BudgetsCostAnomalyDetectionUnifiedStack(this, ..., {
...commonStackProps,
anomalyMonitorArn: anomalyStack.monitorArn,
// ...
});
I'll admit it stings a bit that Stack 3 ended up depending on Stack 2, despite the whole premise being "five independent patterns." But since this is a hard constraint on AWS's side, I decided it's more honest to make the dependency explicit in both code and documentation rather than hide it.
Once the deploy went through, I noticed something else. Asking myself "what's actually different between drillexercises-dev-service-anomaly-subscription (Stack 2) and drillexercises-dev-unified-anomaly-subscription (Stack 3)," the answer turned out to be: they're watching exactly the same thing. Both are attached to the same monitor, and both read thresholdPercentage/thresholdAbsoluteUsd from the same params.anomalyDetection, so by default the thresholds are identical too. That means deploying both Stack 2 and Stack 3 results in two notifications for the same single anomaly — an email via Stack 2, plus an email (and optionally Slack) via Stack 3.
To address this, I made it possible to set a stricter threshold on Stack 3 specifically.
// lib/types/anomaly-params.ts
export interface AnomalyDetectionParams extends AnomalyThreshold {
readonly monitorDimension?: 'SERVICE' | 'LINKED_ACCOUNT';
// Escalation threshold for Stack 3. Falls back to the base value if unset
readonly unifiedEscalation?: AnomalyThreshold;
}
// lib/stacks/budgets-cost-anomaly-detection-unified-stack.ts
const anomalyThreshold = anomalyParams.unifiedEscalation ?? anomalyParams;
new CostAnomalyDetection(this, 'UnifiedAnomalyDetection', {
existingMonitorArn: props.anomalyMonitorArn,
thresholdPercentage: anomalyThreshold.thresholdPercentage,
thresholdAbsoluteUsd: anomalyThreshold.thresholdAbsoluteUsd,
// ...
});
In dev-params.ts, the base threshold is "20%+ actual cost AND $5+ absolute," while Stack 3's escalation threshold is "50%+ actual cost AND $20+ absolute." This demonstrates a severity split: Stack 2 casts a wide net via email, while Stack 3 (the unified/Slack channel) only receives the more serious anomalies.
To be honest, though, this doesn't eliminate the duplication itself. An anomaly large enough to satisfy both thresholds still triggers two notifications. In real-world usage, the straightforward choice is to use only one of Stack 2 or Stack 3 for anomaly detection. Think of unifiedEscalation purely as a demonstration that "a single monitor can feed multiple severity-tiered subscriptions."
5. AWS Chatbot's guardrail policy default is AdministratorAccess (for both Slack and Teams)
This is the biggest "hurts if you don't know it" gotcha of this whole exercise. Both SlackChannelConfigurationProps.guardrailPolicies and CfnMicrosoftTeamsChannelConfigurationProps.guardrailPolicies apply the AWS-managed AdministratorAccess policy by default when left unspecified.
"So can I disable it by passing an empty array []?" — that's the trap. CDK's synth step strips empty list properties out of the synthesized CloudFormation template entirely, so from the API's perspective the property is simply unset, and the AdministratorAccess default kicks in anyway. I only noticed this by actually inspecting cdk synth output.
// common/constructs/cost/safe-slack-channel.ts
guardrailPolicies:
props.guardrailPolicies && props.guardrailPolicies.length > 0
? props.guardrailPolicies
: [iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess')],
Since Microsoft Teams has no CDK L2 construct at all, SafeMicrosoftTeamsChannelConfiguration also auto-generates the minimal "notification only" IAM role (cloudwatch:Describe*/Get*/List* only) used by AWS's own sample Chatbot policy.
6. Reusable cost constructs (common/constructs/cost/)
The pieces above aren't specific to this workspace, so they're factored out into infrastructure/common/constructs/cost/ for reuse across the whole repository.
| Construct | Wraps | Used by |
|---|---|---|
CostAlertTopic |
SNS Topic + conditional resource policy + email subscription | Stack 1, 2, 3, 4 |
CostBudget |
CfnBudget driven by BudgetNotificationRule[]
|
Stack 1 (x2), Stack 3 |
CostAnomalyDetection |
CfnAnomalyMonitor + CfnAnomalySubscription (skips monitor creation when existingMonitorArn is given) |
Stack 2, Stack 3 |
BillingAlarm |
AWS/Billing EstimatedCharges alarm |
Stack 4 |
SafeSlackChannelConfiguration |
Slack configuration with safe guardrail defaults | Stack 3, Stack 5 |
SafeMicrosoftTeamsChannelConfiguration |
Teams configuration with a least-privilege role plus safe guardrails | Stack 5 |
Stack 5's Step Functions/JSONata logic itself isn't factored out, since the message formatting is tightly coupled to this workspace's specific content; only the Chatbot delivery portion uses the safe wrappers above.
7. The cost digest's JSONata state machine (Stack 5)
Stack 5 generalizes a "post a weekly cost summary to Teams" CloudFormation template originally hand-written for Microsoft Teams so it also supports Slack — the biggest challenge of this exercise. The state machine consists of exactly two states, both written as raw ASL via sfn.CustomState (since there's no typed CDK task for the Cost Explorer or SNS AWS-SDK integrations).
const getCostAndUsage = new sfn.CustomState(this, 'GetCostAndUsage', {
stateJson: {
Type: 'Task',
Resource: 'arn:aws:states:::aws-sdk:costexplorer:getCostAndUsage',
Arguments: { /* Granularity, Metrics, TimePeriod (JSONata), GroupBy, Filter */ },
Assign: { AngryThreshold: angryThresholdUsd, AccountId: cdk.Aws.ACCOUNT_ID, /* ... */ },
Output: { Start: '{% ... %}', CostSum: '{% ... %}', CostSorted: '{% ... %}' },
},
});
const publishCostDigest = new sfn.CustomState(this, 'PublishCostDigest', {
stateJson: {
Type: 'Task',
Resource: 'arn:aws:states:::sns:publish',
Arguments: { Message: { /* title/description, JSONata templates */ }, TopicArn: topic.topicArn },
},
});
getCostAndUsage.next(publishCostDigest);
new sfn.StateMachine(this, 'CostDigestStateMachine', {
definitionBody: sfn.DefinitionBody.fromChainable(getCostAndUsage),
queryLanguage: sfn.QueryLanguage.JSONATA,
tracingEnabled: true,
logs: { destination: logGroup, level: sfn.LogLevel.ALL, includeExecutionData: true },
});
Next/End aren't hand-written anywhere. Just chaining with .next() is enough — CDK's state graph composition attaches them automatically (Next: "PublishCostDigest" on the first state, End: true on the last).
A JS string-escaping trap worth knowing about. The description message concatenates JSONata string literals for line breaks (
"\n") and a regex for stripping theAWS/Amazonprefix off service names ($replace(/^(AWS|Amazon)\s*/, "")). Write either escape as a single backslash inside a TypeScript template literal and JavaScript resolves it immediately at the string-literal level —\nsilently becomes a real newline character, and\s(not a recognized JS escape) silently loses its backslash entirely, leaving a baresthat turns the regex intos*and quietly breaks the service-name cleanup. The fix for both is the same: write\\nand\\s(double backslash) in the TypeScript source, so what actually reaches JSONata — after this string round-trips through JSON as part of the synthesized ASL — is the two-character escape sequence\n/\s, not a literal control character or a mangled regex. I found this not just by reading the JSONata spec, but by actually inspecting the synthesizedcdk synthoutput before writing tests.
The EventBridge Scheduler's StepFunctionsStartExecution target (aws-scheduler-targets) automatically generates and attaches the scheduler's own execution role — unlike the original CloudFormation template this was ported from, there's no need to hand-write the states:StartExecution IAM statement.
Also worth noting: the title/description JSONata expressions aren't one-off inline strings — they come from two locale-specific builder functions, buildCostDigestTitleExpression/buildCostDigestDescriptionExpression. Flipping params.costDigest.locale ('ja' | 'en', default 'en') is all it takes to switch to the Japanese digest message (e.g. "😱 Costs are spiking" / "😊 Costs are steady" becomes "😱 コストが跳ね上がっています" / "😊 コストは落ち着いています").
Deploy & Verify
export PROJECT=your-project
export ENV=dev
npm run bootstrap -w workspaces/budgets-cost-anomaly-detection # first time only
npm run synth -w workspaces/budgets-cost-anomaly-detection
npm run deploy:all -w workspaces/budgets-cost-anomaly-detection
Before deploying, replace the placeholder email address in parameters/dev-params.ts with a real one (SNS/Budgets email subscriptions require confirmation). To use Slack/Teams delivery, uncomment notification.slack/notification.teams.
# Pattern A: check the budget configuration (real billing data updates only a few times a day, so no instant trigger)
aws budgets describe-budgets --account-id <account-id>
# Pattern B: check the anomaly monitor/subscription (baseline establishment takes 24h+)
aws ce get-anomaly-monitors
aws ce get-anomaly-subscriptions
# Pattern C: check the topic's subscriptions
aws sns list-subscriptions-by-topic --topic-arn <finops-alert-topic-arn>
# Pattern D: check the alarm and its data (this stack only exists in us-east-1)
aws cloudwatch describe-alarms --alarm-names <project>-<env>-estimated-charges --region us-east-1
# Pattern E: run on demand without waiting for the schedule
aws stepfunctions start-execution --state-machine-arn <cost-digest-state-machine-arn>
Cost Estimate
💰 Rough monthly estimate (Tokyo region, Stack 4 only in us-east-1)
| Service | Applies to | Rough monthly cost |
|---|---|---|
| AWS Budgets | A, C | First 2 budgets free; ~$0.02/day per budget after that |
| AWS Cost Anomaly Detection | B, C | No additional charge |
| Amazon SNS | All | First 1M requests within the free tier |
| AWS Chatbot | C, E (optional) | No additional charge |
| CloudWatch alarm (billing) | D | ~$0.10/alarm/month |
| Step Functions (Standard) | E | First 4,000 state transitions/month free; negligible at once daily |
| EventBridge Scheduler | E | First 14M invocations/month free |
Rough total: effectively $0-2/month at typical alert volumes.
Summary
What we learned from this pattern:
- Pattern A (Budgets): Best for a known, fixed cost ceiling. Notifications fail silently if the SNS topic policy isn't set explicitly
-
Pattern B (Anomaly Detection): Best for catching unexpected spend even within budget. SNS delivery requires
IMMEDIATEfrequency, andthresholdExpressionis a hand-assembled JSON string -
Pattern C (Unified): Consolidating into one alert channel is more operationally realistic than managing a separate topic per signal — but since AWS caps AWS-managed Cost Anomaly Detection monitors at effectively one or two per account, Stack 3 doesn't create its own monitor and instead attaches an extra subscription to Stack 2's monitor via a real cross-stack reference.
unifiedEscalationlets you split by severity by raising Stack 3's threshold, but as long as both stacks are deployed, the duplicate notification itself never goes away — so in real usage, pick either Stack 2 or Stack 3 for anomaly detection, not both -
Pattern D (Billing Alarm): The simplest safety net, but has two gotchas CDK can't express: the manual "Receive Billing Alerts" opt-in, and being pinned to
us-east-1 -
Pattern E (Cost Digest): A proactive, scheduled digest complements the other four patterns well.
sfn.CustomStatelets you write raw ASL/JSONata and skip Lambda entirely, but watch the string escaping at the JS → JSON → JSONata boundaries -
AWS Chatbot's
AdministratorAccessguardrail default applies to both Slack and Teams, and an empty array doesn't disable it — always pin an explicit, least-privilege guardrail - Wiring that repeats across stacks is worth factoring out into a construct like
common/constructs/cost/once, rather than copy-pasting
References
- AWS Budgets User Guide
- Creating an Amazon SNS topic for budget notifications
- AWS Cost Anomaly Detection
- Extending AWS managed monitors in AWS Cost Anomaly Detection (the source for the "one SERVICE monitor per account" limit behind Stack 3's design)
- Create a billing alarm to monitor your estimated AWS charges
- Setting up AWS Chatbot with Microsoft Teams
- Variables and JSONata in AWS Step Functions
- Amazon EventBridge Scheduler
Let's keep learning practical AWS CDK patterns through the 100 drill exercises!
If you found this helpful, please ⭐ the repository!
📌 You can see the entire code in my GitHub repository.

Top comments (0)