Repository: cdklabs/cdk-monitoring-constructs (language: python)

Issue:
[billing] Add billing anomaly alarm
### Feature scope

Billing monitoring

### Describe your suggested feature

We would like to know if our total account cost is unexpectedly high or low (an anomaly).


Discussion / hints:

Fix patch:
diff --git a/API.md b/API.md
index 2d980728..03b2fe0c 100644
--- a/API.md
+++ b/API.md
@@ -7766,6 +7766,7 @@ const billingMonitoringOptions: BillingMonitoringOptions = { ... }
 | <code><a href="#cdk-monitoring-constructs.BillingMonitoringOptions.property.addToDetailDashboard">addToDetailDashboard</a></code> | <code>boolean</code> | Flag indicating if the widgets should be added to detailed dashboard. |
 | <code><a href="#cdk-monitoring-constructs.BillingMonitoringOptions.property.addToSummaryDashboard">addToSummaryDashboard</a></code> | <code>boolean</code> | Flag indicating if the widgets should be added to summary dashboard. |
 | <code><a href="#cdk-monitoring-constructs.BillingMonitoringOptions.property.useCreatedAlarms">useCreatedAlarms</a></code> | <code><a href="#cdk-monitoring-constructs.IAlarmConsumer">IAlarmConsumer</a></code> | Calls provided function to process all alarms created. |
+| <code><a href="#cdk-monitoring-constructs.BillingMonitoringOptions.property.addTotalCostAnomalyAlarm">addTotalCostAnomalyAlarm</a></code> | <code>{[ key: string ]: <a href="#cdk-monitoring-constructs.AnomalyDetectionThreshold">AnomalyDetectionThreshold</a>}</code> | *No description.* |
 
 ---
 
@@ -7867,6 +7868,16 @@ Calls provided function to process all alarms created.
 
 ---
 
+##### `addTotalCostAnomalyAlarm`<sup>Optional</sup> <a name="addTotalCostAnomalyAlarm" id="cdk-monitoring-constructs.BillingMonitoringOptions.property.addTotalCostAnomalyAlarm"></a>
+
+```typescript
+public readonly addTotalCostAnomalyAlarm: {[ key: string ]: AnomalyDetectionThreshold};
+```
+
+- *Type:* {[ key: string ]: <a href="#cdk-monitoring-constructs.AnomalyDetectionThreshold">AnomalyDetectionThreshold</a>}
+
+---
+
 ### BillingMonitoringProps <a name="BillingMonitoringProps" id="cdk-monitoring-constructs.BillingMonitoringProps"></a>
 
 #### Initializer <a name="Initializer" id="cdk-monitoring-constructs.BillingMonitoringProps.Initializer"></a>
@@ -7888,6 +7899,7 @@ const billingMonitoringProps: BillingMonitoringProps = { ... }
 | <code><a href="#cdk-monitoring-constructs.BillingMonitoringProps.property.addToDetailDashboard">addToDetailDashboard</a></code> | <code>boolean</code> | Flag indicating if the widgets should be added to detailed dashboard. |
 | <code><a href="#cdk-monitoring-constructs.BillingMonitoringProps.property.addToSummaryDashboard">addToSummaryDashboard</a></code> | <code>boolean</code> | Flag indicating if the widgets should be added to summary dashboard. |
 | <code><a href="#cdk-monitoring-constructs.BillingMonitoringProps.property.useCreatedAlarms">useCreatedAlarms</a></code> | <code><a href="#cdk-monitoring-constructs.IAlarmConsumer">IAlarmConsumer</a></code> | Calls provided function to process all alarms created. |
+| <code><a href="#cdk-monitoring-constructs.BillingMonitoringProps.property.addTotalCostAnomalyAlarm">addTotalCostAnomalyAlarm</a></code> | <code>{[ key: string ]: <a href="#cdk-monitoring-constructs.AnomalyDetectionThreshold">AnomalyDetectionThreshold</a>}</code> | *No description.* |
 
 ---
 
@@ -7989,6 +8001,16 @@ Calls provided function to process all alarms created.
 
 ---
 
+##### `addTotalCostAnomalyAlarm`<sup>Optional</sup> <a name="addTotalCostAnomalyAlarm" id="cdk-monitoring-constructs.BillingMonitoringProps.property.addTotalCostAnomalyAlarm"></a>
+
+```typescript
+public readonly addTotalCostAnomalyAlarm: {[ key: string ]: AnomalyDetectionThreshold};
+```
+
+- *Type:* {[ key: string ]: <a href="#cdk-monitoring-constructs.AnomalyDetectionThreshold">AnomalyDetectionThreshold</a>}
+
+---
+
 ### CertificateManagerMetricFactoryProps <a name="CertificateManagerMetricFactoryProps" id="cdk-monitoring-constructs.CertificateManagerMetricFactoryProps"></a>
 
 #### Initializer <a name="Initializer" id="cdk-monitoring-constructs.CertificateManagerMetricFactoryProps.Initializer"></a>
