@tlj-blocks/billing-alarm-cdk 0.1.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/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # @tlj-blocks/billing-alarm-cdk
2
+
3
+ CloudWatch alarms that email you when your AWS bill passes a spend threshold.
4
+ The default thresholds are 10 USD and 100 USD.
5
+
6
+ There is one alarm per threshold, so crossing 10 USD sends an email and crossing
7
+ 100 USD later in the same month sends a second one.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install @tlj-blocks/billing-alarm-cdk
13
+ ```
14
+
15
+ `aws-cdk-lib` and `constructs` are peer dependencies. Your CDK app already has
16
+ them, but if not:
17
+
18
+ ```sh
19
+ npm install aws-cdk-lib constructs
20
+ ```
21
+
22
+ Requires `aws-cdk-lib` 2.160.0 or later and `constructs` 10.x.
23
+
24
+ ## Quick start
25
+
26
+ ```ts
27
+ // bin/app.ts
28
+ import { App } from "aws-cdk-lib";
29
+ import { BillingAlarmStack } from "@tlj-blocks/billing-alarm-cdk";
30
+
31
+ const app = new App();
32
+
33
+ new BillingAlarmStack(app, "BillingAlarms", {
34
+ notificationEmail: "me@example.com",
35
+ // thresholds defaults to [10, 100]
36
+ env: { account: process.env.CDK_DEFAULT_ACCOUNT },
37
+ });
38
+ ```
39
+
40
+ ```sh
41
+ cdk deploy BillingAlarms
42
+ ```
43
+
44
+ You do not set a region. The stack pins itself to `us-east-1`, because that is
45
+ the only region where AWS publishes the billing metric. See
46
+ [Why us-east-1](#why-us-east-1).
47
+
48
+ ## Two steps you have to do by hand
49
+
50
+ CloudFormation cannot do either of these, and the alarms report no data until
51
+ you finish the first one.
52
+
53
+ ### 1. Turn on billing alerts
54
+
55
+ Open the [billing preferences
56
+ page](https://console.aws.amazon.com/billing/home#/preferences), tick "Receive
57
+ CloudWatch billing alerts", and save. Do this in the payer account if you use
58
+ AWS Organizations.
59
+
60
+ Until you do, AWS never publishes the `EstimatedCharges` metric and every alarm
61
+ sits in `INSUFFICIENT_DATA`. After you switch it on, the first data point can
62
+ take up to 24 hours to appear.
63
+
64
+ ### 2. Confirm the subscription email
65
+
66
+ The first deploy creates an SNS email subscription, and AWS sends a confirmation
67
+ link to `notificationEmail`. Click it. Until you do, SNS delivers nothing.
68
+
69
+ ## Using the construct in a stack you already have
70
+
71
+ Use `BillingAlarm` instead of `BillingAlarmStack` if you would rather not deploy
72
+ a separate stack. Put it in a `us-east-1` stack yourself.
73
+
74
+ ```ts
75
+ import { Stack, StackProps } from "aws-cdk-lib";
76
+ import { Construct } from "constructs";
77
+ import { BillingAlarm } from "@tlj-blocks/billing-alarm-cdk";
78
+
79
+ export class OpsStack extends Stack {
80
+ constructor(scope: Construct, id: string, props?: StackProps) {
81
+ super(scope, id, { ...props, env: { ...props?.env, region: "us-east-1" } });
82
+
83
+ new BillingAlarm(this, "BillingAlarm", {
84
+ notificationEmail: "me@example.com",
85
+ thresholds: [10, 100, 500],
86
+ alarmNamePrefix: "personal",
87
+ });
88
+ }
89
+ }
90
+ ```
91
+
92
+ ## Props
93
+
94
+ `BillingAlarm` and `BillingAlarmStack` both take these. `BillingAlarmStack` also
95
+ takes the usual `StackProps`, except that it sets `env.region` itself.
96
+
97
+ | Prop | Type | Default | Notes |
98
+ | ------------------- | ------------- | ----------- | --------------------------------------------------------------- |
99
+ | `notificationEmail` | `string` | required | Who gets the email. Optional only if you pass `topic`. |
100
+ | `thresholds` | `number[]` | `[10, 100]` | One alarm each. Must be positive and must not repeat. |
101
+ | `currency` | `string` | `"USD"` | Must match your billing currency or no data arrives. |
102
+ | `topic` | `sns.ITopic` | a new topic | Notify a topic you already have. Ignores `notificationEmail`. |
103
+ | `topicName` | `string` | generated | Names the created topic. |
104
+ | `alarmNamePrefix` | `string` | `"aws"` | Alarms are named `prefix-billing-over-threshold-currency`. |
105
+ | `period` | `Duration` | 6 hours | Evaluation period. The metric updates a few times a day. |
106
+
107
+ ## What the construct exposes
108
+
109
+ `topic` is the `sns.ITopic` the alarms notify. Add subscribers to it for SMS or
110
+ a chatbot.
111
+
112
+ `alarms` is the array of `cloudwatch.Alarm`, one per threshold, in the order you
113
+ listed them.
114
+
115
+ `BillingAlarmStack` exposes its `BillingAlarm` as `billingAlarm`, so you can
116
+ reach both of the above from the stack.
117
+
118
+ To email a second address:
119
+
120
+ ```ts
121
+ import { Subscription, SubscriptionProtocol } from "aws-cdk-lib/aws-sns";
122
+
123
+ const billing = new BillingAlarm(this, "BillingAlarm", {
124
+ notificationEmail: "me@example.com",
125
+ });
126
+
127
+ new Subscription(this, "Ops", {
128
+ topic: billing.topic,
129
+ protocol: SubscriptionProtocol.EMAIL,
130
+ endpoint: "ops@example.com",
131
+ });
132
+ ```
133
+
134
+ ## How the alarms behave
135
+
136
+ `AWS/Billing EstimatedCharges` is a running total for the current month. An
137
+ alarm that fires on the 12th stays in `ALARM` until charges reset on the 1st,
138
+ then returns to `OK` and can fire again. You therefore get one email per
139
+ threshold per month rather than one every evaluation period.
140
+
141
+ The flip side is that spend climbing from 10 USD to 99 USD sends no further
142
+ email, because the 10 USD alarm is already in `ALARM`. Add more thresholds if
143
+ you want finer steps.
144
+
145
+ The number AWS reports is an estimate. It excludes credits and refunds that get
146
+ applied later, so it will not match your final invoice.
147
+
148
+ Missing data counts as `notBreaching`, so the first days of a month and the
149
+ period before you switch on billing alerts do not raise false alarms.
150
+
151
+ ## Why us-east-1
152
+
153
+ AWS publishes billing metrics to `us-east-1` only, whatever regions you actually
154
+ spend in. An alarm on `AWS/Billing` created anywhere else receives no data and
155
+ never fires. `BillingAlarmStack` sets the region for you and throws if you pass
156
+ a different one, rather than deploying a stack that cannot work.
157
+
158
+ ## Alarms or AWS Budgets
159
+
160
+ This package uses CloudWatch alarms. They are plain CloudFormation, and the
161
+ first 10 alarms in an account are free.
162
+
163
+ AWS Budgets is the better fit if you want alerts on a forecast, such as "you are
164
+ on track to exceed 100 USD", or budgets split by service or tag, or alerts on
165
+ usage rather than cost. Nothing stops you running both.
166
+
167
+ ## Resources created
168
+
169
+ An `AWS::SNS::Topic`, an `AWS::SNS::Subscription` of protocol `email`, and one
170
+ `AWS::CloudWatch::Alarm` per threshold.
171
+
172
+ ## Troubleshooting
173
+
174
+ If the alarms stay in `INSUFFICIENT_DATA`, billing alerts are probably still
175
+ off. Check step 1 above, then confirm the metric exists:
176
+
177
+ ```sh
178
+ aws cloudwatch list-metrics --namespace AWS/Billing --region us-east-1
179
+ ```
180
+
181
+ An empty result means AWS is not publishing it yet. Allow 24 hours after
182
+ enabling the preference.
183
+
184
+ If an alarm fires but no email arrives, the SNS subscription is unconfirmed.
185
+ Look in the inbox and the spam folder for the confirmation from AWS.
186
+
187
+ If real spend never trips an alarm, check that `currency` matches your billing
188
+ currency, and that you deployed into the payer account. Member account charges
189
+ in AWS Organizations roll up to the payer, not to the member.
@@ -0,0 +1,41 @@
1
+ import { Stack, type StackProps } from "aws-cdk-lib";
2
+ import { Construct } from "constructs";
3
+ import { BillingAlarm, type BillingAlarmProps } from "./billing-alarm";
4
+ /** The only region in which AWS publishes the `AWS/Billing` metrics. */
5
+ export declare const BILLING_METRIC_REGION = "us-east-1";
6
+ /**
7
+ * Configuration for {@link BillingAlarmStack}. It takes everything
8
+ * {@link BillingAlarm} takes, plus the usual stack properties.
9
+ */
10
+ export interface BillingAlarmStackProps extends StackProps, BillingAlarmProps {
11
+ }
12
+ /**
13
+ * A stack that holds account billing alarms and nothing else.
14
+ *
15
+ * It wraps {@link BillingAlarm} and sets the region to `us-east-1`, the only
16
+ * region where AWS publishes the billing metric, so you do not have to remember
17
+ * to. Passing a different region throws, rather than deploying a stack whose
18
+ * alarms can never fire.
19
+ *
20
+ * One manual step remains. In the billing console, open Billing preferences,
21
+ * tick "Receive CloudWatch billing alerts", and save.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * // bin/app.ts
26
+ * import { App } from "aws-cdk-lib";
27
+ * import { BillingAlarmStack } from "@tlj-blocks/billing-alarm-cdk";
28
+ *
29
+ * const app = new App();
30
+ * new BillingAlarmStack(app, "BillingAlarms", {
31
+ * notificationEmail: "me@example.com",
32
+ * thresholds: [10, 100], // the default
33
+ * });
34
+ * ```
35
+ */
36
+ export declare class BillingAlarmStack extends Stack {
37
+ /** The alarms and the topic this stack created. */
38
+ readonly billingAlarm: BillingAlarm;
39
+ constructor(scope: Construct, id: string, props: BillingAlarmStackProps);
40
+ }
41
+ //# sourceMappingURL=billing-alarm-stack.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-alarm-stack.d.ts","sourceRoot":"","sources":["../src/billing-alarm-stack.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEvE,wEAAwE;AACxE,eAAO,MAAM,qBAAqB,cAAc,CAAC;AAEjD;;;GAGG;AACH,MAAM,WAAW,sBAAuB,SAAQ,UAAU,EAAE,iBAAiB;CAAG;AAEhF;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,mDAAmD;IACnD,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC;gBAExB,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,sBAAsB;CAcxE"}
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BillingAlarmStack = exports.BILLING_METRIC_REGION = void 0;
4
+ const aws_cdk_lib_1 = require("aws-cdk-lib");
5
+ const billing_alarm_1 = require("./billing-alarm");
6
+ /** The only region in which AWS publishes the `AWS/Billing` metrics. */
7
+ exports.BILLING_METRIC_REGION = "us-east-1";
8
+ /**
9
+ * A stack that holds account billing alarms and nothing else.
10
+ *
11
+ * It wraps {@link BillingAlarm} and sets the region to `us-east-1`, the only
12
+ * region where AWS publishes the billing metric, so you do not have to remember
13
+ * to. Passing a different region throws, rather than deploying a stack whose
14
+ * alarms can never fire.
15
+ *
16
+ * One manual step remains. In the billing console, open Billing preferences,
17
+ * tick "Receive CloudWatch billing alerts", and save.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * // bin/app.ts
22
+ * import { App } from "aws-cdk-lib";
23
+ * import { BillingAlarmStack } from "@tlj-blocks/billing-alarm-cdk";
24
+ *
25
+ * const app = new App();
26
+ * new BillingAlarmStack(app, "BillingAlarms", {
27
+ * notificationEmail: "me@example.com",
28
+ * thresholds: [10, 100], // the default
29
+ * });
30
+ * ```
31
+ */
32
+ class BillingAlarmStack extends aws_cdk_lib_1.Stack {
33
+ /** The alarms and the topic this stack created. */
34
+ billingAlarm;
35
+ constructor(scope, id, props) {
36
+ const { env, ...rest } = props;
37
+ if (env?.region && env.region !== exports.BILLING_METRIC_REGION) {
38
+ throw new Error(`BillingAlarmStack only works in ${exports.BILLING_METRIC_REGION}, because AWS publishes billing metrics there and nowhere else. Got ${env.region}. ` +
39
+ `Use the BillingAlarm construct directly if you need it in a stack of your own.`);
40
+ }
41
+ super(scope, id, { ...rest, env: { ...env, region: exports.BILLING_METRIC_REGION } });
42
+ this.billingAlarm = new billing_alarm_1.BillingAlarm(this, "BillingAlarm", props);
43
+ }
44
+ }
45
+ exports.BillingAlarmStack = BillingAlarmStack;
46
+ //# sourceMappingURL=billing-alarm-stack.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-alarm-stack.js","sourceRoot":"","sources":["../src/billing-alarm-stack.ts"],"names":[],"mappings":";;;AAAA,6CAAqD;AAErD,mDAAuE;AAEvE,wEAAwE;AAC3D,QAAA,qBAAqB,GAAG,WAAW,CAAC;AAQjD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAa,iBAAkB,SAAQ,mBAAK;IAC1C,mDAAmD;IAC1C,YAAY,CAAe;IAEpC,YAAY,KAAgB,EAAE,EAAU,EAAE,KAA6B;QACrE,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,GAAG,KAAK,CAAC;QAE/B,IAAI,GAAG,EAAE,MAAM,IAAI,GAAG,CAAC,MAAM,KAAK,6BAAqB,EAAE,CAAC;YACxD,MAAM,IAAI,KAAK,CACb,mCAAmC,6BAAqB,uEAAuE,GAAG,CAAC,MAAM,IAAI;gBAC3I,gFAAgF,CACnF,CAAC;QACJ,CAAC;QAED,KAAK,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,EAAE,GAAG,GAAG,EAAE,MAAM,EAAE,6BAAqB,EAAE,EAAE,CAAC,CAAC;QAE9E,IAAI,CAAC,YAAY,GAAG,IAAI,4BAAY,CAAC,IAAI,EAAE,cAAc,EAAE,KAAK,CAAC,CAAC;IACpE,CAAC;CACF;AAlBD,8CAkBC"}
@@ -0,0 +1,117 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import { Construct } from "constructs";
3
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
4
+ import * as sns from "aws-cdk-lib/aws-sns";
5
+ /** Spend thresholds used when {@link BillingAlarmProps.thresholds} is omitted. */
6
+ export declare const DEFAULT_THRESHOLDS: number[];
7
+ /**
8
+ * Configuration for the {@link BillingAlarm} construct.
9
+ */
10
+ export interface BillingAlarmProps {
11
+ /**
12
+ * Email address that receives the alarm notifications.
13
+ *
14
+ * The construct creates an SNS email subscription for this address. After
15
+ * the first deploy, AWS sends one confirmation email here. You must click the
16
+ * link in it before SNS delivers any notification.
17
+ *
18
+ * Ignored when {@link topic} is supplied.
19
+ */
20
+ notificationEmail?: string;
21
+ /**
22
+ * Spend levels, in {@link currency}, to alarm at. The construct creates one
23
+ * alarm per threshold, so you get a separate email each time the bill crosses
24
+ * one of them.
25
+ *
26
+ * @default [10, 100]
27
+ */
28
+ thresholds?: number[];
29
+ /**
30
+ * Currency of the estimated-charges metric. This must match the currency
31
+ * your account is billed in. AWS publishes the metric under that one currency
32
+ * only, so a mismatch produces alarms that never receive data.
33
+ *
34
+ * @default "USD"
35
+ */
36
+ currency?: string;
37
+ /**
38
+ * An existing SNS topic to notify instead of creating one. Use this to send
39
+ * billing alarms to a topic you already have, such as one wired to Slack or
40
+ * PagerDuty. When set, the construct ignores {@link notificationEmail}.
41
+ *
42
+ * @default - a new topic with an email subscription to `notificationEmail`
43
+ */
44
+ topic?: sns.ITopic;
45
+ /**
46
+ * Explicit SNS topic name for the created topic. Leave unset to let
47
+ * CloudFormation generate a unique name.
48
+ *
49
+ * @default - a CloudFormation-generated name
50
+ */
51
+ topicName?: string;
52
+ /**
53
+ * Prefix for the generated alarm names. `MyAccount` yields
54
+ * `MyAccount-billing-over-10-USD`. The alarm name is what you see in the
55
+ * CloudWatch console and in the subject of the notification email.
56
+ *
57
+ * @default "aws"
58
+ */
59
+ alarmNamePrefix?: string;
60
+ /**
61
+ * How often the alarms evaluate the estimated-charges metric. AWS refreshes
62
+ * this metric a few times a day, so a period shorter than a few hours only
63
+ * adds evaluations that find no new data.
64
+ *
65
+ * @default Duration.hours(6)
66
+ */
67
+ period?: Duration;
68
+ }
69
+ /**
70
+ * CloudWatch billing alarms for an AWS account.
71
+ *
72
+ * In your stack it creates an {@link sns.Topic} with an email subscription,
73
+ * unless you pass your own topic, and one {@link cloudwatch.Alarm} per
74
+ * threshold. Each alarm watches the `AWS/Billing EstimatedCharges` metric and
75
+ * notifies the topic when the month-to-date charges pass its threshold.
76
+ *
77
+ * CloudFormation cannot do either of the two account-level prerequisites, so
78
+ * you have to do them once by hand.
79
+ *
80
+ * 1. Turn on billing alerts. In the billing console, open Billing preferences,
81
+ * tick "Receive CloudWatch billing alerts", and save. Until you do, AWS
82
+ * never publishes the metric and the alarms stay in `INSUFFICIENT_DATA`.
83
+ * 2. Deploy to `us-east-1`. AWS publishes the billing metric there only,
84
+ * whatever regions you spend in. {@link BillingAlarmStack} sets the region
85
+ * for you. If you use this construct directly, put it in a `us-east-1`
86
+ * stack.
87
+ *
88
+ * The charges reset at the start of each month, so an alarm that fired in
89
+ * January returns to `OK` in February and can fire again.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * import { Stack, StackProps } from "aws-cdk-lib";
94
+ * import { Construct } from "constructs";
95
+ * import { BillingAlarm } from "@tlj-blocks/billing-alarm-cdk";
96
+ *
97
+ * export class OpsStack extends Stack {
98
+ * constructor(scope: Construct, id: string, props?: StackProps) {
99
+ * // The billing metric only exists in us-east-1.
100
+ * super(scope, id, { ...props, env: { ...props?.env, region: "us-east-1" } });
101
+ *
102
+ * new BillingAlarm(this, "BillingAlarm", {
103
+ * notificationEmail: "me@example.com",
104
+ * thresholds: [10, 100],
105
+ * });
106
+ * }
107
+ * }
108
+ * ```
109
+ */
110
+ export declare class BillingAlarm extends Construct {
111
+ /** The topic the alarms notify. Add more subscribers to it if you need them. */
112
+ readonly topic: sns.ITopic;
113
+ /** One alarm per threshold, in the order the thresholds were given. */
114
+ readonly alarms: cloudwatch.Alarm[];
115
+ constructor(scope: Construct, id: string, props?: BillingAlarmProps);
116
+ }
117
+ //# sourceMappingURL=billing-alarm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-alarm.d.ts","sourceRoot":"","sources":["../src/billing-alarm.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,KAAK,UAAU,MAAM,4BAA4B,CAAC;AAEzD,OAAO,KAAK,GAAG,MAAM,qBAAqB,CAAC;AAG3C,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,UAAY,CAAC;AAE5C;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IAEtB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC;IAEnB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,QAAQ,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,qBAAa,YAAa,SAAQ,SAAS;IACzC,gFAAgF;IAChF,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,KAAK,EAAE,CAAC;gBAExB,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,GAAE,iBAAsB;CAyExE"}
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.BillingAlarm = exports.DEFAULT_THRESHOLDS = void 0;
37
+ const aws_cdk_lib_1 = require("aws-cdk-lib");
38
+ const constructs_1 = require("constructs");
39
+ const cloudwatch = __importStar(require("aws-cdk-lib/aws-cloudwatch"));
40
+ const cloudwatchActions = __importStar(require("aws-cdk-lib/aws-cloudwatch-actions"));
41
+ const sns = __importStar(require("aws-cdk-lib/aws-sns"));
42
+ const snsSubscriptions = __importStar(require("aws-cdk-lib/aws-sns-subscriptions"));
43
+ /** Spend thresholds used when {@link BillingAlarmProps.thresholds} is omitted. */
44
+ exports.DEFAULT_THRESHOLDS = [10, 100];
45
+ /**
46
+ * CloudWatch billing alarms for an AWS account.
47
+ *
48
+ * In your stack it creates an {@link sns.Topic} with an email subscription,
49
+ * unless you pass your own topic, and one {@link cloudwatch.Alarm} per
50
+ * threshold. Each alarm watches the `AWS/Billing EstimatedCharges` metric and
51
+ * notifies the topic when the month-to-date charges pass its threshold.
52
+ *
53
+ * CloudFormation cannot do either of the two account-level prerequisites, so
54
+ * you have to do them once by hand.
55
+ *
56
+ * 1. Turn on billing alerts. In the billing console, open Billing preferences,
57
+ * tick "Receive CloudWatch billing alerts", and save. Until you do, AWS
58
+ * never publishes the metric and the alarms stay in `INSUFFICIENT_DATA`.
59
+ * 2. Deploy to `us-east-1`. AWS publishes the billing metric there only,
60
+ * whatever regions you spend in. {@link BillingAlarmStack} sets the region
61
+ * for you. If you use this construct directly, put it in a `us-east-1`
62
+ * stack.
63
+ *
64
+ * The charges reset at the start of each month, so an alarm that fired in
65
+ * January returns to `OK` in February and can fire again.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * import { Stack, StackProps } from "aws-cdk-lib";
70
+ * import { Construct } from "constructs";
71
+ * import { BillingAlarm } from "@tlj-blocks/billing-alarm-cdk";
72
+ *
73
+ * export class OpsStack extends Stack {
74
+ * constructor(scope: Construct, id: string, props?: StackProps) {
75
+ * // The billing metric only exists in us-east-1.
76
+ * super(scope, id, { ...props, env: { ...props?.env, region: "us-east-1" } });
77
+ *
78
+ * new BillingAlarm(this, "BillingAlarm", {
79
+ * notificationEmail: "me@example.com",
80
+ * thresholds: [10, 100],
81
+ * });
82
+ * }
83
+ * }
84
+ * ```
85
+ */
86
+ class BillingAlarm extends constructs_1.Construct {
87
+ /** The topic the alarms notify. Add more subscribers to it if you need them. */
88
+ topic;
89
+ /** One alarm per threshold, in the order the thresholds were given. */
90
+ alarms;
91
+ constructor(scope, id, props = {}) {
92
+ super(scope, id);
93
+ const currency = props.currency ?? "USD";
94
+ const prefix = props.alarmNamePrefix ?? "aws";
95
+ const period = props.period ?? aws_cdk_lib_1.Duration.hours(6);
96
+ const thresholds = props.thresholds ?? exports.DEFAULT_THRESHOLDS;
97
+ if (thresholds.length === 0) {
98
+ throw new Error("BillingAlarm: `thresholds` must not be empty.");
99
+ }
100
+ for (const threshold of thresholds) {
101
+ if (!Number.isFinite(threshold) || threshold <= 0) {
102
+ throw new Error(`BillingAlarm: thresholds must be positive numbers, got ${threshold}.`);
103
+ }
104
+ }
105
+ if (new Set(thresholds).size !== thresholds.length) {
106
+ // Duplicates would collide on the generated alarm name.
107
+ throw new Error("BillingAlarm: `thresholds` must not contain duplicates.");
108
+ }
109
+ if (props.topic) {
110
+ this.topic = props.topic;
111
+ }
112
+ else {
113
+ if (!props.notificationEmail) {
114
+ throw new Error("BillingAlarm: pass `notificationEmail`, or `topic` to notify an existing topic.");
115
+ }
116
+ const topic = new sns.Topic(this, "Topic", {
117
+ topicName: props.topicName,
118
+ displayName: "AWS Billing Alarms",
119
+ });
120
+ // SNS delivers nothing until the recipient confirms this subscription.
121
+ topic.addSubscription(new snsSubscriptions.EmailSubscription(props.notificationEmail));
122
+ this.topic = topic;
123
+ }
124
+ const action = new cloudwatchActions.SnsAction(this.topic);
125
+ this.alarms = thresholds.map((threshold) => {
126
+ // AWS/Billing EstimatedCharges is a cumulative month-to-date total. AWS
127
+ // publishes it in us-east-1 only, a few times a day. Maximum over the
128
+ // period is the right statistic for a running total.
129
+ const metric = new cloudwatch.Metric({
130
+ namespace: "AWS/Billing",
131
+ metricName: "EstimatedCharges",
132
+ dimensionsMap: { Currency: currency },
133
+ statistic: "Maximum",
134
+ period,
135
+ });
136
+ const alarm = new cloudwatch.Alarm(this, `Over${threshold}`, {
137
+ alarmName: `${prefix}-billing-over-${threshold}-${currency}`,
138
+ alarmDescription: `Estimated AWS charges for the current month exceeded ${threshold} ${currency}.`,
139
+ metric,
140
+ threshold,
141
+ evaluationPeriods: 1,
142
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
143
+ // Early in the month, and before you switch billing alerts on, there
144
+ // is no data. That is not a billing problem, so do not alarm on it.
145
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
146
+ });
147
+ alarm.addAlarmAction(action);
148
+ return alarm;
149
+ });
150
+ }
151
+ }
152
+ exports.BillingAlarm = BillingAlarm;
153
+ //# sourceMappingURL=billing-alarm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"billing-alarm.js","sourceRoot":"","sources":["../src/billing-alarm.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export { BillingAlarm, DEFAULT_THRESHOLDS, type BillingAlarmProps, } from "./billing-alarm";
2
+ export { BillingAlarmStack, BILLING_METRIC_REGION, type BillingAlarmStackProps, } from "./billing-alarm-stack";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,EACZ,kBAAkB,EAClB,KAAK,iBAAiB,GACvB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,iBAAiB,EACjB,qBAAqB,EACrB,KAAK,sBAAsB,GAC5B,MAAM,uBAAuB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BILLING_METRIC_REGION = exports.BillingAlarmStack = exports.DEFAULT_THRESHOLDS = exports.BillingAlarm = void 0;
4
+ var billing_alarm_1 = require("./billing-alarm");
5
+ Object.defineProperty(exports, "BillingAlarm", { enumerable: true, get: function () { return billing_alarm_1.BillingAlarm; } });
6
+ Object.defineProperty(exports, "DEFAULT_THRESHOLDS", { enumerable: true, get: function () { return billing_alarm_1.DEFAULT_THRESHOLDS; } });
7
+ var billing_alarm_stack_1 = require("./billing-alarm-stack");
8
+ Object.defineProperty(exports, "BillingAlarmStack", { enumerable: true, get: function () { return billing_alarm_stack_1.BillingAlarmStack; } });
9
+ Object.defineProperty(exports, "BILLING_METRIC_REGION", { enumerable: true, get: function () { return billing_alarm_stack_1.BILLING_METRIC_REGION; } });
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,iDAIyB;AAHvB,6GAAA,YAAY,OAAA;AACZ,mHAAA,kBAAkB,OAAA;AAGpB,6DAI+B;AAH7B,wHAAA,iBAAiB,OAAA;AACjB,4HAAA,qBAAqB,OAAA"}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@tlj-blocks/billing-alarm-cdk",
3
+ "version": "0.1.0",
4
+ "description": "AWS CDK stack and construct that email you when your AWS bill crosses spend thresholds",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/TimothyJones/tlj-blocks.git",
8
+ "directory": "packages/tlj-billing-alarm"
9
+ },
10
+ "homepage": "https://github.com/TimothyJones/tlj-blocks/tree/main/packages/tlj-billing-alarm#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/TimothyJones/tlj-blocks/issues"
13
+ },
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "files": [
17
+ "dist",
18
+ "src"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "typecheck": "tsc -p tsconfig.json",
26
+ "clean": "rm -rf dist",
27
+ "pretest": "npm run build",
28
+ "test": "node --test",
29
+ "prepublishOnly": "npm run clean && npm run build && npm test"
30
+ },
31
+ "peerDependencies": {
32
+ "aws-cdk-lib": "^2.160.0",
33
+ "constructs": "^10.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "aws-cdk-lib": "^2.160.0",
37
+ "constructs": "^10.3.0"
38
+ },
39
+ "license": "MIT"
40
+ }
@@ -0,0 +1,56 @@
1
+ import { Stack, type StackProps } from "aws-cdk-lib";
2
+ import { Construct } from "constructs";
3
+ import { BillingAlarm, type BillingAlarmProps } from "./billing-alarm";
4
+
5
+ /** The only region in which AWS publishes the `AWS/Billing` metrics. */
6
+ export const BILLING_METRIC_REGION = "us-east-1";
7
+
8
+ /**
9
+ * Configuration for {@link BillingAlarmStack}. It takes everything
10
+ * {@link BillingAlarm} takes, plus the usual stack properties.
11
+ */
12
+ export interface BillingAlarmStackProps extends StackProps, BillingAlarmProps {}
13
+
14
+ /**
15
+ * A stack that holds account billing alarms and nothing else.
16
+ *
17
+ * It wraps {@link BillingAlarm} and sets the region to `us-east-1`, the only
18
+ * region where AWS publishes the billing metric, so you do not have to remember
19
+ * to. Passing a different region throws, rather than deploying a stack whose
20
+ * alarms can never fire.
21
+ *
22
+ * One manual step remains. In the billing console, open Billing preferences,
23
+ * tick "Receive CloudWatch billing alerts", and save.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // bin/app.ts
28
+ * import { App } from "aws-cdk-lib";
29
+ * import { BillingAlarmStack } from "@tlj-blocks/billing-alarm-cdk";
30
+ *
31
+ * const app = new App();
32
+ * new BillingAlarmStack(app, "BillingAlarms", {
33
+ * notificationEmail: "me@example.com",
34
+ * thresholds: [10, 100], // the default
35
+ * });
36
+ * ```
37
+ */
38
+ export class BillingAlarmStack extends Stack {
39
+ /** The alarms and the topic this stack created. */
40
+ readonly billingAlarm: BillingAlarm;
41
+
42
+ constructor(scope: Construct, id: string, props: BillingAlarmStackProps) {
43
+ const { env, ...rest } = props;
44
+
45
+ if (env?.region && env.region !== BILLING_METRIC_REGION) {
46
+ throw new Error(
47
+ `BillingAlarmStack only works in ${BILLING_METRIC_REGION}, because AWS publishes billing metrics there and nowhere else. Got ${env.region}. ` +
48
+ `Use the BillingAlarm construct directly if you need it in a stack of your own.`,
49
+ );
50
+ }
51
+
52
+ super(scope, id, { ...rest, env: { ...env, region: BILLING_METRIC_REGION } });
53
+
54
+ this.billingAlarm = new BillingAlarm(this, "BillingAlarm", props);
55
+ }
56
+ }
@@ -0,0 +1,200 @@
1
+ import { Duration } from "aws-cdk-lib";
2
+ import { Construct } from "constructs";
3
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
4
+ import * as cloudwatchActions from "aws-cdk-lib/aws-cloudwatch-actions";
5
+ import * as sns from "aws-cdk-lib/aws-sns";
6
+ import * as snsSubscriptions from "aws-cdk-lib/aws-sns-subscriptions";
7
+
8
+ /** Spend thresholds used when {@link BillingAlarmProps.thresholds} is omitted. */
9
+ export const DEFAULT_THRESHOLDS = [10, 100];
10
+
11
+ /**
12
+ * Configuration for the {@link BillingAlarm} construct.
13
+ */
14
+ export interface BillingAlarmProps {
15
+ /**
16
+ * Email address that receives the alarm notifications.
17
+ *
18
+ * The construct creates an SNS email subscription for this address. After
19
+ * the first deploy, AWS sends one confirmation email here. You must click the
20
+ * link in it before SNS delivers any notification.
21
+ *
22
+ * Ignored when {@link topic} is supplied.
23
+ */
24
+ notificationEmail?: string;
25
+
26
+ /**
27
+ * Spend levels, in {@link currency}, to alarm at. The construct creates one
28
+ * alarm per threshold, so you get a separate email each time the bill crosses
29
+ * one of them.
30
+ *
31
+ * @default [10, 100]
32
+ */
33
+ thresholds?: number[];
34
+
35
+ /**
36
+ * Currency of the estimated-charges metric. This must match the currency
37
+ * your account is billed in. AWS publishes the metric under that one currency
38
+ * only, so a mismatch produces alarms that never receive data.
39
+ *
40
+ * @default "USD"
41
+ */
42
+ currency?: string;
43
+
44
+ /**
45
+ * An existing SNS topic to notify instead of creating one. Use this to send
46
+ * billing alarms to a topic you already have, such as one wired to Slack or
47
+ * PagerDuty. When set, the construct ignores {@link notificationEmail}.
48
+ *
49
+ * @default - a new topic with an email subscription to `notificationEmail`
50
+ */
51
+ topic?: sns.ITopic;
52
+
53
+ /**
54
+ * Explicit SNS topic name for the created topic. Leave unset to let
55
+ * CloudFormation generate a unique name.
56
+ *
57
+ * @default - a CloudFormation-generated name
58
+ */
59
+ topicName?: string;
60
+
61
+ /**
62
+ * Prefix for the generated alarm names. `MyAccount` yields
63
+ * `MyAccount-billing-over-10-USD`. The alarm name is what you see in the
64
+ * CloudWatch console and in the subject of the notification email.
65
+ *
66
+ * @default "aws"
67
+ */
68
+ alarmNamePrefix?: string;
69
+
70
+ /**
71
+ * How often the alarms evaluate the estimated-charges metric. AWS refreshes
72
+ * this metric a few times a day, so a period shorter than a few hours only
73
+ * adds evaluations that find no new data.
74
+ *
75
+ * @default Duration.hours(6)
76
+ */
77
+ period?: Duration;
78
+ }
79
+
80
+ /**
81
+ * CloudWatch billing alarms for an AWS account.
82
+ *
83
+ * In your stack it creates an {@link sns.Topic} with an email subscription,
84
+ * unless you pass your own topic, and one {@link cloudwatch.Alarm} per
85
+ * threshold. Each alarm watches the `AWS/Billing EstimatedCharges` metric and
86
+ * notifies the topic when the month-to-date charges pass its threshold.
87
+ *
88
+ * CloudFormation cannot do either of the two account-level prerequisites, so
89
+ * you have to do them once by hand.
90
+ *
91
+ * 1. Turn on billing alerts. In the billing console, open Billing preferences,
92
+ * tick "Receive CloudWatch billing alerts", and save. Until you do, AWS
93
+ * never publishes the metric and the alarms stay in `INSUFFICIENT_DATA`.
94
+ * 2. Deploy to `us-east-1`. AWS publishes the billing metric there only,
95
+ * whatever regions you spend in. {@link BillingAlarmStack} sets the region
96
+ * for you. If you use this construct directly, put it in a `us-east-1`
97
+ * stack.
98
+ *
99
+ * The charges reset at the start of each month, so an alarm that fired in
100
+ * January returns to `OK` in February and can fire again.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * import { Stack, StackProps } from "aws-cdk-lib";
105
+ * import { Construct } from "constructs";
106
+ * import { BillingAlarm } from "@tlj-blocks/billing-alarm-cdk";
107
+ *
108
+ * export class OpsStack extends Stack {
109
+ * constructor(scope: Construct, id: string, props?: StackProps) {
110
+ * // The billing metric only exists in us-east-1.
111
+ * super(scope, id, { ...props, env: { ...props?.env, region: "us-east-1" } });
112
+ *
113
+ * new BillingAlarm(this, "BillingAlarm", {
114
+ * notificationEmail: "me@example.com",
115
+ * thresholds: [10, 100],
116
+ * });
117
+ * }
118
+ * }
119
+ * ```
120
+ */
121
+ export class BillingAlarm extends Construct {
122
+ /** The topic the alarms notify. Add more subscribers to it if you need them. */
123
+ readonly topic: sns.ITopic;
124
+ /** One alarm per threshold, in the order the thresholds were given. */
125
+ readonly alarms: cloudwatch.Alarm[];
126
+
127
+ constructor(scope: Construct, id: string, props: BillingAlarmProps = {}) {
128
+ super(scope, id);
129
+
130
+ const currency = props.currency ?? "USD";
131
+ const prefix = props.alarmNamePrefix ?? "aws";
132
+ const period = props.period ?? Duration.hours(6);
133
+ const thresholds = props.thresholds ?? DEFAULT_THRESHOLDS;
134
+
135
+ if (thresholds.length === 0) {
136
+ throw new Error("BillingAlarm: `thresholds` must not be empty.");
137
+ }
138
+ for (const threshold of thresholds) {
139
+ if (!Number.isFinite(threshold) || threshold <= 0) {
140
+ throw new Error(
141
+ `BillingAlarm: thresholds must be positive numbers, got ${threshold}.`,
142
+ );
143
+ }
144
+ }
145
+ if (new Set(thresholds).size !== thresholds.length) {
146
+ // Duplicates would collide on the generated alarm name.
147
+ throw new Error("BillingAlarm: `thresholds` must not contain duplicates.");
148
+ }
149
+
150
+ if (props.topic) {
151
+ this.topic = props.topic;
152
+ } else {
153
+ if (!props.notificationEmail) {
154
+ throw new Error(
155
+ "BillingAlarm: pass `notificationEmail`, or `topic` to notify an existing topic.",
156
+ );
157
+ }
158
+ const topic = new sns.Topic(this, "Topic", {
159
+ topicName: props.topicName,
160
+ displayName: "AWS Billing Alarms",
161
+ });
162
+ // SNS delivers nothing until the recipient confirms this subscription.
163
+ topic.addSubscription(
164
+ new snsSubscriptions.EmailSubscription(props.notificationEmail),
165
+ );
166
+ this.topic = topic;
167
+ }
168
+
169
+ const action = new cloudwatchActions.SnsAction(this.topic);
170
+
171
+ this.alarms = thresholds.map((threshold) => {
172
+ // AWS/Billing EstimatedCharges is a cumulative month-to-date total. AWS
173
+ // publishes it in us-east-1 only, a few times a day. Maximum over the
174
+ // period is the right statistic for a running total.
175
+ const metric = new cloudwatch.Metric({
176
+ namespace: "AWS/Billing",
177
+ metricName: "EstimatedCharges",
178
+ dimensionsMap: { Currency: currency },
179
+ statistic: "Maximum",
180
+ period,
181
+ });
182
+
183
+ const alarm = new cloudwatch.Alarm(this, `Over${threshold}`, {
184
+ alarmName: `${prefix}-billing-over-${threshold}-${currency}`,
185
+ alarmDescription: `Estimated AWS charges for the current month exceeded ${threshold} ${currency}.`,
186
+ metric,
187
+ threshold,
188
+ evaluationPeriods: 1,
189
+ comparisonOperator:
190
+ cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
191
+ // Early in the month, and before you switch billing alerts on, there
192
+ // is no data. That is not a billing problem, so do not alarm on it.
193
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
194
+ });
195
+
196
+ alarm.addAlarmAction(action);
197
+ return alarm;
198
+ });
199
+ }
200
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ BillingAlarm,
3
+ DEFAULT_THRESHOLDS,
4
+ type BillingAlarmProps,
5
+ } from "./billing-alarm";
6
+ export {
7
+ BillingAlarmStack,
8
+ BILLING_METRIC_REGION,
9
+ type BillingAlarmStackProps,
10
+ } from "./billing-alarm-stack";