Skip to main content

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.logCloudWatch solution
Lost on server restartPersisted indefinitely (configurable retention)
Spread across multiple serversCentralized, searchable across all instances
No structureJSON logs that are filterable and queryable
No alertingMetric 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 install @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.

config/logger.js
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

routes/users.js
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:

middlewares/requestLogger.js
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();
}
index.js
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:

middlewares/errorHandler.js
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 }),
});
}
index.js
// 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

  1. Go to CloudWatch → Log groups → Your log group
  2. Click Metric filters → Create metric filter
  3. Filter pattern: { $.level = "error" } (for JSON logs)
  4. Create a metric named ApplicationErrors

Create an Alarm

  1. Go to CloudWatch → Alarms → Create alarm
  2. Select your ApplicationErrors metric
  3. Set condition: trigger if errors > 10 in 5 minutes
  4. 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:

  1. Go to CloudWatch → Log groups → Your log group → Actions → Edit retention
  2. Set to 30, 60, or 90 days depending on your compliance needs

Or set it programmatically at startup:

config/logger.js
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-cloudwatch for structured JSON logs with custom metadata
  • Always log a requestId to 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 warn for expected errors (404s, bad input) and error for unexpected failures (DB down, uncaught exceptions)