Logging & Monitoring with AWS CloudWatch
AWS CloudWatch is the observability service for AWS. It collects logs, metrics, and events from your application so you can understand what's happening in production, debug issues, and get alerted when something goes wrong.
For an Express app, the two most useful CloudWatch features are:
- CloudWatch Logs — centralized log storage and search
- CloudWatch Alarms — alerts when a metric crosses a threshold (e.g., error rate spikes)
Why CloudWatch instead of console.log?
Problem with console.log | CloudWatch solution |
|---|---|
| Lost on server restart | Persisted indefinitely (configurable retention) |
| Spread across multiple servers | Centralized, searchable across all instances |
| No structure | JSON logs that are filterable and queryable |
| No alerting | Metric filters + Alarms → SNS → SMS/email |
Option 1: Automatic Logging (EC2/ECS/Lambda)
If your app runs on EC2, ECS, or Lambda, CloudWatch collects logs automatically when you configure the CloudWatch agent or use the built-in Lambda logging. Every console.log() in your app is forwarded to a log group.
This is the simplest setup — no SDK calls required. Just write to console.log and configure the log agent on your compute.
Option 2: Direct SDK Logging (Any Environment)
If you want fine-grained control — custom log groups, structured JSON, metadata on every log line — send logs directly via the SDK.
Install
- npm
- Yarn
- pnpm
- Bun
npm install @aws-sdk/client-cloudwatch-logs winston winston-cloudwatch dotenv
yarn add @aws-sdk/client-cloudwatch-logs winston winston-cloudwatch dotenv
pnpm add @aws-sdk/client-cloudwatch-logs winston winston-cloudwatch dotenv
bun add @aws-sdk/client-cloudwatch-logs winston winston-cloudwatch dotenv
Structured Logger with Winston + CloudWatch
winston is the most popular Node.js logging library. winston-cloudwatch sends logs to CloudWatch as a transport.
import winston from "winston";
import WinstonCloudWatch from "winston-cloudwatch";
const isDev = process.env.NODE_ENV !== "production";
const logger = winston.createLogger({
level: isDev ? "debug" : "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
// Always log to console
new winston.transports.Console({
format: isDev
? winston.format.combine(winston.format.colorize(), winston.format.simple())
: winston.format.json(),
}),
],
});
// Only send to CloudWatch in non-development environments
if (!isDev) {
logger.add(
new WinstonCloudWatch({
logGroupName: `/my-app/${process.env.NODE_ENV}`,
logStreamName: `express-${new Date().toISOString().split("T")[0]}`,
awsRegion: process.env.AWS_REGION,
jsonMessage: true,
})
);
}
export default logger;
Using the Logger in Routes
import logger from "../config/logger.js";
router.get("/:id", async (req, res) => {
logger.info("Fetching user", { userId: req.params.id, requestId: req.id });
try {
const user = await getUserById(req.params.id);
if (!user) {
logger.warn("User not found", { userId: req.params.id });
return res.status(404).json({ message: "User not found" });
}
logger.info("User fetched successfully", { userId: req.params.id });
res.json(user);
} catch (err) {
logger.error("Failed to fetch user", {
userId: req.params.id,
error: err.message,
stack: err.stack,
});
res.status(500).json({ message: "Internal server error" });
}
});
Request Logging Middleware
Log every incoming request automatically with a middleware:
import logger from "../config/logger.js";
import { randomUUID } from "crypto";
export function requestLogger(req, res, next) {
req.id = randomUUID(); // Attach a unique ID to trace the request through logs
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
const level = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info";
logger[level]("Request completed", {
requestId: req.id,
method: req.method,
path: req.path,
statusCode: res.statusCode,
durationMs: duration,
userAgent: req.get("user-agent"),
ip: req.ip,
});
});
next();
}
import { requestLogger } from "./middlewares/requestLogger.js";
app.use(requestLogger);
This produces structured logs like:
{
"level": "info",
"message": "Request completed",
"requestId": "a1b2c3d4-...",
"method": "GET",
"path": "/users/123",
"statusCode": 200,
"durationMs": 42,
"timestamp": "2024-04-05T10:00:00.000Z"
}
Error Logging Middleware
Hook into Express error handling to log unhandled errors before sending the response:
import logger from "../config/logger.js";
export function errorHandler(err, req, res, next) {
logger.error("Unhandled error", {
requestId: req.id,
method: req.method,
path: req.path,
error: err.message,
stack: err.stack,
statusCode: err.status || 500,
});
res.status(err.status || 500).json({
success: false,
message: err.message || "Internal Server Error",
...(process.env.NODE_ENV === "development" && { stack: err.stack }),
});
}
// Must be registered after all routes
app.use(errorHandler);
CloudWatch Alarms
Once your logs are in CloudWatch, you can create Metric Filters to count specific patterns (e.g., lines containing "level":"error") and then set Alarms to notify you when the error count spikes.
Create a Metric Filter
- Go to CloudWatch → Log groups → Your log group
- Click Metric filters → Create metric filter
- Filter pattern:
{ $.level = "error" }(for JSON logs) - Create a metric named
ApplicationErrors
Create an Alarm
- Go to CloudWatch → Alarms → Create alarm
- Select your
ApplicationErrorsmetric - Set condition: trigger if errors > 10 in 5 minutes
- Notification: send to an SNS topic → your email or SMS
IAM Permissions for CloudWatch Logs
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": "arn:aws:logs:*:*:*"
}
Log Retention
By default, CloudWatch Logs retain log data indefinitely. Set a retention policy to control costs:
- Go to CloudWatch → Log groups → Your log group → Actions → Edit retention
- Set to 30, 60, or 90 days depending on your compliance needs
Or set it programmatically at startup:
import { CloudWatchLogsClient, PutRetentionPolicyCommand } from "@aws-sdk/client-cloudwatch-logs";
const cwlClient = new CloudWatchLogsClient({ region: process.env.AWS_REGION });
await cwlClient.send(
new PutRetentionPolicyCommand({
logGroupName: `/my-app/production`,
retentionInDays: 30,
})
);
Key Takeaways
- On EC2/ECS/Lambda, stdout (
console.log) is automatically forwarded to CloudWatch when the agent is configured — no SDK calls needed - Use
winston+winston-cloudwatchfor structured JSON logs with custom metadata - Always log a
requestIdto trace a request through multiple log lines - Set up Metric Filters + Alarms on error counts so you're paged when production breaks
- Set a retention policy on log groups to avoid unbounded storage costs
- Log
warnfor expected errors (404s, bad input) anderrorfor unexpected failures (DB down, uncaught exceptions)