@@ -40803,7 +40825,7 @@ public metricSearchTopCostByServiceInUsd(): IMetric
 ##### `metricTotalCostInUsd` <a name="metricTotalCostInUsd" id="cdk-monitoring-constructs.BillingMetricFactory.metricTotalCostInUsd"></a>
 
 ```typescript
-public metricTotalCostInUsd(): IMetric
+public metricTotalCostInUsd(): Metric | MathExpression
 ```
 
 
@@ -40987,9 +41009,31 @@ public createTotalChargesWidget(width: number, height: number): SingleValueWidge
 
 | **Name** | **Type** | **Description** |
 | --- | --- | --- |
+| <code><a href="#cdk-monitoring-constructs.BillingMonitoring.property.alarmFactory">alarmFactory</a></code> | <code><a href="#cdk-monitoring-constructs.AlarmFactory">AlarmFactory</a></code> | *No description.* |
+| <code><a href="#cdk-monitoring-constructs.BillingMonitoring.property.anomalyDetectingAlarmFactory">anomalyDetectingAlarmFactory</a></code> | <code><a href="#cdk-monitoring-constructs.AnomalyDetectingAlarmFactory">AnomalyDetectingAlarmFactory</a></code> | *No description.* |
 | <code><a href="#cdk-monitoring-constructs.BillingMonitoring.property.costByServiceMetric">costByServiceMetric</a></code> | <code>aws-cdk-lib.aws_cloudwatch.IMetric</code> | *No description.* |
 | <code><a href="#cdk-monitoring-constructs.BillingMonitoring.property.title">title</a></code> | <code>string</code> | *No description.* |
-| <code><a href="#cdk-monitoring-constructs.BillingMonitoring.property.totalCostMetric">totalCostMetric</a></code> | <code>aws-cdk-lib.aws_cloudwatch.IMetric</code> | *No description.* |
+| <code><a href="#cdk-monitoring-constructs.BillingMonitoring.property.totalCostMetric">totalCostMetric</a></code> | <code>aws-cdk-lib.aws_cloudwatch.Metric \| aws-cdk-lib.aws_cloudwatch.MathExpression</code> | *No description.* |
+
+---
+
+##### `alarmFactory`<sup>Required</sup> <a name="alarmFactory" id="cdk-monitoring-constructs.BillingMonitoring.property.alarmFactory"></a>
+
+```typescript
+public readonly alarmFactory: AlarmFactory;
+```
+
+- *Type:* <a href="#cdk-monitoring-constructs.AlarmFactory">AlarmFactory</a>
+
+---
+
+##### `anomalyDetectingAlarmFactory`<sup>Required</sup> <a name="anomalyDetectingAlarmFactory" id="cdk-monitoring-constructs.BillingMonitoring.property.anomalyDetectingAlarmFactory"></a>
+
+```typescript
+public readonly anomalyDetectingAlarmFactory: AnomalyDetectingAlarmFactory;
+```
+
+- *Type:* <a href="#cdk-monitoring-constructs.AnomalyDetectingAlarmFactory">AnomalyDetectingAlarmFactory</a>
 
 ---
 
@@ -41016,10 +41060,10 @@ public readonly title: string;
 ##### `totalCostMetric`<sup>Required</sup> <a name="totalCostMetric" id="cdk-monitoring-constructs.BillingMonitoring.property.totalCostMetric"></a>
 
 ```typescript
-public readonly totalCostMetric: IMetric;
+public readonly totalCostMetric: Metric | MathExpression;
 ```
 
-- *Type:* aws-cdk-lib.aws_cloudwatch.IMetric
+- *Type:* aws-cdk-lib.aws_cloudwatch.Metric | aws-cdk-lib.aws_cloudwatch.MathExpression
 
 ---
 
diff --git a/README.md b/README.md
index 8da2911a..34d85954 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@ You can browse the documentation at https://constructs.dev/packages/cdk-monitori
 | 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 |
 | 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 |
 | AWS AppSync (GraphQL API) (`.monitorAppSyncApi()`) | TPS, latency, errors | Latency, error count/rate, low/high TPS | |
