Skip to content

Commit

Permalink
feat(cloudwatch): support cross-environment search expressions (#16539)
Browse files Browse the repository at this point in the history
Fixes #9039 (also some prior discussion on #9034)

Currently CDK gives no way to specify account or region on MathExpressions which means it is not possible to run search expressions against environments other than the deployment environment.

The CloudWatch console currently has this capability, however [the documentation](https://docs.aws.amazon.com/en_us/AmazonCloudWatch/latest/APIReference/CloudWatch-Dashboard-Body-Structure.html#CloudWatch-Dashboard-Properties-Metric-Widget-Object) still states that `accountId` or `region` should only be used on metric widgets. I've left feedback to ask whether this documentation is out of date or whether it is an undocumented feature.

This change adds in the `searchAccount` and `searchRegion` property which can be optionally specified for the Dashboards use case. When a MathExpression with a searchAccount or searchRegion set is attempted to be used within an Alarm, the change throws an error as Alarms do not support search expressions.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
LeahHirst authored Sep 27, 2021
1 parent 62b6762 commit c165138
Show file tree
Hide file tree
Showing 7 changed files with 183 additions and 7 deletions.
22 changes: 19 additions & 3 deletions packages/@aws-cdk/aws-cloudwatch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const metric = new Metric({
dimensionsMap: {
HostedZoneId: hostedZone.hostedZoneId
}
})
});
```

### Instantiating a new Metric object
Expand Down Expand Up @@ -73,7 +73,7 @@ const allProblems = new MathExpression({
errors: myConstruct.metricErrors(),
faults: myConstruct.metricFaults(),
}
})
});
```

