diff --git a/packages/@aws-cdk/aws-cloudwatch/README.md b/packages/@aws-cdk/aws-cloudwatch/README.md index e12e54d9d9f5c..30b80f9646398 100644 --- a/packages/@aws-cdk/aws-cloudwatch/README.md +++ b/packages/@aws-cdk/aws-cloudwatch/README.md @@ -166,6 +166,60 @@ below). > happen to know the Metric you want to alarm on makes sense as a rate > (`Average`) you can always choose to change the statistic. +### Available Aggregation Statistics + +For your metrics aggregation, you can use the following statistics: + +| Statistic | Short format | Long format | Factory name | +| ------------------------ | :-----------------: | :------------------------------------------: | -------------------- | +| SampleCount (n) | ❌ | ❌ | `Stats.SAMPLE_COUNT` | +| Average (avg) | ❌ | ❌ | `Stats.AVERAGE` | +| Sum | ❌ | ❌ | `Stats.SUM` | +| Minimum (min) | ❌ | ❌ | `Stats.MINIMUM` | +| Maximum (max) | ❌ | ❌ | `Stats.MAXIMUM` | +| Interquartile mean (IQM) | ❌ | ❌ | `Stats.IQM` | +| Percentile (p) | `p99` | ❌ | `Stats.p(99)` | +| Winsorized mean (WM) | `wm99` = `WM(:99%)` | `WM(x:y) \| WM(x%:y%) \| WM(x%:) \| WM(:y%)` | `Stats.wm(10, 90)` | +| Trimmed count (TC) | `tc99` = `TC(:99%)` | `TC(x:y) \| TC(x%:y%) \| TC(x%:) \| TC(:y%)` | `Stats.tc(10, 90)` | +| Trimmed sum (TS) | `ts99` = `TS(:99%)` | `TS(x:y) \| TS(x%:y%) \| TS(x%:) \| TS(:y%)` | `Stats.ts(10, 90)` | +| Percentile rank (PR) | ❌ | `PR(x:y) \| PR(x:) \| PR(:y)` | `Stats.pr(10, 5000)` | + +The most common values are provided in the `cloudwatch.Stats` class. You can provide any string if your statistic is not in the class. + +Read more at [CloudWatch statistics definitions](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Statistics-definitions.html). + +```ts +new cloudwatch.Metric({ + namespace: 'AWS/Route53', + metricName: 'DNSQueries', + dimensionsMap: { + HostedZoneId: hostedZone.hostedZoneId + }, + statistic: cloudwatch.Stats.SAMPLE_COUNT, + period: cloudwatch.Duration.minutes(5) +}); + +new cloudwatch.Metric({ + namespace: 'AWS/Route53', + metricName: 'DNSQueries', + dimensionsMap: { + HostedZoneId: hostedZone.hostedZoneId + }, + statistic: cloudwatch.Stats.p(99), + period: cloudwatch.Duration.minutes(5) +}); + +new cloudwatch.Metric({ + namespace: 'AWS/Route53', + metricName: 'DNSQueries', + dimensionsMap: { + HostedZoneId: hostedZone.hostedZoneId + }, + statistic: 'TS(7.5%:90%)', + period: cloudwatch.Duration.minutes(5) +}); +``` + ### Labels Metric labels are displayed in the legend of graphs that include the metrics. diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts b/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts index cecf0dd4f3476..74227317d4d4f 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/alarm.ts @@ -9,7 +9,7 @@ import { IMetric, MetricExpressionConfig, MetricStatConfig } from './metric-type import { dispatchMetric, metricPeriod } from './private/metric-util'; import { dropUndefined } from './private/object'; import { MetricSet } from './private/rendering'; -import { parseStatistic } from './private/statistic'; +import { normalizeStatistic, parseStatistic } from './private/statistic'; /** * Properties for Alarms @@ -413,7 +413,7 @@ function renderIfSimpleStatistic(statistic?: string): string | undefined { const parsed = parseStatistic(statistic); if (parsed.type === 'simple') { - return parsed.statistic; + return normalizeStatistic(parsed); } return undefined; } @@ -422,14 +422,9 @@ function renderIfExtendedStatistic(statistic?: string): string | undefined { if (statistic === undefined) { return undefined; } const parsed = parseStatistic(statistic); - if (parsed.type === 'percentile') { - // Already percentile. Avoid parsing because we might get into - // floating point rounding issues, return as-is but lowercase the p. - return statistic.toLowerCase(); - } else if (parsed.type === 'generic') { - return statistic; + if (parsed.type === 'single' || parsed.type === 'pair') { + return normalizeStatistic(parsed); } - return undefined; } diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts b/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts index f2a8093e27685..5068187f9b9ae 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts @@ -2,11 +2,12 @@ import * as iam from '@aws-cdk/aws-iam'; import * as cdk from '@aws-cdk/core'; import { Construct, IConstruct } from 'constructs'; import { Alarm, ComparisonOperator, TreatMissingData } from './alarm'; -import { Dimension, IMetric, MetricAlarmConfig, MetricConfig, MetricGraphConfig, Unit } from './metric-types'; +import { Dimension, IMetric, MetricAlarmConfig, MetricConfig, MetricGraphConfig, Statistic, Unit } from './metric-types'; import { dispatchMetric, metricKey } from './private/metric-util'; -import { normalizeStatistic, parseStatistic } from './private/statistic'; +import { normalizeStatistic, pairStatisticToString, parseStatistic, singleStatisticToString } from './private/statistic'; +import { Stats } from './stats'; -export type DimensionHash = {[dim: string]: any}; +export type DimensionHash = { [dim: string]: any }; export type DimensionsMap = { [dim: string]: string }; @@ -24,6 +25,8 @@ export interface CommonMetricOptions { /** * What function to use for aggregating. * + * Use the `aws_cloudwatch.Stats` helper class to construct valid input strings. + * * Can be one of the following: * * - "Minimum" | "min" @@ -38,8 +41,6 @@ export interface CommonMetricOptions { * - "tcNN.NN" | "tc(NN.NN%:NN.NN%)" * - "tsNN.NN" | "ts(NN.NN%:NN.NN%)" * - * Use the factory functions on the `Stats` object to construct valid input strings. - * * @default Average */ readonly statistic?: string; @@ -197,13 +198,13 @@ export interface MathExpressionOptions { readonly searchAccount?: string; /** - * Region to evaluate search expressions within. - * - * Specifying a searchRegion has no effect to the region used - * for metrics within the expression (passed via usingMetrics). - * - * @default - Deployment region. - */ + * Region to evaluate search expressions within. + * + * Specifying a searchRegion has no effect to the region used + * for metrics within the expression (passed via usingMetrics). + * + * @default - Deployment region. + */ readonly searchRegion?: string; } @@ -291,17 +292,30 @@ export class Metric implements IMetric { if (periodSec !== 1 && periodSec !== 5 && periodSec !== 10 && periodSec !== 30 && periodSec % 60 !== 0) { throw new Error(`'period' must be 1, 5, 10, 30, or a multiple of 60 seconds, received ${periodSec}`); } + + this.warnings = undefined; this.dimensions = this.validateDimensions(props.dimensionsMap ?? props.dimensions); this.namespace = props.namespace; this.metricName = props.metricName; - // Try parsing, this will throw if it's not a valid stat - this.statistic = normalizeStatistic(props.statistic || 'Average'); + + const parsedStat = parseStatistic(props.statistic || Stats.AVERAGE); + if (parsedStat.type === 'generic') { + // Unrecognized statistic, do not throw, just warn + // There may be a new statistic that this lib does not support yet + const label = props.label ? `, label "${props.label}"`: ''; + this.warnings = [ + `Unrecognized statistic "${props.statistic}" for metric with namespace "${props.namespace}"${label} and metric name "${props.metricName}".` + + ' Preferably use the `aws_cloudwatch.Stats` helper class to specify a statistic.' + + ' You can ignore this warning if your statistic is valid but not yet supported by the `aws_cloudwatch.Stats` helper class.', + ]; + } + this.statistic = normalizeStatistic(parsedStat); + this.label = props.label; this.color = props.color; this.unit = props.unit; this.account = props.account; this.region = props.region; - this.warnings = undefined; } /** @@ -389,14 +403,22 @@ export class Metric implements IMetric { throw new Error('Using a math expression is not supported here. Pass a \'Metric\' object instead'); } - const stat = parseStatistic(metricConfig.metricStat.statistic); + const parsed = parseStatistic(metricConfig.metricStat.statistic); + + let extendedStatistic: string | undefined = undefined; + if (parsed.type === 'single') { + extendedStatistic = singleStatisticToString(parsed); + } else if (parsed.type === 'pair') { + extendedStatistic = pairStatisticToString(parsed); + } + return { dimensions: metricConfig.metricStat.dimensions, namespace: metricConfig.metricStat.namespace, metricName: metricConfig.metricStat.metricName, period: metricConfig.metricStat.period.toSeconds(), - statistic: stat.type === 'simple' ? stat.statistic : undefined, - extendedStatistic: stat.type === 'percentile' ? 'p' + stat.percentile : undefined, + statistic: parsed.type === 'simple' ? parsed.statistic as Statistic : undefined, + extendedStatistic, unit: this.unit, }; } diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/private/statistic.ts b/packages/@aws-cdk/aws-cloudwatch/lib/private/statistic.ts index 1d81a683da576..db3bc69e45ffa 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/private/statistic.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/private/statistic.ts @@ -1,11 +1,8 @@ +import { Stats } from '../stats'; + export interface SimpleStatistic { type: 'simple'; - statistic: Statistic; -} - -export interface PercentileStatistic { - type: 'percentile'; - percentile: number; + statistic: string; } export interface GenericStatistic { @@ -13,90 +10,262 @@ export interface GenericStatistic { statistic: string; } +export interface ParseableStatistic { + statPrefix: string; + statName: string; + rawStatistic: string; +} + +export interface SingleStatistic extends ParseableStatistic { + type: 'single'; + value: number; +} + +export interface PairStatistic extends ParseableStatistic { + type: 'pair'; + isPercent: boolean; + lower?: number; + upper?: number; + canBeSingleStat: boolean; + asSingleStatStr?: string; +} + +export interface PercentileStatistic extends SingleStatistic { + statName: 'percentile'; +} + +export interface TrimmedMeanStatistic extends PairStatistic { + statName: 'trimmedMean'; +} + +export interface WinsorizedMeanStatistic extends PairStatistic { + statName: 'winsorizedMean'; +} + +export interface TrimmedCountStatistic extends PairStatistic { + statName: 'trimmedCount'; +} + +export interface TrimmedSumStatistic extends PairStatistic { + statName: 'trimmedSum'; +} + +function parseSingleStatistic(statistic: string, prefix: string): Omit | undefined { + const prefixLower = prefix.toLowerCase(); + + // Allow `P99` uppercase + statistic = statistic.toLowerCase(); + + if (!statistic.startsWith(prefixLower)) { + return undefined; + } + + let r: RegExpExecArray | null = null; + + // p99.99 + // /^p(\d{1,2}(?:\.\d+)?)$/ + r = new RegExp(`^${prefixLower}(\\d{1,2}(?:\\.\\d+)?)$`).exec(statistic); + if (r) { + return { + type: 'single', + rawStatistic: statistic, + statPrefix: prefixLower, + value: parseFloat(r[1]), + }; + } + + return undefined; +} + +function parsePairStatistic(statistic: string, prefix: string): Omit | undefined { + const prefixUpper = prefix.toUpperCase(); + + // Allow `tm(10%:90%)` lowercase + statistic = statistic.toUpperCase(); + + if (!statistic.startsWith(prefixUpper)) { + return undefined; + } + + const common: Omit = { + type: 'pair', + canBeSingleStat: false, + rawStatistic: statistic, + statPrefix: prefixUpper, + }; + + let r: RegExpExecArray | null = null; + + // TM(99.999:) + // /TM\((\d{1,2}(?:\.\d+)?):\)/ + r = new RegExp(`^${prefixUpper}\\((\\d+(?:\\.\\d+)?)\\:\\)$`).exec(statistic); + if (r) { + return { + ...common, + lower: parseFloat(r[1]), + upper: undefined, + isPercent: false, + }; + } + + // TM(99.999%:) + // /TM\((\d{1,2}(?:\.\d+)?)%:\)/ + r = new RegExp(`^${prefixUpper}\\((\\d{1,2}(?:\\.\\d+)?)%\\:\\)$`).exec(statistic); + if (r) { + return { + ...common, + lower: parseFloat(r[1]), + upper: undefined, + isPercent: true, + }; + } + + // TM(:99.999) + // /TM\(:(\d{1,2}(?:\.\d+)?)\)/ + r = new RegExp(`^${prefixUpper}\\(\\:(\\d+(?:\\.\\d+)?)\\)$`).exec(statistic); + if (r) { + return { + ...common, + lower: undefined, + upper: parseFloat(r[1]), + isPercent: false, + }; + } + + // TM(:99.999%) + // /TM\(:(\d{1,2}(?:\.\d+)?)%\)/ + // Note: this can be represented as a single stat! TM(:90%) = tm90 + r = new RegExp(`^${prefixUpper}\\(\\:(\\d{1,2}(?:\\.\\d+)?)%\\)$`).exec(statistic); + if (r) { + return { + ...common, + canBeSingleStat: true, + asSingleStatStr: `${prefix.toLowerCase()}${r[1]}`, + lower: undefined, + upper: parseFloat(r[1]), + isPercent: true, + }; + } + + // TM(99.999:99.999) + // /TM\((\d{1,2}(?:\.\d+)?):(\d{1,2}(?:\.\d+)?)\)/ + r = new RegExp(`^${prefixUpper}\\((\\d+(?:\\.\\d+)?)\\:(\\d+(?:\\.\\d+)?)\\)$`).exec(statistic); + if (r) { + return { + ...common, + lower: parseFloat(r[1]), + upper: parseFloat(r[2]), + isPercent: false, + }; + } + + // TM(99.999%:99.999%) + // /TM\((\d{1,2}(?:\.\d+)?)%:(\d{1,2}(?:\.\d+)?)%\)/ + r = new RegExp(`^${prefixUpper}\\((\\d{1,2}(?:\\.\\d+)?)%\\:(\\d{1,2}(?:\\.\\d+)?)%\\)$`).exec(statistic); + if (r) { + return { + ...common, + lower: parseFloat(r[1]), + upper: parseFloat(r[2]), + isPercent: true, + }; + } + + return undefined; +} + +export function singleStatisticToString(parsed: SingleStatistic): string { + return `${parsed.statPrefix}${parsed.value}`; +} + +export function pairStatisticToString(parsed: PairStatistic): string { + const percent = parsed.isPercent ? '%' : ''; + const lower = parsed.lower ? `${parsed.lower}${percent}` : ''; + const upper = parsed.upper ? `${parsed.upper}${percent}` : ''; + return `${parsed.statPrefix}(${lower}:${upper})`; +} + /** - * Parse a statistic, returning the type of metric that was used (simple or percentile) + * Parse a statistic, returning the type of metric that was used */ -export function parseStatistic(stat: string): SimpleStatistic | PercentileStatistic | GenericStatistic { +export function parseStatistic( + stat: string, +): + | SimpleStatistic + | PercentileStatistic + | TrimmedMeanStatistic + | WinsorizedMeanStatistic + | TrimmedCountStatistic + | TrimmedSumStatistic + | GenericStatistic { 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.SAMPLE_COUNT, - n: Statistic.SAMPLE_COUNT, - sum: Statistic.SUM, + const statMap: { [k: string]: string } = { + average: Stats.AVERAGE, + avg: Stats.AVERAGE, + minimum: Stats.MINIMUM, + min: Stats.MINIMUM, + maximum: Stats.MAXIMUM, + max: Stats.MAXIMUM, + samplecount: Stats.SAMPLE_COUNT, + n: Stats.SAMPLE_COUNT, + sum: Stats.SUM, + iqm: Stats.IQM, }; if (lowerStat in statMap) { return { type: 'simple', statistic: statMap[lowerStat], - }; + } as SimpleStatistic; } + let m: ReturnType | ReturnType = undefined; + // Percentile statistics - const re = /^p([\d.]+)$/; - const m = re.exec(lowerStat); - if (m) { - return { - type: 'percentile', - percentile: parseFloat(m[1]), - }; - } + m = parseSingleStatistic(stat, 'p'); + if (m) return { ...m, statName: 'percentile' } as PercentileStatistic; + + // Trimmed mean statistics + m = parseSingleStatistic(stat, 'tm') || parsePairStatistic(stat, 'tm'); + if (m) return { ...m, statName: 'trimmedMean' } as TrimmedMeanStatistic; + + // Winsorized mean statistics + m = parseSingleStatistic(stat, 'wm') || parsePairStatistic(stat, 'wm'); + if (m) return { ...m, statName: 'winsorizedMean' } as WinsorizedMeanStatistic; + + // Trimmed count statistics + m = parseSingleStatistic(stat, 'tc') || parsePairStatistic(stat, 'tc'); + if (m) return { ...m, statName: 'trimmedCount' } as TrimmedCountStatistic; + + // Trimmed sum statistics + m = parseSingleStatistic(stat, 'ts') || parsePairStatistic(stat, 'ts'); + if (m) return { ...m, statName: 'trimmedSum' } as TrimmedSumStatistic; return { type: 'generic', statistic: stat, - }; + } as GenericStatistic; } -export function normalizeStatistic(stat: string): string { - const parsed = parseStatistic(stat); +export function normalizeStatistic(parsed: ReturnType): string { if (parsed.type === 'simple' || parsed.type === 'generic') { return parsed.statistic; - } else { - // Already percentile. Avoid parsing because we might get into - // floating point rounding issues, return as-is but lowercase the p. - return stat.toLowerCase(); + } else if (parsed.type === 'single') { + // Avoid parsing because we might get into + // floating point rounding issues, return as-is but lowercase the stat prefix. + return parsed.rawStatistic.toLowerCase(); + } else if (parsed.type === 'pair') { + // Avoid parsing because we might get into + // floating point rounding issues, return as-is but uppercase the stat prefix. + return parsed.rawStatistic.toUpperCase(); } + + return ''; } -/** - * Enum for simple statistics - * - * (This is a private copy of the type in `metric-types.ts`; this type should always - * been private, the public one has been deprecated and isn't used anywhere). - * - * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Statistics-definitions.html - */ -export enum Statistic { - /** - * The count (number) of data points used for the statistical calculation. - */ - SAMPLE_COUNT = 'SampleCount', - - /** - * The value of Sum / SampleCount during the specified period. - */ - AVERAGE = 'Average', - /** - * All values submitted for the matching metric added together. - * This statistic can be useful for determining the total volume of a metric. - */ - SUM = 'Sum', - /** - * The lowest value observed during the specified period. - * You can use this value to determine low volumes of activity for your application. - */ - MINIMUM = 'Minimum', - /** - * The highest value observed during the specified period. - * You can use this value to determine high volumes of activity for your application. - */ - MAXIMUM = 'Maximum', -} \ No newline at end of file +export function normalizeRawStringStatistic(stat: string): string { + const parsed = parseStatistic(stat); + return normalizeStatistic(parsed); +} diff --git a/packages/@aws-cdk/aws-cloudwatch/test/alarm.test.ts b/packages/@aws-cdk/aws-cloudwatch/test/alarm.test.ts index d7c6a8a1411ad..e793cf1b9745d 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/alarm.test.ts +++ b/packages/@aws-cdk/aws-cloudwatch/test/alarm.test.ts @@ -1,7 +1,7 @@ import { Match, Template, Annotations } from '@aws-cdk/assertions'; import { Duration, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; -import { Alarm, IAlarm, IAlarmAction, Metric, MathExpression, IMetric } from '../lib'; +import { Alarm, IAlarm, IAlarmAction, Metric, MathExpression, IMetric, Stats } from '../lib'; const testMetric = new Metric({ namespace: 'CDK/Test', @@ -269,7 +269,82 @@ describe('Alarm', () => { }); }); - test('metric warnings are added to Alarm', () => { + test('can use a generic pair string for extended statistic to make alarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + testMetric.with({ + statistic: 'TM(10%:90%)', + }).createAlarm(stack, 'Alarm', { + threshold: 1000, + evaluationPeriods: 2, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + Statistic: Match.absent(), + ExtendedStatistic: 'TM(10%:90%)', + }); + }); + + test('can use stats class to make alarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + testMetric.with({ + statistic: Stats.p(99.9), + }).createAlarm(stack, 'Alarm', { + threshold: 1000, + evaluationPeriods: 2, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + ExtendedStatistic: 'p99.9', + }); + }); + + test('can use stats class pair to make alarm', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + testMetric.with({ + statistic: Stats.ts(10, 90), + }).createAlarm(stack, 'Alarm', { + threshold: 1000, + evaluationPeriods: 2, + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::CloudWatch::Alarm', { + ExtendedStatistic: 'TS(10%:90%)', + }); + }); + + test('metric warnings are added to Alarm for unrecognized statistic', () => { + const stack = new Stack(undefined, 'MyStack'); + const m = new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', + statistic: 'invalid', + }); + + // WHEN + new Alarm(stack, 'MyAlarm', { + metric: m, + evaluationPeriods: 1, + threshold: 1, + }); + + // THEN + const template = Annotations.fromStack(stack); + template.hasWarning('/MyStack/MyAlarm', Match.stringLikeRegexp('Unrecognized statistic.*Preferably use the `aws_cloudwatch.Stats` helper class to specify a statistic')); + }); + + test('metric warnings are added to Alarm for math expressions', () => { const stack = new Stack(undefined, 'MyStack'); const m = new MathExpression({ expression: 'oops' }); diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/DashboardWithGraphWidgetWithStatisticIntegrationTest.assets.json b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/DashboardWithGraphWidgetWithStatisticIntegrationTest.assets.json new file mode 100644 index 0000000000000..d270063e23aae --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/DashboardWithGraphWidgetWithStatisticIntegrationTest.assets.json @@ -0,0 +1,19 @@ +{ + "version": "22.0.0", + "files": { + "fa0b1fe0c3043238b7413b794c626bac246c94f150aa6e3ff441a030d7dce521": { + "source": { + "path": "DashboardWithGraphWidgetWithStatisticIntegrationTest.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "fa0b1fe0c3043238b7413b794c626bac246c94f150aa6e3ff441a030d7dce521.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/DashboardWithGraphWidgetWithStatisticIntegrationTest.template.json b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/DashboardWithGraphWidgetWithStatisticIntegrationTest.template.json new file mode 100644 index 0000000000000..1ab5ef2c8b8b1 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/DashboardWithGraphWidgetWithStatisticIntegrationTest.template.json @@ -0,0 +1,55 @@ +{ + "Resources": { + "DashCCD7F836": { + "Type": "AWS::CloudWatch::Dashboard", + "Properties": { + "DashboardBody": { + "Fn::Join": [ + "", + [ + "{\"widgets\":[{\"type\":\"metric\",\"width\":6,\"height\":6,\"x\":0,\"y\":0,\"properties\":{\"view\":\"timeSeries\",\"title\":\"My fancy graph\",\"region\":\"", + { + "Ref": "AWS::Region" + }, + "\",\"metrics\":[[\"CDK/Test\",\"Metric\",{\"label\":\"Metric left 1 - p99\",\"stat\":\"p99\"}],[\"CDK/Test\",\"Metric\",{\"label\":\"Metric left 2 - TC_10P_90P\",\"stat\":\"TC(10%:90%)\"}],[\"CDK/Test\",\"Metric\",{\"label\":\"Metric left 3 - TS(5%:95%)\",\"stat\":\"TS(5%:95%)\"}],[\"CDK/Test\",\"Metric\",{\"label\":\"Metric right 1 - p90.1234\",\"stat\":\"p90.1234\",\"yAxis\":\"right\"}]],\"yAxis\":{}}}]}" + ] + ] + } + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/cdk.out b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/cdk.out new file mode 100644 index 0000000000000..145739f539580 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"22.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/manifest.json b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/manifest.json new file mode 100644 index 0000000000000..f27e3aee540e0 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/manifest.json @@ -0,0 +1,64 @@ +{ + "version": "22.0.0", + "artifacts": { + "DashboardWithGraphWidgetWithStatisticIntegrationTest.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "DashboardWithGraphWidgetWithStatisticIntegrationTest.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "DashboardWithGraphWidgetWithStatisticIntegrationTest": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "DashboardWithGraphWidgetWithStatisticIntegrationTest.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/fa0b1fe0c3043238b7413b794c626bac246c94f150aa6e3ff441a030d7dce521.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "DashboardWithGraphWidgetWithStatisticIntegrationTest.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "DashboardWithGraphWidgetWithStatisticIntegrationTest.assets" + ], + "metadata": { + "/DashboardWithGraphWidgetWithStatisticIntegrationTest/Dash/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "DashCCD7F836" + } + ], + "/DashboardWithGraphWidgetWithStatisticIntegrationTest/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/DashboardWithGraphWidgetWithStatisticIntegrationTest/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "DashboardWithGraphWidgetWithStatisticIntegrationTest" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/tree.json b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/tree.json new file mode 100644 index 0000000000000..381eadf6af6b4 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.js.snapshot/tree.json @@ -0,0 +1,82 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "DashboardWithGraphWidgetWithStatisticIntegrationTest": { + "id": "DashboardWithGraphWidgetWithStatisticIntegrationTest", + "path": "DashboardWithGraphWidgetWithStatisticIntegrationTest", + "children": { + "Dash": { + "id": "Dash", + "path": "DashboardWithGraphWidgetWithStatisticIntegrationTest/Dash", + "children": { + "Resource": { + "id": "Resource", + "path": "DashboardWithGraphWidgetWithStatisticIntegrationTest/Dash/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Dashboard", + "aws:cdk:cloudformation:props": { + "dashboardBody": { + "Fn::Join": [ + "", + [ + "{\"widgets\":[{\"type\":\"metric\",\"width\":6,\"height\":6,\"x\":0,\"y\":0,\"properties\":{\"view\":\"timeSeries\",\"title\":\"My fancy graph\",\"region\":\"", + { + "Ref": "AWS::Region" + }, + "\",\"metrics\":[[\"CDK/Test\",\"Metric\",{\"label\":\"Metric left 1 - p99\",\"stat\":\"p99\"}],[\"CDK/Test\",\"Metric\",{\"label\":\"Metric left 2 - TC_10P_90P\",\"stat\":\"TC(10%:90%)\"}],[\"CDK/Test\",\"Metric\",{\"label\":\"Metric left 3 - TS(5%:95%)\",\"stat\":\"TS(5%:95%)\"}],[\"CDK/Test\",\"Metric\",{\"label\":\"Metric right 1 - p90.1234\",\"stat\":\"p90.1234\",\"yAxis\":\"right\"}]],\"yAxis\":{}}}]}" + ] + ] + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.CfnDashboard", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-cloudwatch.Dashboard", + "version": "0.0.0" + } + }, + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "DashboardWithGraphWidgetWithStatisticIntegrationTest/BootstrapVersion", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "DashboardWithGraphWidgetWithStatisticIntegrationTest/CheckBootstrapVersion", + "constructInfo": { + "fqn": "@aws-cdk/core.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + }, + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.1.161" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/core.App", + "version": "0.0.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.ts b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.ts new file mode 100644 index 0000000000000..86cc7fff16e9b --- /dev/null +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.dashboard-with-graphwidget-with-statistic.ts @@ -0,0 +1,52 @@ +import { App, Stack, StackProps } from '@aws-cdk/core'; +import { Dashboard, Metric, Stats, GraphWidget } from '../lib'; + +class DashboardWithGraphWidgetWithStatisticIntegrationTest extends Stack { + constructor(scope: App, id: string, props?: StackProps) { + super(scope, id, props); + + const dashboard = new Dashboard(this, 'Dash'); + + const widget = new GraphWidget({ + title: 'My fancy graph', + left: [ + new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', + label: 'Metric left 1 - p99', + statistic: Stats.p(99), + }), + + new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', + label: 'Metric left 2 - TC_10P_90P', + statistic: Stats.tc(10, 90), + }), + + new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', + label: 'Metric left 3 - TS(5%:95%)', + statistic: 'TS(5%:95%)', + }), + ], + right: [ + new Metric({ + namespace: 'CDK/Test', + metricName: 'Metric', + label: 'Metric right 1 - p90.1234', + statistic: 'p90.1234', + }), + ], + }); + + dashboard.addWidgets(widget); + } +} + +const app = new App(); + +new DashboardWithGraphWidgetWithStatisticIntegrationTest(app, 'DashboardWithGraphWidgetWithStatisticIntegrationTest'); + +app.synth(); diff --git a/packages/@aws-cdk/aws-cloudwatch/test/metrics.test.ts b/packages/@aws-cdk/aws-cloudwatch/test/metrics.test.ts index 49b9384df870d..370dbcb60dc6d 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/metrics.test.ts +++ b/packages/@aws-cdk/aws-cloudwatch/test/metrics.test.ts @@ -2,7 +2,8 @@ import { Template } from '@aws-cdk/assertions'; import * as iam from '@aws-cdk/aws-iam'; import { testDeprecated } from '@aws-cdk/cdk-build-tools'; import * as cdk from '@aws-cdk/core'; -import { Alarm, Metric } from '../lib'; +import { Alarm, Metric, Stats } from '../lib'; +import { PairStatistic, parseStatistic, SingleStatistic } from '../lib/private/statistic'; describe('Metrics', () => { test('metric grant', () => { @@ -242,4 +243,112 @@ describe('Metrics', () => { expect(metric.statistic).toEqual(customStat); }); + + test('statistic is properly parsed', () => { + const checkParsingSingle = (statistic: string, statPrefix: string, statName: string, value: number) => { + const parsed = parseStatistic(statistic); + expect(parsed.type).toEqual('single'); + expect((parsed as SingleStatistic).value).toEqual(value); + expect((parsed as SingleStatistic).statPrefix).toEqual(statPrefix); + expect((parsed as SingleStatistic).statName).toEqual(statName); + }; + + const checkParsingPair = ( + statistic: string, + statPrefix: string, + statName: string, + isPercent: boolean, + canBeSingleStat: boolean, + asSingleStatStr?: string, + lower?: number, + upper?: number, + ) => { + const parsed = parseStatistic(statistic); + expect(parsed.type).toEqual('pair'); + expect((parsed as PairStatistic).isPercent).toEqual(isPercent); + expect((parsed as PairStatistic).lower).toEqual(lower); + expect((parsed as PairStatistic).upper).toEqual(upper); + expect((parsed as PairStatistic).canBeSingleStat).toEqual(canBeSingleStat); + expect((parsed as PairStatistic).asSingleStatStr).toEqual(asSingleStatStr); + expect((parsed as PairStatistic).statPrefix).toEqual(statPrefix); + expect((parsed as PairStatistic).statName).toEqual(statName); + }; + + expect(parseStatistic(Stats.SAMPLE_COUNT).type).toEqual('simple'); + expect(parseStatistic(Stats.AVERAGE).type).toEqual('simple'); + expect(parseStatistic(Stats.SUM).type).toEqual('simple'); + expect(parseStatistic(Stats.MINIMUM).type).toEqual('simple'); + expect(parseStatistic(Stats.MAXIMUM).type).toEqual('simple'); + expect(parseStatistic(Stats.IQM).type).toEqual('simple'); + + /* eslint-disable no-multi-spaces */ + + // Check single statistics + checkParsingSingle('p9', 'p', 'percentile', 9); + checkParsingSingle('p99', 'p', 'percentile', 99); + checkParsingSingle('P99', 'p', 'percentile', 99); + checkParsingSingle('p99.99', 'p', 'percentile', 99.99); + checkParsingSingle('tm99', 'tm', 'trimmedMean', 99); + checkParsingSingle('wm99', 'wm', 'winsorizedMean', 99); + checkParsingSingle('tc99', 'tc', 'trimmedCount', 99); + checkParsingSingle('ts99', 'ts', 'trimmedSum', 99); + + // Check all pair statistics + checkParsingPair('TM(10%:90%)', 'TM', 'trimmedMean', true, false, undefined, 10, 90); + checkParsingPair('TM(10.99%:90.99%)', 'TM', 'trimmedMean', true, false, undefined, 10.99, 90.99); + checkParsingPair('WM(10%:90%)', 'WM', 'winsorizedMean', true, false, undefined, 10, 90); + checkParsingPair('TC(10%:90%)', 'TC', 'trimmedCount', true, false, undefined, 10, 90); + checkParsingPair('TS(10%:90%)', 'TS', 'trimmedSum', true, false, undefined, 10, 90); + + // Check can be represented as a single statistic + checkParsingPair('TM(:90%)', 'TM', 'trimmedMean', true, true, 'tm90', undefined, 90); + + // Check every case + checkParsingPair('tm(10%:90%)', 'TM', 'trimmedMean', true, false, undefined, 10, 90); + checkParsingPair('TM(10%:90%)', 'TM', 'trimmedMean', true, false, undefined, 10, 90); + checkParsingPair('TM(:90%)', 'TM', 'trimmedMean', true, true, 'tm90', undefined, 90); + checkParsingPair('TM(10%:)', 'TM', 'trimmedMean', true, false, undefined, 10, undefined); + checkParsingPair('TM(10:1500)', 'TM', 'trimmedMean', false, false, undefined, 10, 1500); + checkParsingPair('TM(10:)', 'TM', 'trimmedMean', false, false, undefined, 10, undefined); + checkParsingPair('TM(:5000)', 'TM', 'trimmedMean', false, false, undefined, undefined, 5000); + checkParsingPair('TM(0.123456789:)', 'TM', 'trimmedMean', false, false, undefined, 0.123456789, undefined); + checkParsingPair('TM(0.123456789:)', 'TM', 'trimmedMean', false, false, undefined, 0.123456789, undefined); + checkParsingPair('TM(:0.123456789)', 'TM', 'trimmedMean', false, false, undefined, undefined, 0.123456789); + checkParsingPair('TM(0.123456789%:)', 'TM', 'trimmedMean', true, false, undefined, 0.123456789, undefined); + checkParsingPair('TM(:0.123456789%)', 'TM', 'trimmedMean', true, true, 'tm0.123456789', undefined, 0.123456789); + checkParsingPair('TM(0.123:0.4543)', 'TM', 'trimmedMean', false, false, undefined, 0.123, 0.4543); + checkParsingPair('TM(0.123%:0.4543%)', 'TM', 'trimmedMean', true, false, undefined, 0.123, 0.4543); + checkParsingPair('TM(0.1000%:0.1000%)', 'TM', 'trimmedMean', true, false, undefined, 0.1, 0.1); + checkParsingPair('TM(0.9999:100.9999)', 'TM', 'trimmedMean', false, false, undefined, 0.9999, 100.9999); + + /* eslint-enable no-multi-spaces */ + + // Check invalid statistics + expect(parseStatistic('p99.99.99').type).toEqual('generic'); + expect(parseStatistic('p200').type).toEqual('generic'); + expect(parseStatistic('pa99').type).toEqual('generic'); + expect(parseStatistic('99').type).toEqual('generic'); + expect(parseStatistic('tm1.').type).toEqual('generic'); + expect(parseStatistic('tm12.').type).toEqual('generic'); + expect(parseStatistic('tm123').type).toEqual('generic'); + expect(parseStatistic('tm123.123456789').type).toEqual('generic'); + expect(parseStatistic('tm.123456789').type).toEqual('generic'); + expect(parseStatistic('TM(10:90%)').type).toEqual('generic'); + expect(parseStatistic('TM(10%:1500)').type).toEqual('generic'); + expect(parseStatistic('TM(10)').type).toEqual('generic'); + expect(parseStatistic('TM()').type).toEqual('generic'); + expect(parseStatistic('TM(0.:)').type).toEqual('generic'); + expect(parseStatistic('TM(:0.)').type).toEqual('generic'); + expect(parseStatistic('()').type).toEqual('generic'); + expect(parseStatistic('(:)').type).toEqual('generic'); + expect(parseStatistic('TM(:)').type).toEqual('generic'); + expect(parseStatistic('TM(').type).toEqual('generic'); + expect(parseStatistic('TM)').type).toEqual('generic'); + expect(parseStatistic('TM(0.123456789%:%)').type).toEqual('generic'); + expect(parseStatistic('TM(0.123:0.4543%)').type).toEqual('generic'); + expect(parseStatistic('TM(0.123%:0.4543)').type).toEqual('generic'); + expect(parseStatistic('TM(1000%:)').type).toEqual('generic'); + expect(parseStatistic('TM(:1000%)').type).toEqual('generic'); + expect(parseStatistic('TM(1000%:1000%)').type).toEqual('generic'); + }); });