Logging
When your app runs on a server, you cannot watch it. If a customer in Lucknow says "my order failed at 9:40 last night", the only way to find out what happened is to read what the app wrote down at that moment. Those records are logs. Good logs answer three questions quickly: what happened, when, and for which request or user.
In this lesson you will see what console offers and where its output goes, then move to structured logging with pino, a fast JSON logger: log levels, extra fields, child loggers, request logs with pino-http, readable output during development, and hiding passwords and tokens.
console and the two output streams
Every process has two output streams: stdout (standard output) for normal output and stderr (standard error) for errors and warnings. Both appear in your terminal, but tools can separate them.
console.log("Server starting"); // normal output (stdout)
console.error("Database connection failed"); // errors (stderr)
console.warn("Disk is 90% full"); // also stderr
console.table([
{ city: "Rohtak", orders: 42 },
{ city: "Kochi", orders: 37 },
]);
console.time("report");
let sum = 0;
for (let i = 0; i < 1e6; i++) sum += i;
console.timeEnd("report");The stdout part:
Server starting ┌─────────┬──────────┬────────┐ │ (index) │ city │ orders │ ├─────────┼──────────┼────────┤ │ 0 │ 'Rohtak' │ 42 │ │ 1 │ 'Kochi' │ 37 │ └─────────┴──────────┴────────┘ report: 9.473ms
and the stderr part:
Database connection failed Disk is 90% full
console.table() is handy for checking arrays of objects, and console.time()/timeEnd() measure how long code takes. For learning and small scripts, console is enough.
Why console.log is not enough for servers
On a busy server, thousands of plain lines such as order failed are almost useless. You cannot tell which order, which user, when, or how serious it was, and you cannot easily search or count them. Real apps need:
- Levels, so you can show only warnings and errors in production and everything while debugging.
- Timestamps on every line.
- Context fields such as
orderId,userIdandrequestId. - A machine-readable format, so log tools (Grafana Loki, Elasticsearch, cloud consoles) can search and filter.
- Speed: logging should not slow down the event loop.
Structured logging writes each entry as one JSON object per line. Libraries such as pino do this for you.
Structured logs with pino
npm install pinoimport pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: ["user.password", "card.number"],
});
logger.info("Server started");
logger.info({ port: 3000, mode: "production" }, "Listening");
logger.debug("This is hidden at level info");
logger.warn({ diskUsedPercent: 91 }, "Disk almost full");
const orderLog = logger.child({ orderId: 1042 });
orderLog.info("Payment received");
orderLog.error({ err: new Error("Courier API timeout") }, "Shipping label failed");
logger.info({ user: { email: "priya@example.in", password: "Jaipur#Pink77" } }, "Login attempt");The output (the error's stack trace is shortened here):
{"level":30,"time":1793357103412,"pid":19996,"hostname":"Cyber","msg":"Server started"}
{"level":30,"time":1793357103415,"pid":19996,"hostname":"Cyber","port":3000,"mode":"production","msg":"Listening"}
{"level":40,"time":1793357103415,"pid":19996,"hostname":"Cyber","diskUsedPercent":91,"msg":"Disk almost full"}
{"level":30,"time":1793357103416,"pid":19996,"hostname":"Cyber","orderId":1042,"msg":"Payment received"}
{"level":50,"time":1793357103416,"pid":19996,"hostname":"Cyber","orderId":1042,"err":{"type":"Error","message":"Courier API timeout","stack":"Error: Courier API timeout\n at file:///C:/node-course/logging/pino-basic.js:15:23\n ..."},"msg":"Shipping label failed"}
{"level":30,"time":1793357103417,"pid":19996,"hostname":"Cyber","user":{"email":"priya@example.in","password":"[Redacted]"},"msg":"Login attempt"}What each part means:
- The first argument can be an object of fields, the second the message. Each line gets
level,time(milliseconds since 1970), the process idpidand thehostnameautomatically. - The
debugmessage did not appear, because the logger's level isinfo. Run withLOG_LEVEL=debugto see it, with no code change. logger.child({ orderId: 1042 })creates a logger that addsorderIdto every line. Create a child per request or per job, and every line about it can be found by searching for that ID.- Pass errors as
{ err }. Pino records the type, message and full stack trace. redactreplaces sensitive fields with[Redacted], so a password never reaches your log files even if someone logs the whole object by mistake.
| Level | Number | Use it for |
|---|---|---|
fatal | 60 | The app cannot continue and is about to exit |
error | 50 | A request or job failed |
warn | 40 | Something unusual that may become a problem |
info | 30 | Normal important events: started, order placed |
debug | 20 | Detail for developers while investigating |
trace | 10 | Very detailed step-by-step information |
Readable logs in development
JSON is perfect for machines but tiring to read. Pipe the output through pino-pretty while developing:
npm install --save-dev pino-pretty
node app.js | npx pino-pretty[16:15:21.466] INFO (14880): Server started
[16:15:21.468] INFO (14880): Listening
port: 3000
mode: "production"
[16:15:21.468] WARN (14880): Disk almost full
diskUsedPercent: 91
[16:15:21.469] INFO (14880): Payment received
orderId: 1042In production keep the raw JSON, so log tools can read it.
Logging every request
pino-http is middleware that logs one line for each finished request, with the method, URL, status and response time, and gives every request its own child logger as req.log:
npm install pino-httpimport express from "express";
import pino from "pino";
import { pinoHttp } from "pino-http";
const logger = pino();
const app = express();
app.use(pinoHttp({
logger,
redact: ["req.headers.authorization", "req.headers.cookie"],
}));
app.get("/api/menu", (req, res) => {
req.log.info("Fetching menu from cache");
res.json(["Masala chai", "Samosa"]);
});
app.listen(3000, () => logger.info("ready"));A request to /api/menu with an Authorization header logs two lines, both with the same request id. The final line looks like this (some headers removed to fit):
{"level":30,"time":1793357171561,"pid":26552,"hostname":"Cyber","req":{"id":1,"method":"GET","url":"/api/menu","query":{},"params":{},"headers":{"host":"localhost:3000","authorization":"[Redacted]","user-agent":"node"},"remoteAddress":"::1","remotePort":64286},"res":{"statusCode":200,"headers":{"content-type":"application/json; charset=utf-8","content-length":"24"}},"responseTime":22,"msg":"request completed"}When a customer reports a problem, you can now find the request, see how long it took and read every line logged during it.
Where should logs go?
Write logs to stdout and let the environment collect them. PM2 saves them to files and can rotate them, Docker keeps them per container, and cloud platforms send them to their log viewers. Do not open and manage log files from inside your app; it complicates rotation and fails on platforms with read-only disks.
Common mistakes
- Only
console.login production. No levels, no context, hard to search. - Logging secrets or personal data. Use
redactand log IDs. - Logging too much at
info. Logs become noise and cost money to store. Usedebugfor detail. - Logging an error as a string.
logger.error("failed: " + err)loses the stack trace; pass{ err }. - Writing log files from the app. Log to stdout and let PM2, Docker or the platform handle files.
Key points
console.logwrites to stdout;console.errorandconsole.warnwrite to stderr.- Structured logging writes one JSON object per line with level, time and context fields.
- Pino has levels from
trace(10) tofatal(60); set the minimum level with an environment variable. - Child loggers add fields such as
orderIdto every line; pass errors as{ err }. - pino-http logs every request with an ID and response time;
redacthides secrets. - Log to stdout and let the process manager or platform collect and rotate logs.
Exercise
Create a pino logger for a train booking service with the level taken from LOG_LEVEL (default "info") and redaction for passenger.phone. Make a child logger for PNR 4521098765, then log: an info message "Booking confirmed" with seats: 2 and the passenger object { name: "Meera", phone: "9812345678" }, and a debug message "Seat map loaded". Run it once normally and once with LOG_LEVEL=debug.
Show solution
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
redact: ["passenger.phone"],
});
const bookingLog = logger.child({ pnr: "4521098765" });
bookingLog.info({ seats: 2, passenger: { name: "Meera", phone: "9812345678" } }, "Booking confirmed");
bookingLog.debug("Seat map loaded");{"level":30,"time":1793357234323,"pid":12896,"hostname":"Cyber","pnr":"4521098765","seats":2,"passenger":{"name":"Meera","phone":"[Redacted]"},"msg":"Booking confirmed"}At the default info level the debug line is hidden. With LOG_LEVEL=debug a second line with "level":20 and "msg":"Seat map loaded" also appears, and both lines carry the PNR from the child logger.
Quick Quiz
1. Which stream does console.error() write to?
Errors and warnings go to the standard error stream, so tools can separate them from normal output.
2. A pino logger has level: "warn". Which call is printed?
Only messages at the logger's level or higher (warn, error, fatal) are written.
3. What is the main benefit of logger.child({ orderId: 1042 })?
Child loggers attach context fields automatically.
4. How should you log an error object with pino to keep its stack trace?
Passing the error in the err field lets pino serialise its type, message and stack.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
