From 0526376463bb786dd5820aa29243961e6b53e367 Mon Sep 17 00:00:00 2001 From: Brandon Dahler Date: Mon, 18 Apr 2022 10:48:48 -0400 Subject: [PATCH] feat(cloudwatch) create AnomalyDetectionAlarm L2 construct Allows customers to create Alarms with anomaly detection enabled. Supports both direct metrics and math expressions. Closes #10540 --- packages/@aws-cdk/aws-cloudwatch/README.md | 33 ++ packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts | 31 +- .../lib/anomaly-detection-alarm.ts | 373 ++++++++++++ packages/@aws-cdk/aws-cloudwatch/lib/index.ts | 1 + .../@aws-cdk/aws-cloudwatch/lib/metric.ts | 52 +- ...etectionAlarmIntegrationTest.template.json | 230 ++++++++ .../cdk.out | 1 + .../manifest.json | 65 +++ .../tree.json | 383 ++++++++++++ .../test/anomaly-detection-alarm.test.ts | 548 ++++++++++++++++++ .../test/integ.anomaly-detection-alarm.ts | 85 +++ 11 files changed, 1776 insertions(+), 26 deletions(-) create mode 100644 packages/@aws-cdk/aws-cloudwatch/lib/anomaly-detection-alarm.ts create mode 100644 packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/AnomalyDetectionAlarmIntegrationTest.template.json create mode 100644 packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/cdk.out create mode 100644 packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/manifest.json create mode 100644 packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/tree.json create mode 100644 packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.test.ts create mode 100644 packages/@aws-cdk/aws-cloudwatch/test/integ.anomaly-detection-alarm.ts diff --git a/packages/@aws-cdk/aws-cloudwatch/README.md b/packages/@aws-cdk/aws-cloudwatch/README.md index af3aaf7c5a765..cd6e6351ac889 100644 --- a/packages/@aws-cdk/aws-cloudwatch/README.md +++ b/packages/@aws-cdk/aws-cloudwatch/README.md @@ -260,6 +260,39 @@ different between them. This affects both the notifications sent out over SNS, as well as the EventBridge events generated by this Alarm. If you are writing code to consume these notifications, be sure to handle both formats. +### Anomaly Detection Alarms + +[Anomaly Detection Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Create_Anomaly_Detection_Alarm.html) can be created on metrics in one of two ways. Either create an `AnomalyDetectionAlarm` +object, passing the `Metric` object to set the alarm on: + +```ts +declare const fn: lambda.Function; + +new cloudwatch.AnomalyDetectionAlarm(this, 'AnomalyDetectionAlarm', { + metric: fn.metricErrors(), + threshold: 2, + evaluationPeriods: 2, +}); +``` + +Alternatively, you can call `metric.createAnomalyDetectionAlarm()`: + +```ts +declare const fn: lambda.Function; + +fn.metricErrors().createAnomalyDetectionAlarm(this, 'AnomalyDetectionAlarm', { + threshold: 2, + evaluationPeriods: 2, +}); +``` + +The most important properties to set while creating an AnomalyDetectionAlarm are: + +- `threshold`: the number of standard deviations away from the expected value to compare the metric's value against. +- `comparisonOperator`: the comparison operation to use, defaults to enforcing both lower and upper bounds. +- `evaluationPeriods`: how many consecutive periods the metric has to be + breaching the the threshold for the alarm to trigger. + ### Composite Alarms [Composite Alarms](https://aws.amazon.com/about-aws/whats-new/2020/03/amazon-cloudwatch-now-allows-you-to-combine-multiple-alarms/) diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts b/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts index eacafe3d1c1ab..04ee4bff1dda8 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts @@ -51,18 +51,24 @@ export enum ComparisonOperator { /** * Specified statistic is lower than or greater than the anomaly model band. * Used only for alarms based on anomaly detection models + * + * @deprecated Use AnomalyDetectionAlarm instead. */ LESS_THAN_LOWER_OR_GREATER_THAN_UPPER_THRESHOLD = 'LessThanLowerOrGreaterThanUpperThreshold', /** * Specified statistic is greater than the anomaly model band. * Used only for alarms based on anomaly detection models + * + * @deprecated Use AnomalyDetectionAlarm instead. */ GREATER_THAN_UPPER_THRESHOLD = 'GreaterThanUpperThreshold', /** * Specified statistic is lower than the anomaly model band. * Used only for alarms based on anomaly detection models + * + * @deprecated Use AnomalyDetectionAlarm instead. */ LESS_THAN_LOWER_THRESHOLD = 'LessThanLowerThreshold', } @@ -74,31 +80,6 @@ const OPERATOR_SYMBOLS: {[key: string]: string} = { LessThanOrEqualToThreshold: '<=', }; -/** - * Specify how missing data points are treated during alarm evaluation - */ -export enum TreatMissingData { - /** - * Missing data points are treated as breaching the threshold - */ - BREACHING = 'breaching', - - /** - * Missing data points are treated as being within the threshold - */ - NOT_BREACHING = 'notBreaching', - - /** - * The current alarm state is maintained - */ - IGNORE = 'ignore', - - /** - * The alarm does not consider missing data points when evaluating whether to change state - */ - MISSING = 'missing' -} - /** * An alarm on a CloudWatch metric */ diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/anomaly-detection-alarm.ts b/packages/@aws-cdk/aws-cloudwatch/lib/anomaly-detection-alarm.ts new file mode 100644 index 0000000000000..8328e408d1812 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/lib/anomaly-detection-alarm.ts @@ -0,0 +1,373 @@ +import { Annotations, ArnFormat, Lazy, Stack, Token } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IAlarmAction } from './alarm-action'; +import { AlarmBase } from './alarm-base'; +import { CfnAlarm } from './cloudwatch.generated';; +import { TreatMissingData } from './metric'; +import { IMetric, MetricExpressionConfig, MetricStatConfig } from './metric-types'; +import { dispatchMetric } from './private/metric-util'; +import { MetricSet } from './private/rendering'; + +/** + * Options for an AnomolyDetectionAlarm + */ +export interface AnomalyDetectionAlarmOptions { + + /** + * Name of the alarm + * + * @default - Automatically generated name + */ + readonly alarmName?: string; + + /** + * Description for the alarm + * + * @default - No description + */ + readonly alarmDescription?: string; + + /** + * Comparison to use to check if metric is breaching + * + * @default - AnomalyDetectionComparisonOperator.LESS_THAN_LOWER_OR_GREATER_THAN_UPPER_THRESHOLD + */ + readonly comparisonOperator?: AnomalyDetectionComparisonOperator; + + /** + * The number of standard deviations used when generating the anomaly detection band. + * + * Higher number means thicker band, lower number means thinner band. + */ + readonly threshold: number; + + /** + * The number of periods over which data is compared to the specified threshold. + */ + readonly evaluationPeriods: number; + + /** + * Sets how this alarm is to handle missing data points. + * + * @default - TreatMissingData.MISSING + */ + readonly treatMissingData?: TreatMissingData; + + /** + * Whether the actions for this alarm are enabled + * + * @default - true + */ + readonly actionsEnabled?: boolean; + + /** + * The number of datapoints that must be breaching to trigger the alarm. This is used only if you are setting an "M + * out of N" alarm. In that case, this value is the M. For more information, see Evaluating an Alarm in the Amazon + * CloudWatch User Guide. + * + * @default - same as evaluationPeriods + * + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html#alarm-evaluation + */ + readonly datapointsToAlarm?: number; +} + +/** + * Properties for Alarms + */ +export interface AnomalyDetectionAlarmProps extends AnomalyDetectionAlarmOptions { + /** + * The metric to add the alarm on + * + * Metric objects can be obtained from most resources, or you can construct + * custom Metric objects by instantiating one. + */ + readonly metric: IMetric; +} + +/** + * Comparison operator for evaluating alarms + */ +export enum AnomalyDetectionComparisonOperator { + /** + * Specified statistic is lower than or greater than the anomaly model band. + * Used only for alarms based on anomaly detection models + */ + LESS_THAN_LOWER_OR_GREATER_THAN_UPPER_THRESHOLD = 'LessThanLowerOrGreaterThanUpperThreshold', + + /** + * Specified statistic is greater than the anomaly model band. + * Used only for alarms based on anomaly detection models + */ + GREATER_THAN_UPPER_THRESHOLD = 'GreaterThanUpperThreshold', + + /** + * Specified statistic is lower than the anomaly model band. + * Used only for alarms based on anomaly detection models + */ + LESS_THAN_LOWER_THRESHOLD = 'LessThanLowerThreshold', +} + +/** + * An anomaly detection alarm on a CloudWatch metric. + * + * @resource AWS::CloudWatch::Alarm + */ +export class AnomalyDetectionAlarm extends AlarmBase { + + /** + * ARN of this alarm + * + * @attribute + */ + public readonly alarmArn: string; + + /** + * Name of this alarm. + * + * @attribute + */ + public readonly alarmName: string; + + /** + * The metric object this alarm was based on + */ + public readonly metric: IMetric; + + constructor(scope: Construct, id: string, props: AnomalyDetectionAlarmProps) { + super(scope, id, { + physicalName: props.alarmName, + }); + + const comparisonOperator = props.comparisonOperator ?? AnomalyDetectionComparisonOperator.LESS_THAN_LOWER_OR_GREATER_THAN_UPPER_THRESHOLD; + + const alarm = new CfnAlarm(this, 'Resource', { + // Meta + alarmDescription: props.alarmDescription, + alarmName: this.physicalName, + + // Evaluation + comparisonOperator, + datapointsToAlarm: props.datapointsToAlarm, + evaluationPeriods: props.evaluationPeriods, + treatMissingData: props.treatMissingData, + + // Actions + actionsEnabled: props.actionsEnabled, + alarmActions: Lazy.list({ produce: () => this.alarmActionArns }), + insufficientDataActions: Lazy.list({ produce: (() => this.insufficientDataActionArns) }), + okActions: Lazy.list({ produce: () => this.okActionArns }), + + // Metric + ...this.renderMetric(props.metric, props.threshold), + }); + + this.alarmArn = this.getResourceArnAttribute(alarm.attrArn, { + service: 'cloudwatch', + resource: 'alarm', + resourceName: this.physicalName, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }); + this.alarmName = this.getResourceNameAttribute(alarm.ref); + + this.metric = props.metric; + + for (const w of this.metric.warnings ?? []) { + Annotations.of(this).addWarning(w); + } + } + + /** + * Trigger this action if the alarm fires + * + * Typically the ARN of an SNS topic or ARN of an AutoScaling policy. + */ + public addAlarmAction(...actions: IAlarmAction[]) { + if (this.alarmActionArns === undefined) { + this.alarmActionArns = []; + } + + this.alarmActionArns.push(...actions.map(a => + this.validateActionArn(a.bind(this, this).alarmActionArn), + )); + } + + private validateActionArn(actionArn: string): string { + const ec2ActionsRegexp: RegExp = /arn:aws:automate:[a-z|\d|-]+:ec2:[a-z]+/; + if (ec2ActionsRegexp.test(actionArn)) { + // Check per-instance metric + const metricConfig = this.metric.toMetricConfig(); + if (metricConfig.metricStat?.dimensions?.length != 1 || metricConfig.metricStat?.dimensions![0].name != 'InstanceId') { + throw new Error(`EC2 alarm actions requires an EC2 Per-Instance Metric. (${JSON.stringify(metricConfig)} does not have an 'InstanceId' dimension)`); + } + } + return actionArn; + } + + private renderMetric(metric: IMetric, threshold: number) { + const self = this; + return dispatchMetric(metric, { + withStat(stat, conf) { + self.validateMetricStat(stat, metric); + + const thresholdMetricId = 'ad1'; + const metricId = 'm1'; + + return { + thresholdMetricId, + metrics: [ + { + metricStat: { + metric: { + metricName: stat.metricName, + namespace: stat.namespace, + dimensions: stat.dimensions, + }, + period: stat.period.toSeconds(), + stat: stat.statistic, + unit: stat.unitFilter, + }, + id: metricId, + label: conf.renderingProperties?.label, + returnData: true, + } as CfnAlarm.MetricDataQueryProperty, + { + expression: `ANOMALY_DETECTION_BAND(${metricId}, ${threshold})`, + id: thresholdMetricId, + label: 'Expected', + returnData: true, + }, + ], + }; + }, + + withExpression() { + // Expand the math expression metric into a set + const mset = new MetricSet(); + mset.addTopLevel(true, metric); + + let eid = 0; + function uniqueMetricId() { + return `expr_${++eid}`; + } + + const thresholdMetricId = uniqueMetricId(); + let metricId: string | null = null; + + const metrics: CfnAlarm.MetricDataQueryProperty[] = []; + + for (const entry of mset.entries) { + if (entry.tag) { + if (metricId !== null) { + throw new Error('Multiple metrics detected as the target for anomaly detection'); + } + + if (entry.id === undefined) { + entry.id = uniqueMetricId(); + } + + metricId = entry.id; + } + + metrics.push( + dispatchMetric(entry.metric, { + withStat(stat, conf) { + self.validateMetricStat(stat, entry.metric); + + return { + metricStat: { + metric: { + metricName: stat.metricName, + namespace: stat.namespace, + dimensions: stat.dimensions, + }, + period: stat.period.toSeconds(), + stat: stat.statistic, + unit: stat.unitFilter, + }, + id: entry.id ?? uniqueMetricId(), + label: conf.renderingProperties?.label, + returnData: entry.tag ? true : false, // entry.tag evaluates to true if the metric is the math expression the alarm is based on. + }; + }, + withExpression(expr, conf) { + + const hasSubmetrics = mathExprHasSubmetrics(expr); + + if (hasSubmetrics) { + assertSubmetricsCount(expr); + } + + self.validateMetricExpression(expr); + + return { + expression: expr.expression, + id: entry.id || uniqueMetricId(), + label: conf.renderingProperties?.label, + period: hasSubmetrics ? undefined : expr.period, + returnData: entry.tag ? true : false, // entry.tag evaluates to true if the metric is the math expression the alarm is based on. + }; + }, + }) as CfnAlarm.MetricDataQueryProperty); + } + + if (metricId === null) { + throw new Error('No metrics detected as the target for anomaly detection'); + } + + return { + thresholdMetricId, + metrics: [ + ...metrics, + { + expression: `ANOMALY_DETECTION_BAND(${metricId}, ${threshold})`, + id: thresholdMetricId, + label: 'Expected', + returnData: true, + }, + ], + }; + }, + }); + } + + /** + * Validate that if an account or region is in the given stat config, they match the Alarm + */ + private validateMetricStat(stat: MetricStatConfig, metric: IMetric) { + const stack = Stack.of(this); + + if (definitelyDifferent(stat.account, stack.account)) { + throw new Error(`Cannot create an Anomaly Detection Alarm in account '${stack.account}' based on metric '${metric}' in '${stat.account}'`); + } + + if (definitelyDifferent(stat.region, stack.region)) { + throw new Error(`Cannot create an Anomaly Detection Alarm in region '${stack.region}' based on metric '${metric}' in '${stat.region}'`); + } + } + + /** + * Validates that the expression config does not specify searchAccount or searchRegion props + * as search expressions are not supported by Alarms. + */ + private validateMetricExpression(expr: MetricExpressionConfig) { + if (expr.searchAccount !== undefined || expr.searchRegion !== undefined) { + throw new Error('Cannot create an Anomaly Detection Alarm based on a MathExpression which specifies a searchAccount or searchRegion'); + } + } +} + +function definitelyDifferent(x: string | undefined, y: string) { + return x && !Token.isUnresolved(y) && x !== y; +} + +function mathExprHasSubmetrics(expr: MetricExpressionConfig) { + return Object.keys(expr.usingMetrics).length > 0; +} + +function assertSubmetricsCount(expr: MetricExpressionConfig) { + if (Object.keys(expr.usingMetrics).length > 9) { + // https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html#alarms-on-metric-math-expressions + throw new Error('Anomaly Detection Alarms on math expressions cannot contain more than 9 individual metrics'); + }; +} diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/index.ts b/packages/@aws-cdk/aws-cloudwatch/lib/index.ts index fbbf8c7bb8b69..04f44d1627306 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/index.ts @@ -2,6 +2,7 @@ export * from './alarm'; export * from './alarm-action'; export * from './alarm-base'; export * from './alarm-rule'; +export * from './anomaly-detection-alarm'; export * from './composite-alarm'; export * from './dashboard'; export * from './graph'; diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts b/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts index 8f959f37ea201..e9299d3b37988 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts @@ -1,7 +1,8 @@ import * as iam from '@aws-cdk/aws-iam'; import * as cdk from '@aws-cdk/core'; import * as constructs from 'constructs'; -import { Alarm, ComparisonOperator, TreatMissingData } from './alarm'; +import { Alarm, ComparisonOperator } from './alarm'; +import { AnomalyDetectionAlarm, AnomalyDetectionAlarmOptions } from './anomaly-detection-alarm'; import { Dimension, IMetric, MetricAlarmConfig, MetricConfig, MetricGraphConfig, Unit } from './metric-types'; import { dispatchMetric, metricKey } from './private/metric-util'; import { normalizeStatistic, parseStatistic } from './private/statistic'; @@ -446,6 +447,16 @@ export class Metric implements IMetric { }); } + /** + * Make a new Anomaly Detection Alarm for this metric. + */ + public createAnomalyDetectionAlarm(scope: Construct, id: string, options: AnomalyDetectionAlarmOptions): AnomalyDetectionAlarm { + return new AnomalyDetectionAlarm(scope, id, { + metric: this, + ...options, + }); + } + public toString() { return this.label || this.metricName; } @@ -677,6 +688,20 @@ export class MathExpression implements IMetric { }); } + /** + * Make a new AnomalyDetectionAlarm for this metric + * + * Combines both properties that may adjust the metric (aggregation) as well + * as alarm properties. + */ + public createAnomalyDetectionAlarm(scope: Construct, id: string, options: AnomalyDetectionAlarmOptions): AnomalyDetectionAlarm { + return new AnomalyDetectionAlarm(scope, id, { + metric: this, + ...options, + }); + } + + public toString() { return this.label || this.expression; } @@ -724,6 +749,31 @@ function allIdentifiersInExpression(x: string) { return Array.from(matchAll(x, FIND_VARIABLE)).map(m => m[0]); } +/** + * Specify how missing data points are treated during alarm evaluation + */ +export enum TreatMissingData { + /** + * Missing data points are treated as breaching the threshold + */ + BREACHING = 'breaching', + + /** + * Missing data points are treated as being within the threshold + */ + NOT_BREACHING = 'notBreaching', + + /** + * The current alarm state is maintained + */ + IGNORE = 'ignore', + + /** + * The alarm does not consider missing data points when evaluating whether to change state + */ + MISSING = 'missing' +} + /** * Properties needed to make an alarm from a metric */ diff --git a/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/AnomalyDetectionAlarmIntegrationTest.template.json b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/AnomalyDetectionAlarmIntegrationTest.template.json new file mode 100644 index 0000000000000..fe052d3c68f1a --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/AnomalyDetectionAlarmIntegrationTest.template.json @@ -0,0 +1,230 @@ +{ + "Resources": { + "Alarm1F9009D71": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "EvaluationPeriods": 3, + "Metrics": [ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "MetricName": "Metric", + "Namespace": "CDK/Test" + }, + "Period": 300, + "Stat": "Average" + }, + "ReturnData": true + }, + { + "Expression": "ANOMALY_DETECTION_BAND(m1, 2)", + "Id": "ad1", + "Label": "Expected", + "ReturnData": true + } + ], + "ThresholdMetricId": "ad1" + } + }, + "Alarm2A7122E13": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "LessThanLowerThreshold", + "EvaluationPeriods": 5, + "ActionsEnabled": false, + "AlarmDescription": "Alarm description 2", + "AlarmName": "Alarm 2", + "DatapointsToAlarm": 3, + "Metrics": [ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "MetricName": "Metric", + "Namespace": "CDK/Test" + }, + "Period": 300, + "Stat": "p90" + }, + "ReturnData": true + }, + { + "Expression": "ANOMALY_DETECTION_BAND(m1, 3)", + "Id": "ad1", + "Label": "Expected", + "ReturnData": true + } + ], + "ThresholdMetricId": "ad1", + "TreatMissingData": "notBreaching" + } + }, + "Alarm32341D8D9": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "EvaluationPeriods": 3, + "Metrics": [ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "MetricName": "Metric", + "Namespace": "CDK/Test" + }, + "Period": 300, + "Stat": "Average" + }, + "ReturnData": true + }, + { + "Expression": "ANOMALY_DETECTION_BAND(m1, 2)", + "Id": "ad1", + "Label": "Expected", + "ReturnData": true + } + ], + "ThresholdMetricId": "ad1" + } + }, + "Alarm4671832C8": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanUpperThreshold", + "EvaluationPeriods": 5, + "ActionsEnabled": false, + "AlarmDescription": "Alarm description 4", + "AlarmName": "Alarm 4", + "DatapointsToAlarm": 3, + "Metrics": [ + { + "Id": "m1", + "MetricStat": { + "Metric": { + "MetricName": "Metric", + "Namespace": "CDK/Test" + }, + "Period": 300, + "Stat": "p90" + }, + "ReturnData": true + }, + { + "Expression": "ANOMALY_DETECTION_BAND(m1, 3)", + "Id": "ad1", + "Label": "Expected", + "ReturnData": true + } + ], + "ThresholdMetricId": "ad1", + "TreatMissingData": "notBreaching" + } + }, + "Alarm548383B2F": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "EvaluationPeriods": 3, + "Metrics": [ + { + "Expression": "testMetric / 60", + "Id": "expr_2", + "ReturnData": true + }, + { + "Id": "testMetric", + "MetricStat": { + "Metric": { + "MetricName": "Metric", + "Namespace": "CDK/Test" + }, + "Period": 300, + "Stat": "Average" + }, + "ReturnData": false + }, + { + "Expression": "ANOMALY_DETECTION_BAND(expr_2, 2)", + "Id": "expr_1", + "Label": "Expected", + "ReturnData": true + } + ], + "ThresholdMetricId": "expr_1" + } + }, + "Alarm65738D89F": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "EvaluationPeriods": 3, + "Metrics": [ + { + "Expression": "testMetric / 60", + "Id": "expr_2", + "ReturnData": true + }, + { + "Id": "testMetric", + "MetricStat": { + "Metric": { + "MetricName": "Metric", + "Namespace": "CDK/Test" + }, + "Period": 300, + "Stat": "Average" + }, + "ReturnData": false + }, + { + "Expression": "ANOMALY_DETECTION_BAND(expr_2, 2)", + "Id": "expr_1", + "Label": "Expected", + "ReturnData": true + } + ], + "ThresholdMetricId": "expr_1" + } + }, + "Alarm77B1024B6": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "EvaluationPeriods": 5, + "ActionsEnabled": false, + "AlarmDescription": "Alarm description 7", + "AlarmName": "Alarm 7", + "DatapointsToAlarm": 3, + "Metrics": [ + { + "Expression": "testMetric / 60", + "Id": "expr_2", + "ReturnData": true + }, + { + "Id": "testMetric", + "MetricStat": { + "Metric": { + "MetricName": "Metric", + "Namespace": "CDK/Test" + }, + "Period": 300, + "Stat": "Average" + }, + "ReturnData": false + }, + { + "Expression": "ANOMALY_DETECTION_BAND(expr_2, 2)", + "Id": "expr_1", + "Label": "Expected", + "ReturnData": true + } + ], + "ThresholdMetricId": "expr_1", + "TreatMissingData": "notBreaching" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/cdk.out b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/cdk.out new file mode 100644 index 0000000000000..90bef2e09ad39 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"17.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/manifest.json b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/manifest.json new file mode 100644 index 0000000000000..09dd555833002 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/manifest.json @@ -0,0 +1,65 @@ +{ + "version": "17.0.0", + "artifacts": { + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + }, + "metadata": {} + }, + "AnomalyDetectionAlarmIntegrationTest": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "AnomalyDetectionAlarmIntegrationTest.template.json", + "validateOnSynth": false + }, + "metadata": { + "/AnomalyDetectionAlarmIntegrationTest/Alarm1/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "Alarm1F9009D71" + } + ], + "/AnomalyDetectionAlarmIntegrationTest/Alarm2/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "Alarm2A7122E13" + } + ], + "/AnomalyDetectionAlarmIntegrationTest/Alarm3/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "Alarm32341D8D9" + } + ], + "/AnomalyDetectionAlarmIntegrationTest/Alarm4/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "Alarm4671832C8" + } + ], + "/AnomalyDetectionAlarmIntegrationTest/Alarm5/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "Alarm548383B2F" + } + ], + "/AnomalyDetectionAlarmIntegrationTest/Alarm6/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "Alarm65738D89F" + } + ], + "/AnomalyDetectionAlarmIntegrationTest/Alarm7/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "Alarm77B1024B6" + } + ] + }, + "displayName": "AnomalyDetectionAlarmIntegrationTest" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/tree.json b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/tree.json new file mode 100644 index 0000000000000..632e548695614 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.integ.snapshot/tree.json @@ -0,0 +1,383 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "@aws-cdk/core.Construct", + "version": "0.0.0" + } + }, + "AnomalyDetectionAlarmIntegrationTest": { + "id": "AnomalyDetectionAlarmIntegrationTest", + "path": "AnomalyDetectionAlarmIntegrationTest", + "children": { + "Alarm1": { + "id": "Alarm1", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm1", + "children": { + "Resource": { + "id": "Resource", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm1/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Alarm", + "aws:cdk:cloudformation:props": { + "comparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "evaluationPeriods": 3, + "metrics": [ + { + "metricStat": { + "metric": { + "metricName": "Metric", + "namespace": "CDK/Test" + }, + "period": 300, + "stat": "Average" + }, + "id": "m1", + "returnData": true + }, + { + "expression": "ANOMALY_DETECTION_BAND(m1, 2)", + "id": "ad1", + "label": "Expected", + "returnData": true + } + ], + "thresholdMetricId": "ad1" + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.CfnAlarm", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.AnomalyDetectionAlarm", + "version": "0.0.0" + } + }, + "Alarm2": { + "id": "Alarm2", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm2", + "children": { + "Resource": { + "id": "Resource", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm2/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Alarm", + "aws:cdk:cloudformation:props": { + "comparisonOperator": "LessThanLowerThreshold", + "evaluationPeriods": 5, + "actionsEnabled": false, + "alarmDescription": "Alarm description 2", + "alarmName": "Alarm 2", + "datapointsToAlarm": 3, + "metrics": [ + { + "metricStat": { + "metric": { + "metricName": "Metric", + "namespace": "CDK/Test" + }, + "period": 300, + "stat": "p90" + }, + "id": "m1", + "returnData": true + }, + { + "expression": "ANOMALY_DETECTION_BAND(m1, 3)", + "id": "ad1", + "label": "Expected", + "returnData": true + } + ], + "thresholdMetricId": "ad1", + "treatMissingData": "notBreaching" + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.CfnAlarm", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.AnomalyDetectionAlarm", + "version": "0.0.0" + } + }, + "Alarm3": { + "id": "Alarm3", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm3", + "children": { + "Resource": { + "id": "Resource", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm3/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Alarm", + "aws:cdk:cloudformation:props": { + "comparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "evaluationPeriods": 3, + "metrics": [ + { + "metricStat": { + "metric": { + "metricName": "Metric", + "namespace": "CDK/Test" + }, + "period": 300, + "stat": "Average" + }, + "id": "m1", + "returnData": true + }, + { + "expression": "ANOMALY_DETECTION_BAND(m1, 2)", + "id": "ad1", + "label": "Expected", + "returnData": true + } + ], + "thresholdMetricId": "ad1" + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.CfnAlarm", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.AnomalyDetectionAlarm", + "version": "0.0.0" + } + }, + "Alarm4": { + "id": "Alarm4", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm4", + "children": { + "Resource": { + "id": "Resource", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm4/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Alarm", + "aws:cdk:cloudformation:props": { + "comparisonOperator": "GreaterThanUpperThreshold", + "evaluationPeriods": 5, + "actionsEnabled": false, + "alarmDescription": "Alarm description 4", + "alarmName": "Alarm 4", + "datapointsToAlarm": 3, + "metrics": [ + { + "metricStat": { + "metric": { + "metricName": "Metric", + "namespace": "CDK/Test" + }, + "period": 300, + "stat": "p90" + }, + "id": "m1", + "returnData": true + }, + { + "expression": "ANOMALY_DETECTION_BAND(m1, 3)", + "id": "ad1", + "label": "Expected", + "returnData": true + } + ], + "thresholdMetricId": "ad1", + "treatMissingData": "notBreaching" + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.CfnAlarm", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.AnomalyDetectionAlarm", + "version": "0.0.0" + } + }, + "Alarm5": { + "id": "Alarm5", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm5", + "children": { + "Resource": { + "id": "Resource", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm5/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Alarm", + "aws:cdk:cloudformation:props": { + "comparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "evaluationPeriods": 3, + "metrics": [ + { + "expression": "testMetric / 60", + "id": "expr_2", + "returnData": true + }, + { + "metricStat": { + "metric": { + "metricName": "Metric", + "namespace": "CDK/Test" + }, + "period": 300, + "stat": "Average" + }, + "id": "testMetric", + "returnData": false + }, + { + "expression": "ANOMALY_DETECTION_BAND(expr_2, 2)", + "id": "expr_1", + "label": "Expected", + "returnData": true + } + ], + "thresholdMetricId": "expr_1" + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.CfnAlarm", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.AnomalyDetectionAlarm", + "version": "0.0.0" + } + }, + "Alarm6": { + "id": "Alarm6", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm6", + "children": { + "Resource": { + "id": "Resource", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm6/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Alarm", + "aws:cdk:cloudformation:props": { + "comparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "evaluationPeriods": 3, + "metrics": [ + { + "expression": "testMetric / 60", + "id": "expr_2", + "returnData": true + }, + { + "metricStat": { + "metric": { + "metricName": "Metric", + "namespace": "CDK/Test" + }, + "period": 300, + "stat": "Average" + }, + "id": "testMetric", + "returnData": false + }, + { + "expression": "ANOMALY_DETECTION_BAND(expr_2, 2)", + "id": "expr_1", + "label": "Expected", + "returnData": true + } + ], + "thresholdMetricId": "expr_1" + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.CfnAlarm", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.AnomalyDetectionAlarm", + "version": "0.0.0" + } + }, + "Alarm7": { + "id": "Alarm7", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm7", + "children": { + "Resource": { + "id": "Resource", + "path": "AnomalyDetectionAlarmIntegrationTest/Alarm7/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Alarm", + "aws:cdk:cloudformation:props": { + "comparisonOperator": "LessThanLowerOrGreaterThanUpperThreshold", + "evaluationPeriods": 5, + "actionsEnabled": false, + "alarmDescription": "Alarm description 7", + "alarmName": "Alarm 7", + "datapointsToAlarm": 3, + "metrics": [ + { + "expression": "testMetric / 60", + "id": "expr_2", + "returnData": true + }, + { + "metricStat": { + "metric": { + "metricName": "Metric", + "namespace": "CDK/Test" + }, + "period": 300, + "stat": "Average" + }, + "id": "testMetric", + "returnData": false + }, + { + "expression": "ANOMALY_DETECTION_BAND(expr_2, 2)", + "id": "expr_1", + "label": "Expected", + "returnData": true + } + ], + "thresholdMetricId": "expr_1", + "treatMissingData": "notBreaching" + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.CfnAlarm", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.AnomalyDetectionAlarm", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.App", + "version": "0.0.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.test.ts b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.test.ts new file mode 100644 index 0000000000000..aeb5f70b9f0d5 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/anomaly-detection-alarm.test.ts @@ -0,0 +1,548 @@ +import { Match, Template, Annotations } from '@aws-cdk/assertions'; +import { Duration, Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { AnomalyDetectionAlarm, IAlarm, IAlarmAction, Metric, MathExpression, IMetric } from '../lib'; + +const testMetric = new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', +}); + +describe('AnomalyDetectionAlarm', () => { + test('alarm does not accept a math expression with more than 9 metrics', () => { + const stack = new Stack(); + + const usingMetrics: Record = {}; + + for (const i of [...Array(10).keys()]) { + const metricName = `metric${i}`; + usingMetrics[metricName] = new Metric({ + namespace: 'CDK/Test', + metricName: metricName, + }); + } + + const math = new MathExpression({ + expression: 'a', + usingMetrics, + }); + + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: math, + threshold: 2, + evaluationPeriods: 3, + }); + }).toThrow(/Anomaly Detection Alarms on math expressions cannot contain more than 9 individual metrics/); + }); + + test('non ec2 instance related alarm does not accept EC2 action', () => { + const stack = new Stack(); + const alarm = new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric, + threshold: 2, + evaluationPeriods: 2, + }); + + expect(() => { + alarm.addAlarmAction(new Ec2TestAlarmAction('arn:aws:automate:us-east-1:ec2:reboot')); + }).toThrow(/EC2 alarm actions requires an EC2 Per-Instance Metric. \(.+ does not have an 'InstanceId' dimension\)/); + }); + + test('can make simple alarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric, + threshold: 2, + evaluationPeriods: 3, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'm1', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 300, + Stat: 'Average', + }, + }, + { + Id: 'ad1', + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 3, + ThresholdMetricId: 'ad1', + }); + }); + + test('can target MathExpression in alarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: new MathExpression({ + expression: 'testMetric / 60', + usingMetrics: { + testMetric, + }, + }), + + threshold: 2, + evaluationPeriods: 3, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'expr_2', + Expression: 'testMetric / 60', + ReturnData: true, + }, + { + Id: 'testMetric', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 300, + Stat: 'Average', + }, + ReturnData: false, + }, + { + Id: 'expr_1', + Expression: 'ANOMALY_DETECTION_BAND(expr_2, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 3, + ThresholdMetricId: 'expr_1', + }); + }); + + test('override metric period in Alarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric.with({ period: Duration.minutes(10) }), + threshold: 2, + evaluationPeriods: 3, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'm1', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 600, + Stat: 'Average', + }, + }, + { + Id: 'ad1', + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 3, + ThresholdMetricId: 'ad1', + }); + }); + + test('override statistic', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric.with({ statistic: 'max' }), + threshold: 2, + evaluationPeriods: 3, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'm1', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 300, + Stat: 'Maximum', + }, + }, + { + Id: 'ad1', + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + }); + }); + + test('can use percentile', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric.with({ statistic: 'P99' }), + threshold: 2, + evaluationPeriods: 3, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'm1', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 300, + Stat: 'p99', + }, + }, + { + Id: 'ad1', + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 3, + ThresholdMetricId: 'ad1', + }); + }); + + test('can set DatapointsToAlarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric, + threshold: 2, + evaluationPeriods: 3, + datapointsToAlarm: 2, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'm1', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 300, + Stat: 'Average', + }, + }, + { + Id: 'ad1', + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 3, + DatapointsToAlarm: 2, + ThresholdMetricId: 'ad1', + }); + }); + + test('can add actions to alarms', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const alarm = new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric, + threshold: 2, + evaluationPeriods: 2, + }); + + alarm.addAlarmAction(new TestAlarmAction('A')); + alarm.addInsufficientDataAction(new TestAlarmAction('B')); + alarm.addOkAction(new TestAlarmAction('C')); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + AlarmActions: ['A'], + InsufficientDataActions: ['B'], + OKActions: ['C'], + }); + }); + + test('can make alarm directly from metric', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + testMetric.with({ + statistic: 'min', + period: Duration.seconds(10), + }).createAnomalyDetectionAlarm(stack, 'Alarm', { + threshold: 2, + evaluationPeriods: 2, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'm1', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 10, + Stat: 'Minimum', + }, + }, + { + Id: 'ad1', + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 2, + ThresholdMetricId: 'ad1', + }); + }); + + test('can use percentile string to make alarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + testMetric.with({ + statistic: 'p99.9', + }).createAnomalyDetectionAlarm(stack, 'Alarm', { + threshold: 2, + evaluationPeriods: 2, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'm1', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 300, + Stat: 'p99.9', + }, + }, + { + Id: 'ad1', + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 2, + ThresholdMetricId: 'ad1', + }); + }); + + test('can use a generic string for extended statistic to make alarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + testMetric.with({ + statistic: 'tm99.9999999999', + }).createAnomalyDetectionAlarm(stack, 'Alarm', { + threshold: 2, + evaluationPeriods: 2, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Metrics: [ + { + Id: 'm1', + MetricStat: { + Metric: { + MetricName: 'Metric', + Namespace: 'CDK/Test', + }, + Period: 300, + Stat: 'tm99.9999999999', + }, + }, + { + Id: 'ad1', + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Label: 'Expected', + ReturnData: true, + }, + ], + + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 2, + ThresholdMetricId: 'ad1', + }); + }); + + test('metric warnings are added to Alarm', () => { + const stack = new Stack(undefined, 'MyStack'); + const m = new MathExpression({ expression: 'oops' }); + + // WHEN + new AnomalyDetectionAlarm(stack, 'MyAlarm', { + metric: m, + evaluationPeriods: 1, + threshold: 1, + }); + + // THEN + const template = Annotations.fromStack(stack); + template.hasWarning('/MyStack/MyAlarm', Match.stringLikeRegexp("Math expression 'oops' references unknown identifiers")); + }); + + test('cross account metrics are not allowed', () => { + const stack = new Stack(undefined, undefined, { + env: { + account: 'a', + }, + }); + + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric.with({ + account: 'b', + }), + threshold: 2, + evaluationPeriods: 3, + }); + }).toThrow(/Cannot create an Anomaly Detection Alarm in account 'a' based on metric 'Metric' in 'b'/); + }); + + test('cross region metrics are not allowed', () => { + const stack = new Stack(undefined, undefined, { + env: { + region: 'a', + }, + }); + + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric.with({ + region: 'b', + }), + threshold: 2, + evaluationPeriods: 3, + }); + }).toThrow(/Cannot create an Anomaly Detection Alarm in region 'a' based on metric 'Metric' in 'b'/); + }); + + + test('cross account metric search is not allowed', () => { + const stack = new Stack(undefined, undefined, { + env: { + account: 'a', + }, + }); + + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: new MathExpression({ + searchAccount: 'b', + expression: '60', + }), + threshold: 2, + evaluationPeriods: 3, + }); + }).toThrow(/Cannot create an Anomaly Detection Alarm based on a MathExpression which specifies a searchAccount or searchRegion/); + }); + + test('cross region metric search is not allowed', () => { + const stack = new Stack(undefined, undefined, { + env: { + region: 'a', + }, + }); + + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: new MathExpression({ + searchRegion: 'b', + expression: '60', + }), + threshold: 2, + evaluationPeriods: 3, + }); + }).toThrow(/Cannot create an Anomaly Detection Alarm based on a MathExpression which specifies a searchAccount or searchRegion/); + }); +}); + +class TestAlarmAction implements IAlarmAction { + constructor(private readonly arn: string) { + } + + public bind(_scope: Construct, _alarm: IAlarm) { + return { alarmActionArn: this.arn }; + } +} + +class Ec2TestAlarmAction implements IAlarmAction { + constructor(private readonly arn: string) { + } + + public bind(_scope: Construct, _alarm: IAlarm) { + return { alarmActionArn: this.arn }; + } +} diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.anomaly-detection-alarm.ts b/packages/@aws-cdk/aws-cloudwatch/test/integ.anomaly-detection-alarm.ts new file mode 100644 index 0000000000000..ed946b4d256cb --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.anomaly-detection-alarm.ts @@ -0,0 +1,85 @@ +import * as cdk from '@aws-cdk/core'; +import * as cloudwatch from '../lib'; + + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'AnomalyDetectionAlarmIntegrationTest'); + +const testMetric = new cloudwatch.Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', +}); + +const testMathExpression = new cloudwatch.MathExpression({ + expression: 'testMetric / 60', + usingMetrics: { + testMetric, + }, +}); + +new cloudwatch.AnomalyDetectionAlarm(stack, 'Alarm1', { + metric: testMetric, + threshold: 2, + evaluationPeriods: 3, +}); + +new cloudwatch.AnomalyDetectionAlarm(stack, 'Alarm2', { + metric: testMetric.with({ + statistic: 'p90', + }), + threshold: 3, + evaluationPeriods: 5, + + actionsEnabled: false, + alarmName: 'Alarm 2', + alarmDescription: 'Alarm description 2', + comparisonOperator: cloudwatch.AnomalyDetectionComparisonOperator.LESS_THAN_LOWER_THRESHOLD, + datapointsToAlarm: 3, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, +}); + +testMetric.createAnomalyDetectionAlarm(stack, 'Alarm3', { + threshold: 2, + evaluationPeriods: 3, +}); + +testMetric + .with({ statistic: 'p90' }) + .createAnomalyDetectionAlarm(stack, 'Alarm4', { + threshold: 3, + evaluationPeriods: 5, + + actionsEnabled: false, + alarmName: 'Alarm 4', + alarmDescription: 'Alarm description 4', + comparisonOperator: cloudwatch.AnomalyDetectionComparisonOperator.GREATER_THAN_UPPER_THRESHOLD, + datapointsToAlarm: 3, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + }); + +new cloudwatch.AnomalyDetectionAlarm(stack, 'Alarm5', { + metric: testMathExpression, + threshold: 2, + evaluationPeriods: 3, +}); + + +testMathExpression.createAnomalyDetectionAlarm(stack, 'Alarm6', { + threshold: 2, + evaluationPeriods: 3, +}); + +testMathExpression.createAnomalyDetectionAlarm(stack, 'Alarm7', { + threshold: 2, + evaluationPeriods: 5, + + actionsEnabled: false, + alarmName: 'Alarm 7', + alarmDescription: 'Alarm description 7', + comparisonOperator: cloudwatch.AnomalyDetectionComparisonOperator.LESS_THAN_LOWER_OR_GREATER_THAN_UPPER_THRESHOLD, + datapointsToAlarm: 3, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, +}); + +app.synth();