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 beforeUser logged in: 42Error occurred: ECONNREFUSEDDatabase query failedRequest to /api/users failedTry 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 flowuserId:42- get all activity for a specific userlevel:error- filter only errorstimestamp:[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 consoleif (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ) }))}
module.exports = loggerThe 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 servicesHere’s how I implemented it in Express:
const { v4: uuidv4 } = require('uuid')
// Middleware to add correlation IDapp.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 contextapp.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 exceptionsprocess.on('uncaughtException', (err) => { logger.error('Uncaught exception', { error: err.message, stack: err.stack }) process.exit(1)})
// Handle unhandled promise rejectionsprocess.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:errorCloudWatch Logs Insights:
fields @timestamp, message, correlationId| filter userId = 42| sort @timestamp desc| limit 50Programmatic 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:
- JSON format is non-negotiable - Plain text logs are useless at scale
- Correlation IDs save hours - Every request needs one, pass it to downstream services
- Child loggers are essential - Don’t manually attach metadata to every log
- File rotation prevents disk death - Set it up day one
- 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:
- 👨💻 Winston Official Documentation
- 👨💻 winston-daily-rotate-file
- 👨💻 Node.js Logging Best Practices
- 👨💻 ELK Stack Introduction
- 👨💻 CloudWatch Logs Insights
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments