Skip to content

Commit 5472b11

Browse files
authored
fix(cloudwatch): MathExpression id contract is not clear (#19825)
It is intended that all metric identifiers referenced in a MathExpression are included in the `usingMetrics` map. This allows passing around complex metrics as a single object, because the math expression object carries around its dependencies with it. This is slightly different than what people might be used to from raw CloudWatch, where there is no hierarchy and all metrics are supposed to be listed in the graph widget or alarm with a unique ID, and then referenced by ID. We can't make this contract obvious anymore by adding a hard validation, but we can add warnings to hint people at the right way to reference metrics in math expressions. Fixes #13942, closes #17126. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent a205734 commit 5472b11

File tree

11 files changed

+162
-13
lines changed

11 files changed

+162
-13
lines changed

packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArnFormat, Lazy, Stack, Token } from '@aws-cdk/core';
1+
import { ArnFormat, Lazy, Stack, Token, Annotations } from '@aws-cdk/core';
22
import { Construct } from 'constructs';
33
import { IAlarmAction } from './alarm-action';
44
import { AlarmBase, IAlarm } from './alarm-base';
@@ -203,6 +203,10 @@ export class Alarm extends AlarmBase {
203203
label: `${this.metric} ${OPERATOR_SYMBOLS[comparisonOperator]} ${props.threshold} for ${datapoints} datapoints within ${describePeriod(props.evaluationPeriods * metricPeriod(props.metric).toSeconds())}`,
204204
value: props.threshold,
205205
};
206+
207+
for (const w of this.metric.warnings ?? []) {
208+
Annotations.of(this).addWarning(w);
209+
}
206210
}
207211

208212
/**

packages/@aws-cdk/aws-cloudwatch/lib/dashboard.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Lazy, Resource, Stack, Token } from '@aws-cdk/core';
1+
import { Lazy, Resource, Stack, Token, Annotations } from '@aws-cdk/core';
22
import { Construct } from 'constructs';
33
import { CfnDashboard } from './cloudwatch.generated';
44
import { Column, Row } from './layout';
@@ -127,7 +127,29 @@ export class Dashboard extends Resource {
127127
return;
128128
}
129129

130+
const warnings = allWidgetsDeep(widgets).flatMap(w => w.warnings ?? []);
131+
for (const w of warnings) {
132+
Annotations.of(this).addWarning(w);
133+
}
134+
130135
const w = widgets.length > 1 ? new Row(...widgets) : widgets[0];
131136
this.rows.push(w);
132137
}
133138
}
139+
140+
function allWidgetsDeep(ws: IWidget[]) {
141+
const ret = new Array<IWidget>();
142+
ws.forEach(recurse);
143+
return ret;
144+
145+
function recurse(w: IWidget) {
146+
ret.push(w);
147+
if (hasSubWidgets(w)) {
148+
w.widgets.forEach(recurse);
149+
}
150+
}
151+
}
152+
153+
function hasSubWidgets(w: IWidget): w is IWidget & { widgets: IWidget[] } {
154+
return 'widgets' in w;
155+
}

packages/@aws-cdk/aws-cloudwatch/lib/graph.ts

+4
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export class GraphWidget extends ConcreteWidget {
256256
this.props = props;
257257
this.leftMetrics = props.left ?? [];
258258
this.rightMetrics = props.right ?? [];
259+
this.copyMetricWarnings(...this.leftMetrics, ...this.rightMetrics);
259260
}
260261

261262
/**
@@ -265,6 +266,7 @@ export class GraphWidget extends ConcreteWidget {
265266
*/
266267
public addLeftMetric(metric: IMetric) {
267268
this.leftMetrics.push(metric);
269+
this.copyMetricWarnings(metric);
268270
}
269271

270272
/**
@@ -274,6 +276,7 @@ export class GraphWidget extends ConcreteWidget {
274276
*/
275277
public addRightMetric(metric: IMetric) {
276278
this.rightMetrics.push(metric);
279+
this.copyMetricWarnings(metric);
277280
}
278281

