Logging with morgan & pino
Reviewed & published by Brayan K
Logging is how a running server tells you what it's doing: morgan automatically records every HTTP request, while pino writes fast, structured JSON logs with severity levels that your tools can search, filter, and alert on.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll see why console.log isn't enough in production, write structured logs with pino, control verbosity with levels, tag requests with child loggers, and add morgan as drop-in HTTP request logging — the logging stack real Node services rely on.
What You'll Learn in This Lesson
- Why console.log falls short for production logging
- How to write structured JSON logs with pino
- What each log level means and when to use it
- How child loggers attach context like a requestId
- How morgan logs every HTTP request automatically
- How to pipe morgan's lines into pino for one format
1️⃣ console.log vs a Real Logger
console.log prints free text with no level, no structure, and no off switch. A real logger like pino writes structured JSON: each line is an object with a level, a time, your message, and any extra fields you attach. That JSON is what log tools ingest so you can search, filter, and alert on it.
Notice the shape below: logger.info(obj, msg) merges your data object into the line. And a child logger created with logger.child({ requestId }) stamps that field onto everything it logs.
// Install once: npm install pino
const pino = require('pino');
// In real apps pino fills in the real time and pid. We pin them here so the
// demo output is reproducible — the SHAPE is exactly what pino prints.
const logger = pino({ base: { pid: 4242 }, timestamp: () => ',"time":1718700000000' });
logger.info('server started');
logger.warn({ retries: 3 }, 'upstream slow');
logger.error({ err: 'ECONNREFUSED' }, 'db connection failed');
// A child logger carries fixed fields onto every line it writes —
// perfect for tagging every log in a request with the same requestId.
const reqLog = logger.child({ requestId: 'abc-123' });
reqLog.info({ method: 'GET', url: '/users' }, 'incoming request');2️⃣ Levels: Controlling the Noise
Every log has a level ranking its importance, from trace (10) up to fatal (60). You set a minimum level, and the logger silently drops anything below it. That single setting is how you run chatty debug logs locally but only info and above in production.
const pino = require('pino');
// Only messages AT OR ABOVE the configured level are printed.
// Here level: 'warn' silences trace/debug/info.
const logger = pino({ level: 'warn', base: { pid: 1 }, timestamp: () => ',"time":1' });
logger.debug('this is hidden'); // 20 < warn -> not printed
logger.info('this is hidden'); // 30 < warn -> not printed
logger.warn('this shows'); // 40 == warn -> printed
logger.error('this shows too'); // 50 > warn -> printed
console.log('Numeric levels: trace=10 debug=20 info=30 warn=40 error=50 fatal=60');3️⃣ morgan: Logging Every Request
You don't want to hand-write a log line in every route. morgan is middleware that does it for you: add app.use(morgan('tiny')) once and every incoming request is logged automatically with its method, URL, status, response size, and timing.
// Install once: npm install express morgan
const express = require('express');
const morgan = require('morgan');
const app = express();
// morgan is HTTP-request middleware. Drop it in once and it logs a line
// for every request automatically. 'tiny' is a compact preset:
// method url status size - response-time
app.use(morgan('tiny'));
app.get('/users', (req, res) => res.json([{ id: 1 }]));
// (A test client makes a GET /users — see the logged line below.)
app.listen(3000);(The exact response time varies per run — yours will show a different millisecond value.)
In production you usually want one consistent format. The classic pattern pipes morgan's HTTP lines into pino, so your access logs and your application logs are all structured JSON — easy to ship to one place:
const express = require('express');
const morgan = require('morgan');
const pino = require('pino');
const app = express();
const logger = pino();
// Pipe morgan's HTTP lines INTO pino so everything is structured JSON.
app.use(morgan('combined', {
stream: { write: (line) => logger.info(line.trim()) },
}));
app.get('/health', (req, res) => res.send('ok'));
// Use pino directly for your own application events.
app.post('/orders', (req, res) => {
const orderLog = logger.child({ route: 'POST /orders' });
orderLog.info({ items: 3 }, 'order received');
res.status(201).json({ ok: true });
});
logger.info({ port: 3000 }, 'listening');
app.listen(3000);Your turn. Fill in the two ___ blanks so the child logger writes the expected JSON line, then run it.
const pino = require('pino');
const logger = pino({ base: { pid: 99 }, timestamp: () => ',"time":1' });
// 🎯 YOUR TURN — replace each ___ then run it.
// 1) Make a child logger that tags every line with orderId 'ORD-7'.
const orderLog = logger.child(___); // 👉 { orderId: 'ORD-7' }
// 2) Log an INFO line with a data field { total: 49.99 } and msg 'order placed'.
orderLog.___({ total: 49.99 }, 'order placed'); // 👉 info
// ✅ Expected output:
// {"level":30,"time":1,"pid":99,"orderId":"ORD-7","total":49.99,"msg":"order placed"}Mini-Challenge: A Login Flow's Logs
No blanks this time — just a brief and an outline. Write it yourself, run it, and check your output. This ties together child loggers, data fields, and level filtering in one short program.
const pino = require('pino');
// 🎯 MINI-CHALLENGE: structured logs for a login flow.
// 1. Make a logger at level 'info' (use base:{pid:5}, timestamp:()=>',"time":1').
// 2. Make a child logger tagged with userId 'u-1'.
// 3. Log: info 'logged in'
// warn with { attempts: 2 } and msg 'password retry'
// debug 'not shown at info level' (this one should NOT appear)
//
// ✅ Example output (two lines — the debug is filtered out):
// {"level":30,"time":1,"pid":5,"userId":"u-1","msg":"logged in"}
// {"level":40,"time":1,"pid":5,"userId":"u-1","attempts":2,"msg":"password retry"}
// your code hereCommon Errors (and the fix)
- My debug logs don't show — Your logger's level is higher than debug (default is info). Set { level: 'debug' } or the LOG_LEVEL env var.
- The data object and message are swapped — pino's signature is logger.info(obj, msg): the object comes first, the string second. Reversed, your fields won't appear as expected.
- morgan logs nothing — app.use(morgan(...)) must come before your routes, and it only logs requests that actually reach Express.
- Logging a whole error as a string — Pass the error as a field (logger.error({ err }, 'failed')) so the stack is captured, instead of String(err) which loses the trace.
- Pretty output crashes in production — pino-pretty is for local dev only. In production, emit raw JSON and let your platform format it.
Pro Tips
- 💡 One child logger per request: tag it with a requestId so you can trace a whole request through your logs by one id.
- 💡 Log objects, not concatenated strings: logger.info({ userId }, 'login') stays searchable; string interpolation does not.
- 💡 Set the level from an env var: pino({ level: process.env.LOG_LEVEL || 'info' }) lets you crank up detail without redeploying.
📋 Quick Reference — Logging
| Task | Syntax |
|---|---|
| Create a logger | const logger = pino() |
| Info with data | logger.info({ userId }, 'msg') |
| Set min level | pino({ level: 'debug' }) |
| Child logger | logger.child({ requestId }) |
| HTTP logging | app.use(morgan('tiny')) |
| morgan → pino | morgan(fmt, { stream }) |
🎉 Lesson Complete!
- ✅ console.log is for quick debugging; production needs a real logger
- ✅ pino writes fast, structured JSON with a level, time, and your fields
- ✅ Levels (trace→fatal) plus a minimum level control how much you log
- ✅ logger.child({ requestId }) tags every line with shared context
- ✅ morgan logs every HTTP request automatically as middleware
- ✅ Pipe morgan into pino to get one consistent JSON format everywhere
Practice quiz
In what format does pino write its log lines by default?
- Structured JSON
- Plain English sentences
- XML
- CSV
Answer: Structured JSON. pino emits structured JSON, one object per line.
What does logger.child({ requestId: 'abc-123' }) create?
- A new process
- A logger that tags every line with that field
- A separate file
- A network connection
Answer: A logger that tags every line with that field. A child logger carries fixed fields (like requestId) onto every line it writes.
With a logger configured at level 'warn', which call is NOT printed?
- logger.error('x')
- logger.fatal('x')
- logger.info('x')
- logger.warn('x')
Answer: logger.info('x'). info (30) is below warn (40), so it is filtered out.
What is the numeric level pino uses for 'info'?
- 10
- 50
- 60
- 30
Answer: 30. pino levels: trace=10, debug=20, info=30, warn=40, error=50, fatal=60.
What is morgan used for?
- Logging an HTTP request line automatically per request
- Connecting to a database
- Parsing JSON bodies
- Hashing passwords
Answer: Logging an HTTP request line automatically per request. morgan is HTTP-request logging middleware for Express.
How is morgan added to an Express app?
- app.get(morgan)
- app.use(morgan('tiny'))
- morgan.listen(app)
- app.morgan()
Answer: app.use(morgan('tiny')). You register it as middleware: app.use(morgan('tiny')).
Which pino level is the most severe?
- info
- warn
- fatal
- error
Answer: fatal. fatal (60) is the highest, most severe level.
Why prefer a real logger like pino over console.log in production?
- console.log is unavailable in Node
- It is slower
- It changes the language
- It produces structured, level-aware, machine-parsable logs
Answer: It produces structured, level-aware, machine-parsable logs. Structured JSON with levels is far easier to filter and search than plain console output.
What is the 'tiny' value passed to morgan?
- A compact predefined log format preset
- A file path
- A port number
- A log level
Answer: A compact predefined log format preset. 'tiny' is one of morgan's built-in format presets (method url status size - response-time).
Which rule decides whether a pino message is printed?
- Only error and above ever print
- Only messages at or above the configured level print
- Every message always prints
- Only the first message prints
Answer: Only messages at or above the configured level print. pino prints messages whose level is at or above the configured level.
Continue this course
- Previous: File Uploads with Multer
- Next: MongoDB with Mongoose — Define schemas and models and run CRUD queries with population
- Quick reference: Node.js cheat sheet
Frequently asked questions
What's wrong with just using console.log?
console.log is fine for a quick debug, but it has no concept of levels, no structure, and no easy way to turn off. In production you can't tell an important error from a throwaway debug line, you can't filter by severity, and the free-text output is hard for log-aggregation tools (Datadog, CloudWatch, ELK) to parse. A real logger like pino gives you levels (info/warn/error...), structured JSON your tools can search and alert on, child loggers for request context, and it's far faster than console.log under load. Keep console.log for one-off local debugging; use a logger for anything that runs in production.
When should I use morgan and when should I use pino?
They solve different problems and are often used together. morgan is HTTP-request logging middleware: add it once and it automatically writes a line for every incoming request with the method, URL, status code, size, and response time — great for an access log. pino is a general-purpose application logger: you call logger.info/warn/error yourself to record your own events (an order was placed, a payment failed) as structured JSON. A common setup pipes morgan's request lines into pino so both your access logs and your app logs come out as consistent JSON.
What do the log levels mean and when do I use each?
Levels rank messages by severity so you can filter noise. trace and debug are detailed developer information you usually silence in production. info marks normal, expected events worth recording (server started, order placed). warn flags something unusual that didn't break anything but deserves attention (a retry, a deprecated call). error means an operation failed and a user was likely affected. fatal means the process can't continue. You set a minimum level (often info in production, debug locally) and the logger drops anything below it, so you control verbosity with one setting.
What is a child logger and why is it useful?
A child logger is created with logger.child({ ...fields }) and automatically stamps those fields onto every line it writes. The classic use is request context: at the start of each request you make a child logger with a unique requestId (and maybe the userId), then use it everywhere in that request. Now every log line for that request shares the same requestId, so when something goes wrong you can grep one id and see the whole story across all your log output — without manually adding the id to each call.