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

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 here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Logging

TaskSyntax
Create a loggerconst logger = pino()
Info with datalogger.info({ userId }, 'msg')
Set min levelpino({ level: 'debug' })
Child loggerlogger.child({ requestId })
HTTP loggingapp.use(morgan('tiny'))
morgan → pinomorgan(fmt, { stream })

🎉 Lesson Complete!

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

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.