Building Centralized SES Email Event Logging with CloudWatch

September 25, 2025Lev Gelfenbuim7 min. read

Email delivery monitoring is crucial for any application that relies on transactional emails, marketing campaigns, or system notifications. When your business depends on reliable email communication, you need visibility into delivery success rates, bounce patterns, and complaint handling. Amazon Simple Email Service (SES) provides excellent delivery capabilities, but its event data needs to be captured and centralized for effective monitoring.
In this guide, I'll show you how to create a robust email event logging system that captures all SES events (deliveries, bounces, complaints, etc.) and stores them in Amazon CloudWatch for analysis and alerting. This serverless solution automatically processes email events in real-time without requiring manual intervention.

📂 Complete Project: You can find the complete, ready-to-deploy code for this implementation on GitHub: https://github.com/levz0r/ses-cloudwatch-logging
Why Centralized Email Event Logging Matters
The Challenge of Email Deliverability

Email deliverability is more complex than it appears. Messages can bounce due to invalid addresses, get rejected by spam filters, or trigger complaints from recipients. Without proper monitoring, you're flying blind when email delivery issues occur, potentially missing critical business communications or damaging your sender reputation.
SES provides comprehensive event tracking, but the data is distributed across SNS notifications and configuration sets. This makes it difficult to get a unified view of your email performance across different campaigns, templates, and time periods.

Real-World Benefits

Implementing centralized logging provides immediate operational benefits. You gain visibility into delivery patterns that help identify problematic email addresses before they impact your sender reputation. Bounce tracking allows you to automatically clean your contact lists, while complaint monitoring helps you identify content or frequency issues that might be annoying recipients.
Perhaps most importantly, centralized logs enable proactive alerting. Instead of discovering delivery issues from customer complaints, you can set up CloudWatch alarms that notify you immediately when bounce rates spike or complaints exceed normal thresholds.

Architecture Overview
System Components
  1. Amazon SES: Email sending service with event publishing
  2. Amazon SNS: Event notification routing and fan-out
  3. AWS Lambda: Serverless event processing function
  4. Amazon CloudWatch Logs: Centralized log storage and analysis
  5. Sentry: Error tracking and monitoring for the processing pipeline
The Event Flow
  1. SES sends email and publishes events to SNS topic
  2. SNS triggers Lambda function with event data
  3. Lambda processes and structures the event information
  4. Structured event data is written to CloudWatch Logs
  5. CloudWatch Logs enables searching, filtering, and alerting
Setting Up SNS Topic and SES Configuration
Creating the SNS Topic

First, we need an SNS topic to receive SES events. This topic will fan out events to our Lambda function:

1# Create SNS topic for SES events
2aws sns create-topic \
3  --name ses-events-topic-dev \
4  --region us-east-1
5
6# Get the topic ARN for later use
7aws sns get-topic-attributes \
8  --topic-arn arn:aws:sns:us-east-1:ACCOUNT:ses-events-topic-dev
Configuring SES Event Publishing

SES needs to be configured to publish events to our SNS topic. This can be done through configuration sets:

1# Create SES configuration set
2aws ses create-configuration-set \
3  --configuration-set Name=email-tracking-dev
4
5# Create event destination for bounces and complaints
6aws ses create-configuration-set-event-destination \
7  --configuration-set-name email-tracking-dev \
8  --event-destination Name=cloudwatch-logging \
9    Enabled=true \
10    MatchingEventTypes=send,reject,bounce,complaint,delivery,renderingFailure \
11    SNSDestination={TopicARN=arn:aws:sns:us-east-1:ACCOUNT:ses-events-topic-dev}
Lambda Function Implementation
Getting Started

You can clone the complete project and deploy it immediately:

1git clone https://github.com/levz0r/ses-cloudwatch-logging.git
2cd ses-cloudwatch-logging
3npm install
4npm run deploy:dev
Core Event Processing Logic

The Lambda function processes SNS messages containing SES events and structures them for CloudWatch logging:

