Skip to content

Commit 3cd95a2

Browse files
authored
feat: add minMetricSamplesToAlarm property (#200)
Fixes #198 --- _By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license_
1 parent 3eaf594 commit 3cd95a2

File tree

4 files changed

+189
-24
lines changed

4 files changed

+189
-24
lines changed

API.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1983,6 +1983,7 @@ const addAlarmProps: AddAlarmProps = { ... }
19831983
| <code><a href="#cdk-monitoring-constructs.AddAlarmProps.property.evaluateLowSampleCountPercentile">evaluateLowSampleCountPercentile</a></code> | <code>boolean</code> | Used only for alarms based on percentiles. |
19841984
| <code><a href="#cdk-monitoring-constructs.AddAlarmProps.property.evaluationPeriods">evaluationPeriods</a></code> | <code>number</code> | Number of periods to consider when checking the number of breaching datapoints. |
19851985
| <code><a href="#cdk-monitoring-constructs.AddAlarmProps.property.fillAlarmRange">fillAlarmRange</a></code> | <code>boolean</code> | Indicates whether the alarming range of values should be highlighted in the widget. |
1986+
| <code><a href="#cdk-monitoring-constructs.AddAlarmProps.property.minMetricSamplesToAlarm">minMetricSamplesToAlarm</a></code> | <code>number</code> | Specifies how many samples (N) of the metric is needed to trigger the alarm. |
19861987
| <code><a href="#cdk-monitoring-constructs.AddAlarmProps.property.overrideAnnotationColor">overrideAnnotationColor</a></code> | <code>string</code> | If specified, it modifies the final alarm annotation color. |
19871988
| <code><a href="#cdk-monitoring-constructs.AddAlarmProps.property.overrideAnnotationLabel">overrideAnnotationLabel</a></code> | <code>string</code> | If specified, it modifies the final alarm annotation label. |
19881989
| <code><a href="#cdk-monitoring-constructs.AddAlarmProps.property.overrideAnnotationVisibility">overrideAnnotationVisibility</a></code> | <code>boolean</code> | If specified, it modifies the final alarm annotation visibility. |
@@ -2243,6 +2244,26 @@ Indicates whether the alarming range of values should be highlighted in the widg
22432244

22442245
---
22452246

2247+
##### `minMetricSamplesToAlarm`<sup>Optional</sup> <a name="minMetricSamplesToAlarm" id="cdk-monitoring-constructs.AddAlarmProps.property.minMetricSamplesToAlarm"></a>
2248+
2249+
```typescript
2250+
public readonly minMetricSamplesToAlarm: number;
2251+
```
2252+
2253+
- *Type:* number
2254+
- *Default:* default behaviour - no condition on sample count will be added to the alarm
2255+
2256+
Specifies how many samples (N) of the metric is needed to trigger the alarm.
2257+
2258+
If this property is specified, an artificial composite alarm is created of the following:
2259+
<ul>
2260+
<li>The original alarm, created without this property being used; this alarm will have no actions set.</li>
2261+
<li>A secondary alarm, which will monitor the same metric with the N (SampleCount) statistic, checking the sample count.</li>
2262+
</ul>
2263+
The newly created composite alarm will be returned as a result, and it will take the original alarm actions.
2264+
2265+
---
2266+
22462267
##### `overrideAnnotationColor`<sup>Optional</sup> <a name="overrideAnnotationColor" id="cdk-monitoring-constructs.AddAlarmProps.property.overrideAnnotationColor"></a>
22472268

22482269
```typescript
@@ -2655,6 +2676,7 @@ const alarmAnnotationStrategyProps: AlarmAnnotationStrategyProps = { ... }
26552676
| <code><a href="#cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.fillAlarmRange">fillAlarmRange</a></code> | <code>boolean</code> | *No description.* |
26562677
| <code><a href="#cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.metric">metric</a></code> | <code>aws-cdk-lib.aws_cloudwatch.Metric \| aws-cdk-lib.aws_cloudwatch.MathExpression</code> | *No description.* |
26572678
| <code><a href="#cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.threshold">threshold</a></code> | <code>number</code> | *No description.* |
2679+
| <code><a href="#cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.minMetricSamplesToAlarm">minMetricSamplesToAlarm</a></code> | <code>number</code> | *No description.* |
26582680
| <code><a href="#cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.overrideAnnotationColor">overrideAnnotationColor</a></code> | <code>string</code> | *No description.* |
26592681
| <code><a href="#cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.overrideAnnotationLabel">overrideAnnotationLabel</a></code> | <code>string</code> | *No description.* |
26602682
| <code><a href="#cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.overrideAnnotationVisibility">overrideAnnotationVisibility</a></code> | <code>boolean</code> | *No description.* |
@@ -2781,6 +2803,16 @@ public readonly threshold: number;
27812803

27822804
---
27832805

2806+
##### `minMetricSamplesToAlarm`<sup>Optional</sup> <a name="minMetricSamplesToAlarm" id="cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.minMetricSamplesToAlarm"></a>
2807+
2808+
```typescript
2809+
public readonly minMetricSamplesToAlarm: number;
2810+
```
2811+
2812+
- *Type:* number
2813+
2814+
---
2815+
27842816
##### `overrideAnnotationColor`<sup>Optional</sup> <a name="overrideAnnotationColor" id="cdk-monitoring-constructs.AlarmAnnotationStrategyProps.property.overrideAnnotationColor"></a>
27852817

27862818
```typescript
@@ -3383,7 +3415,7 @@ const alarmWithAnnotation: AlarmWithAnnotation = { ... }
33833415
| <code><a href="#cdk-monitoring-constructs.AlarmWithAnnotation.property.customTags">customTags</a></code> | <code>string[]</code> | *No description.* |
33843416
| <code><a href="#cdk-monitoring-constructs.AlarmWithAnnotation.property.dedupeString">dedupeString</a></code> | <code>string</code> | *No description.* |
33853417
| <code><a href="#cdk-monitoring-constructs.AlarmWithAnnotation.property.disambiguator">disambiguator</a></code> | <code>string</code> | *No description.* |
3386-
| <code><a href="#cdk-monitoring-constructs.AlarmWithAnnotation.property.alarm">alarm</a></code> | <code>aws-cdk-lib.aws_cloudwatch.Alarm</code> | *No description.* |
3418+
| <code><a href="#cdk-monitoring-constructs.AlarmWithAnnotation.property.alarm">alarm</a></code> | <code>aws-cdk-lib.aws_cloudwatch.AlarmBase</code> | *No description.* |
33873419
| <code><a href="#cdk-monitoring-constructs.AlarmWithAnnotation.property.alarmDescription">alarmDescription</a></code> | <code>string</code> | *No description.* |
33883420
| <code><a href="#cdk-monitoring-constructs.AlarmWithAnnotation.property.alarmLabel">alarmLabel</a></code> | <code>string</code> | *No description.* |
33893421
| <code><a href="#cdk-monitoring-constructs.AlarmWithAnnotation.property.alarmName">alarmName</a></code> | <code>string</code> | *No description.* |
@@ -3448,10 +3480,10 @@ public readonly disambiguator: string;
34483480
##### `alarm`<sup>Required</sup> <a name="alarm" id="cdk-monitoring-constructs.AlarmWithAnnotation.property.alarm"></a>
34493481

34503482
```typescript
3451-
public readonly alarm: Alarm;
3483+
public readonly alarm: AlarmBase;
34523484
```
34533485

3454-
- *Type:* aws-cdk-lib.aws_cloudwatch.Alarm
3486+
- *Type:* aws-cdk-lib.aws_cloudwatch.AlarmBase
34553487

34563488
---
34573489

lib/common/alarm/AlarmFactory.ts

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Duration } from "aws-cdk-lib";
22
import {
3-
Alarm,
3+
AlarmBase,
44
AlarmRule,
55
AlarmState,
66
ComparisonOperator,
@@ -11,7 +11,11 @@ import {
1111
} from "aws-cdk-lib/aws-cloudwatch";
1212
import { Construct } from "constructs";
1313

14-
import { MetricFactoryDefaults, MetricWithAlarmSupport } from "../metric";
14+
import {
15+
MetricFactoryDefaults,
16+
MetricStatistic,
17+
MetricWithAlarmSupport,
18+
} from "../metric";
1519
import { removeBracketsWithDynamicLabels } from "../strings";
1620
import { AlarmNamingStrategy } from "./AlarmNamingStrategy";
1721
import { IAlarmActionStrategy } from "./IAlarmActionStrategy";
@@ -45,7 +49,7 @@ export interface AlarmMetadata {
4549
* Representation of an alarm with additional information.
4650
*/
4751
export interface AlarmWithAnnotation extends AlarmMetadata {
48-
readonly alarm: Alarm;
52+
readonly alarm: AlarmBase;
4953
readonly alarmName: string;
5054
readonly alarmNameSuffix: string;
5155
readonly alarmLabel: string;
@@ -178,6 +182,18 @@ export interface AddAlarmProps {
178182
*/
179183
readonly evaluateLowSampleCountPercentile?: boolean;
180184

185+
/**
186+
* Specifies how many samples (N) of the metric is needed to trigger the alarm.
187+
* If this property is specified, an artificial composite alarm is created of the following:
188+
* <ul>
189+
* <li>The original alarm, created without this property being used; this alarm will have no actions set.</li>
190+
* <li>A secondary alarm, which will monitor the same metric with the N (SampleCount) statistic, checking the sample count.</li>
191+
* </ul>
192+
* The newly created composite alarm will be returned as a result, and it will take the original alarm actions.
193+
* @default - default behaviour - no condition on sample count will be added to the alarm
194+
*/
195+
readonly minMetricSamplesToAlarm?: number;
196+
181197
/**
182198
* This allows user to attach custom values to this alarm, which can later be accessed from the "useCreatedAlarms" method.
183199
*
@@ -451,6 +467,8 @@ export class AlarmFactory {
451467
metric: MetricWithAlarmSupport,
452468
props: AddAlarmProps
453469
): AlarmWithAnnotation {
470+
// prepare the metric
471+
454472
let adjustedMetric = metric;
455473
if (props.period) {
456474
// Adjust metric period for the alarm
@@ -462,6 +480,9 @@ export class AlarmFactory {
462480
label: removeBracketsWithDynamicLabels(adjustedMetric.label),
463481
});
464482
}
483+
484+
// prepare primary alarm properties
485+
465486
const actionsEnabled = this.determineActionsEnabled(
466487
props.actionsEnabled,
467488
props.disambiguator
@@ -496,20 +517,64 @@ export class AlarmFactory {
496517
);
497518
}
498519

499-
const alarm = adjustedMetric.createAlarm(this.alarmScope, alarmName, {
520+
// create primary alarm
521+
522+
const primaryAlarm = adjustedMetric.createAlarm(
523+
this.alarmScope,
500524
alarmName,
501-
alarmDescription,
502-
threshold: props.threshold,
503-
comparisonOperator: props.comparisonOperator,
504-
treatMissingData: props.treatMissingData,
505-
// default value (undefined) means "evaluate"
506-
evaluateLowSampleCountPercentile: evaluateLowSampleCountPercentile
507-
? undefined
508-
: "ignore",
509-
datapointsToAlarm,
510-
evaluationPeriods,
511-
actionsEnabled,
512-
});
525+
{
526+
alarmName,
527+
alarmDescription,
528+
threshold: props.threshold,
529+
comparisonOperator: props.comparisonOperator,
530+
treatMissingData: props.treatMissingData,
531+
// default value (undefined) means "evaluate"
532+
evaluateLowSampleCountPercentile: evaluateLowSampleCountPercentile
533+
? undefined
534+
: "ignore",
535+
datapointsToAlarm,
536+
evaluationPeriods,
537+
actionsEnabled,
538+
}
539+
);
540+
541+
let alarm: AlarmBase = primaryAlarm;
542+
543+
// create composite alarm for min metric samples (if defined)
544+
545+
if (props.minMetricSamplesToAlarm) {
546+
const metricSampleCount = adjustedMetric.with({
547+
statistic: MetricStatistic.N,
548+
});
549+
const noSamplesAlarm = metricSampleCount.createAlarm(
550+
this.alarmScope,
551+
`${alarmName}-NoSamples`,
552+
{
553+
alarmName: `${alarmName}-NoSamples`,
554+
alarmDescription: `The metric (${adjustedMetric}) does not have enough samples to alarm. Must have at least ${props.minMetricSamplesToAlarm}.`,
555+
threshold: props.minMetricSamplesToAlarm,
556+
comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
557+
treatMissingData: TreatMissingData.BREACHING,
558+
datapointsToAlarm: 1,
559+
evaluationPeriods: 1,
560+
actionsEnabled,
561+
}
562+
);
563+
alarm = new CompositeAlarm(this.alarmScope, `${alarmName}-WithSamples`, {
564+
actionsEnabled,
565+
compositeAlarmName: `${alarmName}-WithSamples`,
566+
alarmDescription: this.joinDescriptionParts(
567+
alarmDescription,
568+
`Min number of samples to alarm: ${props.minMetricSamplesToAlarm}`
569+
),
570+
alarmRule: AlarmRule.allOf(
571+
AlarmRule.fromAlarm(primaryAlarm, AlarmState.ALARM),
572+
AlarmRule.not(AlarmRule.fromAlarm(noSamplesAlarm, AlarmState.ALARM))
573+
),
574+
});
575+
}
576+
577+
// attach alarm actions
513578

514579
action.addAlarmActions({
515580
alarm,
@@ -520,13 +585,16 @@ export class AlarmFactory {
520585
customParams: props.customParams ?? {},
521586
});
522587

588+
// create annotation for the primary alarm
589+
523590
const annotation = this.createAnnotation({
524-
alarm,
591+
alarm: primaryAlarm,
525592
action,
526593
metric: adjustedMetric,
527594
evaluationPeriods,
528595
datapointsToAlarm,
529596
dedupeString,
597+
minMetricSamplesToAlarm: props.minMetricSamplesToAlarm,
530598
fillAlarmRange: props.fillAlarmRange ?? false,
531599
overrideAnnotationColor: props.overrideAnnotationColor,
532600
overrideAnnotationLabel: props.overrideAnnotationLabel,
@@ -538,6 +606,8 @@ export class AlarmFactory {
538606
customParams: props.customParams ?? {},
539607
});
540608

609+
// return the final result
610+
541611
return {
542612
alarm,
543613
action,
@@ -611,7 +681,7 @@ export class AlarmFactory {
611681
case CompositeAlarmOperator.OR:
612682
return AlarmRule.anyOf(...alarmRules);
613683
default:
614-
throw new Error("Unsupported composite alarm operator: " + operator);
684+
throw new Error(`Unsupported composite alarm operator: ${operator}`);
615685
}
616686
}
617687

@@ -663,6 +733,10 @@ export class AlarmFactory {
663733
parts.push(`Documentation: ${documentationLink}`);
664734
}
665735

736+
return this.joinDescriptionParts(...parts);
737+
}
738+
739+
protected joinDescriptionParts(...parts: string[]) {
666740
return parts.join(" \r\n");
667741
}
668742

lib/common/alarm/IAlarmAnnotationStrategy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface AlarmAnnotationStrategyProps extends AlarmMetadata {
1212
readonly alarm: Alarm;
1313
readonly metric: MetricWithAlarmSupport;
1414
readonly comparisonOperator: ComparisonOperator;
15+
readonly minMetricSamplesToAlarm?: number;
1516
readonly threshold: number;
1617
readonly datapointsToAlarm: number;
1718
readonly evaluationPeriods: number;

test/common/alarm/AlarmFactory.test.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Duration, Stack } from "aws-cdk-lib";
2-
import { Template } from "aws-cdk-lib/assertions";
2+
import { Capture, Template } from "aws-cdk-lib/assertions";
33
import {
4+
Alarm,
45
ComparisonOperator,
56
Metric,
67
Shading,
@@ -193,9 +194,9 @@ test("addAlarm: period override is propagated to alarm metric", () => {
193194
alarmNameSuffix: "TwoHoursPeriod",
194195
period: Duration.hours(2),
195196
});
196-
const alarm1hConfig = alarm1h.alarm.metric.toMetricConfig();
197+
const alarm1hConfig = (alarm1h.alarm as Alarm).metric.toMetricConfig();
197198
expect(alarm1hConfig.metricStat?.period).toStrictEqual(Duration.hours(1));
198-
const alarm2hConfig = alarm2h.alarm.metric.toMetricConfig();
199+
const alarm2hConfig = (alarm2h.alarm as Alarm).metric.toMetricConfig();
199200
expect(alarm2hConfig.metricStat?.period).toStrictEqual(Duration.hours(2));
200201
});
201202

@@ -252,6 +253,63 @@ test("addAlarm: annotation overrides are applied", () => {
252253
});
253254
});
254255

256+
test("addAlarm: check created alarms when minMetricSamplesToAlarm is used", () => {
257+
const stack = new Stack();
258+
const factory = new AlarmFactory(stack, {
259+
globalMetricDefaults,
260+
globalAlarmDefaults,
261+
localAlarmNamePrefix: "prefix",
262+
});
263+
factory.addAlarm(metric, {
264+
...props,
265+
alarmNameSuffix: "none",
266+
comparisonOperator: ComparisonOperator.LESS_THAN_THRESHOLD,
267+
minMetricSamplesToAlarm: 42,
268+
});
269+
const template = Template.fromStack(stack);
270+
271+
template.hasResourceProperties("AWS::CloudWatch::Alarm", {
272+
AlarmName: "DummyServiceAlarms-prefix-none",
273+
MetricName: "DummyMetric1",
274+
Statistic: "Average",
275+
});
276+
template.hasResourceProperties("AWS::CloudWatch::Alarm", {
277+
AlarmName: "DummyServiceAlarms-prefix-none-NoSamples",
278+
AlarmDescription:
279+
"The metric (DummyMetric1) does not have enough samples to alarm. Must have at least 42.",
280+
ComparisonOperator: "LessThanThreshold",
281+
DatapointsToAlarm: 1,
282+
EvaluationPeriods: 1,
283+
MetricName: "DummyMetric1",
284+
Statistic: "SampleCount",
285+
Threshold: 42,
286+
TreatMissingData: "breaching",
287+
});
288+
const alarmRuleCapture = new Capture();
289+
template.hasResourceProperties("AWS::CloudWatch::CompositeAlarm", {
290+
AlarmName: "DummyServiceAlarms-prefix-none-WithSamples",
291+
AlarmRule: alarmRuleCapture,
292+
});
293+
const expectedPrimaryAlarmArn = {
294+
"Fn::GetAtt": ["DummyServiceAlarmsprefixnoneF01556DA", "Arn"],
295+
};
296+
const expectedSecondaryAlarmArn = {
297+
"Fn::GetAtt": ["DummyServiceAlarmsprefixnoneNoSamples414211DB", "Arn"],
298+
};
299+
expect(alarmRuleCapture.asObject()).toStrictEqual({
300+
["Fn::Join"]: [
301+
"",
302+
[
303+
'(ALARM("',
304+
expectedPrimaryAlarmArn,
305+
'") AND (NOT (ALARM("',
306+
expectedSecondaryAlarmArn,
307+
'"))))',
308+
],
309+
],
310+
});
311+
});
312+
255313
test("addCompositeAlarm: snapshot for operator", () => {
256314
const stack = new Stack();
257315
const factory = new AlarmFactory(stack, {

0 commit comments

Comments
 (0)