From 0f548211d6bcb32b9fde0c68587ddf221a21b0e5 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 26 Jun 2018 16:17:11 +0200 Subject: [PATCH 1/6] feat(@aws-cdk/assert): allow specifying a function to match resources This makes it more flexible to match resources that may have complex properties. A typical case in which you would need this is if you have resources that have JSON embedded in a string on which you might want to do structural matching. --- .../assert/lib/assertions/have-resource.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts index 753a7d2de9ba4..48f510016686b 100644 --- a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts +++ b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts @@ -3,6 +3,11 @@ import { StackInspector } from "../inspector"; /** * An assertion to check whether a resource of a given type and with the given properties exists, disregarding properties + * + * Properties can be: + * + * - An object, in which case its properties will be compared to those of the actual resource found + * - A callablage, in which case it will be treated as a predicate that is applied to the Properties of the found resources. */ export function haveResource(resourceType: string, properties?: any): Assertion { return new HaveResourceAssertion(resourceType, properties); @@ -21,7 +26,17 @@ class HaveResourceAssertion extends Assertion { const resource = inspector.value.Resources[logicalId]; if (resource.Type === this.resourceType) { this.inspected.push(resource); - if (isSuperObject(resource.Properties, this.properties)) { + + let matches: boolean; + if (typeof this.properties === 'function') { + // If 'properties' is a callable, invoke it + matches = this.properties(resource.Properties); + } else { + // Otherwise treat as property bag that we check superset of + matches = isSuperObject(resource.Properties, this.properties); + } + + if (matches) { return true; } } @@ -47,7 +62,7 @@ class HaveResourceAssertion extends Assertion { * * A super-object has the same or more property values, recursing into nested objects. */ -function isSuperObject(superObj: any, obj: any): boolean { +export function isSuperObject(superObj: any, obj: any): boolean { if (obj == null) { return true; } if (Array.isArray(superObj) !== Array.isArray(obj)) { return false; } if (Array.isArray(superObj)) { From bc0f29f434be46b98966dd33f67f64eddf0d8230 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 26 Jun 2018 16:18:23 +0200 Subject: [PATCH 2/6] feat(@aws-cdk/cloudwatch): initial library for CloudWatch Supports both Alarms and Dashboards. Adds a 'Metric' abstraction which can be exposed by other resources. --- packages/@aws-cdk/cloudwatch/.gitignore | 7 + packages/@aws-cdk/cloudwatch/README.md | 44 ++ packages/@aws-cdk/cloudwatch/lib/alarm.ts | 195 ++++++++ packages/@aws-cdk/cloudwatch/lib/dashboard.ts | 51 ++ packages/@aws-cdk/cloudwatch/lib/graph.ts | 301 +++++++++++ packages/@aws-cdk/cloudwatch/lib/index.ts | 7 + packages/@aws-cdk/cloudwatch/lib/layout.ts | 157 ++++++ packages/@aws-cdk/cloudwatch/lib/metric.ts | 469 ++++++++++++++++++ packages/@aws-cdk/cloudwatch/lib/text.ts | 55 ++ packages/@aws-cdk/cloudwatch/lib/widget.ts | 61 +++ packages/@aws-cdk/cloudwatch/package.json | 48 ++ .../@aws-cdk/cloudwatch/test/test.alarm.ts | 106 ++++ .../cloudwatch/test/test.dashboard.ts | 82 +++ .../@aws-cdk/cloudwatch/test/test.layout.ts | 84 ++++ 14 files changed, 1667 insertions(+) create mode 100644 packages/@aws-cdk/cloudwatch/.gitignore create mode 100644 packages/@aws-cdk/cloudwatch/README.md create mode 100644 packages/@aws-cdk/cloudwatch/lib/alarm.ts create mode 100644 packages/@aws-cdk/cloudwatch/lib/dashboard.ts create mode 100644 packages/@aws-cdk/cloudwatch/lib/graph.ts create mode 100644 packages/@aws-cdk/cloudwatch/lib/index.ts create mode 100644 packages/@aws-cdk/cloudwatch/lib/layout.ts create mode 100644 packages/@aws-cdk/cloudwatch/lib/metric.ts create mode 100644 packages/@aws-cdk/cloudwatch/lib/text.ts create mode 100644 packages/@aws-cdk/cloudwatch/lib/widget.ts create mode 100644 packages/@aws-cdk/cloudwatch/package.json create mode 100644 packages/@aws-cdk/cloudwatch/test/test.alarm.ts create mode 100644 packages/@aws-cdk/cloudwatch/test/test.dashboard.ts create mode 100644 packages/@aws-cdk/cloudwatch/test/test.layout.ts diff --git a/packages/@aws-cdk/cloudwatch/.gitignore b/packages/@aws-cdk/cloudwatch/.gitignore new file mode 100644 index 0000000000000..541a7635fd070 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/.gitignore @@ -0,0 +1,7 @@ +*.js +tsconfig.json +tslint.json +*.js.map +*.d.ts +dist +lib/generated/resources.ts diff --git a/packages/@aws-cdk/cloudwatch/README.md b/packages/@aws-cdk/cloudwatch/README.md new file mode 100644 index 0000000000000..a8c592bc0cb85 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/README.md @@ -0,0 +1,44 @@ +Add alarms and graphs to CDK applications +========================================= + + +Making Alarms +------------- + +Making Dashboards +----------------- + +Dashboards are set of Widgets stored server-side which can be accessed quickly +from the AWS console. Available widgets are graphs of a metric over time, the +current value of a metric, or a static piece of Markdown which explains what the +graphs mean. + +The following widgets are available: + +- `GraphWidget` -- shows any number of metrics on both the left and right + vertical axes. +- `AlarmWidget` -- shows the graph and alarm line for a single alarm. +- `SingleValueWidget` -- shows the current value of a set of metrics. +- `TextWidget` -- shows some static Markdown. + + +Dashboard Layout +---------------- + +The widgets on a dashboard are visually laid out in a grid that is 24 columns +wide. Normally you specify X and Y coordinates for the widgets on a Dashboard, +but because this is inconvenient to do manually, the library contains a simple +layout system to help you lay out your dashboards the way you want them to. + +Widgets have a `width` and `height` property, and they will be automatically +laid out either horizontally or vertically stacked to fill out the available +space. + +Widgets are added to a Dashboard by calling `add(widget1, widget2, ...)`. +Widgets given in the same call will be laid out horizontally. Widgets given +in different calls will be laid out vertically. To make more complex layouts, +you can use the following widgets to pack widgets together in different ways: + +- `Column`: stack two or more widgets vertically. +- `Row`: lay out two or more widgets horizontally. +- `Spacer`: take up empty space diff --git a/packages/@aws-cdk/cloudwatch/lib/alarm.ts b/packages/@aws-cdk/cloudwatch/lib/alarm.ts new file mode 100644 index 0000000000000..37351ed7ef8a5 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/lib/alarm.ts @@ -0,0 +1,195 @@ +import { Arn, Construct, Token } from '@aws-cdk/core'; +import { cloudwatch } from '@aws-cdk/resources'; +import { Metric } from './metric'; + +/** + * Properties for Alarms + */ +export interface AlarmProps { + /** + * The metric to add the alarm on + */ + metric: Metric; + + /** + * Name of the alarm + * + * @default Automatically generated name + */ + alarmName?: string; + + /** + * Description for the alarm + * + * @default No description + */ + alarmDescription?: string; + + /** + * Comparison to use to check if metric is breaching + * + * @default GreaterThanOrEqualToThreshold + */ + comparisonOperator?: ComparisonOperator; + + /** + * The value against which the specified statistic is compared. + */ + threshold: number; + + /** + * The number of periods over which data is compared to the specified threshold. + */ + evaluationPeriods: number; + + /** + * Number of datapoints that must be breaching to trigger the alarm + * + * This is used only if you are setting an "M out of N" alarm. In that case, this value is the M. + * + * @default Not an "M out of N" alarm. + */ + datapointsToAlarm?: number; + + /** + * Specifies whether to evaluate the data and potentially change the alarm state if there are too few data points to be statistically significant. + * + * Used only for alarms that are based on percentiles. + */ + evaluateLowSampleCountPercentile?: string; + + /** + * Sets how this alarm is to handle missing data points. + * + * @default TreatMissingData.Missing + */ + treatMissingData?: TreatMissingData; + + /** + * Whether the actions for this alarm are enabled + * + * @default true + */ + actionsEnabled?: boolean; +} + +/** + * Comparison operator for evaluating alarms + */ +export enum ComparisonOperator { + GreaterThanOrEqualToThreshold = 'GreaterThanOrEqualToThreshold', + GreaterThanThreshold = 'GreaterThanThreshold', + LessThanThreshold = 'LessThanThreshold', + LessThanOrEqualToThreshold = 'LessThanOrEqualToThreshold', +} + +/** + * Specify how missing data points are treated during alarm evaluation + */ +export enum TreatMissingData { + /** + * Missing data points are treated as breaching the threshold + */ + Breaching = 'breaching', + + /** + * Missing data points are treated as being within the threshold + */ + NotBreaching = 'notBreaching', + + /** + * The current alarm state is maintained + */ + Ignore = 'ignore', + + /** + * The alarm does not consider missing data points when evaluating whether to change state + */ + Missing = 'missing' +} + +/** + * An alarm on a CloudWatch metric + */ +export class Alarm extends Construct { + /** + * ARN of this alarm + */ + public readonly alarmArn: AlarmArn; + + /** + * The metric object this alarm was based on + */ + public readonly metric: Metric; + + private alarmActions?: Arn[]; + private insufficientDataActions?: Arn[]; + private okActions?: Arn[]; + + constructor(parent: Construct, name: string, props: AlarmProps) { + super(parent, name); + + const alarm = new cloudwatch.AlarmResource(this, 'Resource', { + actionsEnabled: props.actionsEnabled, + alarmActions: new Token(() => this.alarmActions), + alarmDescription: props.alarmDescription, + alarmName: props.alarmName, + comparisonOperator: props.comparisonOperator || ComparisonOperator.GreaterThanOrEqualToThreshold, + evaluateLowSampleCountPercentile: props.evaluateLowSampleCountPercentile, + evaluationPeriods: props.evaluationPeriods, + insufficientDataActions: new Token(() => this.insufficientDataActions), + okActions: new Token(() => this.okActions), + threshold: props.threshold, + treatMissingData: props.treatMissingData, + ...props.metric.toAlarmJson() + }); + + this.alarmArn = alarm.alarmArn; + this.metric = props.metric; + } + + /** + * Trigger this action if the alarm fires + * + * Typically the ARN of an SNS topic or ARN of an AutoScaling policy. + */ + public onAlarm(arn: Arn) { + if (this.alarmActions === undefined) { + this.alarmActions = []; + } + + this.alarmActions.push(arn); + } + + /** + * Trigger this action if there is insufficient data to evaluate the alarm + * + * Typically the ARN of an SNS topic or ARN of an AutoScaling policy. + */ + public onInsufficientData(arn: Arn) { + if (this.insufficientDataActions === undefined) { + this.insufficientDataActions = []; + } + + this.insufficientDataActions.push(arn); + } + + /** + * Trigger this action if the alarm returns from breaching state into ok state + * + * Typically the ARN of an SNS topic or ARN of an AutoScaling policy. + */ + public onOk(arn: Arn) { + if (this.okActions === undefined) { + this.okActions = []; + } + + this.okActions.push(arn); + } +} + +/** + * The ARN of an Alarm + */ +export class AlarmArn extends Arn { +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/lib/dashboard.ts b/packages/@aws-cdk/cloudwatch/lib/dashboard.ts new file mode 100644 index 0000000000000..755b35ca852a5 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/lib/dashboard.ts @@ -0,0 +1,51 @@ +import { Construct, resolve, Token } from "@aws-cdk/core"; +import { cloudwatch } from "@aws-cdk/resources"; +import { Column, Row } from "./layout"; +import { Widget } from "./widget"; + +export interface DashboardProps { + /** + * Name of the dashboard + * + * @default Automatically generated name + */ + dashboardName?: string; +} + +/** + * A CloudWatch dashboard + */ +export class Dashboard extends Construct { + private readonly rows: Widget[] = []; + + constructor(parent: Construct, name: string, props?: DashboardProps) { + super(parent, name); + + new cloudwatch.DashboardResource(this, 'Resource', { + dashboardName: props && props.dashboardName, + dashboardBody: new Token(() => { + const column = new Column(...this.rows); + column.position(0, 0); + return JSON.stringify(resolve({ widgets: column.toJson() })); + }) + }); + } + + /** + * Add a widget to the dashboard. + * + * Widgets given in multiple calls to add() will be laid out stacked on + * top of each other. + * + * Multiple widgets added in the same call to add() will be laid out next + * to each other. + */ + public add(...widgets: Widget[]) { + if (widgets.length === 0) { + return; + } + + const w = widgets.length > 1 ? new Row(...widgets) : widgets[0]; + this.rows.push(w); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/lib/graph.ts b/packages/@aws-cdk/cloudwatch/lib/graph.ts new file mode 100644 index 0000000000000..7a6a5503210d8 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/lib/graph.ts @@ -0,0 +1,301 @@ +import { AwsRegion, Token } from "@aws-cdk/core"; +import { Alarm } from "./alarm"; +import { Metric } from "./metric"; +import { ConcreteWidget } from "./widget"; + +/** + * An AWS region + */ +export class Region extends Token { +} + +/** + * Basic properties for widgets that display metrics + */ +export interface MetricWidgetProps { + /** + * Title for the graph + */ + title?: string; + + /** + * The region the metrics of this graph should be taken from + * + * @default Current region + */ + region?: Region; + + /** + * Width of the widget, in a grid of 24 units wide + * + * @default 6 + */ + width?: number; + + /** + * Height of the widget + * + * @default 6 + */ + height?: number; +} + +/** + * Properties for an AlarmWidget + */ +export interface AlarmWidgetProps extends MetricWidgetProps { + /** + * The alarm to show + */ + alarm: Alarm; + + /** + * Minimum of left Y axis + * + * @default 0 + */ + leftAxisMin?: number; + + /** + * Maximum of left Y axis + * + * @default Automatic + */ + leftAxisMax?: number; +} + +/** + * Display the metric associated with an alarm, including the alarm line + */ +export class AlarmWidget extends ConcreteWidget { + private readonly props: AlarmWidgetProps; + + constructor(props: AlarmWidgetProps) { + super(props.width || 6, props.height || 6); + this.props = props; + } + + public toJson(): any[] { + return [{ + type: 'metric', + width: this.width, + height: this.height, + x: this.x, + y: this.y, + properties: { + view: 'timeSeries', + title: this.props.title, + region: this.props.region || new AwsRegion(), + annotations: { + alarms: [this.props.alarm.alarmArn] + }, + yAxis: { + left: { + min: this.props.leftAxisMin, + max: this.props.leftAxisMax + } + } + } + }]; + } +} + +/** + * Properties for a GraphWidget + */ +export interface GraphWidgetProps extends MetricWidgetProps { + /** + * Metrics to display on left Y axis + */ + left?: Metric[]; + + /** + * Metrics to display on right Y axis + */ + right?: Metric[]; + + /** + * Annotations for the left Y axis + */ + leftAnnotations?: HorizontalAnnotation[]; + + /** + * Annotations for the right Y axis + */ + rightAnnotations?: HorizontalAnnotation[]; + + /** + * Whether the graph should be shown as stacked lines + */ + stacked?: boolean; + + /** + * Minimum of left Y axis + * + * @default 0 + */ + leftAxisMin?: number; + + /** + * Maximum of left Y axis + * + * @default Automatic + */ + leftAxisMax?: number; + + /** + * Minimum of right Y axis + * + * @default 0 + */ + rightAxisMin?: number; + + /** + * Maximum of right Y axis + * + * @default Automatic + */ + rightAxisMax?: number; +} + +/** + * A dashboard widget that displays MarkDown + */ +export class GraphWidget extends ConcreteWidget { + private readonly props: GraphWidgetProps; + + constructor(props: GraphWidgetProps) { + super(props.width || 6, props.height || 6); + this.props = props; + } + + public toJson(): any[] { + return [{ + type: 'metric', + width: this.width, + height: this.height, + x: this.x, + y: this.y, + properties: { + view: 'timeSeries', + title: this.props.title, + region: this.props.region || new AwsRegion(), + metrics: (this.props.left || []).map(m => m.toGraphJson('left')).concat( + (this.props.right || []).map(m => m.toGraphJson('right'))), + annotations: { + horizontal: (this.props.leftAnnotations || []).map(mapAnnotation('left')).concat( + (this.props.leftAnnotations || []).map(mapAnnotation('right'))) + }, + yAxis: { + left: { + min: this.props.leftAxisMin, + max: this.props.leftAxisMax + }, + right: { + min: this.props.rightAxisMin, + max: this.props.rightAxisMax + }, + } + } + }]; + } +} + +/** + * Properties for a SingleValueWidget + */ +export interface SingleValueWidgetProps extends MetricWidgetProps { + /** + * Metrics to display + */ + metrics: Metric[]; +} + +/** + * A dashboard widget that displays the most recent value for every metric + */ +export class SingleValueWidget extends ConcreteWidget { + private readonly props: SingleValueWidgetProps; + + constructor(props: SingleValueWidgetProps) { + super(props.width || 6, props.height || 6); + this.props = props; + } + + public toJson(): any[] { + return [{ + type: 'metric', + width: this.width, + height: this.height, + x: this.x, + y: this.y, + properties: { + view: 'singleValue', + title: this.props.title, + region: this.props.region || new AwsRegion(), + metrics: this.props.metrics.map(m => m.toGraphJson('left')) + } + }]; + } +} + +/** + * Horizontal annotation to be added to a graph + */ +export interface HorizontalAnnotation { + /** + * The value of the annotation + */ + value: number; + + /** + * Label for the annotation + * + * @default No label + */ + label?: string; + + /** + * Hex color code to be used for the annotation + * + * @default Automatic color + */ + color?: string; + + /** + * Add shading above or below the annotation + * + * @default No shading + */ + fill?: Shading; + + /** + * Whether the annotation is visible + * + * @default true + */ + visible?: boolean; +} + +export enum Shading { + /** + * Don't add shading + */ + None = 'none', + + /** + * Add shading above the annotation + */ + Above = 'above', + + /** + * Add shading below the annotation + */ + Below = 'below' +} + +function mapAnnotation(yAxis: string): ((x: HorizontalAnnotation) => any) { + return (a: HorizontalAnnotation) => { + return { ...a, yAxis }; + }; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/lib/index.ts b/packages/@aws-cdk/cloudwatch/lib/index.ts new file mode 100644 index 0000000000000..10806264b3394 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/lib/index.ts @@ -0,0 +1,7 @@ +export * from './alarm'; +export * from './dashboard'; +export * from './graph'; +export * from './layout'; +export * from './metric'; +export * from './text'; +export * from './widget'; \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/lib/layout.ts b/packages/@aws-cdk/cloudwatch/lib/layout.ts new file mode 100644 index 0000000000000..ff7f29177fdfd --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/lib/layout.ts @@ -0,0 +1,157 @@ +import { GRID_WIDTH, Widget } from "./widget"; + +// This file contains widgets that exist for layout purposes + +/** + * A widget that contains other widgets in a horizontal row + * + * Widgets will be laid out next to each other + */ +export class Row implements Widget { + public readonly width: number; + public readonly height: number; + + /** + * List of contained widgets + */ + private readonly widgets: Widget[]; + + /** + * Relative position of each widget inside this row + */ + private readonly offsets: Vector[] = []; + + constructor(...widgets: Widget[]) { + this.widgets = widgets; + + this.width = 0; + this.height = 0; + let x = 0; + let y = 0; + for (const widget of widgets) { + // See if we need to horizontally wrap to add this widget + if (x + widget.width > GRID_WIDTH) { + y = this.height; + x = 0; + } + + this.offsets.push({x, y}); + this.width = Math.max(this.width, x + widget.width); + this.height = Math.max(this.height, y + widget.height); + + x += widget.width; + } + } + + public position(x: number, y: number): void { + for (let i = 0; i < this.widgets.length; i++) { + this.widgets[i].position(x + this.offsets[i].x, y + this.offsets[i].y); + } + } + + public toJson(): any[] { + const ret: any[] = []; + for (const widget of this.widgets) { + ret.push(...widget.toJson()); + } + return ret; + } +} + +/** + * A widget that contains other widgets in a vertical column + * + * Widgets will be laid out next to each other + */ +export class Column implements Widget { + public readonly width: number; + public readonly height: number; + + /** + * List of contained widgets + */ + private readonly widgets: Widget[]; + + constructor(...widgets: Widget[]) { + this.widgets = widgets; + + // There's no vertical wrapping so this one's a lot easier + this.width = Math.max(...this.widgets.map(w => w.width)); + this.height = sum(...this.widgets.map(w => w.height)); + } + + public position(x: number, y: number): void { + let widgetY = y; + for (const widget of this.widgets) { + widget.position(x, widgetY); + widgetY += widget.height; + } + } + + public toJson(): any[] { + const ret: any[] = []; + for (const widget of this.widgets) { + ret.push(...widget.toJson()); + } + return ret; + } +} + +/** + * Props of the spacer + */ +export interface SpacerProps { + /** + * Width of the spacer + * + * @default 1 + */ + width?: number; + + /** + * Height of the spacer + * + * @default: 1 + */ + height?: number; +} + +/** + * A widget that doesn't display anything but takes up space + */ +export class Spacer implements Widget { + public readonly width: number; + public readonly height: number; + + constructor(props: SpacerProps) { + this.width = props.width || 1; + this.height = props.height || 1; + } + + public position(_x: number, _y: number): void { + // Don't need to do anything, not a physical widget + } + + public toJson(): any[] { + return []; + } +} + +/** + * Interface representing a 2D vector (for internal use) + */ +interface Vector { + x: number; + y: number; +} + +/** + * Return the sum of a list of numbers + */ +function sum(...xs: number[]) { + let ret = 0; + for (const x of xs) { + ret += x; + } + return ret; +} diff --git a/packages/@aws-cdk/cloudwatch/lib/metric.ts b/packages/@aws-cdk/cloudwatch/lib/metric.ts new file mode 100644 index 0000000000000..ea00cefd23c50 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/lib/metric.ts @@ -0,0 +1,469 @@ +import { Construct } from "@aws-cdk/core"; +import { Alarm, ComparisonOperator, TreatMissingData } from "./alarm"; + +export type DimensionHash = {[dim: string]: any}; + +/** + * Properties for a metric + */ +export interface MetricProps { + /** + * Dimensions of the metric + * + * @default No dimensions + */ + dimensions?: DimensionHash; + + /** + * Namespace of the metric. + */ + namespace: string; + + /** + * Name of the metric. + */ + metricName: string; + + /** + * The period over which the specified statistic is applied. + * + * Specify time in seconds, in multiples of 60. + * + * @default 300 + */ + periodSec?: number; + + /** + * What function to use for aggregating. + * + * Can be one of the following (case insensitive) + * + * - "minimum" | "min" + * - "maximum" | "max" + * - "average" | "avg" + * - "sum" + * - "samplecount | "n" + * - "pNN.NN" + * + * @default Average + */ + statistic?: string; + + /** + * Unit for the metric that is associated with the alarm + */ + unit?: Unit; + + /** + * Label for this metric when added to a Graph in a Dashboard + */ + label?: string; + + /** + * Color for this metric when added to a Graph in a Dashboard + */ + color?: string; +} + +/** + * A metric emitted by a service + * + * This class does not represent a resource, so hence is not a construct. Instead, + * Metric is an abstraction that makes it easy to specify metrics for use in both + * alarms and graphs. + */ +export class Metric { + public readonly dimensions?: DimensionHash; + public readonly namespace: string; + public readonly metricName: string; + public readonly periodSec: number; + public readonly statistic: string; + public readonly unit?: Unit; + public readonly label?: string; + public readonly color?: string; + + constructor(props: MetricProps) { + if (props.periodSec !== undefined + && props.periodSec !== 1 && props.periodSec !== 5 && props.periodSec !== 10 && props.periodSec !== 30 + && props.periodSec % 60 !== 0) { + throw new Error("'periodSec' must be 1, 5, 10, 30, or a multiple of 60"); + } + + this.dimensions = props.dimensions; + this.namespace = props.namespace; + this.metricName = props.metricName; + this.periodSec = props.periodSec !== undefined ? props.periodSec : 300; + this.statistic = props.statistic || "Average"; + this.unit = props.unit; + + // Try parsing, this will throw if it's not a valid stat + parseStatistic(this.statistic); + } + + /** + * Return a copy of Metric with properties changed + * @param props Re + */ + public with(props: ChangeMetricProps): Metric { + return new Metric({ + dimensions: ifUndefined(props.dimensions, this.dimensions), + namespace: this.namespace, + metricName: this.metricName, + periodSec: ifUndefined(props.periodSec, this.periodSec), + statistic: ifUndefined(props.statistic, this.statistic), + unit: ifUndefined(props.unit, this.unit), + label: ifUndefined(props.label, this.label), + color: ifUndefined(props.color, this.color) + }); + } + + /** + * Make a new Alarm for this metric + * + * Combines both properties that may adjust the metric (aggregation) as well + * as alarm properties. + */ + public newAlarm(parent: Construct, name: string, props: NewAlarmProps) { + new Alarm(parent, name, { + metric: this.with({ + statistic: props.statistic, + periodSec: props.periodSec, + }), + alarmName: props.alarmName, + alarmDescription: props.alarmDescription, + comparisonOperator: props.comparisonOperator, + threshold: props.threshold, + evaluationPeriods: props.evaluationPeriods, + datapointsToAlarm: props.datapointsToAlarm, + evaluateLowSampleCountPercentile: props.evaluateLowSampleCountPercentile, + treatMissingData: props.treatMissingData, + actionsEnabled: props.actionsEnabled, + }); + } + + /** + * Return the JSON structure which represents this metric in an alarm + */ + public toAlarmJson(): MetricAlarmJson { + const stat = parseStatistic(this.statistic); + + return { + dimensions: hashToDimensions(this.dimensions), + namespace: this.namespace, + metricName: this.metricName, + period: this.periodSec, + statistic: stat.type === 'simple' ? stat.statistic : undefined, + extendedStatistic: stat.type === 'percentile' ? 'p' + stat.percentile : undefined, + unit: this.unit + }; + } + + /** + * Return the JSON structure which represents this metric in a graph + */ + public toGraphJson(yAxis: string): any[] { + // Namespace and metric Name + const ret: any[] = [ + this.namespace, + this.metricName, + ]; + + // Dimensions + for (const dim of hashToDimensions(this.dimensions) || []) { + ret.push(dim.name, dim.value); + } + + // Options + const stat = parseStatistic(this.statistic); + ret.push({ + yAxis, + label: this.label, + color: this.color, + period: this.periodSec, + stat: stat.type === 'simple' ? stat.statistic : 'p' + stat.percentile.toString(), + }); + + return ret; + } +} + +export interface MetricAlarmJson { + dimensions?: Dimension[]; + namespace: string; + metricName: string; + period: number; + statistic?: Statistic; + extendedStatistic?: string; + unit?: Unit; +} + +/** + * Metric dimension + */ +export interface Dimension { + /** + * Name of the dimension + */ + name: string; + + /** + * Value of the dimension + */ + value: any; +} + +/** + * Statistic to use over the aggregation period + */ +export enum Statistic { + SampleCount = 'SampleCount', + Average = 'Average', + Sum = 'Sum', + Minimum = 'Minimum', + Maximum = 'Maximum', +} + +/** + * Unit for metric + */ +export enum Unit { + Seconds = 'Seconds', + Microseconds = 'Microseconds', + Milliseconds = 'Milliseconds', + Bytes_ = 'Bytes', + Kilobytes = 'Kilobytes', + Megabytes = 'Megabytes', + Gigabytes = 'Gigabytes', + Terabytes = 'Terabytes', + Bits = 'Bits', + Kilobits = 'Kilobits', + Megabits = 'Megabits', + Gigabits = 'Gigabits', + Terabits = 'Terabits', + Percent = 'Percent', + Count = 'Count', + BytesPerSecond = 'Bytes/Second', + KilobytesPerSecond = 'Kilobytes/Second', + MegabytesPerSecond = 'Megabytes/Second', + GigabytesPerSecond = 'Gigabytes/Second', + TerabytesPerSecond = 'Terabytes/Second', + BitsPerSecond = 'Bits/Second', + KilobitsPerSecond = 'Kilobits/Second', + MegabitsPerSecond = 'Megabits/Second', + GigabitsPerSecond = 'Gigabits/Second', + TerabitsPerSecond = 'Terabits/Second', + CountPerSecond = 'Count/Second', + None = 'None' +} + +/** + * Properties of a metric that can be changed + */ +export interface ChangeMetricProps { + /** + * Dimensions of the metric + * + * @default No dimensions + */ + dimensions?: DimensionHash; + + /** + * The period over which the specified statistic is applied. + * + * Specify time in seconds, in multiples of 60. + * + * @default 300 + */ + periodSec?: number; + + /** + * What function to use for aggregating. + * + * Can be one of the following: + * + * - "Minimum" | "min" + * - "Maximum" | "max" + * - "Average" | "avg" + * - "Sum" | "sum" + * - "SampleCount | "n" + * - "pNN.NN" + * + * @default Average + */ + statistic?: string; + + /** + * Unit for the metric that is associated with the alarm + */ + unit?: Unit; + + /** + * Label for this metric when added to a Graph in a Dashboard + */ + label?: string; + + /** + * Color for this metric when added to a Graph in a Dashboard + */ + color?: string; +} + +/** + * Properties to make an alarm from a metric + */ +export interface NewAlarmProps { + /** + * The period over which the specified statistic is applied. + * + * Specify time in seconds, in multiples of 60. + * + * @default 300 + */ + periodSec?: number; + + /** + * What function to use for aggregating. + * + * Can be one of the following: + * + * - "Minimum" | "min" + * - "Maximum" | "max" + * - "Average" | "avg" + * - "Sum" | "sum" + * - "SampleCount | "n" + * - "pNN.NN" + * + * @default Average + */ + statistic?: string; + + /** + * Name of the alarm + * + * @default Automatically generated name + */ + alarmName?: string; + + /** + * Description for the alarm + * + * @default No description + */ + alarmDescription?: string; + + /** + * Comparison to use to check if metric is breaching + * + * @default GreaterThanOrEqualToThreshold + */ + comparisonOperator?: ComparisonOperator; + + /** + * The value against which the specified statistic is compared. + */ + threshold: number; + + /** + * The number of periods over which data is compared to the specified threshold. + */ + evaluationPeriods: number; + + /** + * Number of datapoints that must be breaching to trigger the alarm + * + * This is used only if you are setting an "M out of N" alarm. In that case, this value is the M. + * + * @default Not an "M out of N" alarm. + */ + datapointsToAlarm?: number; + + /** + * Specifies whether to evaluate the data and potentially change the alarm state if there are too few data points to be statistically significant. + * + * Used only for alarms that are based on percentiles. + */ + evaluateLowSampleCountPercentile?: string; + + /** + * Sets how this alarm is to handle missing data points. + * + * @default TreatMissingData.Missing + */ + treatMissingData?: TreatMissingData; + + /** + * Whether the actions for this alarm are enabled + * + * @default true + */ + actionsEnabled?: boolean; +} + +function hashToDimensions(x?: DimensionHash): Dimension[] | undefined { + if (x === undefined) { + return undefined; + } + + const list = Object.keys(x).map(key => ({ name: key, value: x[key] })); + if (list.length === 0) { + return undefined; + } + + return list; +} + +function ifUndefined(x: T | undefined, def: T | undefined): T | undefined { + if (x !== undefined) { + return x; + } + return def; +} + +interface SimpleStatistic { + type: 'simple'; + statistic: Statistic; +} + +interface PercentileStatistic { + type: 'percentile'; + percentile: number; +} + +/** + * Parse a statistic, returning a value of the parsed type + */ +function parseStatistic(stat: string): SimpleStatistic | PercentileStatistic { + const lowerStat = stat.toLowerCase(); + + // Simple statistics + const statMap: {[k: string]: Statistic} = { + average: Statistic.Average, + avg: Statistic.Average, + minimum: Statistic.Minimum, + min: Statistic.Minimum, + maximum: Statistic.Maximum, + max: Statistic.Maximum, + samplecount: Statistic.SampleCount, + n: Statistic.SampleCount, + sum: Statistic.Sum, + }; + + if (lowerStat in statMap) { + return { + type: 'simple', + statistic: statMap[lowerStat] + }; + } + + // Percentile statistics + const re = /^p([\d.]+)$/; + const m = re.exec(lowerStat); + if (m) { + return { + type: 'percentile', + percentile: parseFloat(m[1]) + }; + } + + throw new Error(`Not a valid statistic: '${stat}', must be one of Average | Minimum | Maximum | SampleCount | Sum | pNN.NN`); +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/lib/text.ts b/packages/@aws-cdk/cloudwatch/lib/text.ts new file mode 100644 index 0000000000000..c9e76d15cfb61 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/lib/text.ts @@ -0,0 +1,55 @@ +import { ConcreteWidget } from "./widget"; + +/** + * Properties for a Text widget + */ +export interface TextWidgetProps { + /** + * The text to display, in MarkDown format + */ + markdown: string; + + /** + * Width of the widget, in a grid of 24 units wide + * + * @default 6 + */ + width?: number; + + /** + * Height of the widget + * + * @default 6 + */ + height?: number; +} + +/** + * A dashboard widget that displays MarkDown + */ +export class TextWidget extends ConcreteWidget { + private readonly markdown: string; + + constructor(props: TextWidgetProps) { + super(props.width || 6, props.height || 6); + this.markdown = props.markdown; + } + + public position(x: number, y: number): void { + this.x = x; + this.y = y; + } + + public toJson(): any[] { + return [{ + type: 'text', + width: this.width, + height: this.height, + x: this.x, + y: this.y, + properties: { + markdown: this.markdown + } + }]; + } +} diff --git a/packages/@aws-cdk/cloudwatch/lib/widget.ts b/packages/@aws-cdk/cloudwatch/lib/widget.ts new file mode 100644 index 0000000000000..f6365d0d76abb --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/lib/widget.ts @@ -0,0 +1,61 @@ +/** + * The width of the grid we're filling + */ +export const GRID_WIDTH = 24; + +/** + * A single dashboard widget + */ +export interface Widget { + /** + * The width of the widget + * + * Will only ever be queried after 'determineSize' has been called. + */ + readonly width: number; + + /** + * The height of the widget + * + * Will only ever be queried after 'determineSize' has been called. + */ + readonly height: number; + + /** + * Place the widget at a given position + */ + position(x: number, y: number): void; + + /** + * Return the widget JSON for use in the dashboard + */ + toJson(): any[]; +} + +/** + * A real CloudWatch widget that has its own fixed size and remembers its position + * + * This is in contrast to other widgets which exist for layout purposes. + */ +export abstract class ConcreteWidget implements Widget { + public readonly width: number; + public readonly height: number; + protected x?: number; + protected y?: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + + if (this.width > GRID_WIDTH) { + throw new Error(`Widget is too wide, max ${GRID_WIDTH} units allowed`); + } + } + + public position(x: number, y: number): void { + this.x = x; + this.y = y; + } + + public abstract toJson(): any[]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/package.json b/packages/@aws-cdk/cloudwatch/package.json new file mode 100644 index 0000000000000..46e7ad50edd8c --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/package.json @@ -0,0 +1,48 @@ +{ + "name": "@aws-cdk/cloudwatch", + "version": "0.7.2-beta", + "description": "CDK Constructs for AWS CloudWatch", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "jsii": { + "outdir": "dist", + "bundledDependencies": [], + "names": { + "java": "com.amazonaws.cdk.cloudwatch", + "dotnet": "Aws.Cdk.CloudWatch" + } + }, + "repository": { + "type": "git", + "url": "git://github.com/awslabs/aws-cdk" + }, + "scripts": { + "prepare": "jsii && tslint -p . && pkglint", + "watch": "jsii -w", + "lint": "tsc && tslint -p . --force", + "test": "nodeunit test/test.*.js && cdk-integ-assert", + "integ": "cdk-integ", + "pkglint": "pkglint -f" + }, + "keywords": [ + "aws", + "cdk", + "constructs", + "cloudwatch" + ], + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com" + }, + "license": "LicenseRef-LICENSE", + "devDependencies": { + "@aws-cdk/assert": "^0.7.2-beta", + "aws-cdk": "^0.7.2-beta", + "pkglint": "^0.7.1" + }, + "dependencies": { + "@aws-cdk/core": "^0.7.2-beta", + "@aws-cdk/sns": "^0.7.2-beta", + "@aws-cdk/resources": "^0.7.2-beta" + } +} diff --git a/packages/@aws-cdk/cloudwatch/test/test.alarm.ts b/packages/@aws-cdk/cloudwatch/test/test.alarm.ts new file mode 100644 index 0000000000000..88c5d01493280 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/test/test.alarm.ts @@ -0,0 +1,106 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import { Arn, Stack } from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import { Alarm, Metric } from '../lib'; + +const testMetric = new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', +}); + +export = { + 'can make simple alarm'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + new Alarm(stack, 'Alarm', { + metric: testMetric, + threshold: 1000, + evaluationPeriods: 2 + }); + + // THEN + expect(stack).to(haveResource('AWS::CloudWatch::Alarm', { + ComparisonOperator: "GreaterThanOrEqualToThreshold", + EvaluationPeriods: 2, + MetricName: "Metric", + Namespace: "CDK/Test", + Period: 300, + Statistic: 'Average', + Threshold: 1000, + })); + + test.done(); + }, + + 'can add actions to alarms'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const alarm = new Alarm(stack, 'Alarm', { + metric: testMetric, + threshold: 1000, + evaluationPeriods: 2 + }); + + alarm.onAlarm(new Arn('A')); + alarm.onInsufficientData(new Arn('B')); + alarm.onOk(new Arn('C')); + + // THEN + expect(stack).to(haveResource('AWS::CloudWatch::Alarm', { + AlarmActions: ['A'], + InsufficientDataActions: ['B'], + OKActions: ['C'], + })); + + test.done(); + }, + + 'can make alarm directly from metric'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + testMetric.newAlarm(stack, 'Alarm', { + threshold: 1000, + evaluationPeriods: 2, + statistic: 'min', + periodSec: 10, + }); + + // THEN + expect(stack).to(haveResource('AWS::CloudWatch::Alarm', { + ComparisonOperator: "GreaterThanOrEqualToThreshold", + EvaluationPeriods: 2, + MetricName: "Metric", + Namespace: "CDK/Test", + Period: 10, + Statistic: 'Minimum', + Threshold: 1000, + })); + + test.done(); + }, + + 'can use percentile string to make alarm'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + testMetric.newAlarm(stack, 'Alarm', { + threshold: 1000, + evaluationPeriods: 2, + statistic: 'p99.9' + }); + + // THEN + expect(stack).to(haveResource('AWS::CloudWatch::Alarm', { + ExtendedStatistic: 'p99.9', + })); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/cloudwatch/test/test.dashboard.ts b/packages/@aws-cdk/cloudwatch/test/test.dashboard.ts new file mode 100644 index 0000000000000..6fa7dee79104f --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/test/test.dashboard.ts @@ -0,0 +1,82 @@ +import { expect, haveResource, isSuperObject } from '@aws-cdk/assert'; +import { Stack } from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import { Dashboard, TextWidget } from '../lib'; + +export = { + 'widgets in different adds are laid out underneath each other'(test: Test) { + // GIVEN + const stack = new Stack(); + const dashboard = new Dashboard(stack, 'Dash'); + + // WHEN + dashboard.add(new TextWidget({ + width: 10, + height: 2, + markdown: "first" + })); + dashboard.add(new TextWidget({ + width: 1, + height: 4, + markdown: "second" + })); + dashboard.add(new TextWidget({ + width: 4, + height: 1, + markdown: "third" + })); + + // THEN + expect(stack).to(haveResource('AWS::CloudWatch::Dashboard', thatHasWidgets([ + { type: 'text', width: 10, height: 2, x: 0, y: 0, properties: { markdown: 'first' } }, + { type: 'text', width: 1, height: 4, x: 0, y: 2, properties: { markdown: 'second' } }, + { type: 'text', width: 4, height: 1, x: 0, y: 6, properties: { markdown: 'third' } }, + ]))); + + test.done(); + }, + + 'widgets in same add are laid out next to each other'(test: Test) { + // GIVEN + const stack = new Stack(); + const dashboard = new Dashboard(stack, 'Dash'); + + // WHEN + dashboard.add( + new TextWidget({ + width: 10, + height: 2, + markdown: "first" + }), + new TextWidget({ + width: 1, + height: 4, + markdown: "second" + }), + new TextWidget({ + width: 4, + height: 1, + markdown: "third" + }), + ); + + // THEN + expect(stack).to(haveResource('AWS::CloudWatch::Dashboard', thatHasWidgets([ + { type: 'text', width: 10, height: 2, x: 0, y: 0, properties: { markdown: 'first' } }, + { type: 'text', width: 1, height: 4, x: 10, y: 0, properties: { markdown: 'second' } }, + { type: 'text', width: 4, height: 1, x: 11, y: 0, properties: { markdown: 'third' } }, + ]))); + + test.done(); + } +}; + +/** + * Returns a property predicate that checks that the given Dashboard has the indicated widgets + */ +function thatHasWidgets(widgets: any): (props: any) => boolean { + return (props: any) => { + const actualWidgets = JSON.parse(props.DashboardBody).widgets; + return isSuperObject(actualWidgets, widgets); + }; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/test/test.layout.ts b/packages/@aws-cdk/cloudwatch/test/test.layout.ts new file mode 100644 index 0000000000000..b7dc381905561 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/test/test.layout.ts @@ -0,0 +1,84 @@ +import { Test } from 'nodeunit'; +import { Column, Row, Spacer, TextWidget, Widget } from '../lib'; + +export = { + 'row has the height of the tallest element'(test: Test) { + // WHEN + const row = new Row( + new Spacer({ width: 10, height: 1 }), + new Spacer({ width: 10, height: 4 }), + ); + + // THEN + test.equal(4, row.height); + test.equal(20, row.width); + + test.done(); + }, + + 'column has the width of the tallest element'(test: Test) { + // WHEN + const col = new Column( + new Spacer({ width: 1, height: 1 }), + new Spacer({ width: 4, height: 4 }), + ); + + // THEN + test.equal(4, col.width); + test.equal(5, col.height); + + test.done(); + }, + + 'row wraps to width of 24, taking tallest widget into account while wrapping'(test: Test) { + // Try the tall box in all positions + for (const heights of [[4, 1, 1], [1, 4, 1], [1, 1, 4]]) { + // GIVEN + const widgets = [ + new TextWidget({ width: 7, height: heights[0], markdown: 'a' }), + new TextWidget({ width: 7, height: heights[1], markdown: 'b' }), + new TextWidget({ width: 7, height: heights[2], markdown: 'c' }), + new TextWidget({ width: 7, height: 1, markdown: 'd' }), + ]; + + // WHEN + const row = new Row(...widgets); + row.position(1000, 1000); // Check that we correctly offset all inner widgets + + // THEN + test.equal(21, row.width); + test.equal(5, row.height); + + function assertWidgetPos(x: number, y: number, w: Widget) { + const json = w.toJson()[0]; + test.equal(x, json.x); + test.equal(y, json.y); + } + + assertWidgetPos(1000, 1000, widgets[0]); + assertWidgetPos(1007, 1000, widgets[1]); + assertWidgetPos(1014, 1000, widgets[2]); + assertWidgetPos(1000, 1004, widgets[3]); + } + + test.done(); + }, + + 'row can fit exactly 3 8-wide widgets without wrapping'(test: Test) { + // Try the tall box in all positions + for (const heights of [[4, 1, 1], [1, 4, 1], [1, 1, 4]]) { + // WHEN + const row = new Row( + new TextWidget({ width: 8, height: heights[0], markdown: 'a' }), + new TextWidget({ width: 8, height: heights[1], markdown: 'b' }), + new TextWidget({ width: 8, height: heights[2], markdown: 'c' }), + ); + + // THEN + test.equal(24, row.width); + test.equal(4, row.height); + } + + test.done(); + } +}; \ No newline at end of file From 7a2b6551dab3acb0c3bc4624609149874e939d29 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 26 Jun 2018 16:19:30 +0200 Subject: [PATCH 3/6] feat(@aws-cdk/lambda): expose Metric objects for built-in Lambda metrics --- packages/@aws-cdk/lambda/lib/lambda-ref.ts | 81 ++++++++++++++++++++++ packages/@aws-cdk/lambda/package.json | 1 + 2 files changed, 82 insertions(+) diff --git a/packages/@aws-cdk/lambda/lib/lambda-ref.ts b/packages/@aws-cdk/lambda/lib/lambda-ref.ts index 8efa0cb0351af..ce3f9067a7337 100644 --- a/packages/@aws-cdk/lambda/lib/lambda-ref.ts +++ b/packages/@aws-cdk/lambda/lib/lambda-ref.ts @@ -1,3 +1,4 @@ +import { Metric } from '@aws-cdk/cloudwatch'; import { AccountPrincipal, Arn, Construct, FnSelect, FnSplit, PolicyPrincipal, PolicyStatement, resolve, ServicePrincipal, Token } from '@aws-cdk/core'; import { EventRuleTarget, IEventRuleTarget } from '@aws-cdk/events'; @@ -135,6 +136,86 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget { arn: this.functionArn, }; } + + /** + * Metric for the Errors executing this Lambda + * + * @default sum over 5 minutes + */ + public get errorsMetric(): Metric { + return LambdaRef.allErrorsMetric.with({ + dimensions: { FunctionName: this.functionName } + }); + } + + /** + * Metric for the Duration of this Lambda + * + * @default average over 5 minutes + */ + public get durationMetric(): Metric { + return LambdaRef.allDurationMetric.with({ + dimensions: { FunctionName: this.functionName } + }); + } + + /** + * Metric for the number of invocations of this Lambda + * + * @default sum over 5 minutes + */ + public get invocationsMetric(): Metric { + return LambdaRef.allInvocationsMetric.with({ + dimensions: { FunctionName: this.functionName } + }); + } + + /** + * Metric for the number of throttled invocations of this Lambda + * + * @default sum over 5 minutes + */ + public get throttlesMetric(): Metric { + return LambdaRef.allThrottlesMetric.with({ + dimensions: { FunctionName: this.functionName } + }); + } + + /** + * Metric for the number of Errors executing all Lambdas + * + * @default sum over 5 minutes + */ + public static get allErrorsMetric(): Metric { + return new Metric({ namespace: 'AWS/Lambda', metricName: 'Errors', statistic: 'sum' }); + } + + /** + * Metric for the Duration executing all Lambdas + * + * @default average over 5 minutes + */ + public static get allDurationMetric(): Metric { + return new Metric({ namespace: 'AWS/Lambda', metricName: 'Duration' }); + } + + /** + * Metric for the number of invocations of all Lambdas + * + * @default sum over 5 minutes + */ + public static get allInvocationsMetric(): Metric { + return new Metric({ namespace: 'AWS/Lambda', metricName: 'Invocations', statistic: 'sum' }); + } + + /** + * Metric for the number of throttled invocations of all Lambdas + * + * @default sum over 5 minutes + */ + public static get allThrottlesMetric(): Metric { + return new Metric({ namespace: 'AWS/Lambda', metricName: 'Throttles', statistic: 'sum' }); + } } class LambdaRefImport extends LambdaRef { diff --git a/packages/@aws-cdk/lambda/package.json b/packages/@aws-cdk/lambda/package.json index 01960b9926de7..471043e57950c 100644 --- a/packages/@aws-cdk/lambda/package.json +++ b/packages/@aws-cdk/lambda/package.json @@ -41,6 +41,7 @@ "pkglint": "^0.7.1" }, "dependencies": { + "@aws-cdk/cloudwatch": "^0.7.2-beta", "@aws-cdk/core": "^0.7.2-beta", "@aws-cdk/events": "^0.7.2-beta", "@aws-cdk/iam": "^0.7.2-beta", From 82ba23df20299dc1d072260e1ef6f2b12cdf56a5 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 26 Jun 2018 16:19:56 +0200 Subject: [PATCH 4/6] feat(@aws-cdk/sns): expose Metric objects for built-in SNS metrics --- packages/@aws-cdk/sns/lib/topic-ref.ts | 69 +++++++++++++++++++++++++- packages/@aws-cdk/sns/lib/topic.ts | 4 +- packages/@aws-cdk/sns/package.json | 1 + 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/sns/lib/topic-ref.ts b/packages/@aws-cdk/sns/lib/topic-ref.ts index de3825e461f00..c629163143f2e 100644 --- a/packages/@aws-cdk/sns/lib/topic-ref.ts +++ b/packages/@aws-cdk/sns/lib/topic-ref.ts @@ -1,4 +1,5 @@ -import { Arn, Construct, Output, PolicyStatement, ServicePrincipal } from '@aws-cdk/core'; +import { Metric } from '@aws-cdk/cloudwatch'; +import { Arn, Construct, Output, PolicyStatement, ServicePrincipal, Token } from '@aws-cdk/core'; import { EventRuleTarget, IEventRuleTarget } from '@aws-cdk/events'; import { IIdentityResource } from '@aws-cdk/iam'; import { LambdaRef } from '@aws-cdk/lambda'; @@ -11,6 +12,11 @@ import { Subscription, SubscriptionProtocol } from './subscription'; */ export class TopicArn extends Arn { } +/** + * Name of a Topic + */ +export class TopicName extends Token { } + /** * Either a new or imported Topic */ @@ -24,6 +30,8 @@ export abstract class TopicRef extends Construct implements IEventRuleTarget { public abstract readonly topicArn: TopicArn; + public abstract readonly topicName: TopicName; + /** * Controls automatic creation of policy objects. * @@ -45,6 +53,7 @@ export abstract class TopicRef extends Construct implements IEventRuleTarget { public export(): TopicRefProps { return { topicArn: new Output(this, 'TopicArn', { value: this.topicArn }).makeImportValue(), + topicName: new Output(this, 'TopicName', { value: this.topicName }).makeImportValue(), }; } @@ -216,6 +225,61 @@ export abstract class TopicRef extends Construct implements IEventRuleTarget { arn: this.topicArn, }; } + + /** + * Metric for the size of messages published through this topic + * + * @default average over 5 minutes + */ + public get publishSizeMetric(): Metric { + return new Metric({ + namespace: 'AWS/SNS', + metricName: 'PublishSize', + dimensions: { TopicName: this.topicName } + }); + } + + /** + * Metric for the number of messages published through this topic + * + * @default sum over 5 minutes + */ + public get numberOfMessagesPublishedMetric(): Metric { + return new Metric({ + namespace: 'AWS/SNS', + metricName: 'NumberOfMessagesPublished', + dimensions: { TopicName: this.topicName }, + statistic: 'sum' + }); + } + + /** + * Metric for the number of messages that failed to publish through this topic + * + * @default sum over 5 minutes + */ + public get numberOfMessagesFailedMetric(): Metric { + return new Metric({ + namespace: 'AWS/SNS', + metricName: 'NumberOfMessagesFailed', + dimensions: { TopicName: this.topicName }, + statistic: 'sum' + }); + } + + /** + * Metric for the number of messages that were successfully delivered through this topic + * + * @default sum over 5 minutes + */ + public get numberOfMessagesDeliveredMetric(): Metric { + return new Metric({ + namespace: 'AWS/SNS', + metricName: 'NumberOfMessagesDelivered', + dimensions: { TopicName: this.topicName }, + statistic: 'sum' + }); + } } /** @@ -223,12 +287,14 @@ export abstract class TopicRef extends Construct implements IEventRuleTarget { */ class ImportedTopic extends TopicRef { public readonly topicArn: TopicArn; + public readonly topicName: TopicName; protected autoCreatePolicy: boolean = false; constructor(parent: Construct, name: string, props: TopicRefProps) { super(parent, name); this.topicArn = props.topicArn; + this.topicName = props.topicName; } } @@ -237,6 +303,7 @@ class ImportedTopic extends TopicRef { */ export interface TopicRefProps { topicArn: TopicArn; + topicName: TopicName; } /** diff --git a/packages/@aws-cdk/sns/lib/topic.ts b/packages/@aws-cdk/sns/lib/topic.ts index b87af3337a500..2655d69985368 100644 --- a/packages/@aws-cdk/sns/lib/topic.ts +++ b/packages/@aws-cdk/sns/lib/topic.ts @@ -1,6 +1,6 @@ import { Construct, } from '@aws-cdk/core'; import { sns } from '@aws-cdk/resources'; -import { TopicArn, TopicRef } from './topic-ref'; +import { TopicArn, TopicName, TopicRef } from './topic-ref'; /** * Properties for a new SNS topic @@ -30,7 +30,7 @@ export interface TopicProps { */ export class Topic extends TopicRef { public readonly topicArn: TopicArn; - public readonly topicName?: sns.TopicName; + public readonly topicName: TopicName; protected autoCreatePolicy: boolean = true; diff --git a/packages/@aws-cdk/sns/package.json b/packages/@aws-cdk/sns/package.json index b24959aea954a..bd6d7bd3f43d3 100644 --- a/packages/@aws-cdk/sns/package.json +++ b/packages/@aws-cdk/sns/package.json @@ -41,6 +41,7 @@ "pkglint": "^0.7.1" }, "dependencies": { + "@aws-cdk/cloudwatch": "^0.7.2-beta", "@aws-cdk/core": "^0.7.2-beta", "@aws-cdk/events": "^0.7.2-beta", "@aws-cdk/iam": "^0.7.2-beta", From 038253a6603a6b7a16cb3b852920c5d9c2e4642e Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 27 Jun 2018 17:38:47 +0200 Subject: [PATCH 5/6] Address review comments - Metric providers on resources are now functions. - Add integration test. - Add unit tests on graphs. - Introduce tokenAwareJsonify() which was necessary to properly create a dashboard with references to alarms in it. --- packages/@aws-cdk/cloudwatch/README.md | 110 ++++++++++- packages/@aws-cdk/cloudwatch/lib/alarm.ts | 106 +++++++++-- packages/@aws-cdk/cloudwatch/lib/dashboard.ts | 12 +- packages/@aws-cdk/cloudwatch/lib/graph.ts | 75 ++++---- packages/@aws-cdk/cloudwatch/lib/layout.ts | 16 +- packages/@aws-cdk/cloudwatch/lib/metric.ts | 53 ++++-- packages/@aws-cdk/cloudwatch/lib/widget.ts | 4 +- packages/@aws-cdk/cloudwatch/package.json | 1 - .../integ.alarm-and-dashboard.expected.json | 51 +++++ .../test/integ.alarm-and-dashboard.ts | 33 ++++ .../@aws-cdk/cloudwatch/test/test.alarm.ts | 17 +- .../@aws-cdk/cloudwatch/test/test.graphs.ts | 179 ++++++++++++++++++ .../@aws-cdk/cloudwatch/test/test.layout.ts | 4 +- .../core/lib/cloudformation/jsonify.ts | 77 ++++++++ packages/@aws-cdk/core/lib/index.ts | 1 + packages/@aws-cdk/core/test/test.jsonify.ts | 40 ++++ packages/@aws-cdk/lambda/lib/lambda-ref.ts | 148 ++++++++------- packages/@aws-cdk/sns/lib/topic-ref.ts | 55 +++--- 18 files changed, 784 insertions(+), 198 deletions(-) create mode 100644 packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.expected.json create mode 100644 packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.ts create mode 100644 packages/@aws-cdk/cloudwatch/test/test.graphs.ts create mode 100644 packages/@aws-cdk/core/lib/cloudformation/jsonify.ts create mode 100644 packages/@aws-cdk/core/test/test.jsonify.ts diff --git a/packages/@aws-cdk/cloudwatch/README.md b/packages/@aws-cdk/cloudwatch/README.md index a8c592bc0cb85..b6e891835a669 100644 --- a/packages/@aws-cdk/cloudwatch/README.md +++ b/packages/@aws-cdk/cloudwatch/README.md @@ -1,9 +1,98 @@ Add alarms and graphs to CDK applications ========================================= +Metric objects +-------------- -Making Alarms -------------- +Metric objects represent a metric that is emitted by AWS services or your own +application, such as `CPUUsage`, `FailureCount` or `Bandwidth`. + +Metric objects can be constructed directly or are exposed by resources as +attributes. Resources that expose metrics will have functions that look +like `metricXxx()` which will return a Metric object, initialized with defaults +that make sense. + +For example, `Lambda` objects have the `lambda.metricErrors()` method, which +represents the amount of errors reported by that Lambda function: + +```ts +const errors = lambda.metricErrors(); +``` + +Aggregation +----------- + +To graph or alarm on metrics you must aggregate them first, using a function +like `Average` or a percentile function like `P99`. By default, most Metric objects +returned by CDK libraries will be configured as `Average` over `300 seconds` (5 minutes). +The exception is if the metric represents a count of discrete events, such as +failures. In that case, the Metric object will be configured as `Sum` over `300 +seconds`, i.e. it represents the number of times that event occurred over the +time period. + +If you want to change the default aggregation of the Metric object (for example, +the function or the period), you can do so by passing additional parameters +to the metric function call: + +```ts +const minuteErrorRate = lambda.metricErrors({ + statistic: 'avg', + periodSec: 60, + label: 'Lambda failure rate' +}); +``` + +This function also allows changing the metric label or color (which will be +useful when embedding them in graphs, see below). + +> Rates versus Sums +> +> The reason for using `Sum` to count discrete events is that *some* events are +> emitted as either `0` or `1` (for example `Errors` for a Lambda) and some are +> only emitted as `1` (for example `NumberOfMessagesPublished` for an SNS +> topic). +> +> In case `0`-metrics are emitted, it makes sense to take the `Average` of this +> metric: the result will be the fraction of errors over all executions. +> +> If `0`-metrics are not emitted, the `Average` will always be equal to `1`, +> and not be very useful. +> +> In order to simplify the mental model of `Metric` objects, we default to +> aggregating using `Sum`, which will be the same for both metrics types. If you +> happen to know the Metric you want to alarm on makes sense as a rate +> (`Average`) you can always choose to change the statistic. + +Alarms +------ + +Alarms can be created on metrics in one of two ways. Either create an `Alarm` +object, passing the `Metric` object to set the alarm on: + + +```ts +new Alarm(this, 'Alarm', { + metric: lambda.metricErrors(), + threshold: 100, + evaluationPeriods: 2, +}); +``` + +Alternatively, you can call `metric.newAlarm()`: + +```ts +lambda.metricErrors().newAlarm(this, 'Alarm', { + threshold: 100, + evaluationPeriods: 2, +}); +``` + +The most important properties to set while creating an Alarms are: + +- `threshold`: the value to compare the metric against. +- `comparisonOperator`: the comparison operation to use, defaults to `metric >= threshold`. +- `evaluationPeriods`: how many consecutive periods the metric has to be + breaching the the threshold for the alarm to trigger. Making Dashboards ----------------- @@ -21,6 +110,23 @@ The following widgets are available: - `SingleValueWidget` -- shows the current value of a set of metrics. - `TextWidget` -- shows some static Markdown. +### Graph widget + +A graph widget can display any number of metrics on either the `left` or +`right` vertical axis: + +```ts +new GraphWidget({ + title: 'Executions vs error rate', + left: +}); +``` + +### Alarm widget + +### Single value widget + +### Text widget Dashboard Layout ---------------- diff --git a/packages/@aws-cdk/cloudwatch/lib/alarm.ts b/packages/@aws-cdk/cloudwatch/lib/alarm.ts index 37351ed7ef8a5..9c086a9732cec 100644 --- a/packages/@aws-cdk/cloudwatch/lib/alarm.ts +++ b/packages/@aws-cdk/cloudwatch/lib/alarm.ts @@ -1,5 +1,6 @@ import { Arn, Construct, Token } from '@aws-cdk/core'; import { cloudwatch } from '@aws-cdk/resources'; +import { HorizontalAnnotation } from './graph'; import { Metric } from './metric'; /** @@ -8,6 +9,9 @@ import { Metric } from './metric'; export interface AlarmProps { /** * The metric to add the alarm on + * + * Metric objects can be obtained from most resources, or you can construct + * custom Metric objects by instantiating one. */ metric: Metric; @@ -43,16 +47,8 @@ export interface AlarmProps { evaluationPeriods: number; /** - * Number of datapoints that must be breaching to trigger the alarm - * - * This is used only if you are setting an "M out of N" alarm. In that case, this value is the M. - * - * @default Not an "M out of N" alarm. - */ - datapointsToAlarm?: number; - - /** - * Specifies whether to evaluate the data and potentially change the alarm state if there are too few data points to be statistically significant. + * Specifies whether to evaluate the data and potentially change the alarm + * state if there are too few data points to be statistically significant. * * Used only for alarms that are based on percentiles. */ @@ -83,6 +79,13 @@ export enum ComparisonOperator { LessThanOrEqualToThreshold = 'LessThanOrEqualToThreshold', } +const OPERATOR_SYMBOLS: {[key: string]: string} = { + GreaterThanOrEqualToThreshold: '>=', + GreaterThanThreshold: '>', + LessThanThreshold: '<', + LessThanOrEqualToThreshold: '>=', +}; + /** * Specify how missing data points are treated during alarm evaluation */ @@ -126,26 +129,45 @@ export class Alarm extends Construct { private insufficientDataActions?: Arn[]; private okActions?: Arn[]; + /** + * This metric as an annotation + */ + private readonly annotation: HorizontalAnnotation; + constructor(parent: Construct, name: string, props: AlarmProps) { super(parent, name); + const comparisonOperator = props.comparisonOperator || ComparisonOperator.GreaterThanOrEqualToThreshold; + const alarm = new cloudwatch.AlarmResource(this, 'Resource', { - actionsEnabled: props.actionsEnabled, - alarmActions: new Token(() => this.alarmActions), + // Meta alarmDescription: props.alarmDescription, alarmName: props.alarmName, - comparisonOperator: props.comparisonOperator || ComparisonOperator.GreaterThanOrEqualToThreshold, + + // Evaluation + comparisonOperator, + threshold: props.threshold, evaluateLowSampleCountPercentile: props.evaluateLowSampleCountPercentile, evaluationPeriods: props.evaluationPeriods, + treatMissingData: props.treatMissingData, + + // Actions + actionsEnabled: props.actionsEnabled, + alarmActions: new Token(() => this.alarmActions), insufficientDataActions: new Token(() => this.insufficientDataActions), okActions: new Token(() => this.okActions), - threshold: props.threshold, - treatMissingData: props.treatMissingData, + + // Metric ...props.metric.toAlarmJson() }); this.alarmArn = alarm.alarmArn; this.metric = props.metric; + this.annotation = { + // tslint:disable-next-line:max-line-length + label: `${this.metric.label || this.metric.metricName} ${OPERATOR_SYMBOLS[comparisonOperator]} ${props.threshold} for ${props.evaluationPeriods} datapoints within ${describePeriod(props.evaluationPeriods * props.metric.periodSec)}`, + value: props.threshold, + }; } /** @@ -153,12 +175,12 @@ export class Alarm extends Construct { * * Typically the ARN of an SNS topic or ARN of an AutoScaling policy. */ - public onAlarm(arn: Arn) { + public onAlarm(action: IAlarmAction) { if (this.alarmActions === undefined) { this.alarmActions = []; } - this.alarmActions.push(arn); + this.alarmActions.push(action.alarmActionArn); } /** @@ -166,12 +188,12 @@ export class Alarm extends Construct { * * Typically the ARN of an SNS topic or ARN of an AutoScaling policy. */ - public onInsufficientData(arn: Arn) { + public onInsufficientData(action: IAlarmAction) { if (this.insufficientDataActions === undefined) { this.insufficientDataActions = []; } - this.insufficientDataActions.push(arn); + this.insufficientDataActions.push(action.alarmActionArn); } /** @@ -179,17 +201,59 @@ export class Alarm extends Construct { * * Typically the ARN of an SNS topic or ARN of an AutoScaling policy. */ - public onOk(arn: Arn) { + public onOk(action: IAlarmAction) { if (this.okActions === undefined) { this.okActions = []; } - this.okActions.push(arn); + this.okActions.push(action.alarmActionArn); + } + + /** + * Turn this alarm into a horizontal annotation + * + * This is useful if you want to represent an Alarm in a non-AlarmWidget. + * An `AlarmWidget` can directly show an alarm, but it can only show a + * single alarm and no other metrics. Instead, you can convert the alarm to + * a HorizontalAnnotation and add it as an annotation to another graph. + * + * This might be useful if: + * + * - You want to show multiple alarms inside a single graph, for example if + * you have both a "small margin/long period" alarm as well as a + * "large margin/short period" alarm. + * + * - You want to show an Alarm line in a graph with multiple metrics in it. + */ + public toAnnotation(): HorizontalAnnotation { + return this.annotation; } } +/** + * Return a human readable string for this period + * + * We know the seconds are always one of a handful of allowed values. + */ +function describePeriod(seconds: number) { + if (seconds === 60) { return '1 minute'; } + if (seconds === 1) { return '1 second'; } + if (seconds > 60) { return (seconds / 60) + ' minutes'; } + return seconds + ' seconds'; +} + /** * The ARN of an Alarm */ export class AlarmArn extends Arn { +} + +/** + * Interface for objects that can be the targets of CloudWatch alarm actions + */ +export interface IAlarmAction { + /** + * Return the ARN that should be used for a CloudWatch Alarm action + */ + readonly alarmActionArn: Arn; } \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/lib/dashboard.ts b/packages/@aws-cdk/cloudwatch/lib/dashboard.ts index 755b35ca852a5..1699fb69de5ad 100644 --- a/packages/@aws-cdk/cloudwatch/lib/dashboard.ts +++ b/packages/@aws-cdk/cloudwatch/lib/dashboard.ts @@ -1,7 +1,7 @@ -import { Construct, resolve, Token } from "@aws-cdk/core"; +import { Construct, Token, tokenAwareJsonify } from "@aws-cdk/core"; import { cloudwatch } from "@aws-cdk/resources"; import { Column, Row } from "./layout"; -import { Widget } from "./widget"; +import { IWidget } from "./widget"; export interface DashboardProps { /** @@ -16,7 +16,7 @@ export interface DashboardProps { * A CloudWatch dashboard */ export class Dashboard extends Construct { - private readonly rows: Widget[] = []; + private readonly rows: IWidget[] = []; constructor(parent: Construct, name: string, props?: DashboardProps) { super(parent, name); @@ -26,7 +26,7 @@ export class Dashboard extends Construct { dashboardBody: new Token(() => { const column = new Column(...this.rows); column.position(0, 0); - return JSON.stringify(resolve({ widgets: column.toJson() })); + return tokenAwareJsonify({ widgets: column.toJson() }); }) }); } @@ -40,7 +40,7 @@ export class Dashboard extends Construct { * Multiple widgets added in the same call to add() will be laid out next * to each other. */ - public add(...widgets: Widget[]) { + public add(...widgets: IWidget[]) { if (widgets.length === 0) { return; } @@ -48,4 +48,4 @@ export class Dashboard extends Construct { const w = widgets.length > 1 ? new Row(...widgets) : widgets[0]; this.rows.push(w); } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/cloudwatch/lib/graph.ts b/packages/@aws-cdk/cloudwatch/lib/graph.ts index 7a6a5503210d8..52938737d190b 100644 --- a/packages/@aws-cdk/cloudwatch/lib/graph.ts +++ b/packages/@aws-cdk/cloudwatch/lib/graph.ts @@ -50,18 +50,11 @@ export interface AlarmWidgetProps extends MetricWidgetProps { alarm: Alarm; /** - * Minimum of left Y axis + * Range of left Y axis * - * @default 0 + * @default 0..automatic */ - leftAxisMin?: number; - - /** - * Maximum of left Y axis - * - * @default Automatic - */ - leftAxisMax?: number; + leftAxisRange?: YAxisRange; } /** @@ -90,10 +83,7 @@ export class AlarmWidget extends ConcreteWidget { alarms: [this.props.alarm.alarmArn] }, yAxis: { - left: { - min: this.props.leftAxisMin, - max: this.props.leftAxisMax - } + left: this.props.leftAxisRange !== undefined ? this.props.leftAxisRange : { min: 0 } } } }]; @@ -130,32 +120,18 @@ export interface GraphWidgetProps extends MetricWidgetProps { stacked?: boolean; /** - * Minimum of left Y axis - * - * @default 0 - */ - leftAxisMin?: number; - - /** - * Maximum of left Y axis - * - * @default Automatic - */ - leftAxisMax?: number; - - /** - * Minimum of right Y axis + * Range of left Y axis * - * @default 0 + * @default 0..automatic */ - rightAxisMin?: number; + leftAxisRange?: YAxisRange; /** - * Maximum of right Y axis + * Range of right Y axis * - * @default Automatic + * @default 0..automatic */ - rightAxisMax?: number; + rightAxisRange?: YAxisRange; } /** @@ -184,17 +160,11 @@ export class GraphWidget extends ConcreteWidget { (this.props.right || []).map(m => m.toGraphJson('right'))), annotations: { horizontal: (this.props.leftAnnotations || []).map(mapAnnotation('left')).concat( - (this.props.leftAnnotations || []).map(mapAnnotation('right'))) + (this.props.rightAnnotations || []).map(mapAnnotation('right'))) }, yAxis: { - left: { - min: this.props.leftAxisMin, - max: this.props.leftAxisMax - }, - right: { - min: this.props.rightAxisMin, - max: this.props.rightAxisMax - }, + left: this.props.leftAxisRange !== undefined ? this.props.leftAxisRange : { min: 0 }, + right: this.props.rightAxisRange !== undefined ? this.props.rightAxisRange : { min: 0 }, } } }]; @@ -239,6 +209,25 @@ export class SingleValueWidget extends ConcreteWidget { } } +/** + * A minimum and maximum value for either the left or right Y axis + */ +export interface YAxisRange { + /** + * The minimum value + * + * @default Automatic + */ + min?: number; + + /** + * The maximum value + * + * @default Automatic + */ + max?: number; +} + /** * Horizontal annotation to be added to a graph */ diff --git a/packages/@aws-cdk/cloudwatch/lib/layout.ts b/packages/@aws-cdk/cloudwatch/lib/layout.ts index ff7f29177fdfd..e18cf353671af 100644 --- a/packages/@aws-cdk/cloudwatch/lib/layout.ts +++ b/packages/@aws-cdk/cloudwatch/lib/layout.ts @@ -1,4 +1,4 @@ -import { GRID_WIDTH, Widget } from "./widget"; +import { GRID_WIDTH, IWidget } from "./widget"; // This file contains widgets that exist for layout purposes @@ -7,21 +7,21 @@ import { GRID_WIDTH, Widget } from "./widget"; * * Widgets will be laid out next to each other */ -export class Row implements Widget { +export class Row implements IWidget { public readonly width: number; public readonly height: number; /** * List of contained widgets */ - private readonly widgets: Widget[]; + private readonly widgets: IWidget[]; /** * Relative position of each widget inside this row */ private readonly offsets: Vector[] = []; - constructor(...widgets: Widget[]) { + constructor(...widgets: IWidget[]) { this.widgets = widgets; this.width = 0; @@ -63,16 +63,16 @@ export class Row implements Widget { * * Widgets will be laid out next to each other */ -export class Column implements Widget { +export class Column implements IWidget { public readonly width: number; public readonly height: number; /** * List of contained widgets */ - private readonly widgets: Widget[]; + private readonly widgets: IWidget[]; - constructor(...widgets: Widget[]) { + constructor(...widgets: IWidget[]) { this.widgets = widgets; // There's no vertical wrapping so this one's a lot easier @@ -119,7 +119,7 @@ export interface SpacerProps { /** * A widget that doesn't display anything but takes up space */ -export class Spacer implements Widget { +export class Spacer implements IWidget { public readonly width: number; public readonly height: number; diff --git a/packages/@aws-cdk/cloudwatch/lib/metric.ts b/packages/@aws-cdk/cloudwatch/lib/metric.ts index ea00cefd23c50..8a4adf74dd443 100644 --- a/packages/@aws-cdk/cloudwatch/lib/metric.ts +++ b/packages/@aws-cdk/cloudwatch/lib/metric.ts @@ -68,6 +68,13 @@ export interface MetricProps { /** * A metric emitted by a service * + * The metric is a combination of a metric identifier (namespace, name and dimensions) + * and an aggregation function (statistic, period and unit). + * + * It also contains metadata which is used only in graphs, such as color and label. + * It makes sense to embed this in here, so that compound constructs can attach + * that metadata to metrics they expose. + * * This class does not represent a resource, so hence is not a construct. Instead, * Metric is an abstraction that makes it easy to specify metrics for use in both * alarms and graphs. @@ -123,8 +130,8 @@ export class Metric { * Combines both properties that may adjust the metric (aggregation) as well * as alarm properties. */ - public newAlarm(parent: Construct, name: string, props: NewAlarmProps) { - new Alarm(parent, name, { + public newAlarm(parent: Construct, name: string, props: NewAlarmProps): Alarm { + return new Alarm(parent, name, { metric: this.with({ statistic: props.statistic, periodSec: props.periodSec, @@ -134,7 +141,6 @@ export class Metric { comparisonOperator: props.comparisonOperator, threshold: props.threshold, evaluationPeriods: props.evaluationPeriods, - datapointsToAlarm: props.datapointsToAlarm, evaluateLowSampleCountPercentile: props.evaluateLowSampleCountPercentile, treatMissingData: props.treatMissingData, actionsEnabled: props.actionsEnabled, @@ -187,13 +193,43 @@ export class Metric { } } +/** + * Properties used to construct the Metric identifying part of an Alarm + */ export interface MetricAlarmJson { + /** + * The dimensions to apply to the alarm + */ dimensions?: Dimension[]; + + /** + * Namespace of the metric + */ namespace: string; + + /** + * Name of the metric + */ metricName: string; + + /** + * How many seconds to aggregate over + */ period: number; + + /** + * Simple aggregation function to use + */ statistic?: Statistic; + + /** + * Percentile aggregation function to use + */ extendedStatistic?: string; + + /** + * The unit of the alarm + */ unit?: Unit; } @@ -368,15 +404,6 @@ export interface NewAlarmProps { */ evaluationPeriods: number; - /** - * Number of datapoints that must be breaching to trigger the alarm - * - * This is used only if you are setting an "M out of N" alarm. In that case, this value is the M. - * - * @default Not an "M out of N" alarm. - */ - datapointsToAlarm?: number; - /** * Specifies whether to evaluate the data and potentially change the alarm state if there are too few data points to be statistically significant. * @@ -430,7 +457,7 @@ interface PercentileStatistic { } /** - * Parse a statistic, returning a value of the parsed type + * Parse a statistic, returning the type of metric that was used (simple or percentile) */ function parseStatistic(stat: string): SimpleStatistic | PercentileStatistic { const lowerStat = stat.toLowerCase(); diff --git a/packages/@aws-cdk/cloudwatch/lib/widget.ts b/packages/@aws-cdk/cloudwatch/lib/widget.ts index f6365d0d76abb..7022979359f52 100644 --- a/packages/@aws-cdk/cloudwatch/lib/widget.ts +++ b/packages/@aws-cdk/cloudwatch/lib/widget.ts @@ -6,7 +6,7 @@ export const GRID_WIDTH = 24; /** * A single dashboard widget */ -export interface Widget { +export interface IWidget { /** * The width of the widget * @@ -37,7 +37,7 @@ export interface Widget { * * This is in contrast to other widgets which exist for layout purposes. */ -export abstract class ConcreteWidget implements Widget { +export abstract class ConcreteWidget implements IWidget { public readonly width: number; public readonly height: number; protected x?: number; diff --git a/packages/@aws-cdk/cloudwatch/package.json b/packages/@aws-cdk/cloudwatch/package.json index 46e7ad50edd8c..45380a4b5a79c 100644 --- a/packages/@aws-cdk/cloudwatch/package.json +++ b/packages/@aws-cdk/cloudwatch/package.json @@ -42,7 +42,6 @@ }, "dependencies": { "@aws-cdk/core": "^0.7.2-beta", - "@aws-cdk/sns": "^0.7.2-beta", "@aws-cdk/resources": "^0.7.2-beta" } } diff --git a/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.expected.json b/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.expected.json new file mode 100644 index 0000000000000..69ecfdfed84bd --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.expected.json @@ -0,0 +1,51 @@ +{ + "Resources": { + "queue": { + "Type": "AWS::SQS::Queue" + }, + "Alarm7103F465": { + "Type": "AWS::CloudWatch::Alarm", + "Properties": { + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "Dimensions": [ + { + "Name": "QueueName", + "Value": { + "Fn::GetAtt": [ + "queue", + "QueueName" + ] + } + } + ], + "EvaluationPeriods": 3, + "MetricName": "ApproximateNumberOfMessagesVisible", + "Namespace": "AWS/SQS", + "Period": 300, + "Statistic": "Average", + "Threshold": 100 + } + }, + "DashCCD7F836": { + "Type": "AWS::CloudWatch::Dashboard", + "Properties": { + "DashboardBody": { + "Fn::Sub": [ + "{\"widgets\":[{\"type\":\"metric\",\"width\":6,\"height\":6,\"x\":0,\"y\":0,\"properties\":{\"view\":\"timeSeries\",\"title\":\"Messages in queue\",\"region\":\"${ref0}\",\"annotations\":{\"alarms\":[\"${ref1}\"]},\"yAxis\":{\"left\":{\"min\":0}}}}]}", + { + "ref0": { + "Ref": "AWS::Region" + }, + "ref1": { + "Fn::GetAtt": [ + "Alarm7103F465", + "Arn" + ] + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.ts b/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.ts new file mode 100644 index 0000000000000..818aa4c3a2dc1 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.ts @@ -0,0 +1,33 @@ +// Integration test to deploy some resources, create an alarm on it and create a dashboard. +// +// Because literally every other library is going to depend on @aws-cdk/cloudwatch, we drop down +// to the very lowest level to create CloudFormation resources by hand, without even generated +// library support. + +import { App, Resource, Stack } from '@aws-cdk/core'; +import { AlarmWidget, Dashboard, Metric } from '../lib'; + +const app = new App(process.argv); + +const stack = new Stack(app, `aws-cdk-cloudwatch`); + +const queue = new Resource(stack, 'queue', { type: 'AWS::SQS::Queue' }); + +const metric = new Metric({ + namespace: 'AWS/SQS', + metricName: 'ApproximateNumberOfMessagesVisible', + dimensions: { QueueName: queue.getAtt('QueueName') } +}); + +const alarm = metric.newAlarm(stack, 'Alarm', { + threshold: 100, + evaluationPeriods: 3 +}); + +const dashboard = new Dashboard(stack, 'Dash'); +dashboard.add(new AlarmWidget({ + title: 'Messages in queue', + alarm, +})); + +process.stdout.write(app.run()); diff --git a/packages/@aws-cdk/cloudwatch/test/test.alarm.ts b/packages/@aws-cdk/cloudwatch/test/test.alarm.ts index 88c5d01493280..524633dce4b45 100644 --- a/packages/@aws-cdk/cloudwatch/test/test.alarm.ts +++ b/packages/@aws-cdk/cloudwatch/test/test.alarm.ts @@ -1,7 +1,7 @@ import { expect, haveResource } from '@aws-cdk/assert'; import { Arn, Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { Alarm, Metric } from '../lib'; +import { Alarm, IAlarmAction, Metric } from '../lib'; const testMetric = new Metric({ namespace: 'CDK/Test', @@ -45,9 +45,9 @@ export = { evaluationPeriods: 2 }); - alarm.onAlarm(new Arn('A')); - alarm.onInsufficientData(new Arn('B')); - alarm.onOk(new Arn('C')); + alarm.onAlarm(new TestAlarmAction('A')); + alarm.onInsufficientData(new TestAlarmAction('B')); + alarm.onOk(new TestAlarmAction('C')); // THEN expect(stack).to(haveResource('AWS::CloudWatch::Alarm', { @@ -104,3 +104,12 @@ export = { test.done(); } }; + +class TestAlarmAction implements IAlarmAction { + constructor(private readonly arn: string) { + } + + public get alarmActionArn(): Arn { + return new Arn(this.arn); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/test/test.graphs.ts b/packages/@aws-cdk/cloudwatch/test/test.graphs.ts new file mode 100644 index 0000000000000..716f7ad426ca9 --- /dev/null +++ b/packages/@aws-cdk/cloudwatch/test/test.graphs.ts @@ -0,0 +1,179 @@ +import { resolve, Stack } from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import { AlarmWidget, GraphWidget, Metric, Shading, SingleValueWidget } from '../lib'; + +export = { + 'add metrics to graphs on either axis'(test: Test) { + // WHEN + const widget = new GraphWidget({ + title: 'My fancy graph', + left: [ + new Metric({ namespace: 'CDK', metricName: 'Test' }) + ], + right: [ + new Metric({ namespace: 'CDK', metricName: 'Tast' }) + ] + }); + + // THEN + test.deepEqual(resolve(widget.toJson()), [{ + type: 'metric', + width: 6, + height: 6, + properties: { + view: 'timeSeries', + title: 'My fancy graph', + region: { Ref: 'AWS::Region' }, + metrics: [ + ['CDK', 'Test', { yAxis: 'left', period: 300, stat: 'Average' }], + ['CDK', 'Tast', { yAxis: 'right', period: 300, stat: 'Average' }] + ], + annotations: { horizontal: [] }, + yAxis: { left: { min: 0 }, right: { min: 0 } } + } + }]); + + test.done(); + }, + + 'singlevalue widget'(test: Test) { + // GIVEN + const metric = new Metric({ namespace: 'CDK', metricName: 'Test' }); + + // WHEN + const widget = new SingleValueWidget({ + metrics: [ metric ] + }); + + // THEN + test.deepEqual(resolve(widget.toJson()), [{ + type: 'metric', + width: 6, + height: 6, + properties: { + view: 'singleValue', + region: { Ref: 'AWS::Region' }, + metrics: [ + ['CDK', 'Test', { yAxis: 'left', period: 300, stat: 'Average' }], + ], + } + }]); + + test.done(); + }, + + 'alarm widget'(test: Test) { + // GIVEN + const stack = new Stack(); + + const alarm = new Metric({ namespace: 'CDK', metricName: 'Test' }).newAlarm(stack, 'Alarm', { + evaluationPeriods: 2, + threshold: 1000 + }); + + // WHEN + const widget = new AlarmWidget({ + alarm, + }); + + // THEN + test.deepEqual(resolve(widget.toJson()), [{ + type: 'metric', + width: 6, + height: 6, + properties: { + view: 'timeSeries', + region: { Ref: 'AWS::Region' }, + annotations: { + alarms: [{ 'Fn::GetAtt': [ 'Alarm7103F465', 'Arn' ] }] + }, + yAxis: { left: { min: 0 } } + } + }]); + + test.done(); + }, + + 'add annotations to graph'(test: Test) { + // WHEN + const widget = new GraphWidget({ + title: 'My fancy graph', + left: [ + new Metric({ namespace: 'CDK', metricName: 'Test' }) + ], + leftAnnotations: [{ + value: 1000, + color: '667788', + fill: Shading.Below, + label: 'this is the annotation', + }] + }); + + // THEN + test.deepEqual(resolve(widget.toJson()), [{ + type: 'metric', + width: 6, + height: 6, + properties: { + view: 'timeSeries', + title: 'My fancy graph', + region: { Ref: 'AWS::Region' }, + metrics: [ + ['CDK', 'Test', { yAxis: 'left', period: 300, stat: 'Average' }], + ], + annotations: { horizontal: [{ + yAxis: 'left', + value: 1000, + color: '667788', + fill: 'below', + label: 'this is the annotation', + }] }, + yAxis: { left: { min: 0 }, right: { min: 0 } } + } + }]); + + test.done(); + }, + + 'convert alarm to annotation'(test: Test) { + // GIVEN + const stack = new Stack(); + + const metric = new Metric({ namespace: 'CDK', metricName: 'Test' }); + + const alarm = metric.newAlarm(stack, 'Alarm', { + evaluationPeriods: 2, + threshold: 1000 + }); + + // WHEN + const widget = new GraphWidget({ + right: [ metric ], + rightAnnotations: [ alarm.toAnnotation() ] + }); + + // THEN + test.deepEqual(resolve(widget.toJson()), [{ + type: 'metric', + width: 6, + height: 6, + properties: { + view: 'timeSeries', + region: { Ref: 'AWS::Region' }, + metrics: [ + ['CDK', 'Test', { yAxis: 'right', period: 300, stat: 'Average' }], + ], + annotations: { + horizontal: [{ + yAxis: 'right', + value: 1000, + label: 'Test >= 1000 for 2 datapoints within 10 minutes', + }] + }, + yAxis: { left: { min: 0 }, right: { min: 0 } } + } + }]); + + test.done(); + }, +}; \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/test/test.layout.ts b/packages/@aws-cdk/cloudwatch/test/test.layout.ts index b7dc381905561..e9b21171948fb 100644 --- a/packages/@aws-cdk/cloudwatch/test/test.layout.ts +++ b/packages/@aws-cdk/cloudwatch/test/test.layout.ts @@ -1,5 +1,5 @@ import { Test } from 'nodeunit'; -import { Column, Row, Spacer, TextWidget, Widget } from '../lib'; +import { Column, IWidget, Row, Spacer, TextWidget } from '../lib'; export = { 'row has the height of the tallest element'(test: Test) { @@ -49,7 +49,7 @@ export = { test.equal(21, row.width); test.equal(5, row.height); - function assertWidgetPos(x: number, y: number, w: Widget) { + function assertWidgetPos(x: number, y: number, w: IWidget) { const json = w.toJson()[0]; test.equal(x, json.x); test.equal(y, json.y); diff --git a/packages/@aws-cdk/core/lib/cloudformation/jsonify.ts b/packages/@aws-cdk/core/lib/cloudformation/jsonify.ts new file mode 100644 index 0000000000000..7284195b5afd0 --- /dev/null +++ b/packages/@aws-cdk/core/lib/cloudformation/jsonify.ts @@ -0,0 +1,77 @@ +import { istoken, resolve, Token } from '../core/tokens'; +import { FnSub } from './fn'; + +/** + * Jsonify a deep structure to a string while preserving tokens + * + * Sometimes we have JSON structures that contain CloudFormation + * intrinsics like { Ref } and { Fn::GetAtt }, but the model requires + * that we stringify the JSON structure and pass it into the parameter. + * + * Doing this makes it so that CloudFormation does not resolve the intrinsics + * anymore, since it does not look into every string. To resolve this, + * we stringify into a string and put placeholders in wich we substitute + * with the resolved references using { Fn::Sub }. + * + * Will only work correctly for intrinsics that return a string value. + */ +export function tokenAwareJsonify(structure: any): FnSub { + // Our strategy is as follows: + // + // - Find all tokens, replace each of them with a string + // token that's highly unlikely to occur naturally. + // Attempt deduplication of the same intrinsic into the same + // string token. + // - JSONify the entire structure. + // - Replace things that LOOK like FnSub references + // with the escape string ${!NotSubstituted}. + // - Replace the special tokens with FnSub references, ${LikeThis}. + let counter = 0; + const tokenId: {[key: string]: string} = {}; + const substitutionMap: {[key: string]: any} = {}; + + function rememberToken(x: Token) { + // Get a representation of the resolved Token that we can use as a hash key. + const reprKey = JSON.stringify(resolve(x)); + if (!(reprKey in tokenId)) { + tokenId[reprKey] = `ref${counter}`; + substitutionMap[tokenId[reprKey]] = x; + counter += 1; + } + return `<<>>`; + } + + function replaceTokens(x: any): any { + if (Array.isArray(x)) { + return x.map(replaceTokens); + } + + if (typeof x === 'object' && x !== null) { + if (istoken(x)) { + // This a token, remember and replace it. + return rememberToken(x); + } else { + // Recurse into regular object + for (const key of Object.keys(x)) { + x[key] = replaceTokens(x[key]); + } + return x; + } + } + + return x; + } + + structure = replaceTokens(structure); + + let stringified = JSON.stringify(structure); + + // Escape things that shouldn't be substituted + // Translate ${Oops} -> ${!Oops} + stringified = stringified.replace(/\$\{([^}]+)\}/g, '$${!$1}'); + + // Now substitute our magic pattern with actual references + stringified = stringified.replace(/<<]+)>>>/g, '$${$1}'); + + return new FnSub(stringified, substitutionMap); +} diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 1a118dcdf51ff..18ca0100ecdaa 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -4,6 +4,7 @@ export * from './core/jsx'; export * from './cloudformation/condition'; export * from './cloudformation/fn'; +export * from './cloudformation/jsonify'; export * from './cloudformation/include'; export * from './cloudformation/logical-id'; export * from './cloudformation/mapping'; diff --git a/packages/@aws-cdk/core/test/test.jsonify.ts b/packages/@aws-cdk/core/test/test.jsonify.ts new file mode 100644 index 0000000000000..e16ea1864e898 --- /dev/null +++ b/packages/@aws-cdk/core/test/test.jsonify.ts @@ -0,0 +1,40 @@ +import { Test } from 'nodeunit'; +import { AwsRegion, resolve, tokenAwareJsonify } from '../lib'; + +export = { + 'substitutes tokens'(test: Test) { + // WHEN + const result = tokenAwareJsonify({ + 'the region': new AwsRegion(), + 'the king': 'me', + }); + + // THEN + test.deepEqual(resolve(result), { + 'Fn::Sub': [ + '{"the region":"${ref0}","the king":"me"}', + { ref0: { Ref: 'AWS::Region' } } + ] + }); + + test.done(); + }, + + 'escape things that look like FnSub values'(test: Test) { + // WHEN + const result = tokenAwareJsonify({ + 'the region': new AwsRegion(), + 'the king': '${Me}', + }); + + // THEN + test.deepEqual(resolve(result), { + 'Fn::Sub': [ + '{"the region":"${ref0}","the king":"${!Me}"}', + { ref0: { Ref: 'AWS::Region' } } + ] + }); + + test.done(); + }, +}; diff --git a/packages/@aws-cdk/lambda/lib/lambda-ref.ts b/packages/@aws-cdk/lambda/lib/lambda-ref.ts index ce3f9067a7337..cdc1f1cd54c5c 100644 --- a/packages/@aws-cdk/lambda/lib/lambda-ref.ts +++ b/packages/@aws-cdk/lambda/lib/lambda-ref.ts @@ -1,4 +1,4 @@ -import { Metric } from '@aws-cdk/cloudwatch'; +import { ChangeMetricProps, Metric } from '@aws-cdk/cloudwatch'; import { AccountPrincipal, Arn, Construct, FnSelect, FnSplit, PolicyPrincipal, PolicyStatement, resolve, ServicePrincipal, Token } from '@aws-cdk/core'; import { EventRuleTarget, IEventRuleTarget } from '@aws-cdk/events'; @@ -39,6 +39,52 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget { return new LambdaRefImport(parent, name, ref); } + /** + * Return the given named metric for this Lambda + */ + public static metricAll(metricName: string, props?: ChangeMetricProps): Metric { + return new Metric({ + namespace: 'AWS/Lambda', + metricName, + ...props + }); + } + /** + * Metric for the number of Errors executing all Lambdas + * + * @default sum over 5 minutes + */ + public static metricAllErrors(props?: ChangeMetricProps): Metric { + return LambdaRef.metricAll('Errors', { statistic: 'sum', ...props }); + } + + /** + * Metric for the Duration executing all Lambdas + * + * @default average over 5 minutes + */ + public static metricAllDuration(props?: ChangeMetricProps): Metric { + return LambdaRef.metricAll('Duration', props); + } + + /** + * Metric for the number of invocations of all Lambdas + * + * @default sum over 5 minutes + */ + public static metricAllInvocations(props?: ChangeMetricProps): Metric { + return LambdaRef.metricAll('Invocations', { statistic: 'sum', ...props }); + } + + /** + * Metric for the number of throttled invocations of all Lambdas + * + * @default sum over 5 minutes + */ + public static metricAllThrottles(props?: ChangeMetricProps): Metric { + return LambdaRef.metricAll('Throttles', { statistic: 'sum', ...props }); + } + /** * The name of the function. */ @@ -98,25 +144,6 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget { this.role.addToPolicy(statement); } - private parsePermissionPrincipal(principal?: PolicyPrincipal) { - if (!principal) { - return undefined; - } - - // use duck-typing, not instance of - - if ('accountId' in principal) { - return (principal as AccountPrincipal).accountId; - } - - if (`service` in principal) { - return (principal as ServicePrincipal).service; - } - - throw new Error(`Invalid principal type for Lambda permission statement: ${JSON.stringify(resolve(principal))}. ` + - 'Supported: AccountPrincipal, ServicePrincipal'); - } - /** * Returns a RuleTarget that can be used to trigger this Lambda as a * result from a CloudWatch event. @@ -137,15 +164,25 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget { }; } + /** + * Return the given named metric for this Lambda + */ + public metric(metricName: string, props?: ChangeMetricProps): Metric { + return new Metric({ + namespace: 'AWS/Lambda', + metricName, + dimensions: { FunctionName: this.functionName }, + ...props + }); + } + /** * Metric for the Errors executing this Lambda * * @default sum over 5 minutes */ - public get errorsMetric(): Metric { - return LambdaRef.allErrorsMetric.with({ - dimensions: { FunctionName: this.functionName } - }); + public metricErrors(props?: ChangeMetricProps): Metric { + return this.metric('Errors', { statistic: 'sum', ...props }); } /** @@ -153,10 +190,8 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget { * * @default average over 5 minutes */ - public get durationMetric(): Metric { - return LambdaRef.allDurationMetric.with({ - dimensions: { FunctionName: this.functionName } - }); + public metricDuration(props?: ChangeMetricProps): Metric { + return this.metric('Duration', props); } /** @@ -164,10 +199,8 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget { * * @default sum over 5 minutes */ - public get invocationsMetric(): Metric { - return LambdaRef.allInvocationsMetric.with({ - dimensions: { FunctionName: this.functionName } - }); + public metricInvocations(props?: ChangeMetricProps): Metric { + return this.metric('Invocations', { statistic: 'sum', ...props }); } /** @@ -175,46 +208,27 @@ export abstract class LambdaRef extends Construct implements IEventRuleTarget { * * @default sum over 5 minutes */ - public get throttlesMetric(): Metric { - return LambdaRef.allThrottlesMetric.with({ - dimensions: { FunctionName: this.functionName } - }); + public metricThrottles(props?: ChangeMetricProps): Metric { + return this.metric('Throttles', { statistic: 'sum', ...props }); } - /** - * Metric for the number of Errors executing all Lambdas - * - * @default sum over 5 minutes - */ - public static get allErrorsMetric(): Metric { - return new Metric({ namespace: 'AWS/Lambda', metricName: 'Errors', statistic: 'sum' }); - } + private parsePermissionPrincipal(principal?: PolicyPrincipal) { + if (!principal) { + return undefined; + } - /** - * Metric for the Duration executing all Lambdas - * - * @default average over 5 minutes - */ - public static get allDurationMetric(): Metric { - return new Metric({ namespace: 'AWS/Lambda', metricName: 'Duration' }); - } + // use duck-typing, not instance of - /** - * Metric for the number of invocations of all Lambdas - * - * @default sum over 5 minutes - */ - public static get allInvocationsMetric(): Metric { - return new Metric({ namespace: 'AWS/Lambda', metricName: 'Invocations', statistic: 'sum' }); - } + if ('accountId' in principal) { + return (principal as AccountPrincipal).accountId; + } - /** - * Metric for the number of throttled invocations of all Lambdas - * - * @default sum over 5 minutes - */ - public static get allThrottlesMetric(): Metric { - return new Metric({ namespace: 'AWS/Lambda', metricName: 'Throttles', statistic: 'sum' }); + if (`service` in principal) { + return (principal as ServicePrincipal).service; + } + + throw new Error(`Invalid principal type for Lambda permission statement: ${JSON.stringify(resolve(principal))}. ` + + 'Supported: AccountPrincipal, ServicePrincipal'); } } diff --git a/packages/@aws-cdk/sns/lib/topic-ref.ts b/packages/@aws-cdk/sns/lib/topic-ref.ts index c629163143f2e..f579cdbf40c19 100644 --- a/packages/@aws-cdk/sns/lib/topic-ref.ts +++ b/packages/@aws-cdk/sns/lib/topic-ref.ts @@ -1,4 +1,4 @@ -import { Metric } from '@aws-cdk/cloudwatch'; +import { ChangeMetricProps, IAlarmAction, Metric } from '@aws-cdk/cloudwatch'; import { Arn, Construct, Output, PolicyStatement, ServicePrincipal, Token } from '@aws-cdk/core'; import { EventRuleTarget, IEventRuleTarget } from '@aws-cdk/events'; import { IIdentityResource } from '@aws-cdk/iam'; @@ -20,7 +20,7 @@ export class TopicName extends Token { } /** * Either a new or imported Topic */ -export abstract class TopicRef extends Construct implements IEventRuleTarget { +export abstract class TopicRef extends Construct implements IEventRuleTarget, IAlarmAction { /** * Import a Topic defined elsewhere */ @@ -226,31 +226,38 @@ export abstract class TopicRef extends Construct implements IEventRuleTarget { }; } + public get alarmActionArn(): Arn { + return this.topicArn; + } + /** - * Metric for the size of messages published through this topic - * - * @default average over 5 minutes + * Construct a Metric object for the current topic for the given metric */ - public get publishSizeMetric(): Metric { + public metric(metricName: string, props?: ChangeMetricProps): Metric { return new Metric({ namespace: 'AWS/SNS', - metricName: 'PublishSize', - dimensions: { TopicName: this.topicName } + dimensions: { TopicName: this.topicName }, + metricName, + ...props }); } + /** + * Metric for the size of messages published through this topic + * + * @default average over 5 minutes + */ + public metricPublishSize(props?: ChangeMetricProps): Metric { + return this.metric('PublishSize', props); + } + /** * Metric for the number of messages published through this topic * * @default sum over 5 minutes */ - public get numberOfMessagesPublishedMetric(): Metric { - return new Metric({ - namespace: 'AWS/SNS', - metricName: 'NumberOfMessagesPublished', - dimensions: { TopicName: this.topicName }, - statistic: 'sum' - }); + public metricNumberOfMessagesPublished(props?: ChangeMetricProps): Metric { + return this.metric('NumberOfMessagesPublished', { statistic: 'sum', ...props }); } /** @@ -258,13 +265,8 @@ export abstract class TopicRef extends Construct implements IEventRuleTarget { * * @default sum over 5 minutes */ - public get numberOfMessagesFailedMetric(): Metric { - return new Metric({ - namespace: 'AWS/SNS', - metricName: 'NumberOfMessagesFailed', - dimensions: { TopicName: this.topicName }, - statistic: 'sum' - }); + public metricNumberOfMessagesFailed(props?: ChangeMetricProps): Metric { + return this.metric('NumberOfMessagesFailed', { statistic: 'sum', ...props }); } /** @@ -272,13 +274,8 @@ export abstract class TopicRef extends Construct implements IEventRuleTarget { * * @default sum over 5 minutes */ - public get numberOfMessagesDeliveredMetric(): Metric { - return new Metric({ - namespace: 'AWS/SNS', - metricName: 'NumberOfMessagesDelivered', - dimensions: { TopicName: this.topicName }, - statistic: 'sum' - }); + public metricNumberOfMessagesDelivered(props?: ChangeMetricProps): Metric { + return this.metric('NumberOfMessagesDelivered', { statistic: 'sum', ...props }); } } From dd6ee2faa425eedabf1a5eb42a7342aefbfa52f1 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 28 Jun 2018 12:04:01 +0200 Subject: [PATCH 6/6] Make default widget heights more appropriate, add explanations to README --- packages/@aws-cdk/cloudwatch/README.md | 48 +++++++++++++++++-- packages/@aws-cdk/cloudwatch/lib/graph.ts | 4 +- packages/@aws-cdk/cloudwatch/lib/text.ts | 4 +- packages/@aws-cdk/cloudwatch/lib/widget.ts | 8 +--- .../integ.alarm-and-dashboard.expected.json | 8 +++- .../test/integ.alarm-and-dashboard.ts | 15 +++++- .../cloudwatch/test/test.dashboard.ts | 39 +++++++++++++-- .../@aws-cdk/cloudwatch/test/test.graphs.ts | 2 +- .../{jsonify.ts => token-aware-jsonify.ts} | 7 ++- packages/@aws-cdk/core/lib/index.ts | 2 +- ...jsonify.ts => test.token-aware-jsonify.ts} | 19 ++++++++ 11 files changed, 133 insertions(+), 23 deletions(-) rename packages/@aws-cdk/core/lib/cloudformation/{jsonify.ts => token-aware-jsonify.ts} (95%) rename packages/@aws-cdk/core/test/{test.jsonify.ts => test.token-aware-jsonify.ts} (66%) diff --git a/packages/@aws-cdk/cloudwatch/README.md b/packages/@aws-cdk/cloudwatch/README.md index b6e891835a669..6818a3661ab0f 100644 --- a/packages/@aws-cdk/cloudwatch/README.md +++ b/packages/@aws-cdk/cloudwatch/README.md @@ -110,24 +110,64 @@ The following widgets are available: - `SingleValueWidget` -- shows the current value of a set of metrics. - `TextWidget` -- shows some static Markdown. +> Warning! Due to a bug in CloudFormation, you cannot update a Dashboard after +> initially creating it if you let its name automatically be generated. You +> must set `dashboardName` if you intend to update the dashboard after creation. +> +> (This note will be removed once the bug is fixed). + ### Graph widget A graph widget can display any number of metrics on either the `left` or `right` vertical axis: ```ts -new GraphWidget({ - title: 'Executions vs error rate', - left: -}); +dashboard.add(new GraphWidget({ + title: "Executions vs error rate", + + left: [executionCountMetric], + + right: [errorCountMetric.with({ + statistic: "average", + label: "Error rate", + color: "00FF00" + })] +})); ``` ### Alarm widget +An alarm widget shows the graph and the alarm line of a single alarm: + +```ts +dashboard.add(new AlarmWidget({ + title: "Errors", + alarm: errorAlarm, +})); +``` + ### Single value widget +A single-value widget shows the latest value of a set of metrics (as opposed +to a graph of the value over time): + +```ts +dashboard.add(new SingleValueWidget({ + metrics: [visitorCount, purchaseCount], +})); +``` + ### Text widget +A text widget shows an arbitrary piece of MarkDown. Use this to add explanations +to your dashboard: + +```ts +dashboard.add(new TextWidget({ + markdown: '# Key Performance Indicators' +})); +``` + Dashboard Layout ---------------- diff --git a/packages/@aws-cdk/cloudwatch/lib/graph.ts b/packages/@aws-cdk/cloudwatch/lib/graph.ts index 52938737d190b..9d9425643c2a1 100644 --- a/packages/@aws-cdk/cloudwatch/lib/graph.ts +++ b/packages/@aws-cdk/cloudwatch/lib/graph.ts @@ -35,7 +35,7 @@ export interface MetricWidgetProps { /** * Height of the widget * - * @default 6 + * @default Depends on the type of widget */ height?: number; } @@ -188,7 +188,7 @@ export class SingleValueWidget extends ConcreteWidget { private readonly props: SingleValueWidgetProps; constructor(props: SingleValueWidgetProps) { - super(props.width || 6, props.height || 6); + super(props.width || 6, props.height || 3); this.props = props; } diff --git a/packages/@aws-cdk/cloudwatch/lib/text.ts b/packages/@aws-cdk/cloudwatch/lib/text.ts index c9e76d15cfb61..844dcdd6f7804 100644 --- a/packages/@aws-cdk/cloudwatch/lib/text.ts +++ b/packages/@aws-cdk/cloudwatch/lib/text.ts @@ -19,7 +19,7 @@ export interface TextWidgetProps { /** * Height of the widget * - * @default 6 + * @default 2 */ height?: number; } @@ -31,7 +31,7 @@ export class TextWidget extends ConcreteWidget { private readonly markdown: string; constructor(props: TextWidgetProps) { - super(props.width || 6, props.height || 6); + super(props.width || 6, props.height || 2); this.markdown = props.markdown; } diff --git a/packages/@aws-cdk/cloudwatch/lib/widget.ts b/packages/@aws-cdk/cloudwatch/lib/widget.ts index 7022979359f52..954e665e52772 100644 --- a/packages/@aws-cdk/cloudwatch/lib/widget.ts +++ b/packages/@aws-cdk/cloudwatch/lib/widget.ts @@ -8,16 +8,12 @@ export const GRID_WIDTH = 24; */ export interface IWidget { /** - * The width of the widget - * - * Will only ever be queried after 'determineSize' has been called. + * The amount of horizontal grid units the widget will take up */ readonly width: number; /** - * The height of the widget - * - * Will only ever be queried after 'determineSize' has been called. + * The amount of vertical grid units the widget will take up */ readonly height: number; diff --git a/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.expected.json b/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.expected.json index 69ecfdfed84bd..3ac45ae5ec290 100644 --- a/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.expected.json +++ b/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.expected.json @@ -31,7 +31,7 @@ "Properties": { "DashboardBody": { "Fn::Sub": [ - "{\"widgets\":[{\"type\":\"metric\",\"width\":6,\"height\":6,\"x\":0,\"y\":0,\"properties\":{\"view\":\"timeSeries\",\"title\":\"Messages in queue\",\"region\":\"${ref0}\",\"annotations\":{\"alarms\":[\"${ref1}\"]},\"yAxis\":{\"left\":{\"min\":0}}}}]}", + "{\"widgets\":[{\"type\":\"text\",\"width\":6,\"height\":2,\"x\":0,\"y\":0,\"properties\":{\"markdown\":\"# This is my dashboard\"}},{\"type\":\"text\",\"width\":6,\"height\":2,\"x\":6,\"y\":0,\"properties\":{\"markdown\":\"you like?\"}},{\"type\":\"metric\",\"width\":6,\"height\":6,\"x\":0,\"y\":2,\"properties\":{\"view\":\"timeSeries\",\"title\":\"Messages in queue\",\"region\":\"${ref0}\",\"annotations\":{\"alarms\":[\"${ref1}\"]},\"yAxis\":{\"left\":{\"min\":0}}}},{\"type\":\"metric\",\"width\":6,\"height\":6,\"x\":0,\"y\":8,\"properties\":{\"view\":\"timeSeries\",\"title\":\"More messages in queue with alarm annotation\",\"region\":\"${ref0}\",\"metrics\":[[\"AWS/SQS\",\"ApproximateNumberOfMessagesVisible\",\"QueueName\",\"${ref2}\",{\"yAxis\":\"left\",\"period\":300,\"stat\":\"Average\"}]],\"annotations\":{\"horizontal\":[{\"label\":\"ApproximateNumberOfMessagesVisible >= 100 for 3 datapoints within 15 minutes\",\"value\":100,\"yAxis\":\"left\"}]},\"yAxis\":{\"left\":{\"min\":0},\"right\":{\"min\":0}}}},{\"type\":\"metric\",\"width\":6,\"height\":3,\"x\":0,\"y\":14,\"properties\":{\"view\":\"singleValue\",\"title\":\"Current messages in queue\",\"region\":\"${ref0}\",\"metrics\":[[\"AWS/SQS\",\"ApproximateNumberOfMessagesVisible\",\"QueueName\",\"${ref2}\",{\"yAxis\":\"left\",\"period\":300,\"stat\":\"Average\"}]]}}]}", { "ref0": { "Ref": "AWS::Region" @@ -41,6 +41,12 @@ "Alarm7103F465", "Arn" ] + }, + "ref2": { + "Fn::GetAtt": [ + "queue", + "QueueName" + ] } } ] diff --git a/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.ts b/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.ts index 818aa4c3a2dc1..eef5c46614148 100644 --- a/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.ts +++ b/packages/@aws-cdk/cloudwatch/test/integ.alarm-and-dashboard.ts @@ -5,7 +5,7 @@ // library support. import { App, Resource, Stack } from '@aws-cdk/core'; -import { AlarmWidget, Dashboard, Metric } from '../lib'; +import { AlarmWidget, Dashboard, GraphWidget, Metric, SingleValueWidget, TextWidget } from '../lib'; const app = new App(process.argv); @@ -25,9 +25,22 @@ const alarm = metric.newAlarm(stack, 'Alarm', { }); const dashboard = new Dashboard(stack, 'Dash'); +dashboard.add( + new TextWidget({ markdown: '# This is my dashboard' }), + new TextWidget({ markdown: 'you like?' }), +); dashboard.add(new AlarmWidget({ title: 'Messages in queue', alarm, })); +dashboard.add(new GraphWidget({ + title: 'More messages in queue with alarm annotation', + left: [metric], + leftAnnotations: [alarm.toAnnotation()] +})); +dashboard.add(new SingleValueWidget({ + title: 'Current messages in queue', + metrics: [metric] +})); process.stdout.write(app.run()); diff --git a/packages/@aws-cdk/cloudwatch/test/test.dashboard.ts b/packages/@aws-cdk/cloudwatch/test/test.dashboard.ts index 6fa7dee79104f..cc17d9207c5f4 100644 --- a/packages/@aws-cdk/cloudwatch/test/test.dashboard.ts +++ b/packages/@aws-cdk/cloudwatch/test/test.dashboard.ts @@ -1,7 +1,7 @@ import { expect, haveResource, isSuperObject } from '@aws-cdk/assert'; import { Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { Dashboard, TextWidget } from '../lib'; +import { Dashboard, GraphWidget, TextWidget } from '../lib'; export = { 'widgets in different adds are laid out underneath each other'(test: Test) { @@ -68,7 +68,32 @@ export = { ]))); test.done(); - } + }, + + 'tokens in widgets are retained through FnSub'(test: Test) { + // GIVEN + const stack = new Stack(); + const dashboard = new Dashboard(stack, 'Dash'); + + // WHEN + dashboard.add( + new GraphWidget({ width: 1, height: 1 }) // GraphWidget has internal reference to current region + ); + + // THEN + expect(stack).to(haveResource('AWS::CloudWatch::Dashboard', { + DashboardBody: { "Fn::Sub": [ + // tslint:disable-next-line:max-line-length + "{\"widgets\":[{\"type\":\"metric\",\"width\":1,\"height\":1,\"x\":0,\"y\":0,\"properties\":{\"view\":\"timeSeries\",\"region\":\"${ref0}\",\"metrics\":[],\"annotations\":{\"horizontal\":[]},\"yAxis\":{\"left\":{\"min\":0},\"right\":{\"min\":0}}}}]}", + { + ref0: { Ref: "AWS::Region" } + } + ] + } + })); + + test.done(); + }, }; /** @@ -76,7 +101,13 @@ export = { */ function thatHasWidgets(widgets: any): (props: any) => boolean { return (props: any) => { - const actualWidgets = JSON.parse(props.DashboardBody).widgets; - return isSuperObject(actualWidgets, widgets); + try { + const actualWidgets = JSON.parse(props.DashboardBody).widgets; + return isSuperObject(actualWidgets, widgets); + } catch (e) { + // tslint:disable-next-line:no-console + console.error('Error parsing', props); + throw e; + } }; } \ No newline at end of file diff --git a/packages/@aws-cdk/cloudwatch/test/test.graphs.ts b/packages/@aws-cdk/cloudwatch/test/test.graphs.ts index 716f7ad426ca9..eaa745dca2640 100644 --- a/packages/@aws-cdk/cloudwatch/test/test.graphs.ts +++ b/packages/@aws-cdk/cloudwatch/test/test.graphs.ts @@ -49,7 +49,7 @@ export = { test.deepEqual(resolve(widget.toJson()), [{ type: 'metric', width: 6, - height: 6, + height: 3, properties: { view: 'singleValue', region: { Ref: 'AWS::Region' }, diff --git a/packages/@aws-cdk/core/lib/cloudformation/jsonify.ts b/packages/@aws-cdk/core/lib/cloudformation/token-aware-jsonify.ts similarity index 95% rename from packages/@aws-cdk/core/lib/cloudformation/jsonify.ts rename to packages/@aws-cdk/core/lib/cloudformation/token-aware-jsonify.ts index 7284195b5afd0..dac05570ab008 100644 --- a/packages/@aws-cdk/core/lib/cloudformation/jsonify.ts +++ b/packages/@aws-cdk/core/lib/cloudformation/token-aware-jsonify.ts @@ -15,7 +15,7 @@ import { FnSub } from './fn'; * * Will only work correctly for intrinsics that return a string value. */ -export function tokenAwareJsonify(structure: any): FnSub { +export function tokenAwareJsonify(structure: any): any { // Our strategy is as follows: // // - Find all tokens, replace each of them with a string @@ -66,6 +66,11 @@ export function tokenAwareJsonify(structure: any): FnSub { let stringified = JSON.stringify(structure); + if (counter === 0) { + // No replacements + return stringified; + } + // Escape things that shouldn't be substituted // Translate ${Oops} -> ${!Oops} stringified = stringified.replace(/\$\{([^}]+)\}/g, '$${!$1}'); diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 18ca0100ecdaa..6a9cc83a3cd13 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -4,7 +4,6 @@ export * from './core/jsx'; export * from './cloudformation/condition'; export * from './cloudformation/fn'; -export * from './cloudformation/jsonify'; export * from './cloudformation/include'; export * from './cloudformation/logical-id'; export * from './cloudformation/mapping'; @@ -20,6 +19,7 @@ export * from './cloudformation/tag'; export * from './cloudformation/removal-policy'; export * from './cloudformation/arn'; export * from './cloudformation/secret'; +export * from './cloudformation/token-aware-jsonify'; export * from './app'; export * from './context'; diff --git a/packages/@aws-cdk/core/test/test.jsonify.ts b/packages/@aws-cdk/core/test/test.token-aware-jsonify.ts similarity index 66% rename from packages/@aws-cdk/core/test/test.jsonify.ts rename to packages/@aws-cdk/core/test/test.token-aware-jsonify.ts index e16ea1864e898..5898dcabd0312 100644 --- a/packages/@aws-cdk/core/test/test.jsonify.ts +++ b/packages/@aws-cdk/core/test/test.token-aware-jsonify.ts @@ -20,6 +20,25 @@ export = { test.done(); }, + 'reuse token substitutions'(test: Test) { + // WHEN + const result = tokenAwareJsonify({ + 'the region': new AwsRegion(), + 'other region': new AwsRegion(), + 'the king': 'me', + }); + + // THEN + test.deepEqual(resolve(result), { + 'Fn::Sub': [ + '{"the region":"${ref0}","other region":"${ref0}","the king":"me"}', + { ref0: { Ref: 'AWS::Region' } } + ] + }); + + test.done(); + }, + 'escape things that look like FnSub values'(test: Test) { // WHEN const result = tokenAwareJsonify({