Skip to content

Commit eb0e196

Browse files
authored
feat(billing): add total cost anomaly alarm (#262)
Fixes #204 --- _By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license_
1 parent 149b41e commit eb0e196

File tree

6 files changed

+228
-13
lines changed

6 files changed

+228
-13
lines changed

API.md

Lines changed: 48 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ You can browse the documentation at https://constructs.dev/packages/cdk-monitori
7272
| AWS API Gateway (REST API) (`.monitorApiGateway()`) | TPS, latency, errors | Latency, error count/rate, low/high TPS | To see metrics, you have to enable Advanced Monitoring |
7373
| AWS API Gateway V2 (HTTP API) (`.monitorApiGatewayV2HttpApi()`) | TPS, latency, errors | Latency, error count/rate, low/high TPS | To see route level metrics, you have to enable Advanced Monitoring |
7474
| AWS AppSync (GraphQL API) (`.monitorAppSyncApi()`) | TPS, latency, errors | Latency, error count/rate, low/high TPS | |
75-
| AWS Billing (`.monitorBilling()`) | AWS account cost | | [Requires enabling](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/gs_monitor_estimated_charges_with_cloudwatch.html#gs_turning_on_billing_metrics) the **Receive Billing Alerts** option in AWS Console / Billing Preferences |
75+
| AWS Billing (`.monitorBilling()`) | AWS account cost | Total cost (anomaly) | [Requires enabling](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/gs_monitor_estimated_charges_with_cloudwatch.html#gs_turning_on_billing_metrics) the **Receive Billing Alerts** option in AWS Console / Billing Preferences |
7676
| AWS Certificate Manager (`.monitorCertificate()`) | Certificate expiration | Days until expiration | |
7777
| AWS CloudFront (`.monitorCloudFrontDistribution()`) | TPS, traffic, latency, errors | Error rate, low/high TPS | |
7878
| AWS CloudWatch Logs (`.monitorLog()`) | Patterns present in the log group | | |

lib/monitoring/aws-billing/BillingMetricFactory.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { Duration } from "aws-cdk-lib";
22
import { IMetric, Metric } from "aws-cdk-lib/aws-cloudwatch";
33

4-
import { MetricStatistic, XaxrMathExpression } from "../../common";
4+
import {
5+
MetricStatistic,
6+
MetricWithAlarmSupport,
7+
XaxrMathExpression,
8+
} from "../../common";
59

610
export const BillingRegion = "us-east-1";
711
export const BillingCurrency = "USD";
@@ -33,7 +37,7 @@ export class BillingMetricFactory {
3337
});
3438
}
3539

36-
metricTotalCostInUsd(): IMetric {
40+
metricTotalCostInUsd(): MetricWithAlarmSupport {
3741
// not using metric factory because we customize everything
3842

3943
return new Metric({

lib/monitoring/aws-billing/BillingMonitoring.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Stack } from "aws-cdk-lib";
21
import {
32
GraphWidget,
43
GraphWidgetView,
@@ -8,11 +7,15 @@ import {
87
} from "aws-cdk-lib/aws-cloudwatch";
98

109
import {
10+
AlarmFactory,
11+
AnomalyDetectingAlarmFactory,
12+
AnomalyDetectionThreshold,
1113
BaseMonitoringProps,
1214
CurrencyAxisUsdFromZero,
1315
DefaultGraphWidgetHeight,
1416
DefaultSummaryWidgetHeight,
1517
FullWidth,
18+
MetricWithAlarmSupport,
1619
Monitoring,
1720
MonitoringScope,
1821
QuarterWidth,
@@ -28,28 +31,53 @@ import {
2831
BillingRegion,
2932
} from "./BillingMetricFactory";
3033

31-
export interface BillingMonitoringOptions extends BaseMonitoringProps {}
34+
export interface BillingMonitoringOptions extends BaseMonitoringProps {
35+
readonly addTotalCostAnomalyAlarm?: Record<string, AnomalyDetectionThreshold>;
36+
}
37+
3238
export interface BillingMonitoringProps extends BillingMonitoringOptions {}
3339

3440
export class BillingMonitoring extends Monitoring {
3541
readonly title: string;
3642

43+
readonly alarmFactory: AlarmFactory;
44+
readonly anomalyDetectingAlarmFactory: AnomalyDetectingAlarmFactory;
45+
3746
readonly costByServiceMetric: IMetric;
38-
readonly totalCostMetric: IMetric;
47+
readonly totalCostMetric: MetricWithAlarmSupport;
3948

4049
constructor(scope: MonitoringScope, props: BillingMonitoringProps) {
4150
super(scope);
4251

43-
const fallbackConstructName = Stack.of(scope).account;
4452
const namingStrategy = new MonitoringNamingStrategy({
4553
...props,
46-
fallbackConstructName,
54+
fallbackConstructName: "Billing",
4755
});
4856
this.title = namingStrategy.resolveHumanReadableName();
57+
this.alarmFactory = scope.createAlarmFactory(
58+
namingStrategy.resolveAlarmFriendlyName()
59+
);
60+
this.anomalyDetectingAlarmFactory = new AnomalyDetectingAlarmFactory(
61+
this.alarmFactory
62+
);
4963
const metricFactory = new BillingMetricFactory();
5064
this.costByServiceMetric =
5165
metricFactory.metricSearchTopCostByServiceInUsd();
5266
this.totalCostMetric = metricFactory.metricTotalCostInUsd();
67+
68+
for (const disambiguator in props.addTotalCostAnomalyAlarm) {
69+
const alarmProps = props.addTotalCostAnomalyAlarm[disambiguator];
70+
const createdAlarm =
71+
this.anomalyDetectingAlarmFactory.addAlarmWhenOutOfBand(
72+
this.totalCostMetric,
73+
"Cost-Anomaly",
74+
disambiguator,
75+
alarmProps
76+
);
77+
this.addAlarm(createdAlarm);
78+
}
79+
80+
props.useCreatedAlarms?.consume(this.createdAlarms());
5381
}
5482

5583
summaryWidgets(): IWidget[] {

test/monitoring/aws-billing/BillingMonitoring.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Stack } from "aws-cdk-lib";
22
import { Template } from "aws-cdk-lib/assertions";
33

4-
import { BillingMonitoring } from "../../../lib";
4+
import { AlarmWithAnnotation, BillingMonitoring } from "../../../lib";
55
import { addMonitoringDashboardsToStack } from "../../utils/SnapshotUtil";
66
import { TestMonitoringScope } from "../TestMonitoringScope";
77

@@ -17,3 +17,31 @@ test("snapshot test", () => {
1717
addMonitoringDashboardsToStack(stack, monitoring);
1818
expect(Template.fromStack(stack)).toMatchSnapshot();
1919
});
20+
21+
test("snapshot test: all alarms", () => {
22+
const stack = new Stack();
23+
24+
const scope = new TestMonitoringScope(stack, "Scope");
25+
26+
let numAlarmsCreated = 0;
27+
28+
const monitoring = new BillingMonitoring(scope, {
29+
humanReadableName: "Billing",
30+
addTotalCostAnomalyAlarm: {
31+
Warning: {
32+
alarmWhenBelowTheBand: false,
33+
alarmWhenAboveTheBand: true,
34+
standardDeviationForAlarm: 5,
35+
},
36+
},
37+
useCreatedAlarms: {
38+
consume(alarms: AlarmWithAnnotation[]) {
39+
numAlarmsCreated = alarms.length;
40+
},
41+
},
42+
});
43+
44+
addMonitoringDashboardsToStack(stack, monitoring);
45+
expect(numAlarmsCreated).toStrictEqual(1);
46+
expect(Template.fromStack(stack)).toMatchSnapshot();
47+
});

test/monitoring/aws-billing/__snapshots__/BillingMonitoring.test.ts.snap

Lines changed: 111 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)