You can use `MathExpression` objects like any other metric, including using
Expand All @@ -86,9 +86,25 @@ const problemPercentage = new MathExpression({
problems: allProblems,
invocations: myConstruct.metricInvocations()
}
})
});
```

### Search Expressions

Math expressions also support search expressions. For example, the following
search expression returns all CPUUtilization metrics that it finds, with the
graph showing the Average statistic with an aggregation period of 5 minutes:

```ts
const cpuUtilization = new MathExpression({
expression: "SEARCH('{AWS/EC2,InstanceId} MetricName=\"CPUUtilization\"', 'Average', 300)"
});
```

Cross-account and cross-region search expressions are also supported. Use
the `searchAccount` and `searchRegion` properties to specify the account
and/or region to evaluate the search expression against.

### Aggregation

To graph or alarm on metrics you must aggregate them first, using a function
Expand Down
12 changes: 12 additions & 0 deletions packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ export class Alarm extends AlarmBase {
assertSubmetricsCount(expr);
}

self.validateMetricExpression(expr);

return {
expression: expr.expression,
id: entry.id || uniqueMetricId(),
Expand All @@ -358,6 +360,16 @@ export class Alarm extends AlarmBase {
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.
*/
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');
}
}
}

function definitelyDifferent(x: string | undefined, y: string) {
Expand Down
14 changes: 14 additions & 0 deletions packages/@aws-cdk/aws-cloudwatch/lib/metric-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,20 @@ export interface MetricExpressionConfig {
* How many seconds to aggregate over
*/
readonly period: number;

/**
* Account to evaluate search expressions within.
*
* @default - Deployment account.
*/
readonly searchAccount?: string;

/**
* Region to evaluate search expressions within.
*
* @default - Deployment region.
*/
readonly searchRegion?: string;
}

/**
Expand Down
55 changes: 51 additions & 4 deletions packages/@aws-cdk/aws-cloudwatch/lib/metric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,26 @@ export interface MathExpressionOptions {
* @default Duration.minutes(5)
*/
readonly period?: cdk.Duration;

/**
* Account to evaluate search expressions within.
*
* Specifying a searchAccount has no effect to the account used
* for metrics within the expression (passed via usingMetrics).
*
* @default - Deployment account.
*/
readonly searchAccount?: string;

/**
* Region to evaluate search expressions within.
*
* Specifying a searchRegion has no effect to the region used
* for metrics within the expression (passed via usingMetrics).
*
* @default - Deployment region.
*/
readonly searchRegion?: string;
}

/**
Expand All @@ -157,6 +177,9 @@ export interface MathExpressionOptions {
export interface MathExpressionProps extends MathExpressionOptions {
/**
* The expression defining the metric.
*
* When an expression contains a SEARCH function, it cannot be used
* within an Alarm.
*/
readonly expression: string;

Expand All @@ -165,8 +188,10 @@ export interface MathExpressionProps extends MathExpressionOptions {
*
* The key is the identifier that represents the given metric in the
* expression, and the value is the actual Metric object.
*
* @default - Empty map.
*/
readonly usingMetrics: Record<string, IMetric>;
readonly usingMetrics?: Record<string, IMetric>;
}

/**
Expand Down Expand Up @@ -451,6 +476,10 @@ function asString(x?: unknown): string | undefined {
* It makes sense to embed this in here, so that compound constructs can attach
* that metadata to metrics they expose.
*
* MathExpression can also be used for search expressions. In this case,
* it also optionally accepts a searchRegion and searchAccount property for cross-environment
* search expressions.
*
* This class does not represent a resource, so hence is not a construct. Instead,
* MathExpression is an abstraction that makes it easy to specify metrics for use in both
* alarms and graphs.
Expand Down Expand Up @@ -482,14 +511,26 @@ export class MathExpression implements IMetric {
*/
public readonly period: cdk.Duration;

/**
* Account to evaluate search expressions within.
*/
public readonly searchAccount?: string;

/**
* Region to evaluate search expressions within.
*/
public readonly searchRegion?: string;

constructor(props: MathExpressionProps) {
this.period = props.period || cdk.Duration.minutes(5);
this.expression = props.expression;
this.usingMetrics = changeAllPeriods(props.usingMetrics, this.period);
this.usingMetrics = changeAllPeriods(props.usingMetrics ?? {}, this.period);
this.label = props.label;
this.color = props.color;
this.searchAccount = props.searchAccount;
this.searchRegion = props.searchRegion;

const invalidVariableNames = Object.keys(props.usingMetrics).filter(x => !validVariableName(x));
const invalidVariableNames = Object.keys(this.usingMetrics).filter(x => !validVariableName(x));
if (invalidVariableNames.length > 0) {
throw new Error(`Invalid variable names in expression: ${invalidVariableNames}. Must start with lowercase letter and only contain alphanumerics.`);
}
Expand All @@ -508,7 +549,9 @@ export class MathExpression implements IMetric {
// Short-circuit creating a new object if there would be no effective change
if ((props.label === undefined || props.label === this.label)
&& (props.color === undefined || props.color === this.color)
&& (props.period === undefined || props.period.toSeconds() === this.period.toSeconds())) {
&& (props.period === undefined || props.period.toSeconds() === this.period.toSeconds())
&& (props.searchAccount === undefined || props.searchAccount === this.searchAccount)
&& (props.searchRegion === undefined || props.searchRegion === this.searchRegion)) {
return this;
}

Expand All @@ -518,6 +561,8 @@ export class MathExpression implements IMetric {
label: ifUndefined(props.label, this.label),
color: ifUndefined(props.color, this.color),
period: ifUndefined(props.period, this.period),
searchAccount: ifUndefined(props.searchAccount, this.searchAccount),
searchRegion: ifUndefined(props.searchRegion, this.searchRegion),
});
}

Expand All @@ -541,6 +586,8 @@ export class MathExpression implements IMetric {
period: this.period.toSeconds(),
expression: this.expression,
usingMetrics: this.usingMetrics,
searchAccount: this.searchAccount,
searchRegion: this.searchRegion,
},
renderingProperties: {
label: this.label,
Expand Down
6 changes: 6 additions & 0 deletions packages/@aws-cdk/aws-cloudwatch/lib/private/metric-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export function metricKey(metric: IMetric): string {
parts.push(id);
parts.push(metricKey(conf.mathExpression.usingMetrics[id]));
}
if (conf.mathExpression.searchRegion) {
parts.push(conf.mathExpression.searchRegion);
}
if (conf.mathExpression.searchAccount) {
parts.push(conf.mathExpression.searchAccount);
}
}
if (conf.metricStat) {
parts.push(conf.metricStat.namespace);
Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-cloudwatch/lib/private/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function metricGraphJson(metric: IMetric, yAxis?: string, id?: string) {

withExpression(expr) {
options.expression = expr.expression;
if (expr.searchAccount) { options.accountId = accountIfDifferentFromStack(expr.searchAccount); }
if (expr.searchRegion) { options.region = regionIfDifferentFromStack(expr.searchRegion); }
if (expr.period && expr.period !== 300) { options.period = expr.period; }
},
});
Expand Down
79 changes: 79 additions & 0 deletions packages/@aws-cdk/aws-cloudwatch/test/cross-environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ describe('cross environment', () => {


});

test('math expressions with explicit account and region will render in environment agnostic stack', () => {
// GIVEN
const expression = 'SEARCH(\'MetricName="ACount"\', \'Sum\', 300)';

const b = new MathExpression({
expression,
usingMetrics: {},
label: 'Test label',
searchAccount: '5678',
searchRegion: 'mars',
});

const graph = new GraphWidget({
left: [
b,
],
});

// THEN
graphMetricsAre(new Stack(), graph, [
[{
expression,
accountId: '5678',
region: 'mars',
label: 'Test label',
}],
]);
});
});

describe('in alarms', () => {
Expand Down Expand Up @@ -234,6 +263,56 @@ describe('cross environment', () => {
],
});
});

test('math expression with different searchAccount will throw', () => {
// GIVEN
const b = new Metric({
namespace: 'Test',
metricName: 'ACount',
account: '1234',
});

const c = new MathExpression({
expression: 'a + b',
usingMetrics: { a: a.attachTo(stack3), b },
period: Duration.minutes(1),
searchAccount: '5678',
});

// THEN
expect(() => {
new Alarm(stack1, 'Alarm', {
threshold: 1,
evaluationPeriods: 1,
metric: c,
});
}).toThrow(/Cannot create an Alarm based on a MathExpression which specifies a searchAccount or searchRegion/);
});

test('match expression with different searchRegion will throw', () => {
// GIVEN
const b = new Metric({
namespace: 'Test',
metricName: 'ACount',
account: '1234',
});

const c = new MathExpression({
expression: 'a + b',
usingMetrics: { a: a.attachTo(stack3), b },
period: Duration.minutes(1),
searchRegion: 'mars',
});

// THEN
expect(() => {
new Alarm(stack1, 'Alarm', {
threshold: 1,
evaluationPeriods: 1,
metric: c,
});
}).toThrow(/Cannot create an Alarm based on a MathExpression which specifies a searchAccount or searchRegion/);
});
});
});

Expand Down

0 comments on commit c165138

Please sign in to comment.