Skip to content

How to Set Up Structured Logging with Winston in Node.js for Production

I got paged at 3 AM last week. Production was down, users couldn’t log in. I SSH’d into the server, grepped through 200MB of text logs, and spent 2 hours trying to piece together what went wrong. The logs were a mess of console.log statements with no structure, no correlation, no way to query them efficiently.

That’s when I decided to implement proper structured logging with Winston.

The Problem with console.log

Let me show you what I was dealing with:

// What my logs looked like before
User logged in: 42
Error occurred: ECONNREFUSED
Database query failed
Request to /api/users failed

Try finding all logs related to user 42’s failed request. Go ahead, I’ll wait.

You can’t. Not easily anyway. These are plain text strings that look different every time someone wrote a log message. There’s no:

  • Consistent timestamp format
  • Log level indicator
  • Request correlation
  • Structured data you can query

When production breaks at 3 AM, you need to answer questions fast:

  • What requests were involved?
  • What was the user doing?
  • What happened before the error?
  • Did this affect other users?

With console.log, you’re basically guessing.

The Solution: Winston + JSON + Correlation IDs

Here’s what structured logs look like with Winston:

{"level":"info","message":"User logged in","userId":42,"ip":"192.168.1.1","timestamp":"2026-03-04T02:30:00.000Z"}
{"level":"error","message":"Database connection failed","error":"ECONNREFUSED","correlationId":"abc-123","timestamp":"2026-03-04T02:30:01.000Z"}
{"level":"info","message":"Request completed","statusCode":500,"duration":"145ms","correlationId":"abc-123","timestamp":"2026-03-04T02:30:01.500Z"}

Now I can query:

  • correlationId:abc-123 - get the entire request flow
  • userId:42 - get all activity for a specific user
  • level:error - filter only errors
  • timestamp:[2026-03-04T02:30:00 TO 2026-03-04T02:35:00] - time range queries

This is the difference between 5-minute debugging and 2-hour debugging.

Setting Up Winston with JSON Format

Here’s my basic Winston setup:

const winston = require('winston')
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'my-app' },
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
})
// In development, also log to console
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}))
}
module.exports = logger

The key here is winston.format.json(). Every log becomes a JSON object that’s:

  • Parseable by machines
  • Queryable by log aggregation tools
  • Consistent in structure

Correlation IDs: Track Every Request

This is the game-changer. A correlation ID is a unique identifier that follows a request through your entire system:

Request arrives → Generate correlation ID → Attach to all logs → Pass to downstream services

Here’s how I implemented it in Express:

const { v4: uuidv4 } = require('uuid')
// Middleware to add correlation ID
app.use((req, res, next) => {
// Use existing ID if provided (from load balancer or upstream service)
const correlationId = req.headers['x-correlation-id'] || uuidv4()
req.correlationId = correlationId
res.setHeader('x-correlation-id', correlationId)
next()
})
// Create child logger with context
app.use((req, res, next) => {
req.logger = logger.child({
correlationId: req.correlationId,
method: req.method,
path: req.path
})
req.logger.info('Request started')
next()
})

Now every log from this request carries the correlation ID automatically:

app.get('/api/users/:id', async (req, res) => {
req.logger.info('Fetching user', { userId: req.params.id })
const user = await getUserById(req.params.id)
if (!user) {
req.logger.warn('User not found')
return res.status(404).json({ error: 'Not found' })
}
req.logger.info('User fetched')
res.json(user)
})

Output:

{"level":"info","message":"Request started","correlationId":"abc-123-def","method":"GET","path":"/api/users/42","timestamp":"..."}
{"level":"info","message":"Fetching user","correlationId":"abc-123-def","userId":"42","timestamp":"..."}
{"level":"info","message":"User fetched","correlationId":"abc-123-def","timestamp":"..."}

When something breaks, I search for the correlation ID and see the complete request journey.

Production Configuration: Daily Rotation

In production, you can’t have infinite log files. Here’s my production-ready Winston config:

const DailyRotateFile = require('winston-daily-rotate-file')
const path = require('path')
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: {
service: process.env.SERVICE_NAME || 'app',
environment: process.env.NODE_ENV
},
transports: [
new DailyRotateFile({
filename: path.join(__dirname, 'logs', 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '20m',
maxFiles: '14d',
zippedArchive: true
}),
new DailyRotateFile({
filename: path.join(__dirname, 'logs', 'application-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '14d',
zippedArchive: true
})
],
exceptionHandlers: [
new DailyRotateFile({
filename: path.join(__dirname, 'logs', 'exceptions-%DATE%.log'),
datePattern: 'YYYY-MM-DD'
})
],
rejectionHandlers: [
new DailyRotateFile({
filename: path.join(__dirname, 'logs', 'rejections-%DATE%.log'),
datePattern: 'YYYY-MM-DD'
})
]
})

This setup:

  • Rotates logs daily
  • Compresses old logs (saves disk space)
  • Keeps 14 days of history
  • Captures uncaught exceptions and unhandled rejections
  • Separates error logs from application logs

Exception Handling: Don’t Lose Crash Info

Here’s something I learned the hard way: uncaught exceptions can crash your process without writing any logs. Winston can handle this:

// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', {
error: err.message,
stack: err.stack
})
process.exit(1)
})
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection', {
reason: reason?.message || reason,
stack: reason?.stack
})
})

Now when your app crashes, you get the full stack trace in your logs instead of just “Process exited unexpectedly.”

Querying Structured Logs

Once you have JSON logs, you can do powerful queries. Here’s what I do with different aggregation tools:

ELK Stack / Datadog:

userId:42 AND level:error

CloudWatch Logs Insights:

fields @timestamp, message, correlationId
| filter userId = 42
| sort @timestamp desc
| limit 50

Programmatic queries with Winston:

logger.query({
from: new Date(Date.now() - 24 * 60 * 60 * 1000),
until: new Date(),
limit: 10,
order: 'desc',
fields: ['message', 'correlationId', 'userId']
}, (err, results) => {
console.log(results.file)
})

Why This Matters

Let me paint a picture:

Before structured logging:

  • 3 AM page: “Users can’t log in”
  • SSH into server
  • grep "error" app.log | head -100
  • Scroll through thousands of lines
  • Try to correlate timestamps manually
  • Find error at 02:30:15
  • Check what happened before… was there a request?
  • 2 hours later: “Looks like database connection issue”

After structured logging:

  • 3 AM page: “Users can’t log in”
  • Log into Datadog/CloudWatch
  • Search: correlationId:<recent-error-id>
  • See entire request flow in 5 minutes
  • “Database connection failed at 02:30:15, user login request from user-42, connection pool exhausted”
  • Restart service, add more connections
  • Back to sleep by 03:15

That’s the difference.

What I’ve Learned

After implementing this in production:

  1. JSON format is non-negotiable - Plain text logs are useless at scale
  2. Correlation IDs save hours - Every request needs one, pass it to downstream services
  3. Child loggers are essential - Don’t manually attach metadata to every log
  4. File rotation prevents disk death - Set it up day one
  5. Exception handlers catch crashes - Without them, you lose critical debugging info

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments