diff --git a/core/dashboards/dashboard.ts b/core/dashboards/dashboard.ts index 917dfc74..96814bc3 100644 --- a/core/dashboards/dashboard.ts +++ b/core/dashboards/dashboard.ts @@ -573,7 +573,8 @@ export default function addDashboard (dashboardConfig: SlicWatchInputDashboardCo dimensions: { LoadBalancer: loadBalancerFullName }, - stat + stat, + yAxis: metricConfig.yAxis }) } } @@ -619,7 +620,8 @@ export default function addDashboard (dashboardConfig: SlicWatchInputDashboardCo LoadBalancer: loadBalancerFullName, TargetGroup: targetGroupFullName }, - stat + stat, + yAxis: metricConfig.yAxis }) } } diff --git a/core/dashboards/tests/dashboard-alb.test.ts b/core/dashboards/tests/dashboard-alb.test.ts new file mode 100644 index 00000000..e6af329f --- /dev/null +++ b/core/dashboards/tests/dashboard-alb.test.ts @@ -0,0 +1,182 @@ +import { merge } from 'lodash' +import { test } from 'tap' +import { type MetricWidgetProperties } from 'cloudwatch-dashboard-types' + +import { type ResourceType } from '../../cf-template' +import addDashboard from '../dashboard' +import defaultConfig from '../../inputs/default-config' + +import { albCfTemplate, createTestCloudFormationTemplate, getDashboardFromTemplate, getDashboardWidgetsByTitle } from '../../tests/testing-utils' + +test('dashboard contains configured ALB resources', (t) => { + t.test('dashboards includes ALB metrics', (t) => { + const template = createTestCloudFormationTemplate(albCfTemplate) + addDashboard(defaultConfig.dashboard, template) + const { dashboard } = getDashboardFromTemplate(template) + + const widgets = getDashboardWidgetsByTitle(dashboard, + /ALB/, + /Target Group/ + ) + + const [albWidget, targetGroupWidget] = widgets + t.ok(albWidget) + t.ok(targetGroupWidget) + t.same((albWidget.properties as MetricWidgetProperties).metrics, [ + ['AWS/ApplicationELB', 'HTTPCode_ELB_5XX_Count', 'LoadBalancer', '${alb.LoadBalancerFullName}', { stat: 'Sum', yAxis: 'left' }], + ['AWS/ApplicationELB', 'RejectedConnectionCount', 'LoadBalancer', '${alb.LoadBalancerFullName}', { stat: 'Sum', yAxis: 'left' }] + ]) + t.same((targetGroupWidget.properties as MetricWidgetProperties).metrics, [ + ['AWS/ApplicationELB', 'HTTPCode_Target_5XX_Count', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'Sum', yAxis: 'left' }], + ['AWS/ApplicationELB', 'UnHealthyHostCount', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'Average', yAxis: 'left' }], + ['AWS/ApplicationELB', 'LambdaInternalError', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'Sum', yAxis: 'left' }], + ['AWS/ApplicationELB', 'LambdaUserError', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'Sum', yAxis: 'left' }] + ]) + + t.end() + }) + + t.test('service config overrides take precedence', (t) => { + const template = createTestCloudFormationTemplate(albCfTemplate) + + const config = merge({}, defaultConfig.dashboard, { + widgets: { + ApplicationELB: { + metricPeriod: 900, + width: 8, + height: 12, + HTTPCode_ELB_5XX_Count: { + Statistic: ['Average'], + enabled: false + }, + RejectedConnectionCount: { + Statistic: ['ts90'], + yAxis: 'right' + } + }, + ApplicationELBTarget: { + metricPeriod: 900, + width: 24, + height: 12, + HTTPCode_Target_5XX_Count: { + Statistic: ['ts80'], + yAxis: 'right' + }, + UnHealthyHostCount: { + Statistic: ['Count'], + yAxis: 'right' + }, + LambdaInternalError: { + Statistic: ['P99'], + yAxis: 'left' + }, + LambdaUserError: { + Statistic: ['Sum'], + enabled: false + } + } + } + }) + + addDashboard(config, template) + const { dashboard } = getDashboardFromTemplate(template) + + const widgets = getDashboardWidgetsByTitle(dashboard, + /ALB/, + /Target Group/ + ) + + const [albWidget, targetGroupWidget] = widgets + t.ok(albWidget) + t.ok(targetGroupWidget) + t.same((albWidget.properties as MetricWidgetProperties).metrics, [ + ['AWS/ApplicationELB', 'RejectedConnectionCount', 'LoadBalancer', '${alb.LoadBalancerFullName}', { stat: 'ts90', yAxis: 'right' }] + ]) + t.same((targetGroupWidget.properties as MetricWidgetProperties).metrics, [ + ['AWS/ApplicationELB', 'HTTPCode_Target_5XX_Count', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'ts80', yAxis: 'right' }], + ['AWS/ApplicationELB', 'UnHealthyHostCount', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'Count', yAxis: 'right' }], + ['AWS/ApplicationELB', 'LambdaInternalError', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'P99', yAxis: 'left' }] + ]) + + for (const widget of widgets) { + const props: MetricWidgetProperties = widget.properties as MetricWidgetProperties + t.ok((props.metrics?.length ?? 0) > 0) + t.equal(props.period, 900) + t.equal(props.view, 'timeSeries') + } + + t.end() + }) + + t.test('resource config overrides take precedence', (t) => { + const template = createTestCloudFormationTemplate(albCfTemplate) + const resources = template.Resources as ResourceType + resources.alb.Metadata = { + slicWatch: { + dashboard: { + HTTPCode_ELB_5XX_Count: { + Statistic: ['Average'], + enabled: false + }, + RejectedConnectionCount: { + Statistic: ['ts90'], + yAxis: 'right' + } + } + } + } + + resources.AlbEventAlbTargetGrouphttpListener.Metadata = { + slicWatch: { + dashboard: { + HTTPCode_Target_5XX_Count: { + Statistic: ['ts99'], + yAxis: 'right' + }, + UnHealthyHostCount: { + Statistic: ['Count'], + yAxis: 'right', + enabled: false + }, + LambdaInternalError: { + Statistic: ['P99'], + yAxis: 'left' + }, + LambdaUserError: { + Statistic: ['Sum'], + enabled: false + } + } + } + } + addDashboard(defaultConfig.dashboard, template) + const { dashboard } = getDashboardFromTemplate(template) + + const widgets = getDashboardWidgetsByTitle(dashboard, + /ALB/, + /Target Group/ + ) + + const [albWidget, targetGroupWidget] = widgets + t.ok(albWidget) + t.ok(targetGroupWidget) + t.same((albWidget.properties as MetricWidgetProperties).metrics, [ + ['AWS/ApplicationELB', 'RejectedConnectionCount', 'LoadBalancer', '${alb.LoadBalancerFullName}', { stat: 'ts90', yAxis: 'right' }] + ]) + t.same((targetGroupWidget.properties as MetricWidgetProperties).metrics, [ + ['AWS/ApplicationELB', 'HTTPCode_Target_5XX_Count', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'ts99', yAxis: 'right' }], + ['AWS/ApplicationELB', 'LambdaInternalError', 'LoadBalancer', '${alb.LoadBalancerFullName}', 'TargetGroup', '${AlbEventAlbTargetGrouphttpListener.TargetGroupFullName}', { stat: 'P99', yAxis: 'left' }] + ]) + + for (const widget of widgets) { + const props: MetricWidgetProperties = widget.properties as MetricWidgetProperties + t.ok((props.metrics?.length ?? 0) > 0) + t.equal(props.period, 300) + t.equal(props.view, 'timeSeries') + } + + t.end() + }) + + t.end() +}) diff --git a/core/dashboards/tests/dashboard-dynamodb.test.ts b/core/dashboards/tests/dashboard-dynamodb.test.ts index 7398dcec..f2829c97 100644 --- a/core/dashboards/tests/dashboard-dynamodb.test.ts +++ b/core/dashboards/tests/dashboard-dynamodb.test.ts @@ -1,6 +1,7 @@ import { merge } from 'lodash' import { test } from 'tap' import { type MetricWidgetProperties } from 'cloudwatch-dashboard-types' +import { type DynamoDB } from 'cloudform' import { type ResourceType } from '../../cf-template' import addDashboard from '../dashboard' @@ -51,6 +52,29 @@ test('dashboard contains configured DynamoDB resources', (t) => { t.end() }) + t.test('dashboards includes DynamoDB metrics without GSIs', (t) => { + const template = createTestCloudFormationTemplate() + delete ((template.Resources as ResourceType).dataTable as DynamoDB.Table).Properties.GlobalSecondaryIndexes + addDashboard(defaultConfig.dashboard, template) + const { dashboard } = getDashboardFromTemplate(template) + + const widgets = getDashboardWidgetsByTitle(dashboard, + /ReadThrottleEvents Table /, + /ReadThrottleEvents GSI GSI1 in /, + /WriteThrottleEvents Table /, + /WriteThrottleEvents GSI GSI1 in / + ) + + const [ + readThrottlesWidget, readThrottlesGsiWidget, writeThrottlesWidget, writeThrottlesGsiWidget + ] = widgets + t.ok(readThrottlesWidget) + t.notOk(readThrottlesGsiWidget) + t.ok(writeThrottlesWidget) + t.notOk(writeThrottlesGsiWidget) + t.end() + }) + t.test('service config overrides take precedence', (t) => { const template = createTestCloudFormationTemplate() diff --git a/core/dashboards/tests/dashboard.test.ts b/core/dashboards/tests/dashboard.test.ts index c7ef46a1..938f4a47 100644 --- a/core/dashboards/tests/dashboard.test.ts +++ b/core/dashboards/tests/dashboard.test.ts @@ -1,12 +1,10 @@ - import _ from 'lodash' import { test } from 'tap' -import { type MetricWidgetProperties } from 'cloudwatch-dashboard-types' import addDashboard from '../dashboard' import defaultConfig from '../../inputs/default-config' -import { createTestCloudFormationTemplate, defaultCfTemplate, albCfTemplate, appSyncCfTemplate, getDashboardFromTemplate } from '../../tests/testing-utils' +import { createTestCloudFormationTemplate, getDashboardFromTemplate } from '../../tests/testing-utils' import { getResourcesByType } from '../../cf-template' import { type Widgets } from '../dashboard-types' @@ -33,210 +31,6 @@ test('A dashboard includes metrics', (t) => { t.end() }) -test('A dashboard includes metrics for ALB', (t) => { - const compiledTemplate = createTestCloudFormationTemplate((albCfTemplate)) - addDashboard(defaultConfig.dashboard, compiledTemplate) - const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) - t.equal(Object.keys(dashResources).length, 1) - const [, dashResource] = Object.entries(dashResources)[0] - t.same(dashResource.Properties?.DashboardName, { 'Fn::Sub': '${AWS::StackName}-${AWS::Region}-Dashboard' }) - const dashboard = JSON.parse(dashResource.Properties?.DashboardBody['Fn::Sub']) - - t.ok(dashboard.start) - - t.test('dashboard includes Application Load Balancer metrics', (t) => { - const widgets = dashboard.widgets.filter(({ properties: { title } }) => - title.startsWith('ALB') - ) - t.equal(widgets.length, 1) - const namespaces = new Set() - for (const widget of widgets) { - for (const metric of widget.properties.metrics) { - namespaces.add(metric[0]) - } - } - t.same(namespaces, new Set(['AWS/ApplicationELB'])) - const expectedTitles = new Set(['ALB ${alb.LoadBalancerName}']) - - const actualTitles = new Set( - widgets.map((widget) => widget.properties.title) - ) - t.same(actualTitles, expectedTitles) - t.end() - }) - - t.test('dashboard includes Application Load Balancer Target Groups metrics', (t) => { - const widgets = dashboard.widgets.filter(({ properties: { title } }) => - title.startsWith('Target') - ) - t.equal(widgets.length, 1) - const namespaces = new Set() - const metricNames: string[] = [] - for (const metric of widgets[0].properties.metrics) { - namespaces.add(metric[0]) - metricNames.push(metric[1]) - } - t.same(namespaces, new Set(['AWS/ApplicationELB'])) - t.same(metricNames.sort(), ['HTTPCode_Target_5XX_Count', 'UnHealthyHostCount', 'LambdaInternalError', 'LambdaUserError'].sort()) - const expectedTitles = new Set(['Target Group ${alb.LoadBalancerName}/${AlbEventAlbTargetGrouphttpListener.TargetGroupName}']) - - const actualTitles = new Set( - widgets.map((widget) => widget.properties.title) - ) - t.same(actualTitles, expectedTitles) - t.end() - - test('No widgets are created if all AppSync metrics are disabled', (t) => { - const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] - const dashConfig = _.cloneDeep(defaultConfig.dashboard) - for (const service of services) { - (dashConfig.widgets as Widgets)[service].enabled = false - } - const compiledTemplate = createTestCloudFormationTemplate((appSyncCfTemplate)) - addDashboard(dashConfig, compiledTemplate) - const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) - t.same(dashResources, {}) - t.end() - }) - - test('No widgets are created if all Application Load Balancer metrics are disabled', (t) => { - const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] - const dashConfig = _.cloneDeep(defaultConfig.dashboard) - for (const service of services) { - (dashConfig.widgets as Widgets)[service].enabled = false - } - const compiledTemplate = createTestCloudFormationTemplate((albCfTemplate)) - addDashboard(dashConfig, compiledTemplate) - const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) - t.same(dashResources, {}) - t.end() - }) - }) - - t.test('target groups with no Lambda targets are excluded from metrics', (t) => { - const compiledTemplate = createTestCloudFormationTemplate({ - Resources: { - listener: { - Type: 'AWS::ElasticLoadBalancingV2::Listener', - Properties: { - DefaultActions: [ - { - TargetGroupArn: { Ref: 'tg' } - } - ], - LoadBalancerArn: { Ref: 'alb' } - } - }, - tg: { - Type: 'AWS::ElasticLoadBalancingV2::TargetGroup', - Properties: { - TargetType: 'redirect' - } - }, - alb: { - Type: '' - } - } - }) - addDashboard(defaultConfig.dashboard, compiledTemplate) - const tgDashResource = Object.values(getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate))[0] - const tgDashBody = JSON.parse(tgDashResource.Properties?.DashboardBody['Fn::Sub']) - - const widgets = tgDashBody.widgets.filter(({ properties: { title } }) => - title.startsWith('Target') - ) - t.equal(widgets.length, 1) - const metricNames: string[] = [] - for (const metric of widgets[0].properties.metrics) { - metricNames.push(metric[1]) - } - t.same(metricNames.sort(), ['HTTPCode_Target_5XX_Count', 'UnHealthyHostCount'].sort()) - t.end() - }) - - test('A dashboard includes metrics for AppSync', (t) => { - const compiledTemplate = createTestCloudFormationTemplate((appSyncCfTemplate)) - addDashboard(defaultConfig.dashboard, compiledTemplate) - const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) - t.equal(Object.keys(dashResources).length, 1) - const [, dashResource] = Object.entries(dashResources)[0] - t.same(dashResource.Properties?.DashboardName, { 'Fn::Sub': '${AWS::StackName}-${AWS::Region}-Dashboard' }) - const dashboard = JSON.parse(dashResource.Properties?.DashboardBody['Fn::Sub']) - - t.ok(dashboard.start) - - t.test('dashboard includes AppSync metrics', (t) => { - const widgets = dashboard.widgets.filter(({ properties: { title } }) => - title.startsWith('AppSync') - ) - t.equal(widgets.length, 2) - const namespaces = new Set() - for (const widget of widgets) { - for (const metric of widget.properties.metrics) { - namespaces.add(metric[0]) - } - } - t.same(namespaces, new Set(['AWS/AppSync'])) - const expectedTitles = new Set([ - 'AppSync API awesome-appsync', - 'AppSync Real-time Subscriptions awesome-appsync' - ]) - const actualTitles = new Set( - widgets.map((widget) => widget.properties.title) - ) - t.same(actualTitles, expectedTitles) - t.end() - }) - - test('No widgets are created if all AppSync metrics are disabled', (t) => { - const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] - const dashConfig = _.cloneDeep(defaultConfig.dashboard) - for (const service of services) { - (dashConfig.widgets as Widgets)[service].enabled = false - } - const compiledTemplate = createTestCloudFormationTemplate((appSyncCfTemplate)) - addDashboard(dashConfig, compiledTemplate) - const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) - t.same(dashResources, {}) - t.end() - }) - - t.end() - }) - - t.end() -}) - -test('DynamoDB widgets are created without GSIs', (t) => { - const tableResource: any = _.cloneDeep(defaultCfTemplate.Resources?.dataTable) - delete tableResource?.Properties?.GlobalSecondaryIndexes - const compTemplates = { - Resources: { - dataTable: tableResource - } - } - - const compiledTemplate = createTestCloudFormationTemplate(compTemplates) - addDashboard(defaultConfig.dashboard, compiledTemplate) - - const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) - const [, dashResource] = Object.entries(dashResources)[0] - const dashboard = JSON.parse(dashResource.Properties?.DashboardBody['Fn::Sub']) - const widgets = dashboard.widgets - - t.equal(widgets.length, 2) - const expectedTitles = new Set([ - 'ReadThrottleEvents Table ${dataTable}', - 'WriteThrottleEvents Table ${dataTable}' - ]) - - const actualTitles = new Set( - widgets.map((widget) => widget.properties.title) - ) - t.same(actualTitles, expectedTitles) - t.end() -}) - test('No dashboard is created if all widgets are disabled', (t) => { const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] const dashConfig = _.cloneDeep(defaultConfig.dashboard)