From 6d4a575ebd4b2912a54cbf3d7ca29df2d80adb79 Mon Sep 17 00:00:00 2001 From: Evangelos Gkolemis Date: Sun, 24 Mar 2024 16:57:48 +0000 Subject: [PATCH] construct for alarms based on anomaly detection --- packages/aws-cdk-lib/aws-cloudwatch/README.md | 35 ++ .../aws-cloudwatch/lib/alarm-base.ts | 36 +- .../aws-cdk-lib/aws-cloudwatch/lib/alarm.ts | 386 +++++++++++++++--- .../aws-cloudwatch/lib/composite-alarm.ts | 35 +- .../aws-cdk-lib/aws-cloudwatch/lib/metric.ts | 3 + .../aws-cloudwatch/test/alarm.test.ts | 272 +++++++++++- .../test/composite-alarm.test.ts | 4 +- 7 files changed, 673 insertions(+), 98 deletions(-) diff --git a/packages/aws-cdk-lib/aws-cloudwatch/README.md b/packages/aws-cdk-lib/aws-cloudwatch/README.md index be53d4f3becdd..7e77d20938952 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/README.md +++ b/packages/aws-cdk-lib/aws-cloudwatch/README.md @@ -316,6 +316,41 @@ 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) are alarms that utilize +[CloudWatch anomaly detection](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Anomaly_Detection.html). The `Anomaly Detection Alarm` provides you the ability to either provide a metric that has already defined an expression containing the `ANOMALY_DETECTION_BAND` metric math function: +```ts +declare const fn: lambda.Function; + +const mathExpr = new MathExpression({ + expression: `ANOMALY_DETECTION_BAND('m1', 2)`, + usingMetrics: { ['m1']: fn.metricErrors() }, +}); + +new cloudwatch.Alarm(this, 'Alarm', { + metric: mathExpr, + threshold: 2, + evaluationPeriods: 2, +}); +``` + +Alternatively, you can set the `generateAnomalyDetectionExpression` property to `true` and the `ANOMALY_DETECTION_BAND` will be automatically added: +```ts +declare const fn: lambda.Function; + +new cloudwatch.Alarm(this, 'Alarm', { + generateAnomalyDetectionExpression: true, + metric: fn.metricErrors(), + threshold: 2, + evaluationPeriods: 2, +}); +``` + +The most important properties to set while creating an `Anomaly Detection Alarm` are: +- `generateAnomalyDetectionExpression`: a boolean flag that denotes whether to automatically generate the `ANOMALY_DETECTION_BAND` metric math function on top of the provide metric, defaults to `false`. +- `threshold`: the anomaly detection threshold, used to calculate the band around the metric. This is used only if the `generateAnomalyDetectionExpression` is set to `true`. The value should be a positive number. A higher number creates a thicker band of "normal" values that is more tolerant of metric changes. A lower number creates a thinner band that will go to `ALARM` state with smaller metric deviations. +- `comparisonOperator`: the comparison operation to use, defaults to `metric > upper band threshold OR metric < lower band threshold`. Only the comparison operators related to the anomaly model band are allowed. + ### 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-lib/aws-cloudwatch/lib/alarm-base.ts b/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm-base.ts index f96340acbf7f6..e2ec4c9e9aa9b 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm-base.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm-base.ts @@ -1,5 +1,6 @@ +import { Construct } from 'constructs'; import { IAlarmAction } from './alarm-action'; -import { IResource, Resource } from '../../core'; +import { ArnFormat, IResource, Resource, Stack } from '../../core'; /** * Interface for Alarm Rule. @@ -37,6 +38,39 @@ export interface IAlarm extends IAlarmRule, IResource { */ export abstract class AlarmBase extends Resource implements IAlarm { + /** + * Import an existing CloudWatch alarm provided a Name. + * + * @param scope The parent creating construct (usually `this`) + * @param id The construct's name + * @param alarmName Alarm Name + */ + public static fromAlarmName(scope: Construct, id: string, alarmName: string): IAlarm { + const stack = Stack.of(scope); + + return this.fromAlarmArn(scope, id, stack.formatArn({ + service: 'cloudwatch', + resource: 'alarm', + resourceName: alarmName, + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + })); + } + + /** + * Import an existing CloudWatch alarm provided an ARN + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name + * @param alarmArn Alarm ARN (i.e. arn:aws:cloudwatch:::alarm:Foo) + */ + public static fromAlarmArn(scope: Construct, id: string, alarmArn: string): IAlarm { + class Import extends AlarmBase implements IAlarm { + public readonly alarmArn = alarmArn; + public readonly alarmName = Stack.of(scope).splitArn(alarmArn, ArnFormat.COLON_RESOURCE_NAME).resourceName!; + } + return new Import(scope, id); + } + /** * @attribute */ diff --git a/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm.ts b/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm.ts index e25bf8cfe1aef..7cf277b9c089b 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/lib/alarm.ts @@ -1,6 +1,6 @@ import { Construct } from 'constructs'; import { IAlarmAction } from './alarm-action'; -import { AlarmBase, IAlarm } from './alarm-base'; +import { AlarmBase } from './alarm-base'; import { CfnAlarm, CfnAlarmProps } from './cloudwatch.generated'; import { HorizontalAnnotation } from './graph'; import { CreateAlarmOptions } from './metric'; @@ -24,6 +24,20 @@ export interface AlarmProps extends CreateAlarmOptions { readonly metric: IMetric; } +/** + * Properties for Anomaly Detection Alarms + */ +export interface AnomalyDetectionAlarmProps extends AlarmProps { + /** + * Defines if the alarm will try to create the anomaly detection expression from the provided metric. + * + * If set to true the threshold property will be used as the threshold for the `ANOMALY_DETECTION_BAND` function. + * + * @default - false + */ + readonly generateAnomalyDetectionExpression?: boolean; +} + /** * Comparison operator for evaluating alarms */ @@ -104,39 +118,6 @@ export enum TreatMissingData { */ export class Alarm extends AlarmBase { - /** - * Import an existing CloudWatch alarm provided an Name. - * - * @param scope The parent creating construct (usually `this`) - * @param id The construct's name - * @param alarmName Alarm Name - */ - public static fromAlarmName(scope: Construct, id: string, alarmName: string): IAlarm { - const stack = Stack.of(scope); - - return this.fromAlarmArn(scope, id, stack.formatArn({ - service: 'cloudwatch', - resource: 'alarm', - resourceName: alarmName, - arnFormat: ArnFormat.COLON_RESOURCE_NAME, - })); - } - - /** - * Import an existing CloudWatch alarm provided an ARN - * - * @param scope The parent creating construct (usually `this`). - * @param id The construct's name - * @param alarmArn Alarm ARN (i.e. arn:aws:cloudwatch:::alarm:Foo) - */ - public static fromAlarmArn(scope: Construct, id: string, alarmArn: string): IAlarm { - class Import extends AlarmBase implements IAlarm { - public readonly alarmArn = alarmArn; - public readonly alarmName = Stack.of(scope).splitArn(alarmArn, ArnFormat.COLON_RESOURCE_NAME).resourceName!; - } - return new Import(scope, id); - } - /** * ARN of this alarm * @@ -278,8 +259,8 @@ export class Alarm extends AlarmBase { const self = this; return dispatchMetric(metric, { withStat(stat, conf) { - self.validateMetricStat(stat, metric); - const canRenderAsLegacyMetric = conf.renderingProperties?.label == undefined && !self.requiresAccountId(stat); + validateMetricStat(self, stat, metric); + const canRenderAsLegacyMetric = conf.renderingProperties?.label == undefined && !requiresAccountId(self, stat); // Do this to disturb existing templates as little as possible if (canRenderAsLegacyMetric) { return dropUndefined({ @@ -307,7 +288,7 @@ export class Alarm extends AlarmBase { unit: stat.unitFilter, }, id: 'm1', - accountId: self.requiresAccountId(stat) ? stat.account : undefined, + accountId: requiresAccountId(self, stat) ? stat.account : undefined, label: conf.renderingProperties?.label, returnData: true, } as CfnAlarm.MetricDataQueryProperty, @@ -328,7 +309,7 @@ export class Alarm extends AlarmBase { return { metrics: mset.entries.map(entry => dispatchMetric(entry.metric, { withStat(stat, conf) { - self.validateMetricStat(stat, entry.metric); + validateMetricStat(self, stat, entry.metric); return { metricStat: { @@ -342,7 +323,7 @@ export class Alarm extends AlarmBase { unit: stat.unitFilter, }, id: entry.id || uniqueMetricId(), - accountId: self.requiresAccountId(stat) ? stat.account : undefined, + accountId: requiresAccountId(self, stat) ? stat.account : undefined, label: conf.renderingProperties?.label, returnData: entry.tag ? undefined : false, // entry.tag evaluates to true if the metric is the math expression the alarm is based on. }; @@ -355,7 +336,7 @@ export class Alarm extends AlarmBase { assertSubmetricsCount(expr); } - self.validateMetricExpression(expr); + validateMetricExpression(expr); return { expression: expr.expression, @@ -370,44 +351,329 @@ export class Alarm extends AlarmBase { }, }); } +} + +/** + * An alarm based on CloudWatch anomaly detection + * + * @resource AWS::CloudWatch::Alarm + */ +export class AnomalyDetectionAlarm extends AlarmBase { + + /** + * ARN of this alarm + * + * @attribute + */ + public readonly alarmArn: string; /** - * Validate that if a region is in the given stat config, they match the Alarm + * Name of this alarm + * + * @attribute */ - private validateMetricStat(stat: MetricStatConfig, metric: IMetric) { - const stack = Stack.of(this); + public readonly alarmName: string; - if (definitelyDifferent(stat.region, stack.region)) { - throw new Error(`Cannot create an Alarm in region '${stack.region}' based on metric '${metric}' in '${stat.region}'`); + /** + * The metric object this alarm was based on + */ + public readonly metric: IMetric; + + /** + * This is the identifier of the thresholdMetricId if the customer doesn't define it. + */ + readonly anomalyDetectorMetricId = 'anomalyDetectorMetric'; + + constructor(scope: Construct, id: string, props: AnomalyDetectionAlarmProps) { + super(scope, id, { + physicalName: props.alarmName, + }); + + const generateAnomalyDetector = props.generateAnomalyDetectionExpression || false; + const bandThreshold = props.threshold; + if (generateAnomalyDetector) { + this.validateThreshold(bandThreshold); } + + const comparisonOperator = props.comparisonOperator || ComparisonOperator.LESS_THAN_LOWER_OR_GREATER_THAN_UPPER_THRESHOLD; + this.validateComparisonOperator(comparisonOperator); + + // Render metric, process potential overrides from the alarm + // We generate the anomaly detection metric if the the customer doesn't provide it. + const metricProps: Writeable> = generateAnomalyDetector ? + this.renderAnomalyDetectionMetric(props.metric, bandThreshold) : + this.renderMetric(props.metric, true); + + if (props.period) { + metricProps.period = props.period.toSeconds(); + } + if (props.statistic) { + // Will overwrite both fields if present + Object.assign(metricProps, { + statistic: renderIfSimpleStatistic(props.statistic), + extendedStatistic: renderIfExtendedStatistic(props.statistic), + }); + } + + const alarm = new CfnAlarm(this, 'Resource', { + // Meta + alarmDescription: props.alarmDescription, + alarmName: this.physicalName, + + // Evaluation + comparisonOperator, + thresholdMetricId: this.anomalyDetectorMetricId, + datapointsToAlarm: props.datapointsToAlarm, + evaluateLowSampleCountPercentile: props.evaluateLowSampleCountPercentile, + 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 + ...metricProps, + }); + + 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; } /** - * Validates that the expression config does not specify searchAccount or searchRegion props - * as search expressions are not supported by Alarms. + * Validates that the anomaly detection threshold is a positive number. + */ + private validateThreshold(threshold: number) { + if (threshold < 0) { + throw new Error('Cannot create an AnomalyDetectionAlarm with a negative anomaly detection threshold'); + } + } + + /** + * Validates that the comparison operator defined is supported by Anomaly Detection Alarms. + */ + private validateComparisonOperator(comparisonOperator: ComparisonOperator) { + if (!(comparisonOperator === ComparisonOperator.LESS_THAN_LOWER_OR_GREATER_THAN_UPPER_THRESHOLD || + comparisonOperator === ComparisonOperator.GREATER_THAN_UPPER_THRESHOLD || + comparisonOperator === ComparisonOperator.LESS_THAN_LOWER_THRESHOLD + )) { + throw new Error(`Cannot create an AnomalyDetectionAlarm with a comparison operator of type '${comparisonOperator}'`); + } + } + + /** + * Creates an anomaly detection expression on top of the metric the customer provided. */ - private validateMetricExpression(expr: MetricExpressionConfig) { - if (expr.searchAccount !== undefined || expr.searchRegion !== undefined) { - throw new Error('Cannot create an Alarm based on a MathExpression which specifies a searchAccount or searchRegion'); + private renderAnomalyDetectionMetric(metric: IMetric, anomalyDetectionThreshold: number) { + const conf = metric.toMetricConfig(); + if (conf.metricStat || conf.mathExpression) { + return { + metrics: [ + { + expression: `ANOMALY_DETECTION_BAND(target, ${anomalyDetectionThreshold})`, + id: this.anomalyDetectorMetricId, + returnData: true, + } as CfnAlarm.MetricDataQueryProperty, + ...this.renderMetric(metric, false).metrics, + ], + }; + } else { + throw new Error('Metric object must have a \'metricStat\' or a \'mathExpression\''); } } /** - * Determine if the accountId property should be included in the metric. + * To render the metric we take into account if the customer provided a thresholdMetricId. + * If they didn't we generate an expression with `ANOMALY_DETECTION_BAND` that targets the metric the customer provided. */ - private requiresAccountId(stat: MetricStatConfig): boolean { - const stackAccount = Stack.of(this).account; + private renderMetric(metric: IMetric, isAnomalyDetectionExpression: boolean) { + const self = this; + const adId = this.anomalyDetectorMetricId; + return dispatchMetric(metric, { + withStat(stat, conf) { + if (isAnomalyDetectionExpression) { + throw new Error('The Metric object for the AnomalyDetection alarm must have a \'mathExpression\''); + } + validateMetricStat(self, stat, metric); + return { + metrics: [ + { + metricStat: { + metric: { + metricName: stat.metricName, + namespace: stat.namespace, + dimensions: stat.dimensions, + }, + period: stat.period.toSeconds(), + stat: stat.statistic, + unit: stat.unitFilter, + }, + id: 'target', + accountId: requiresAccountId(self, stat) ? stat.account : undefined, + label: conf.renderingProperties?.label, + returnData: true, + } as CfnAlarm.MetricDataQueryProperty, + ], + }; + }, + + withExpression() { + // Expand the math expression metric into a set + const mset = new MetricSet(); + mset.addTopLevel(true, metric); + const targetMetricId = getAnomalyDetectionExpressionTargetId(metric.toMetricConfig().mathExpression); + + let eid = 0; + function uniqueMetricId() { + return `expr_${++eid}`; + } + + return { + metrics: mset.entries.map(entry => dispatchMetric(entry.metric, { + withStat(stat, conf) { + if (entry.tag && isAnomalyDetectionExpression) { + throw new Error('The Metric object for the AnomalyDetection alarm must have a \'mathExpression\''); + } + + validateMetricStat(self, stat, entry.metric); + + const isTarget = targetMetricId ? targetMetricId === entry.id : false; + + 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(), + accountId: requiresAccountId(self, stat) ? stat.account : undefined, + label: conf.renderingProperties?.label, + returnData: entry.tag || isTarget ? true : false, // entry.tag evaluates to true if the metric is the math expression the alarm is based on. + }; + }, + withExpression(expr, conf) { + // Verify the anomaly detection metric expression exists and if we expect it to be defined. + validateAnomalyDetectionMetricExpression(expr, entry.tag || false, isAnomalyDetectionExpression); - // if stat.account is undefined, it's by definition in the same account - if (stat.account === undefined) { - return false; + const hasSubmetrics = mathExprHasSubmetrics(expr); + + if (hasSubmetrics) { + assertSubmetricsCount(expr); + } + + validateMetricExpression(expr); + + // Ensure that the top level expression has the correct ID to reference it + const metricId = entry.tag ? isAnomalyDetectionExpression ? adId : 'target' : entry.id || uniqueMetricId(); + // Ensure that the metric the anomaly detection targets returns data + const isTarget = targetMetricId ? targetMetricId === entry.id : false; + + return { + expression: expr.expression, + id: metricId, + label: conf.renderingProperties?.label, + period: hasSubmetrics ? undefined : expr.period, + returnData: entry.tag || isTarget ? true : false, // entry.tag evaluates to true if the metric is the math expression the alarm is based on. + }; + }, + }) as CfnAlarm.MetricDataQueryProperty), + }; + }, + }); + } +} + +/** + * Validate that if a region is in the given stat config, they match the Alarm + */ +function validateMetricStat(scope: Construct, stat: MetricStatConfig, metric: IMetric) { + const stack = Stack.of(scope); + + if (definitelyDifferent(stat.region, stack.region)) { + throw new Error(`Cannot create an 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. + */ +function validateMetricExpression(expr: MetricExpressionConfig) { + if (expr.searchAccount !== undefined || expr.searchRegion !== undefined) { + throw new Error('Cannot create an Alarm based on a MathExpression which specifies a searchAccount or searchRegion'); + } +} + +/** + * Return the metric id from the first argument of the `ANOMALY_DETECTION_BAND` function. + */ +function getAnomalyDetectionExpressionTargetId(expr: MetricExpressionConfig | undefined) { + if (expr === undefined) { + return undefined; + } + const matches = expr.expression.match(/ANOMALY_DETECTION_BAND\(([^,\s]+),/); + if (matches && matches.length > 1) { + return matches[1].trim(); + } + return undefined; +} + +/** + * Validates a metric is an expression config that contains `ANOMALY_DETECTION_BAND` + * as it is required by Anomaly Detection Alarms. + */ +function isAnomalyDetectionMetricExpression(expr: MetricExpressionConfig | undefined) { + if (expr === undefined) { + return false; + } + const regex = new RegExp(/ANOMALY_DETECTION_BAND\s*\(\s*([^,\s]+)\s*,\s*([^,\s]+)\s*\)/); + return regex.test(expr.expression); +} + +/** + * Validates that the expression config contains `ANOMALY_DETECTION_BAND` + * as it is required by Anomaly Detection Alarms. + */ +function validateAnomalyDetectionMetricExpression(expr: MetricExpressionConfig, isTopLevel: boolean, containsAnomalyDetectionExpression: boolean) { + const isAnomalyDetectionExpression = isAnomalyDetectionMetricExpression(expr); + if (isTopLevel) { + if (containsAnomalyDetectionExpression && !isAnomalyDetectionExpression) { + throw new Error('The Metric Object has a MathExpression with malformed or missing ANOMALY_DETECTION_BAND'); + } else if (!containsAnomalyDetectionExpression && isAnomalyDetectionExpression) { + throw new Error('The Metric Object has a MathExpression which contains ANOMALY_DETECTION_BAND, but it is not defined in the thresholdMetricId property'); } + } +} + +/** + * Determine if the accountId property should be included in the metric. + */ +function requiresAccountId(scope: Construct, stat: MetricStatConfig): boolean { + const stackAccount = Stack.of(scope).account; - // Return true if they're different. The ACCOUNT_ID token is interned - // so will always have the same string value (and even if we guess wrong - // it will still work). - return stackAccount !== stat.account; + // if stat.account is undefined, it's by definition in the same account + if (stat.account === undefined) { + return false; } + + // Return true if they're different. The ACCOUNT_ID token is interned + // so will always have the same string value (and even if we guess wrong + // it will still work). + return stackAccount !== stat.account; } function definitelyDifferent(x: string | undefined, y: string) { diff --git a/packages/aws-cdk-lib/aws-cloudwatch/lib/composite-alarm.ts b/packages/aws-cdk-lib/aws-cloudwatch/lib/composite-alarm.ts index 3343cdcdecdff..bd25e8e3c41e8 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/lib/composite-alarm.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/lib/composite-alarm.ts @@ -1,7 +1,7 @@ import { Construct } from 'constructs'; import { AlarmBase, IAlarm, IAlarmRule } from './alarm-base'; import { CfnCompositeAlarm } from './cloudwatch.generated'; -import { ArnFormat, Lazy, Names, Stack, Duration } from '../../core'; +import { ArnFormat, Lazy, Names, Duration } from '../../core'; /** * Properties for creating a Composite Alarm @@ -63,39 +63,6 @@ export interface CompositeAlarmProps { */ export class CompositeAlarm extends AlarmBase { - /** - * Import an existing CloudWatch composite alarm provided an Name. - * - * @param scope The parent creating construct (usually `this`) - * @param id The construct's name - * @param compositeAlarmName Composite Alarm Name - */ - public static fromCompositeAlarmName(scope: Construct, id: string, compositeAlarmName: string): IAlarm { - const stack = Stack.of(scope); - - return this.fromCompositeAlarmArn(scope, id, stack.formatArn({ - service: 'cloudwatch', - resource: 'alarm', - resourceName: compositeAlarmName, - arnFormat: ArnFormat.COLON_RESOURCE_NAME, - })); - } - - /** - * Import an existing CloudWatch composite alarm provided an ARN. - * - * @param scope The parent creating construct (usually `this`) - * @param id The construct's name - * @param compositeAlarmArn Composite Alarm ARN (i.e. arn:aws:cloudwatch:::alarm:CompositeAlarmName) - */ - public static fromCompositeAlarmArn(scope: Construct, id: string, compositeAlarmArn: string): IAlarm { - class Import extends AlarmBase implements IAlarm { - public readonly alarmArn = compositeAlarmArn; - public readonly alarmName = Stack.of(scope).splitArn(compositeAlarmArn, ArnFormat.COLON_RESOURCE_NAME).resourceName!; - } - return new Import(scope, id); - } - /** * ARN of this alarm * diff --git a/packages/aws-cdk-lib/aws-cloudwatch/lib/metric.ts b/packages/aws-cdk-lib/aws-cloudwatch/lib/metric.ts index 8b6c05bc45122..3582523d99b13 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/lib/metric.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/lib/metric.ts @@ -826,6 +826,9 @@ export interface CreateAlarmOptions { /** * The value against which the specified statistic is compared. + * + * For anomaly detection alarms this represents the anomaly detection threshold. + * A higher number create a thicker band for the anomaly detection. */ readonly threshold: number; diff --git a/packages/aws-cdk-lib/aws-cloudwatch/test/alarm.test.ts b/packages/aws-cdk-lib/aws-cloudwatch/test/alarm.test.ts index 85feb315eda91..22435161bc88d 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/test/alarm.test.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/test/alarm.test.ts @@ -3,7 +3,7 @@ import { Match, Template, Annotations } from '../../assertions'; import { Ec2Action, Ec2InstanceAction } from '../../aws-cloudwatch-actions/lib'; import { Duration, Stack, App } from '../../core'; import { ENABLE_PARTITION_LITERALS } from '../../cx-api'; -import { Alarm, IAlarm, IAlarmAction, Metric, MathExpression, IMetric, Stats } from '../lib'; +import { Alarm, IAlarm, IAlarmAction, Metric, MathExpression, IMetric, Stats, AnomalyDetectionAlarm, ComparisonOperator } from '../lib'; const testMetric = new Metric({ namespace: 'CDK/Test', @@ -459,6 +459,276 @@ describe('Alarm', () => { }); }); +describe('AnomalyDetectionAlarm', () => { + test('can create an anomaly detection alarm', () => { + // GIVEN + const stack = new Stack(); + + const metricId = 'm1'; + const usingMetrics: Record = {}; + usingMetrics[metricId] = testMetric; + const math = new MathExpression({ + expression: `ANOMALY_DETECTION_BAND(${metricId}, 2)`, + usingMetrics, + }); + + // WHEN + new AnomalyDetectionAlarm(stack, 'AnomalyDetectionAlarm', { + metric: math, + threshold: 1, + evaluationPeriods: 3, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + ComparisonOperator: 'LessThanLowerOrGreaterThanUpperThreshold', + EvaluationPeriods: 3, + Metrics: [ + { + Expression: 'ANOMALY_DETECTION_BAND(m1, 2)', + Id: 'anomalyDetectorMetric', + ReturnData: true, + }, + { + Id: 'm1', + MetricStat: { + Metric: { + Namespace: 'CDK/Test', + MetricName: 'Metric', + }, + Period: 300, + Stat: 'Average', + }, + ReturnData: true, + }, + ], + ThresholdMetricId: 'anomalyDetectorMetric', + }); + }); + + test('can generate an anomaly detection alarm from a metric stat', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new AnomalyDetectionAlarm(stack, 'AnomalyDetectionAlarm', { + metric: testMetric, + generateAnomalyDetectionExpression: true, + threshold: 1, + evaluationPeriods: 3, + comparisonOperator: ComparisonOperator.GREATER_THAN_UPPER_THRESHOLD, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + ComparisonOperator: 'GreaterThanUpperThreshold', + EvaluationPeriods: 3, + Metrics: [ + { + Expression: 'ANOMALY_DETECTION_BAND(target, 1)', + Id: 'anomalyDetectorMetric', + ReturnData: true, + }, + { + Id: 'target', + MetricStat: { + Metric: { + Namespace: 'CDK/Test', + MetricName: 'Metric', + }, + Period: 300, + Stat: 'Average', + }, + ReturnData: true, + }, + ], + ThresholdMetricId: 'anomalyDetectorMetric', + }); + }); + + test('can generate an anomaly detection alarm from a metric expression', () => { + // GIVEN + const stack = new Stack(); + + const metricId = 'm2'; + const usingMetrics: Record = {}; + usingMetrics[metricId] = testMetric; + const math = new MathExpression({ + expression: `${metricId} / 2`, + usingMetrics, + }); + + // WHEN + new AnomalyDetectionAlarm(stack, 'AnomalyDetectionAlarm', { + metric: math, + generateAnomalyDetectionExpression: true, + threshold: 1, + evaluationPeriods: 3, + comparisonOperator: ComparisonOperator.GREATER_THAN_UPPER_THRESHOLD, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + ComparisonOperator: 'GreaterThanUpperThreshold', + EvaluationPeriods: 3, + Metrics: [ + { + Expression: 'ANOMALY_DETECTION_BAND(target, 1)', + Id: 'anomalyDetectorMetric', + ReturnData: true, + }, + { + Expression: 'm2 / 2', + Id: 'target', + ReturnData: true, + }, + { + Id: 'm2', + MetricStat: { + Metric: { + Namespace: 'CDK/Test', + MetricName: 'Metric', + }, + Period: 300, + Stat: 'Average', + }, + ReturnData: false, + }, + ], + ThresholdMetricId: 'anomalyDetectorMetric', + }); + }); + + test('alarm does not accept comparison operator that is not for anomaly detection', () => { + // GIVEN + const stack = new Stack(); + + const metricId = 'm1'; + const usingMetrics: Record = {}; + usingMetrics[metricId] = testMetric; + const math = new MathExpression({ + expression: `ANOMALY_DETECTION_BAND(${metricId}, 2)`, + usingMetrics, + }); + + // WHEN + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: math, + threshold: 1, + evaluationPeriods: 3, + comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, + }); + }).toThrow(/Cannot create an AnomalyDetectionAlarm with a comparison operator of type/); + }); + + test('alarm does not accept negative threshold', () => { + // GIVEN + const stack = new Stack(); + + const metricId = 'm1'; + const usingMetrics: Record = {}; + usingMetrics[metricId] = testMetric; + const math = new MathExpression({ + expression: `ANOMALY_DETECTION_BAND(${metricId}, 2)`, + usingMetrics, + }); + + // WHEN + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: math, + generateAnomalyDetectionExpression: true, + threshold: -1, + evaluationPeriods: 3, + }); + }).toThrow(/Cannot create an AnomalyDetectionAlarm with a negative anomaly detection threshold/); + }); + + test('alarm does not accept metric stat for anomaly detection', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: testMetric, + generateAnomalyDetectionExpression: false, + threshold: 1, + evaluationPeriods: 3, + }); + }).toThrow(/The Metric object for the AnomalyDetection alarm must have a 'mathExpression'/); + }); + + test('alarm does not accept expression without ANOMALY_DETECTION_BAND', () => { + // GIVEN + const stack = new Stack(); + + const metricId = 'm1'; + const usingMetrics: Record = {}; + usingMetrics[metricId] = testMetric; + const math = new MathExpression({ + expression: `${metricId}/2`, + usingMetrics, + }); + + // WHEN + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: math, + threshold: 1, + evaluationPeriods: 3, + }); + }).toThrow(/The Metric Object has a MathExpression with malformed or missing ANOMALY_DETECTION_BAND/); + }); + + test('alarm does not accept expression with malformed ANOMALY_DETECTION_BAND', () => { + // GIVEN + const stack = new Stack(); + + const metricId = 'm1'; + const usingMetrics: Record = {}; + usingMetrics[metricId] = testMetric; + const math = new MathExpression({ + expression: 'ANOMALY_DETECTION_BAND(, 2)', + usingMetrics, + }); + + // WHEN + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: math, + threshold: 1, + evaluationPeriods: 3, + }); + }).toThrow(/The Metric Object has a MathExpression with malformed or missing ANOMALY_DETECTION_BAND/); + }); + + test('alarm with generated expression does not accept expression with ANOMALY_DETECTION_BAND', () => { + // GIVEN + const stack = new Stack(); + + const metricId = 'm1'; + const usingMetrics: Record = {}; + usingMetrics[metricId] = testMetric; + const math = new MathExpression({ + expression: `ANOMALY_DETECTION_BAND(${metricId}, 2)`, + usingMetrics, + }); + + // WHEN + expect(() => { + new AnomalyDetectionAlarm(stack, 'Alarm', { + metric: math, + generateAnomalyDetectionExpression: true, + threshold: 1, + evaluationPeriods: 3, + }); + }).toThrow(/The Metric Object has a MathExpression which contains ANOMALY_DETECTION_BAND, but it is not defined in the thresholdMetricId property/); + }); + +}); + class TestAlarmAction implements IAlarmAction { constructor(private readonly arn: string) { } diff --git a/packages/aws-cdk-lib/aws-cloudwatch/test/composite-alarm.test.ts b/packages/aws-cdk-lib/aws-cloudwatch/test/composite-alarm.test.ts index a6a8d1f21e6c0..9c4456c79175f 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/test/composite-alarm.test.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/test/composite-alarm.test.ts @@ -195,12 +195,12 @@ describe('CompositeAlarm', () => { test('imported alarm arn and name generated correctly', () => { const stack = new Stack(); - const alarmFromArn = CompositeAlarm.fromCompositeAlarmArn(stack, 'AlarmFromArn', 'arn:aws:cloudwatch:us-west-2:123456789012:alarm:TestAlarmName'); + const alarmFromArn = CompositeAlarm.fromAlarmArn(stack, 'AlarmFromArn', 'arn:aws:cloudwatch:us-west-2:123456789012:alarm:TestAlarmName'); expect(alarmFromArn.alarmName).toEqual('TestAlarmName'); expect(alarmFromArn.alarmArn).toMatch(/:alarm:TestAlarmName$/); - const alarmFromName = CompositeAlarm.fromCompositeAlarmName(stack, 'AlarmFromName', 'TestAlarmName'); + const alarmFromName = CompositeAlarm.fromAlarmName(stack, 'AlarmFromName', 'TestAlarmName'); expect(alarmFromName.alarmName).toEqual('TestAlarmName'); expect(alarmFromName.alarmArn).toMatch(/:alarm:TestAlarmName$/);