279282
public toJson(): any[] {
@@ -343,6 +346,7 @@ export class SingleValueWidget extends ConcreteWidget {
343346
constructor(props: SingleValueWidgetProps) {
344347
super(props.width || 6, props.height || 3);
345348
this.props = props;
349+
this.copyMetricWarnings(...props.metrics);
346350
}
347351

348352
public toJson(): any[] {

packages/@aws-cdk/aws-cloudwatch/lib/layout.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class Row implements IWidget {
1414
/**
1515
* List of contained widgets
1616
*/
17-
private readonly widgets: IWidget[];
17+
public readonly widgets: IWidget[];
1818

1919
/**
2020
* Relative position of each widget inside this row
@@ -70,7 +70,7 @@ export class Column implements IWidget {
7070
/**
7171
* List of contained widgets
7272
*/
73-
private readonly widgets: IWidget[];
73+
public readonly widgets: IWidget[];
7474

7575
constructor(...widgets: IWidget[]) {
7676
this.widgets = widgets;

packages/@aws-cdk/aws-cloudwatch/lib/metric-types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ import { Duration } from '@aws-cdk/core';
44
* Interface for metrics
55
*/
66
export interface IMetric {
7+
/**
8+
* Any warnings related to this metric
9+
*
10+
* Should be attached to the consuming construct.
11+
*
12+
* @default - None
13+
*/
14+
readonly warnings?: string[];
15+
716
/**
817
* Inspect the details of the metric object
918
*/

packages/@aws-cdk/aws-cloudwatch/lib/metric.ts

+50-2
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,11 @@ export class MathExpression implements IMetric {
553553
*/
554554
public readonly searchRegion?: string;
555555

556+
/**
557+
* Warnings generated by this math expression
558+
*/
559+
public readonly warnings?: string[];
560+
556561
constructor(props: MathExpressionProps) {
557562
this.period = props.period || cdk.Duration.minutes(5);
558563
this.expression = props.expression;
@@ -568,6 +573,27 @@ export class MathExpression implements IMetric {
568573
}
569574

570575
this.validateNoIdConflicts();
576+
577+
// Check that all IDs used in the expression are also in the `usingMetrics` map. We
578+
// can't throw on this anymore since we didn't use to do this validation from the start
579+
// and now there will be loads of people who are violating the expected contract, but
580+
// we can add warnings.
581+
const missingIdentifiers = allIdentifiersInExpression(this.expression).filter(i => !this.usingMetrics[i]);
582+
583+
const warnings = [];
584+
585+
if (missingIdentifiers.length > 0) {
586+
warnings.push(`Math expression '${this.expression}' references unknown identifiers: ${missingIdentifiers.join(', ')}. Please add them to the 'usingMetrics' map.`);
587+
}
588+
589+
// Also copy warnings from deeper levels so graphs, alarms only have to inspect the top-level objects
590+
for (const m of Object.values(this.usingMetrics)) {
591+
warnings.push(...m.warnings ?? []);
592+
}
593+
594+
if (warnings.length > 0) {
595+
this.warnings = warnings;
596+
}
571597
}
572598

573599
/**
@@ -677,15 +703,27 @@ export class MathExpression implements IMetric {
677703
});
678704
}
679705
}
680-
681706
}
682707

683-
const VALID_VARIABLE = new RegExp('^[a-z][a-zA-Z0-9_]*$');
708+
/**
709+
* Pattern for a variable name. Alphanum starting with lowercase.
710+
*/
711+
const VARIABLE_PAT = '[a-z][a-zA-Z0-9_]*';
712+
713+
const VALID_VARIABLE = new RegExp(`^${VARIABLE_PAT}$`);
714+
const FIND_VARIABLE = new RegExp(VARIABLE_PAT, 'g');
684715

685716
function validVariableName(x: string) {
686717
return VALID_VARIABLE.test(x);
687718
}
688719

720+
/**
721+
* Return all variable names used in an expression
722+
*/
723+
function allIdentifiersInExpression(x: string) {
724+
return Array.from(matchAll(x, FIND_VARIABLE)).map(m => m[0]);
725+
}
726+
689727
/**
690728
* Properties needed to make an alarm from a metric
691729
*/
@@ -842,3 +880,13 @@ interface IModifiableMetric {
842880
function isModifiableMetric(m: any): m is IModifiableMetric {
843881
return typeof m === 'object' && m !== null && !!m.with;
844882
}
883+
884+
// Polyfill for string.matchAll(regexp)
885+
function matchAll(x: string, re: RegExp): RegExpMatchArray[] {
886+
const ret = new Array<RegExpMatchArray>();
887+
let m: RegExpExecArray | null;
888+
while (m = re.exec(x)) {
889+
ret.push(m);
890+
}
891+
return ret;
892+
}

packages/@aws-cdk/aws-cloudwatch/lib/widget.ts

+16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { IMetric } from './metric-types';
2+
13
/**
24
* The width of the grid we're filling
35
*/
@@ -17,6 +19,11 @@ export interface IWidget {
1719
*/
1820
readonly height: number;
1921

22+
/**
23+
* Any warnings that are produced as a result of putting together this widget
24+
*/
25+
readonly warnings?: string[];
26+
2027
/**
2128
* Place the widget at a given position
2229
*/
@@ -39,6 +46,8 @@ export abstract class ConcreteWidget implements IWidget {
3946
protected x?: number;
4047
protected y?: number;
4148

49+
public readonly warnings: string[] | undefined = [];
50+
4251
constructor(width: number, height: number) {
4352
this.width = width;
4453
this.height = height;
@@ -54,4 +63,11 @@ export abstract class ConcreteWidget implements IWidget {
5463
}
5564

5665
public abstract toJson(): any[];
66+
67+
/**
68+
* Copy the warnings from the given metric
69+
*/
70+
protected copyMetricWarnings(...ms: IMetric[]) {
71+
this.warnings?.push(...ms.flatMap(m => m.warnings ?? []));
72+
}
5773
}

packages/@aws-cdk/aws-cloudwatch/test/alarm.test.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Match, Template } from '@aws-cdk/assertions';
1+
import { Match, Template, Annotations } from '@aws-cdk/assertions';
22
import { Duration, Stack } from '@aws-cdk/core';
33
import { Construct } from 'constructs';
44
import { Alarm, IAlarm, IAlarmAction, Metric, MathExpression, IMetric } from '../lib';
@@ -252,6 +252,22 @@ describe('Alarm', () => {
252252
ExtendedStatistic: 'tm99.9999999999',
253253
});
254254
});
255+
256+
test('metric warnings are added to Alarm', () => {
257+
const stack = new Stack(undefined, 'MyStack');
258+
const m = new MathExpression({ expression: 'oops' });
259+
260+
// WHEN
261+
new Alarm(stack, 'MyAlarm', {
262+
metric: m,
263+
evaluationPeriods: 1,
264+
threshold: 1,
265+
});
266+
267+
// THEN
268+
const template = Annotations.fromStack(stack);
269+
template.hasWarning('/MyStack/MyAlarm', Match.stringLikeRegexp("Math expression 'oops' references unknown identifiers"));
270+
});
255271
});
256272

257273
class TestAlarmAction implements IAlarmAction {

packages/@aws-cdk/aws-cloudwatch/test/dashboard.test.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Template } from '@aws-cdk/assertions';
1+
import { Template, Annotations, Match } from '@aws-cdk/assertions';
22
import { App, Stack } from '@aws-cdk/core';
3-
import { Dashboard, GraphWidget, PeriodOverride, TextWidget } from '../lib';
3+
import { Dashboard, GraphWidget, PeriodOverride, TextWidget, MathExpression } from '../lib';
44

55
describe('Dashboard', () => {
66
test('widgets in different adds are laid out underneath each other', () => {
@@ -175,8 +175,23 @@ describe('Dashboard', () => {
175175

176176
// THEN
177177
expect(() => toThrow()).toThrow(/field dashboardName contains invalid characters/);
178+
});
179+
180+
test('metric warnings are added to dashboard', () => {
181+
const app = new App();
182+
const stack = new Stack(app, 'MyStack');
183+
const m = new MathExpression({ expression: 'oops' });
178184

185+
// WHEN
186+
new Dashboard(stack, 'MyDashboard', {
187+
widgets: [
188+
[new GraphWidget({ left: [m] }), new TextWidget({ markdown: 'asdf' })],
189+
],
190+
});
179191

192+
// THEN
193+
const template = Annotations.fromStack(stack);
194+
template.hasWarning('/MyStack/MyDashboard', Match.stringLikeRegexp("Math expression 'oops' references unknown identifiers"));
180195
});
181196
});
182197

packages/@aws-cdk/aws-cloudwatch/test/graphs.test.ts

-4
Original file line numberDiff line numberDiff line change
@@ -681,8 +681,6 @@ describe('Graphs', () => {
681681
setPeriodToTimeRange: true,
682682
},
683683
}]);
684-
685-
686684
});
687685

688686
test('GraphWidget supports stat and period', () => {
@@ -710,7 +708,5 @@ describe('Graphs', () => {
710708
period: 172800,
711709
},
712710
}]);
713-
714-
715711
});
716712
});

packages/@aws-cdk/aws-cloudwatch/test/metric-math.test.ts

+19
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,27 @@ describe('Metric Math', () => {
6565
expect(m.with({ period: Duration.minutes(10) })).toEqual(m);
6666

6767
expect(m.with({ period: Duration.minutes(5) })).not.toEqual(m);
68+
});
69+
70+
test('math expression referring to unknown expressions produces a warning', () => {
71+
const m = new MathExpression({
72+
expression: 'm1 + m2',
73+
});
6874

75+
expect(m.warnings).toContainEqual(expect.stringContaining("'m1 + m2' references unknown identifiers"));
76+
});
77+
78+
test('math expression referring to unknown expressions produces a warning, even when nested', () => {
79+
const m = new MathExpression({
80+
expression: 'e1 + 5',
81+
usingMetrics: {
82+
e1: new MathExpression({
83+
expression: 'm1 + m2',
84+
}),
85+
},
86+
});
6987

88+
expect(m.warnings).toContainEqual(expect.stringContaining("'m1 + m2' references unknown identifiers"));
7089
});
7190

7291
describe('in graphs', () => {

0 commit comments

Comments
 (0)