From 6d669e57ba6c8e01d33f9e134ca2c8a28426849d Mon Sep 17 00:00:00 2001 From: Cezar Rata Date: Thu, 24 Jul 2025 17:13:44 +0000 Subject: [PATCH] feat: add cloudwatch metrics, alarm, and dashboard to CloudFront CDN Signed-off-by: Cezar Rata --- lib/cloudfront-monitoring-stack.ts | 174 +++++++++++++++++++++++++++++ lib/cloudfront_cdn.ts | 5 + test/cloudfront_cdn.test.ts | 124 ++++++++++++++++++-- 3 files changed, 293 insertions(+), 10 deletions(-) create mode 100644 lib/cloudfront-monitoring-stack.ts diff --git a/lib/cloudfront-monitoring-stack.ts b/lib/cloudfront-monitoring-stack.ts new file mode 100644 index 00000000..ad925819 --- /dev/null +++ b/lib/cloudfront-monitoring-stack.ts @@ -0,0 +1,174 @@ +import * as cdk from 'aws-cdk-lib'; +import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; +import { Construct } from 'constructs'; + +const DEFAULT_METRIC_PERIOD: cdk.Duration = cdk.Duration.minutes(5); + +interface CloudfrontMonitoringStackProps { + distributionId: string; +} + +export class CloudfrontMonitoringStack extends Construct { + private metrics: Record = {}; + + constructor(scope: Construct, id: string, props: CloudfrontMonitoringStackProps) { + super(scope, id); + + this.createMetrics(props); + + this.createAlarms(props); + + this.createDashboard(props, id); + } + + createMetrics(props: CloudfrontMonitoringStackProps) { + const distributionId = props.distributionId; + + // Request metrics + this.metrics.requests = new cloudwatch.Metric({ + namespace: 'AWS/CloudFront', + metricName: 'Requests', + dimensionsMap: { DistributionId: distributionId }, + statistic: 'Sum', + period: DEFAULT_METRIC_PERIOD + }); + + // Data transfer metrics + this.metrics.bytesDownloaded = new cloudwatch.Metric({ + namespace: 'AWS/CloudFront', + metricName: 'BytesDownloaded', + dimensionsMap: { DistributionId: distributionId }, + statistic: 'Sum', + period: DEFAULT_METRIC_PERIOD + }); + + this.metrics.bytesUploaded = new cloudwatch.Metric({ + namespace: 'AWS/CloudFront', + metricName: 'BytesUploaded', + dimensionsMap: { DistributionId: distributionId }, + statistic: 'Sum', + period: DEFAULT_METRIC_PERIOD + }); + + // Error rate metrics + this.metrics.errorRate4xx = new cloudwatch.Metric({ + namespace: 'AWS/CloudFront', + metricName: '4xxErrorRate', + dimensionsMap: { DistributionId: distributionId }, + statistic: 'Average', + period: DEFAULT_METRIC_PERIOD + }); + + this.metrics.errorRate5xx = new cloudwatch.Metric({ + namespace: 'AWS/CloudFront', + metricName: '5xxErrorRate', + dimensionsMap: { DistributionId: distributionId }, + statistic: 'Average', + period: DEFAULT_METRIC_PERIOD + }); + + this.metrics.totalErrorRate = new cloudwatch.Metric({ + namespace: 'AWS/CloudFront', + metricName: 'TotalErrorRate', + dimensionsMap: { DistributionId: distributionId }, + statistic: 'Average', + period: DEFAULT_METRIC_PERIOD + }); + + // Performance metrics + this.metrics.originLatency = new cloudwatch.Metric({ + namespace: 'AWS/CloudFront', + metricName: 'OriginLatency', + dimensionsMap: { DistributionId: distributionId }, + statistic: 'Average', + period: DEFAULT_METRIC_PERIOD + }); + + // Cache metrics + this.metrics.cacheHitRate = new cloudwatch.Metric({ + namespace: 'AWS/CloudFront', + metricName: 'CacheHitRate', + dimensionsMap: { DistributionId: distributionId }, + statistic: 'Average', + period: DEFAULT_METRIC_PERIOD + }); + } + + createAlarms(props: CloudfrontMonitoringStackProps) { + // High Error Rate Alarm + new cloudwatch.Alarm(this, 'HighErrorRateAlarm', { + alarmName: `CloudFront-${props.distributionId}-HighErrorRate`, + alarmDescription: 'CloudFront distribution has high error rate', + metric: this.metrics.totalErrorRate, + threshold: 5, // 5% error rate + evaluationPeriods: 2, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD + }); + + // High Origin Latency Alarm + new cloudwatch.Alarm(this, 'HighOriginLatencyAlarm', { + alarmName: `CloudFront-${props.distributionId}-HighLatency`, + alarmDescription: 'CloudFront distribution has high origin latency', + metric: this.metrics.originLatency, + threshold: 3000, // 3 seconds + evaluationPeriods: 3, + treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD + }); + } + + createDashboard(props: CloudfrontMonitoringStackProps, id: string) { + new cloudwatch.Dashboard(this, 'CloudFrontDashboard', { + dashboardName: `CloudFront-${props.distributionId}-Dashboard`, + widgets: [ + [ + // Request metrics + new cloudwatch.GraphWidget({ + title: 'Requests', + left: [this.metrics.requests], + width: 12, + height: 6 + }), + // Cache hit rate + new cloudwatch.GraphWidget({ + title: 'Cache Hit Rate (%)', + left: [this.metrics.cacheHitRate], + width: 12, + height: 6 + }) + ], + [ + // Error rates + new cloudwatch.GraphWidget({ + title: 'Error Rates (%)', + left: [ + this.metrics.errorRate4xx, + this.metrics.errorRate5xx, + this.metrics.totalErrorRate + ], + width: 12, + height: 6 + }), + // Origin latency + new cloudwatch.GraphWidget({ + title: 'Origin Latency (ms)', + left: [this.metrics.originLatency], + width: 12, + height: 6 + }) + ], + [ + // Data transfer + new cloudwatch.GraphWidget({ + title: 'Data Transfer (Bytes)', + left: [this.metrics.bytesDownloaded], + right: [this.metrics.bytesUploaded], + width: 24, + height: 6 + }) + ] + ] + }); + } +} \ No newline at end of file diff --git a/lib/cloudfront_cdn.ts b/lib/cloudfront_cdn.ts index 646e4ff3..a95f6c1e 100644 --- a/lib/cloudfront_cdn.ts +++ b/lib/cloudfront_cdn.ts @@ -4,6 +4,7 @@ import * as cloudfront_origins from 'aws-cdk-lib/aws-cloudfront-origins'; import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; import { CfnOutput, Stack } from 'aws-cdk-lib'; +import { CloudfrontMonitoringStack } from './cloudfront-monitoring-stack'; interface CloudfrontCdnProps { bucket: s3.Bucket; @@ -42,5 +43,9 @@ export class CloudfrontCdn extends Construct { this.urlOutput = new CfnOutput(this, 'Distribution Domain', { value: distribution.domainName }); + + new CloudfrontMonitoringStack(this, `${id}-Monitoring`, { + distributionId: distribution.distributionId + }); } } diff --git a/test/cloudfront_cdn.test.ts b/test/cloudfront_cdn.test.ts index eedd57ad..9c8753a3 100644 --- a/test/cloudfront_cdn.test.ts +++ b/test/cloudfront_cdn.test.ts @@ -5,24 +5,27 @@ import { CfnBucket } from 'aws-cdk-lib/aws-s3'; import { CloudfrontCdn } from '../lib/cloudfront_cdn'; describe('CloudfrontCdn', () => { - test('synthesizes the way we expect', () => { - const app = new cdk.App(); + let app: cdk.App; + let cloudfrontCdnStack: cdk.Stack; + let bucket: s3.Bucket; + let cloudfrontCdn: CloudfrontCdn; + let template: Template; - // create a stack for CloudfrontCdn to live in - const cloudfrontCdnStack = new cdk.Stack(app, 'CloudfrontCdnStack'); - const bucket = new s3.Bucket(cloudfrontCdnStack, 'TestBucket', { + beforeEach(() => { + app = new cdk.App(); + cloudfrontCdnStack = new cdk.Stack(app, 'CloudfrontCdnStack'); + bucket = new s3.Bucket(cloudfrontCdnStack, 'TestBucket', { bucketName: 'test-bucket', publicReadAccess: false, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL }); - - // create the CloudfrontCdn stack for assertions - const cloudfrontCdn = new CloudfrontCdn(cloudfrontCdnStack, 'CloudfrontCdn', { + cloudfrontCdn = new CloudfrontCdn(cloudfrontCdnStack, 'CloudfrontCdn', { bucket: bucket }); + template = Template.fromStack(cloudfrontCdnStack); + }); - const template = Template.fromStack(cloudfrontCdnStack); - + test('synthesizes the way we expect', () => { // assert it creates the s3 bucket template.resourceCountIs('AWS::S3::Bucket', 1); template.hasResource('AWS::S3::Bucket', { @@ -110,4 +113,105 @@ describe('CloudfrontCdn', () => { } }); }); + + describe('CloudWatch Monitoring', () => { + test('creates CloudWatch alarms for error rate and latency', () => { + // The monitoring resources are created in the same stack, not nested + template.resourceCountIs('AWS::CloudWatch::Alarm', 2); + + // Assert High Error Rate Alarm + template.hasResourceProperties('AWS::CloudWatch::Alarm', { + AlarmDescription: 'CloudFront distribution has high error rate', + ComparisonOperator: 'GreaterThanThreshold', + EvaluationPeriods: 2, + MetricName: 'TotalErrorRate', + Namespace: 'AWS/CloudFront', + Statistic: 'Average', + Threshold: 5, + TreatMissingData: 'notBreaching' + }); + + // Assert High Origin Latency Alarm + template.hasResourceProperties('AWS::CloudWatch::Alarm', { + AlarmDescription: 'CloudFront distribution has high origin latency', + ComparisonOperator: 'GreaterThanThreshold', + EvaluationPeriods: 3, + MetricName: 'OriginLatency', + Namespace: 'AWS/CloudFront', + Statistic: 'Average', + Threshold: 3000, + TreatMissingData: 'notBreaching' + }); + }); + + test('creates CloudWatch dashboard with all metrics', () => { + // Assert that 1 CloudWatch dashboard is created + template.resourceCountIs('AWS::CloudWatch::Dashboard', 1); + + // Just verify the dashboard exists with the right name pattern + template.hasResourceProperties('AWS::CloudWatch::Dashboard', { + DashboardName: { + 'Fn::Join': [ + '', + [ + 'CloudFront-', + { + Ref: Match.anyValue() + }, + '-Dashboard' + ] + ] + } + }); + + // Verify the dashboard body contains widgets (it's a complex Fn::Join structure) + template.hasResourceProperties('AWS::CloudWatch::Dashboard', { + DashboardBody: { + 'Fn::Join': [ + '', + Match.arrayWith([ + Match.stringLikeRegexp('.*widgets.*') + ]) + ] + } + }); + }); + + test('alarm names include distribution ID', () => { + // Get all alarm resources and check their names + const alarms = template.findResources('AWS::CloudWatch::Alarm'); + + expect(Object.keys(alarms)).toHaveLength(2); + + Object.values(alarms).forEach((alarm: any) => { + expect(alarm.Properties.AlarmName).toEqual({ + 'Fn::Join': [ + '', + [ + 'CloudFront-', + expect.any(Object), // This will be the distribution ID reference + expect.any(String) // This will be the alarm suffix like '-HighErrorRate' + ] + ] + }); + }); + }); + + test('dashboard contains all expected metric types', () => { + const dashboards = template.findResources('AWS::CloudWatch::Dashboard'); + const dashboardBody = Object.values(dashboards)[0] as any; + + // The dashboard body is a complex Fn::Join - just verify it contains key metric names + const bodyString = JSON.stringify(dashboardBody.Properties.DashboardBody); + + expect(bodyString).toContain('Requests'); + expect(bodyString).toContain('CacheHitRate'); + expect(bodyString).toContain('4xxErrorRate'); + expect(bodyString).toContain('5xxErrorRate'); + expect(bodyString).toContain('TotalErrorRate'); + expect(bodyString).toContain('OriginLatency'); + expect(bodyString).toContain('BytesDownloaded'); + expect(bodyString).toContain('BytesUploaded'); + }); + }); });