1// handler.js
2const Sentry = require("@sentry/serverless");
3const {
4  CloudWatchLogsClient,
5  CreateLogGroupCommand,
6  CreateLogStreamCommand,
7  PutLogEventsCommand,
8  DescribeLogStreamsCommand,
9} = require("@aws-sdk/client-cloudwatch-logs");
10
11// Initialize Sentry for error tracking
12Sentry.AWSLambda.init({
13  dsn: process.env.SENTRY_DSN,
14  environment: process.env.NODE_ENV,
15  tracePropagationTargets: [],
16});
17
18// CloudWatch Logs client
19const cloudwatchLogs = new CloudWatchLogsClient({
20  region: process.env.AWS_REGION,
21});
22
23const LOG_GROUP_NAME = process.env.LOG_GROUP_NAME;
24const LOG_STREAM_NAME = "ses-email-events-stream";
Event Processing and Structuring

The function processes different types of SES events and extracts relevant information:

1function processSESEvent(snsMessage) {
2  const processedEvent = {
3    timestamp: new Date().toISOString(),
4    messageId: snsMessage.mail?.messageId || "unknown",
5    eventType: snsMessage.eventType || "unknown",
6    recipient: snsMessage.mail?.destination?.[0] || "unknown",
7    source: snsMessage.mail?.source || "unknown",
8    subject: snsMessage.mail?.commonHeaders?.subject || "unknown",
9    rawEvent: snsMessage,
10  };
11
12  // Add event-specific details
13  switch (snsMessage.eventType.toLowerCase()) {
14    case "bounce":
15      const bounce = snsMessage.bounce || {};
16      processedEvent.bounceType = bounce.bounceType || "unknown";
17      processedEvent.bounceSubType = bounce.bounceSubType || "unknown";
18      processedEvent.bouncedRecipients = bounce.bouncedRecipients || [];
19      break;
20
21    case "complaint":
22      const complaint = snsMessage.complaint || {};
23      processedEvent.complaintFeedbackType =
24        complaint.complaintFeedbackType || "unknown";
25      processedEvent.complainedRecipients =
26        complaint.complainedRecipients || [];
27      break;
28
29    case "delivery":
30      const delivery = snsMessage.delivery || {};
31      processedEvent.processingTimeMillis = delivery.processingTimeMillis || 0;
32      processedEvent.smtpResponse = delivery.smtpResponse || "unknown";
33      break;
34  }
35
36  return processedEvent;
37}
CloudWatch Logs Integration

Writing events to CloudWatch requires handling sequence tokens and potential race conditions:

1/**
2 * Get the sequence token for the log stream
3 */
4async function getSequenceToken() {
5  try {
6    const response = await cloudwatchLogs.send(
7      new DescribeLogStreamsCommand({
8        logGroupName: LOG_GROUP_NAME,
9        logStreamNamePrefix: LOG_STREAM_NAME,
10      })
11    );
12
13    if (response.logStreams && response.logStreams.length > 0) {
14      return response.logStreams[0].uploadSequenceToken;
15    }
16    return null;
17  } catch (error) {
18    console.error(`Error getting sequence token: ${error.message}`);
19    return null;
20  }
21}
22
23async function writeToCloudWatch(message) {
24  try {
25    const sequenceToken = await getSequenceToken();
26
27    const logEvent = {
28      timestamp: Date.now(),
29      message: JSON.stringify(message),
30    };
31
32    const putLogEventsParams = {
33      logGroupName: LOG_GROUP_NAME,
34      logStreamName: LOG_STREAM_NAME,
35      logEvents: [logEvent],
36    };
37
38    if (sequenceToken) {
39      putLogEventsParams.sequenceToken = sequenceToken;
40    }
41
42    const response = await cloudwatchLogs.send(
43      new PutLogEventsCommand(putLogEventsParams)
44    );
45    console.log("Successfully wrote event to CloudWatch Logs");
46    return true;
47  } catch (error) {
48    console.error(`Error writing to CloudWatch Logs: ${error.message}`);
49
50    // Handle sequence token conflicts with retry logic
51    if (error.name === "InvalidSequenceTokenException") {
52      try {
53        const match = error.message.match(
54          /The next expected sequenceToken is: (\S+)/
55        );
56        const retryParams = { ...putLogEventsParams };
57        if (match) {
58          retryParams.sequenceToken = match[1];
59        }
60
61        await cloudwatchLogs.send(new PutLogEventsCommand(retryParams));
62        console.log("Successfully wrote event to CloudWatch Logs (retry)");
63        return true;
64      } catch (retryError) {
65        console.error(`Retry failed: ${retryError.message}`);
66        return false;
67      }
68    }
69    return false;
70  }
71}
Serverless Configuration
AWS Lambda Function Setup

