@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 +189 -0
- package/dist/billing-alarm-stack.d.ts +41 -0
- package/dist/billing-alarm-stack.d.ts.map +1 -0
- package/dist/billing-alarm-stack.js +46 -0
- package/dist/billing-alarm-stack.js.map +1 -0
- package/dist/billing-alarm.d.ts +117 -0
- package/dist/billing-alarm.d.ts.map +1 -0
- package/dist/billing-alarm.js +153 -0
- package/dist/billing-alarm.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
- package/src/billing-alarm-stack.ts +56 -0
- package/src/billing-alarm.ts +200 -0
- package/src/index.ts +10 -0
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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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|