Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions lib/cloudfront-monitoring-stack.ts
Original file line number Diff line number Diff line change
@@ -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<string, cloudwatch.Metric> = {};

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
})
]
]
});
}
}
5 changes: 5 additions & 0 deletions lib/cloudfront_cdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
});
}
}
124 changes: 114 additions & 10 deletions test/cloudfront_cdn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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');
});
});
});