The Serverless Framework configuration defines the Lambda function with proper IAM permissions:

1# serverless.yml
2functions:
3  sesEvents:
4    handler: handler.sesEventsHandler
5    memorySize: 256
6    timeout: 30
7    iamRoleStatementsName: ses-events-cloudwatch-${sls:stage}
8    environment:
9      LOG_GROUP_NAME: "/aws/ses/email-events-${sls:stage}"
10    iamRoleStatements:
11      - Effect: "Allow"
12        Action:
13          - logs:CreateLogGroup
14        Resource:
15          - "arn:aws:logs:${self:provider.region}:ACCOUNT:log-group:/aws/ses/email-events-${sls:stage}"
16      - Effect: "Allow"
17        Action:
18          - logs:CreateLogStream
19          - logs:PutLogEvents
20          - logs:DescribeLogGroups
21          - logs:DescribeLogStreams
22        Resource:
23          - "arn:aws:logs:${self:provider.region}:ACCOUNT:log-group:/aws/ses/email-events-${sls:stage}"
24          - "arn:aws:logs:${self:provider.region}:ACCOUNT:log-group:/aws/ses/email-events-${sls:stage}:*"
25    events:
26      - sns:
27          arn: arn:aws:sns:${self:provider.region}:ACCOUNT:ses-events-topic-${sls:stage}
28          topicName: ses-events-topic-${sls:stage}
IAM Permissions Strategy

The function requires specific CloudWatch Logs permissions to create and write to log groups and streams. The permissions are scoped to specific log group patterns to follow the principle of least privilege while allowing the function to manage its own logging infrastructure.

Error Handling and Reliability
Robust Error Management

Email event processing must be reliable since missing events can impact business operations. The implementation includes comprehensive error handling:

1exports.handler = Sentry.AWSLambda.wrapHandler(async (event, context) => {
2  console.log("Received event:", JSON.stringify(event, null, 2));
3
4  // Ensure log infrastructure exists
5  await ensureLogGroupExists();
6
7  try {
8    for (const record of event.Records) {
9      if (record.EventSource === "aws:sns") {
10        let snsMessage;
11        try {
12          snsMessage = JSON.parse(record.Sns.Message);
13        } catch (parseError) {
14          console.error("Failed to parse SNS message:", parseError.message);
15          continue; // Skip malformed records
16        }
17
18        if (snsMessage.eventType || snsMessage.notificationType) {
19          const processedEvent = processSESEvent(snsMessage);
20
21          if (await writeToCloudWatch(processedEvent)) {
22            console.log(
23              `Processed ${snsMessage.eventType} event for ${processedEvent.recipient}`
24            );
25          } else {
26            console.error("Failed to write event to CloudWatch");
27          }
28        }
29      }
30    }
31  } catch (error) {
32    console.error(`Error processing event: ${error.message}`);
33    throw error;
34  }
35
36  return {
37    statusCode: 200,
38    body: JSON.stringify("Events processed successfully"),
39  };
40});
Log Group Management

The function automatically creates log groups and streams if they don't exist, handling access permission edge cases:

1async function ensureLogGroupExists() {
2  try {
3    await cloudwatchLogs.send(
4      new CreateLogGroupCommand({
5        logGroupName: LOG_GROUP_NAME,
6      })
7    );
8    console.log(`Created log group: ${LOG_GROUP_NAME}`);
9  } catch (error) {
10    if (error.name === "ResourceAlreadyExistsException") {
11      console.log(`Log group already exists: ${LOG_GROUP_NAME}`);
12    } else if (error.name === "AccessDeniedException") {
13      console.log(
14        `Cannot create log group (access denied), assuming it exists: ${LOG_GROUP_NAME}`
15      );
16    } else {
17      throw error;
18    }
19  }
20}
Deployment and Testing
Deploying the Solution

Deploy the Lambda function and related infrastructure using the Serverless Framework:

1# Deploy to development environment
2serverless deploy --stage dev
3
4# Deploy to production environment
5serverless deploy --stage prod
Testing Email Event Flow

Verify the system works by sending test emails and monitoring CloudWatch Logs:

1# Send test email using AWS CLI
2aws ses send-email \
3  --source "sender@yourdomain.com" \
4  --destination ToAddresses="test@example.com" \
5  --message Subject={Data="Test Email"},Body={Text={Data="Testing SES logging"}} \
6  --configuration-set-name email-tracking-dev
7
8# Check CloudWatch Logs for events
9aws logs filter-log-events \
10  --log-group-name "/aws/ses/email-events-dev" \
11  --start-time $(date -d "1 hour ago" +%s)000
Monitoring and Alerting
CloudWatch Insights Queries

Use CloudWatch Logs Insights to analyze email performance patterns:

1-- Find all bounce events in the last 24 hours
2fields @timestamp, eventType, recipient, bounceType, bounceSubType
3| filter eventType = "bounce"
4| sort @timestamp desc
5| limit 100
6
7-- Calculate bounce rate by hour
8fields @timestamp, eventType
9| filter eventType in ["send", "bounce"]
10| stats count(*) as total, sum(eventType = "bounce") as bounces by bin(5m)
11| sort @timestamp desc
Setting Up Alerts

Create CloudWatch alarms for critical email metrics:

1# Alert on high bounce rates
2aws cloudwatch put-metric-alarm \
3  --alarm-name "SES-High-Bounce-Rate" \
4  --alarm-description "Alert when bounce rate exceeds 5%" \
5  --metric-name BounceRate \
6  --namespace AWS/SES \
7  --statistic Average \
8  --period 300 \
9  --threshold 5.0 \
10  --comparison-operator GreaterThanThreshold
11
12# Alert on complaint rate
13aws cloudwatch put-metric-alarm \
14  --alarm-name "SES-High-Complaint-Rate" \
15  --alarm-description "Alert when complaint rate exceeds 0.1%" \
16  --metric-name ComplaintRate \
17  --namespace AWS/SES \
18  --statistic Average \
19  --period 300 \
20  --threshold 0.1 \
21  --comparison-operator GreaterThanThreshold
Best Practices and Optimization
Performance Considerations

The Lambda function is configured with appropriate memory and timeout settings for processing email events efficiently. The 256MB memory allocation provides sufficient resources for JSON parsing and CloudWatch API calls, while the 30-second timeout allows for potential API retries without timing out.
Batch processing multiple SNS records in a single Lambda invocation improves cost efficiency and reduces the number of CloudWatch API calls, which helps stay within service limits during high-volume email campaigns.

Cost Optimization

CloudWatch Logs charges for data ingestion and storage, so consider implementing log retention policies to manage costs. For high-volume email senders, you might want to filter events to only log bounces, complaints, and failures while excluding routine delivery confirmations.
Monitor your CloudWatch API usage to ensure you're not hitting throttling limits during peak email volumes. If necessary, implement exponential backoff and jitter in your retry logic to spread API calls across time.

Security Best Practices

The implementation follows security best practices by using IAM roles with minimal required permissions. The function only has access to its specific log group and cannot read or modify other CloudWatch resources.
Sensitive information like message content is logged selectively, ensuring that personally identifiable information (PII) is not unnecessarily stored in logs. The raw event data is included for debugging purposes but can be filtered out in production if privacy requirements are strict.