-| 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 |
+| 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 |
 | AWS Certificate Manager (`.monitorCertificate()`) | Certificate expiration | Days until expiration | |
 | AWS CloudFront (`.monitorCloudFrontDistribution()`) | TPS, traffic, latency, errors | Error rate, low/high TPS | |
 | AWS CloudWatch Logs (`.monitorLog()`) | Patterns present in the log group | | |
diff --git a/lib/monitoring/aws-billing/BillingMetricFactory.ts b/lib/monitoring/aws-billing/BillingMetricFactory.ts
index 29850c35..853695af 100644
--- a/lib/monitoring/aws-billing/BillingMetricFactory.ts
+++ b/lib/monitoring/aws-billing/BillingMetricFactory.ts
@@ -1,7 +1,11 @@
 import { Duration } from "aws-cdk-lib";
 import { IMetric, Metric } from "aws-cdk-lib/aws-cloudwatch";
 
-import { MetricStatistic, XaxrMathExpression } from "../../common";
+import {
+  MetricStatistic,
+  MetricWithAlarmSupport,
+  XaxrMathExpression,
+} from "../../common";
 
 export const BillingRegion = "us-east-1";
 export const BillingCurrency = "USD";
@@ -33,7 +37,7 @@ export class BillingMetricFactory {
     });
   }
 
-  metricTotalCostInUsd(): IMetric {
+  metricTotalCostInUsd(): MetricWithAlarmSupport {
     // not using metric factory because we customize everything
 
     return new Metric({
diff --git a/lib/monitoring/aws-billing/BillingMonitoring.ts b/lib/monitoring/aws-billing/BillingMonitoring.ts
index c34966b8..f6659692 100644
--- a/lib/monitoring/aws-billing/BillingMonitoring.ts
+++ b/lib/monitoring/aws-billing/BillingMonitoring.ts
@@ -1,4 +1,3 @@
-import { Stack } from "aws-cdk-lib";
 import {
   GraphWidget,
   GraphWidgetView,
@@ -8,11 +7,15 @@ import {
 } from "aws-cdk-lib/aws-cloudwatch";
 
 import {
+  AlarmFactory,
+  AnomalyDetectingAlarmFactory,
+  AnomalyDetectionThreshold,
   BaseMonitoringProps,
   CurrencyAxisUsdFromZero,
   DefaultGraphWidgetHeight,
   DefaultSummaryWidgetHeight,
   FullWidth,
+  MetricWithAlarmSupport,
   Monitoring,
   MonitoringScope,
   QuarterWidth,
@@ -28,28 +31,53 @@ import {
   BillingRegion,
 } from "./BillingMetricFactory";
 
-export interface BillingMonitoringOptions extends BaseMonitoringProps {}
+export interface BillingMonitoringOptions extends BaseMonitoringProps {
+  readonly addTotalCostAnomalyAlarm?: Record<string, AnomalyDetectionThreshold>;
+}
+
 export interface BillingMonitoringProps extends BillingMonitoringOptions {}
 
 export class BillingMonitoring extends Monitoring {
   readonly title: string;
 
+  readonly alarmFactory: AlarmFactory;
+  readonly anomalyDetectingAlarmFactory: AnomalyDetectingAlarmFactory;
+
   readonly costByServiceMetric: IMetric;
-  readonly totalCostMetric: IMetric;
+  readonly totalCostMetric: MetricWithAlarmSupport;
 
   constructor(scope: MonitoringScope, props: BillingMonitoringProps) {
     super(scope);
 
-    const fallbackConstructName = Stack.of(scope).account;
     const namingStrategy = new MonitoringNamingStrategy({
       ...props,
-      fallbackConstructName,
+      fallbackConstructName: "Billing",
     });
     this.title = namingStrategy.resolveHumanReadableName();
+    this.alarmFactory = scope.createAlarmFactory(
+      namingStrategy.resolveAlarmFriendlyName()
+    );
+    this.anomalyDetectingAlarmFactory = new AnomalyDetectingAlarmFactory(
+      this.alarmFactory
+    );
     const metricFactory = new BillingMetricFactory();
     this.costByServiceMetric =
       metricFactory.metricSearchTopCostByServiceInUsd();
     this.totalCostMetric = metricFactory.metricTotalCostInUsd();
+
+    for (const disambiguator in props.addTotalCostAnomalyAlarm) {
+      const alarmProps = props.addTotalCostAnomalyAlarm[disambiguator];
+      const createdAlarm =
+        this.anomalyDetectingAlarmFactory.addAlarmWhenOutOfBand(
+          this.totalCostMetric,
+          "Cost-Anomaly",
+          disambiguator,
+          alarmProps
+        );
+      this.addAlarm(createdAlarm);
+    }
+
+    props.useCreatedAlarms?.consume(this.createdAlarms());
   }
 
   summaryWidgets(): IWidget[] {


Test patch:
diff --git a/test/monitoring/aws-billing/BillingMonitoring.test.ts b/test/monitoring/aws-billing/BillingMonitoring.test.ts
index 4451e6c6..7978cbe5 100644
--- a/test/monitoring/aws-billing/BillingMonitoring.test.ts
+++ b/test/monitoring/aws-billing/BillingMonitoring.test.ts
@@ -1,7 +1,7 @@
 import { Stack } from "aws-cdk-lib";
 import { Template } from "aws-cdk-lib/assertions";
 
-import { BillingMonitoring } from "../../../lib";
+import { AlarmWithAnnotation, BillingMonitoring } from "../../../lib";
 import { addMonitoringDashboardsToStack } from "../../utils/SnapshotUtil";
 import { TestMonitoringScope } from "../TestMonitoringScope";
 
@@ -17,3 +17,31 @@ test("snapshot test", () => {
   addMonitoringDashboardsToStack(stack, monitoring);
   expect(Template.fromStack(stack)).toMatchSnapshot();
 });
+
+test("snapshot test: all alarms", () => {
+  const stack = new Stack();
+
+  const scope = new TestMonitoringScope(stack, "Scope");
+
+  let numAlarmsCreated = 0;
+
+  const monitoring = new BillingMonitoring(scope, {
+    humanReadableName: "Billing",
+    addTotalCostAnomalyAlarm: {
+      Warning: {
+        alarmWhenBelowTheBand: false,
+        alarmWhenAboveTheBand: true,
+        standardDeviationForAlarm: 5,
+      },
+    },
+    useCreatedAlarms: {
+      consume(alarms: AlarmWithAnnotation[]) {
+        numAlarmsCreated = alarms.length;
+      },
+    },
+  });
+
+  addMonitoringDashboardsToStack(stack, monitoring);
+  expect(numAlarmsCreated).toStrictEqual(1);
+  expect(Template.fromStack(stack)).toMatchSnapshot();
+});
diff --git a/test/monitoring/aws-billing/__snapshots__/BillingMonitoring.test.ts.snap b/test/monitoring/aws-billing/__snapshots__/BillingMonitoring.test.ts.snap
index 84a685f1..f45fa1eb 100644
--- a/test/monitoring/aws-billing/__snapshots__/BillingMonitoring.test.ts.snap
+++ b/test/monitoring/aws-billing/__snapshots__/BillingMonitoring.test.ts.snap
@@ -58,3 +58,114 @@ Object {
   },
 }
 `;
+
+exports[`snapshot test: all alarms 1`] = `
+Object {
+  "Parameters": Object {
+    "BootstrapVersion": Object {
+      "Default": "/cdk-bootstrap/hnb659fds/version",
+      "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]",
+      "Type": "AWS::SSM::Parameter::Value<String>",
+    },
+  },
+  "Resources": Object {
+    "Alarm7103F465": Object {
+      "Properties": Object {
+        "DashboardBody": Object {
+          "Fn::Join": Array [
+            "",
+            Array [
+              "{\\"widgets\\":[{\\"type\\":\\"metric\\",\\"width\\":6,\\"height\\":4,\\"x\\":0,\\"y\\":0,\\"properties\\":{\\"view\\":\\"timeSeries\\",\\"region\\":\\"",
+              Object {
+                "Ref": "AWS::Region",
+              },
+              "\\",\\"annotations\\":{\\"alarms\\":[\\"",
+              Object {
+                "Fn::GetAtt": Array [
+                  "ScopeTestBillingCostAnomalyWarning9DBFD429",
+                  "Arn",
+                ],
+              },
+              "\\"]},\\"yAxis\\":{}}}]}",
+            ],
+          ],
+        },
+      },
+      "Type": "AWS::CloudWatch::Dashboard",
+    },
+    "Resource": Object {
+      "Properties": Object {
+        "DashboardBody": "{\\"widgets\\":[{\\"type\\":\\"text\\",\\"width\\":24,\\"height\\":1,\\"x\\":0,\\"y\\":0,\\"properties\\":{\\"markdown\\":\\"### AWS Account Billing **Billing**\\"}},{\\"type\\":\\"metric\\",\\"width\\":18,\\"height\\":5,\\"x\\":0,\\"y\\":1,\\"properties\\":{\\"view\\":\\"bar\\",\\"title\\":\\"Most Expensive Services (USD)\\",\\"region\\":\\"us-east-1\\",\\"metrics\\":[[{\\"label\\":\\" \\",\\"region\\":\\"us-east-1\\",\\"expression\\":\\"SORT(search, MAX, DESC, 10)\\",\\"period\\":86400}],[{\\"label\\":\\" \\",\\"region\\":\\"us-east-1\\",\\"expression\\":\\"SEARCH('{AWS/Billing,Currency,ServiceName} MetricName=\\\\\\"EstimatedCharges\\\\\\"', 'Maximum', 86400)\\",\\"period\\":86400,\\"visible\\":false,\\"id\\":\\"search\\"}]],\\"yAxis\\":{\\"left\\":{\\"min\\":0,\\"label\\":\\"USD\\",\\"showUnits\\":false}}}},{\\"type\\":\\"metric\\",\\"width\\":6,\\"height\\":5,\\"x\\":18,\\"y\\":1,\\"properties\\":{\\"view\\":\\"singleValue\\",\\"title\\":\\"Total Cost (USD)\\",\\"region\\":\\"us-east-1\\",\\"metrics\\":[[\\"AWS/Billing\\",\\"EstimatedCharges\\",\\"Currency\\",\\"USD\\",{\\"label\\":\\"Estimated Charges\\",\\"region\\":\\"us-east-1\\",\\"period\\":86400,\\"stat\\":\\"Maximum\\"}]],\\"setPeriodToTimeRange\\":false,\\"singleValueFullPrecision\\":false}}]}",
+      },
+      "Type": "AWS::CloudWatch::Dashboard",
+    },
+    "ScopeTestBillingCostAnomalyWarning9DBFD429": Object {
+      "Properties": Object {
+        "ActionsEnabled": true,
+        "AlarmDescription": "Anomaly detection: value is above the expected band.",
+        "AlarmName": "Test-Billing-Cost-Anomaly-Warning",
+        "ComparisonOperator": "GreaterThanUpperThreshold",
+        "DatapointsToAlarm": 3,
+        "EvaluationPeriods": 3,
+        "Metrics": Array [
+          Object {
+            "Id": "m1",
+            "Label": "Estimated Charges",
+            "MetricStat": Object {
+              "Metric": Object {
+                "Dimensions": Array [
+                  Object {
+                    "Name": "Currency",
+                    "Value": "USD",
+                  },
+                ],
+                "MetricName": "EstimatedCharges",
+                "Namespace": "AWS/Billing",
+              },
+              "Period": 86400,
+              "Stat": "Maximum",
+            },
+            "ReturnData": true,
+          },
+        ],
+        "Threshold": 0,
+        "TreatMissingData": "missing",
+      },
+      "Type": "AWS::CloudWatch::Alarm",
+    },
+    "Summary68521F81": Object {
+      "Properties": Object {
+        "DashboardBody": "{\\"widgets\\":[{\\"type\\":\\"text\\",\\"width\\":24,\\"height\\":1,\\"x\\":0,\\"y\\":0,\\"properties\\":{\\"markdown\\":\\"### AWS Account Billing **Billing**\\"}},{\\"type\\":\\"metric\\",\\"width\\":24,\\"height\\":6,\\"x\\":0,\\"y\\":1,\\"properties\\":{\\"view\\":\\"singleValue\\",\\"title\\":\\"Total Cost (USD)\\",\\"region\\":\\"us-east-1\\",\\"metrics\\":[[\\"AWS/Billing\\",\\"EstimatedCharges\\",\\"Currency\\",\\"USD\\",{\\"label\\":\\"Estimated Charges\\",\\"region\\":\\"us-east-1\\",\\"period\\":86400,\\"stat\\":\\"Maximum\\"}]],\\"setPeriodToTimeRange\\":false,\\"singleValueFullPrecision\\":false}}]}",
+      },
+      "Type": "AWS::CloudWatch::Dashboard",
+    },
+  },
+  "Rules": Object {
+    "CheckBootstrapVersion": Object {
+      "Assertions": Array [
+        Object {
+          "Assert": Object {
+            "Fn::Not": Array [
+              Object {
+                "Fn::Contains": Array [
+                  Array [
+                    "1",
+                    "2",
+                    "3",
+                    "4",
+                    "5",
+                  ],
+                  Object {
+                    "Ref": "BootstrapVersion",
+                  },
+                ],
+              },
+            ],
+          },
+          "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.",
+        },
+      ],
+    },
+  },
+}
+`;