DEV Community

Cover image for AWS CDK 100 Drill Exercises #010: Budget Alerts & Cost Anomaly Detection — 5 FinOps Alerting Patterns in CDK

AWS CDK 100 Drill Exercises #010: Budget Alerts & Cost Anomaly Detection — 5 FinOps Alerting Patterns in CDK

Level 300

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: IMMEDIATE constraint required for SNS delivery in AWS Cost Anomaly Detection, and how to hand-assemble thresholdExpression as a JSON string
  • How AWS Budgets, Cost Anomaly Detection, and CloudWatch alarms each require SNS publish permissions in their own distinct way (different SourceAccount/SourceArn conditions 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 guardrailPolicies defaults to AdministratorAccess when 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 (\n happens to work, \s silently breaks)
  • Six reusable constructs factored out into common/constructs/cost/ for reuse across the whole repository

📁 Code repository: GitHub


Architecture Overview

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)
Enter fullscreen mode Exit fullscreen mode

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
    ],
},
Enter fullscreen mode Exit fullscreen mode

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,
}));
Enter fullscreen mode Exit fullscreen mode

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'));
}
Enter fullscreen mode Exit fullscreen mode

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:Decrypt granted in the key policy. Here we enforce enforceSSL: true for 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)],
            },
        },
    ],
}),
Enter fullscreen mode Exit fullscreen mode

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 on IMMEDIATE subscriptions — DAILY/WEEKLY are email-only. If you also want a daily email summary, add a second CfnAnomalySubscription (frequency: DAILY, EMAIL subscriber) pointing at the same monitor.

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)
Enter fullscreen mode Exit fullscreen mode

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],
    // ...
});
Enter fullscreen mode Exit fullscreen mode

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,
    // ...
});
Enter fullscreen mode Exit fullscreen mode

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,
    // ...
});
Enter fullscreen mode Exit fullscreen mode

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')],
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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 the AWS/Amazon prefix 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 — \n silently becomes a real newline character, and \s (not a recognized JS escape) silently loses its backslash entirely, leaving a bare s that turns the regex into s* and quietly breaks the service-name cleanup. The fix for both is the same: write \\n and \\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 synthesized cdk synth output 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Pattern A (Budgets): Best for a known, fixed cost ceiling. Notifications fail silently if the SNS topic policy isn't set explicitly
  2. Pattern B (Anomaly Detection): Best for catching unexpected spend even within budget. SNS delivery requires IMMEDIATE frequency, and thresholdExpression is a hand-assembled JSON string
  3. 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. unifiedEscalation lets 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
  4. 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
  5. Pattern E (Cost Digest): A proactive, scheduled digest complements the other four patterns well. sfn.CustomState lets you write raw ASL/JSONata and skip Lambda entirely, but watch the string escaping at the JS → JSON → JSONata boundaries
  6. AWS Chatbot's AdministratorAccess guardrail default applies to both Slack and Teams, and an empty array doesn't disable it — always pin an explicit, least-privilege guardrail
  7. Wiring that repeats across stacks is worth factoring out into a construct like common/constructs/cost/ once, rather than copy-pasting

References


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)