Troubleshooting Common Issues
SNS Message Parsing Failures

SNS message format can vary between different SES event types and AWS regions. The implementation includes error handling for JSON parsing failures, logging the problematic message content for debugging while continuing to process other events.
If you encounter consistent parsing failures, check that your SES configuration set is properly configured and that the SNS topic is receiving events from SES rather than other AWS services.

CloudWatch Logs Sequence Token Errors

CloudWatch Logs requires sequence tokens to maintain log ordering within a stream. The implementation handles `InvalidSequenceTokenException` errors by extracting the expected token from the error message and retrying the operation.
For high-concurrency scenarios, consider using multiple log streams with time-based or hash-based partitioning to reduce contention on sequence tokens.

Permission and Access Issues

IAM permission errors typically manifest as `AccessDeniedException` when trying to create log groups or write log events. Verify that your Lambda execution role has the necessary CloudWatch Logs permissions and that the resource ARNs match your actual log group names.

Measuring Success
Key Performance Indicators

Monitor several metrics to evaluate the effectiveness of your email logging system. Track the percentage of email events successfully captured versus the volume of emails sent through SES to ensure comprehensive coverage.
Measure the time between email events occurring and appearing in CloudWatch Logs to validate real-time processing capabilities. Monitor Lambda function execution metrics like duration, errors, and throttles to ensure reliable processing during peak volumes.

Operational Benefits

With centralized logging in place, you'll gain immediate visibility into email delivery patterns that were previously hidden. Delivery rate trends help identify reputation issues before they become critical, while bounce pattern analysis enables proactive list cleaning.
The structured log format enables automated analysis and reporting, making it easy to generate email performance dashboards and compliance reports for stakeholders.

Conclusion

Implementing centralized SES event logging with CloudWatch provides essential visibility into your email delivery infrastructure. This serverless solution automatically captures, processes, and stores email events without requiring ongoing maintenance or manual intervention.
The combination of real-time event processing, structured logging, and comprehensive error handling ensures that you'll have reliable data for monitoring email performance and troubleshooting delivery issues. The investment in proper email observability pays dividends through improved deliverability, proactive issue resolution, and better understanding of your email program's effectiveness.
Ready to get started? Clone the complete project from GitHub and deploy it to your AWS account in minutes. The repository includes everything you need: Lambda function code, Serverless configuration, deployment scripts, and documentation to get your SES logging infrastructure up and running quickly.

Article last update: October 2, 2025

Serverless
AWS
Email

Frequently Asked Questions

To capture all SES events (deliveries, bounces, complaints, etc.) and store them in CloudWatch for analysis and alerting, providing visibility into email delivery performance and enabling proactive monitoring.

Amazon SES, Amazon SNS, AWS Lambda, Amazon CloudWatch Logs, and Sentry (for error tracking).

SES sends email and publishes events to an SNS topic; SNS triggers a Lambda function with the event data; the Lambda processes and structures the event information and writes it to CloudWatch Logs.

Create an SNS topic to receive SES events and configure SES to publish events to that topic via configuration sets.

It processes SNS messages containing SES events, structures the data for logging, handles different event types, and extracts relevant information for CloudWatch Logs.

Handling sequence tokens and potential race conditions; the function creates log groups and streams if they don't exist and manages access permissions.

Memory is set to 256MB with a 30-second timeout; batch processing of multiple SNS records is used; consider log retention policies and filtering to log only key events (bounces, complaints, failures); monitor CloudWatch API usage and implement exponential backoff and jitter.

Use IAM roles with minimal permissions; the function only has access to its own log group; avoid storing PII unnecessarily; the raw event data can be included for debugging but can be filtered in production.

Use CloudWatch Logs Insights to analyze email performance and set up CloudWatch alarms for critical metrics like bounce rates or complaints to receive alerts.

Deploy the Lambda function and related infrastructure using the Serverless Framework, test by sending test emails, and monitor CloudWatch Logs. The GitHub repository contains the code, configuration, deployment scripts, and documentation.