Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cloudwatch): make Metric objects region-aware #5628

Merged
merged 6 commits into from
Jan 6, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 83 additions & 58 deletions packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Construct, IResource, Lazy, Resource, Stack } from '@aws-cdk/core';
import { Construct, IResource, Lazy, Resource, Stack, Token } from '@aws-cdk/core';
import { IAlarmAction } from './alarm-action';
import { CfnAlarm } from './cloudwatch.generated';
import { HorizontalAnnotation } from './graph';
import { CreateAlarmOptions } from './metric';
import { IMetric } from './metric-types';
import { dispatchMetric, dropUndefined, metricPeriod, MetricSet } from './metric-util';
import { parseStatistic } from './util.statistic';
import { IMetric, MetricStatConfig } from './metric-types';
import { dispatchMetric, metricPeriod } from './private/metric-util';
import { MetricSet } from './private/rendering';
import { dropUndefined } from './private/util';
import { parseStatistic } from './private/util.statistic';

export interface IAlarm extends IResource {
/**
Expand Down Expand Up @@ -142,7 +144,7 @@ export class Alarm extends Resource implements IAlarm {
okActions: Lazy.listValue({ produce: () => this.okActionArns }),

// Metric
...renderAlarmMetric(props.metric),
...this.renderMetric(props.metric),
...dropUndefined({
// Alarm overrides
period: props.period && props.period.toSeconds(),
Expand Down Expand Up @@ -225,63 +227,86 @@ export class Alarm extends Resource implements IAlarm {
public toAnnotation(): HorizontalAnnotation {
return this.annotation;
}
}

function renderAlarmMetric(metric: IMetric) {
return dispatchMetric(metric, {
withStat(st) {
return dropUndefined({
dimensions: st.dimensions,
namespace: st.namespace,
metricName: st.metricName,
period: st.period?.toSeconds(),
statistic: renderIfSimpleStatistic(st.statistic),
extendedStatistic: renderIfExtendedStatistic(st.statistic),
unit: st.unitFilter,
});
},

withExpression() {
// Expand the math expression metric into a set
const mset = new MetricSet<boolean>();
mset.addTopLevel(true, metric);

let eid = 0;
function uniqueMetricId() {
return `expr_${++eid}`;
private renderMetric(metric: IMetric) {
const self = this;
return dispatchMetric(metric, {
withStat(st) {
self.validateMetricStat(st, metric);

return dropUndefined({
dimensions: st.dimensions,
namespace: st.namespace,
metricName: st.metricName,
period: st.period?.toSeconds(),
statistic: renderIfSimpleStatistic(st.statistic),
extendedStatistic: renderIfExtendedStatistic(st.statistic),
unit: st.unitFilter,
});
},

withExpression() {
// Expand the math expression metric into a set
const mset = new MetricSet<boolean>();
mset.addTopLevel(true, metric);

let eid = 0;
function uniqueMetricId() {
return `expr_${++eid}`;
}

return {
metrics: mset.entries.map(entry => dispatchMetric(entry.metric, {
withStat(stat, conf) {
self.validateMetricStat(stat, entry.metric);

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(),
label: conf.renderingProperties?.label,
returnData: entry.tag ? undefined : false, // Tag stores "primary" attribute, default is "true"
};
},
withExpression(expr, conf) {
return {
expression: expr.expression,
id: entry.id || uniqueMetricId(),
label: conf.renderingProperties?.label,
returnData: entry.tag ? undefined : false, // Tag stores "primary" attribute, default is "true"
};
},
}) as CfnAlarm.MetricDataQueryProperty)
};
}
});
}

return {
metrics: mset.entries.map(entry => dispatchMetric(entry.metric, {
withStat(stat, conf) {
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(),
label: conf.renderingProperties?.label,
returnData: entry.tag ? undefined : false, // Tag stores "primary" attribute, default is "true"
};
},
withExpression(expr, conf) {
return {
expression: expr.expression,
id: entry.id ?? uniqueMetricId(),
label: conf.renderingProperties?.label,
returnData: entry.tag ? undefined : false, // Tag stores "primary" attribute, default is "true"
};
},
}) as CfnAlarm.MetricDataQueryProperty)
};
/**
* Validate that if a region and account are in the given stat config, they match the Alarm
*/
private validateMetricStat(stat: MetricStatConfig, metric: IMetric) {
const stack = Stack.of(this);

if (definitelyDifferent(stat.region, stack.region)) {
throw new Error(`Cannot create an Alarm in region '${stack.region}' based on metric '${metric}' in '${stat.region}'`);
}
if (definitelyDifferent(stat.account, stack.account)) {
throw new Error(`Cannot create an Alarm in account '${stack.account}' based on metric '${metric}' in '${stat.account}'`);
}
});
}
}

function definitelyDifferent(x: string | undefined, y: string) {
return x && !Token.isUnresolved(y) && x !== y;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-cloudwatch/lib/graph.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as cdk from '@aws-cdk/core';
import { IAlarm } from "./alarm";
import { IMetric } from "./metric-types";
import { allMetricsGraphJson } from './metric-util';
import { allMetricsGraphJson } from './private/rendering';
import { ConcreteWidget } from "./widget";

/**
Expand Down
45 changes: 37 additions & 8 deletions packages/@aws-cdk/aws-cloudwatch/lib/metric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import * as iam from '@aws-cdk/aws-iam';
import * as cdk from '@aws-cdk/core';
import { Alarm, ComparisonOperator, TreatMissingData } from './alarm';
import { Dimension, IMetric, MetricAlarmConfig, MetricConfig, MetricGraphConfig, Unit } from './metric-types';
import { metricKey, validateNoIdConflicts } from './metric-util';
import { normalizeStatistic, parseStatistic } from './util.statistic';
import { validateNoIdConflicts } from './private/metric-util';
import { normalizeStatistic, parseStatistic } from './private/util.statistic';

export type DimensionHash = {[dim: string]: any};

Expand Down Expand Up @@ -234,7 +234,21 @@ export class Metric implements IMetric {
* @param props The set of properties to change.
*/
public with(props: MetricOptions): Metric {
const ret = new Metric({
// 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.statistic === undefined || props.statistic === this.statistic)
&& (props.unit === undefined || props.unit === this.unit)
&& (props.account === undefined || props.account === this.account)
&& (props.region === undefined || props.region === this.region)
// For these we're not going to do deep equality, misses some opportunity for optimization
// but that's okay.
&& (props.dimensions === undefined)
&& (props.period === undefined || props.period.toSeconds() === this.period.toSeconds())) {
return this;
}

return new Metric({
dimensions: ifUndefined(props.dimensions, this.dimensions),
namespace: this.namespace,
metricName: this.metricName,
Expand All @@ -246,12 +260,27 @@ export class Metric implements IMetric {
account: ifUndefined(props.account, this.account),
region: ifUndefined(props.region, this.region)
});
}

// Save on objects: if the returned object is the same as the current
// object, just return ourselves.
if (metricKey(ret) === metricKey(this) && ret.color === this.color && ret.label === this.label) { return this; }
/**
* Attach the metric object to the given construct scope
*
* Returns a Metric object that uses the account and region from the Stack
* the given construct is defined in. If the metric is subsequently used
* in a Dashboard or Alarm in a different Stack defined in a different
* account or region, the appropriate 'region' and 'account' fields
* will be added to it.
*
* If the scope we attach to is in an environment-agnostic stack,
* nothing is done and the same Metric object is returned.
*/
public attachTo(scope: cdk.Construct): Metric {
const stack = cdk.Stack.of(scope);

return ret;
return this.with({
region: cdk.Token.isUnresolved(stack.region) ? undefined : stack.region,
account: cdk.Token.isUnresolved(stack.account) ? undefined : stack.account,
});
}

public toMetricConfig(): MetricConfig {
Expand Down Expand Up @@ -433,7 +462,7 @@ 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 === undefined || props.period.toSeconds() === this.period.toSeconds())) {
return this;
}

Expand Down
75 changes: 75 additions & 0 deletions packages/@aws-cdk/aws-cloudwatch/lib/private/env-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as cdk from '@aws-cdk/core';
import { dropUndefined } from './util';

/**
* Make a Token that renders to given region if used in a different stack, otherwise undefined
*/
export function regionIfDifferentFromStack(region: string): string {
return cdk.Token.asString(new StackDependentToken(region, stack => stack.region));
}

/**
* Make a Token that renders to given account if used in a different stack, otherwise undefined
*/
export function accountIfDifferentFromStack(account: string): string {
return cdk.Token.asString(new StackDependentToken(account, stack => stack.account));
}

/**
* A lazy token that requires an instance of Stack to evaluate
*/
class StackDependentToken implements cdk.IResolvable {
public readonly creationStack: string[];
constructor(private readonly originalValue: string, private readonly fn: (stack: cdk.Stack) => string) {
this.creationStack = cdk.captureStackTrace();
}

public resolve(context: cdk.IResolveContext) {
const stackValue = this.fn(cdk.Stack.of(context.scope));

if (cdk.Token.isUnresolved(stackValue) || stackValue === this.originalValue) {
return undefined;
}

return this.originalValue;
}

public toString() {
return cdk.Token.asString(this);
}

public toJSON() {
return this.originalValue;
}
}

/**
* A token that will not return an empty "options" object as the last element in an array
*
* Necessary to prevent options objects that only contain "region" and "account" keys
* that evaluate to "undefined" from showing up in the rendered JSON.
*/
export class DropEmptyGraphOptions implements cdk.IResolvable, cdk.IPostProcessor {
public readonly creationStack: string[];

constructor(private readonly value: any) {
this.creationStack = cdk.captureStackTrace();
}

public resolve(context: cdk.IResolveContext) {
context.registerPostProcessor(this);
return context.resolve(this.value);
}

public postProcess(o: any, _context: cdk.IResolveContext): any {
if (!Array.isArray(o)) { return o; }

const lastEl = o[o.length - 1];

if (typeof lastEl === 'object' && lastEl !== null && Object.keys(dropUndefined(lastEl)).length === 0) {
return o.slice(0, o.length - 1);
}

return o;
}
}
Loading