Skip to content

Commit 6aad34f

Browse files
authored
feat(route53): Support Route53 Health Checks (#411)
WHAT? Introduce `interface IMetricAdjuster` which allows to adjust a metric before `AlarmFactory` creates an alarm from it. Additionally created three implementations of `IMetricAdjuster`: - `DefaultMetricAdjuster`: Contains the metric adjustment logic which used to be inlined in the `AlarmFactory.addAlarm()` method. - `CompositeMetricAdjuster`: Allows to apply a collection of `IMetricAdjuster` instances, one after the other. - `Route53HealthCheckMetricAdjuster`: Validates a metric's configuration and transforms it so that it can be used to create CloudWatch alarms compatible with Route53 Health Checks. WHY? Route53 Health Checks have strict requirements about which CloudWatch alarms can be used. By default, alarms created by `MonitoringFacade` use metrics with the `label` property set which results in alarms that are incompatible with Route53 Health Checks. This commit introdues an opt-in `Route53HealthCheckMetricAdjuster` class that allows users to mark alarms to be suitable for Route53 Health Checks. HOW? `npx yarn build` succeeds all the way to `package:js`. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license_
1 parent b7a6d6c commit 6aad34f

14 files changed

+1543
-14
lines changed

API.md

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

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,58 @@ monitorCustom({
254254

255255
Search metrics do not support setting an alarm, which is a CloudWatch limitation.
256256

257+
### Route53 Health Checks
258+
259+
Route53 has [strict requirements](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/health-checks-types.html) as to which alarms are allowed to be referenced in Health Checks.
260+
You adjust the metric for an alarm sot hat it can be used in a Route53 Health Checks as follows:
261+
262+
```ts
263+
monitoring
264+
.monitorSomething(something, {
265+
addSomeAlarm: {
266+
Warning: {
267+
// ...other props
268+
metricAdjuster: Route53HealthCheckMetricAdjuster.INSTANCE,
269+
}
270+
}
271+
});
272+
```
273+
274+
This will ensure the alarm can be used on a Route53 Health Check or otherwise throw an `Error` indicating why the alarm can't be used.
275+
In order to easily find your Route53 Health Check alarms later on, you can apply a custom tag to them as follows:
276+
277+
```ts
278+
import { CfnHealthCheck } from "aws-cdk-lib/aws-route53";
279+
280+
monitoring
281+
.monitorSomething(something, {
282+
addSomeAlarm: {
283+
Warning: {
284+
// ...other props
285+
customTags: ["route53-health-check"],
286+
metricAdjuster: Route53HealthCheckMetricAdjuster.INSTANCE,
287+
}
288+
}
289+
});
290+
291+
const alarms = monitoring.createdAlarmsWithTag("route53-health-check");
292+
293+
const healthChecks = alarms.map(({ alarm }) => {
294+
const id = getHealthCheckConstructId(alarm);
295+
296+
return new CfnHealthCheck(scope, id, {
297+
healthCheckConfig: {
298+
// ...other props
299+
type: "CLOUDWATCH_METRIC",
300+
alarmIdentifier: {
301+
name: alarm.alarmName,
302+
region: alarm.stack.region,
303+
},
304+
},
305+
});
306+
});
307+
```
308+
257309
### Custom monitoring segments
258310

259311
If you want even more flexibility, you can create your own segment.

lib/common/alarm/AlarmFactory.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ import {
2020
} from "./IAlarmAnnotationStrategy";
2121
import { IAlarmDedupeStringProcessor } from "./IAlarmDedupeStringProcessor";
2222
import { IAlarmNamingStrategy } from "./IAlarmNamingStrategy";
23+
import {
24+
CompositeMetricAdjuster,
25+
DefaultMetricAdjuster,
26+
IMetricAdjuster,
27+
} from "./metric-adjuster";
2328
import {
2429
MetricFactoryDefaults,
2530
MetricStatistic,
2631
MetricWithAlarmSupport,
2732
} from "../metric";
28-
import { removeBracketsWithDynamicLabels } from "../strings";
2933

3034
const DefaultDatapointsToAlarm = 3;
3135

@@ -235,6 +239,13 @@ export interface AddAlarmProps {
235239
* @default - no override (default visibility)
236240
*/
237241
readonly overrideAnnotationVisibility?: boolean;
242+
243+
/**
244+
* If specified, adjusts the metric before creating an alarm from it.
245+
*
246+
* @default - no adjuster
247+
*/
248+
readonly metricAdjuster?: IMetricAdjuster;
238249
}
239250

240251
/**
@@ -486,19 +497,19 @@ export class AlarmFactory {
486497
metric: MetricWithAlarmSupport,
487498
props: AddAlarmProps
488499
): AlarmWithAnnotation {
489-
// prepare the metric
490-
491-
let adjustedMetric = metric;
492-
if (props.period) {
493-
// Adjust metric period for the alarm
494-
adjustedMetric = adjustedMetric.with({ period: props.period });
495-
}
496-
if (adjustedMetric.label) {
497-
// Annotations do not support dynamic labels, so we have to remove them from metric name
498-
adjustedMetric = adjustedMetric.with({
499-
label: removeBracketsWithDynamicLabels(adjustedMetric.label),
500-
});
501-
}
500+
// adjust the metric
501+
502+
const metricAdjuster = props.metricAdjuster
503+
? CompositeMetricAdjuster.of(
504+
props.metricAdjuster,
505+
DefaultMetricAdjuster.INSTANCE
506+
)
507+
: DefaultMetricAdjuster.INSTANCE;
508+
const adjustedMetric = metricAdjuster.adjustMetric(
509+
metric,
510+
this.alarmScope,
511+
props
512+
);
502513

503514
// prepare primary alarm properties
504515

lib/common/alarm/CustomAlarmThreshold.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "aws-cdk-lib/aws-cloudwatch";
66

77
import { IAlarmActionStrategy } from "./action";
8+
import { IMetricAdjuster } from "./metric-adjuster";
89

910
/**
1011
* Common customization that can be attached to each alarm.
@@ -136,4 +137,11 @@ export interface CustomAlarmThreshold {
136137
* @default - false
137138
*/
138139
readonly fillAlarmRange?: boolean;
140+
141+
/**
142+
* If specified, adjusts the metric before creating an alarm from it.
143+
*
144+
* @default - no adjuster
145+
*/
146+
readonly metricAdjuster?: IMetricAdjuster;
139147
}

lib/common/alarm/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./action";
2+
export * from "./metric-adjuster";
23
export * from "./AlarmFactory";
34
export * from "./AlarmNamingStrategy";
45
export * from "./CustomAlarmThreshold";
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Construct } from "constructs";
2+
import { IMetricAdjuster } from "./IMetricAdjuster";
3+
import { MetricWithAlarmSupport } from "../../metric";
4+
import { AddAlarmProps } from "../AlarmFactory";
5+
6+
/**
7+
* Allows to apply a collection of {@link IMetricAdjuster} to a metric.
8+
*/
9+
export class CompositeMetricAdjuster implements IMetricAdjuster {
10+
constructor(private readonly adjusters: IMetricAdjuster[]) {}
11+
12+
static of(...adjusters: IMetricAdjuster[]) {
13+
return new CompositeMetricAdjuster(adjusters);
14+
}
15+
16+
/** @inheritdoc */
17+
adjustMetric(
18+
metric: MetricWithAlarmSupport,
19+
alarmScope: Construct,
20+
props: AddAlarmProps
21+
): MetricWithAlarmSupport {
22+
let adjustedMetric = metric;
23+
for (const adjuster of this.adjusters) {
24+
adjustedMetric = adjuster.adjustMetric(adjustedMetric, alarmScope, props);
25+
}
26+
27+
return adjustedMetric;
28+
}
29+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Construct } from "constructs";
2+
import { IMetricAdjuster } from "./IMetricAdjuster";
3+
import { MetricWithAlarmSupport } from "../../metric";
4+
import { removeBracketsWithDynamicLabels } from "../../strings";
5+
import { AddAlarmProps } from "../AlarmFactory";
6+
7+
/**
8+
* Applies the default metric adjustments.
9+
* These adjustments are always applied last, regardless the value configured in {@link AddAlarmProps.metricAdjuster}.
10+
*/
11+
export class DefaultMetricAdjuster implements IMetricAdjuster {
12+
static readonly INSTANCE = new DefaultMetricAdjuster();
13+
14+
/** @inheritdoc */
15+
adjustMetric(
16+
metric: MetricWithAlarmSupport,
17+
_: Construct,
18+
props: AddAlarmProps
19+
): MetricWithAlarmSupport {
20+
let adjustedMetric = metric;
21+
if (props.period) {
22+
// Adjust metric period for the alarm
23+
adjustedMetric = adjustedMetric.with({ period: props.period });
24+
}
25+
26+
if (adjustedMetric.label) {
27+
// Annotations do not support dynamic labels, so we have to remove them from metric name
28+
adjustedMetric = adjustedMetric.with({
29+
label: removeBracketsWithDynamicLabels(adjustedMetric.label),
30+
});
31+
}
32+
33+
return adjustedMetric;
34+
}
35+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Construct } from "constructs";
2+
import { MetricWithAlarmSupport } from "../../metric";
3+
import { AddAlarmProps } from "../AlarmFactory";
4+
5+
/**
6+
* Adjusts a metric before creating adding an alarm to it.
7+
*/
8+
export interface IMetricAdjuster {
9+
/**
10+
* Adjusts a metric.
11+
* @param metric The metric to adjust.
12+
* @param alarmScope The alarm scope.
13+
* @param props The props specified for adding the alarm.
14+
* @returns The adjusted metric.
15+
*/
16+
adjustMetric(
17+
metric: MetricWithAlarmSupport,
18+
alarmScope: Construct,
19+
props: AddAlarmProps
20+
): MetricWithAlarmSupport;
21+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Stack } from "aws-cdk-lib";
2+
import { Metric } from "aws-cdk-lib/aws-cloudwatch";
3+
import { Construct } from "constructs";
4+
import { IMetricAdjuster } from "./IMetricAdjuster";
5+
import { MetricStatistic, MetricWithAlarmSupport } from "../../metric";
6+
import { AddAlarmProps } from "../AlarmFactory";
7+
8+
/**
9+
* The supported statistics by Route53 Health Checks.
10+
* @see https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/health-checks-types.html
11+
*/
12+
const SUPPORTED_STATS = new Set<string>([
13+
MetricStatistic.AVERAGE,
14+
MetricStatistic.MIN,
15+
MetricStatistic.MAX,
16+
MetricStatistic.SUM,
17+
MetricStatistic.N,
18+
]);
19+
20+
/**
21+
* Adjusts a metric so that alarms created from it can be used in Route53 Health Checks.
22+
* The metric will be validated to ensure it satisfies Route53 Health Check alarm requirements, otherwise it will throw an {@link Error}.
23+
* @see https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/health-checks-types.html
24+
*/
25+
export class Route53HealthCheckMetricAdjuster implements IMetricAdjuster {
26+
static readonly INSTANCE = new Route53HealthCheckMetricAdjuster();
27+
28+
/** @inheritdoc */
29+
adjustMetric(
30+
metric: MetricWithAlarmSupport,
31+
alarmScope: Construct,
32+
props: AddAlarmProps
33+
): MetricWithAlarmSupport {
34+
// Route53 health checks do not support composite alarms
35+
if (props.minMetricSamplesToAlarm) {
36+
throw new Error(
37+
"Alarms with 'minMetricSamplesToAlarm' are not supported."
38+
);
39+
}
40+
41+
// Route53 health checks do to support metric math
42+
if (!(metric instanceof Metric)) {
43+
throw new Error("The specified metric must be a Metric instance.");
44+
}
45+
46+
const { account, period, statistic } = metric;
47+
48+
if (account && account !== Stack.of(alarmScope).account) {
49+
throw new Error("Cross-account metrics are not supported.");
50+
}
51+
52+
// Route53 health checks do not support high-resolution metrics
53+
if (period && period.toSeconds() < 60) {
54+
throw new Error("High resolution metrics are not supported.");
55+
}
56+
57+
// Route53 health checks only support a subset of statistics
58+
if (!SUPPORTED_STATS.has(statistic)) {
59+
throw new Error(
60+
`Metrics with statistic '${statistic}' are not supported.`
61+
);
62+
}
63+
64+
// Can't use `metric.with()` to remove the label, only change it
65+
// See: https://github.com/aws/aws-cdk/blob/v2.65.0/packages/%40aws-cdk/aws-cloudwatch/lib/metric.ts#L314-L342
66+
return new Metric({
67+
...metric,
68+
// all fields except dimensions have the same names
69+
dimensionsMap: metric.dimensions,
70+
/*
71+
* `AWS::CloudWatch::Alarm` CFN resource types have two variants:
72+
* - Based on a single metric: Uses `MetricName` property.
73+
* - Based on metric math: Uses `Metrics` property.
74+
*
75+
* If the `label` of a `Metric` instance is defined, when an
76+
* alarm is created from it, even if it doesn't use metric math,
77+
* it will use the `Metrics` property. Since Route53 does not
78+
* support metric math it assumes any alarm created using the
79+
* `Metrics` property must use metric math, thus it must be removed.
80+
*
81+
* See: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cloudwatch-alarm.html
82+
* See: https://github.com/aws/aws-cdk/blob/v2.65.0/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts#L262-L298
83+
*/
84+
label: undefined,
85+
});
86+
}
87+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from "./CompositeMetricAdjuster";
2+
export * from "./DefaultMetricAdjuster";
3+
export * from "./IMetricAdjuster";
4+
export * from "./Route53HealthCheckMetricAdjuster";

0 commit comments

Comments
 (0)