Build a Production Logger in Node.js
Build a structured JSON logger from scratch with transports and redaction.
Introduction
30 Second Summary
When something breaks in production at 2am, your first question is "what happened?" Logs are how you answer that. But console.log gives you nothing useful to search, filter, or trace back to a specific request.
In this project, you will build a production-grade structured JSON logger from scratch using only Node.js built-in modules, then integrate it with an Express API to see it handle real requests with timestamps, levels, correlation IDs, and automatic sensitive data redaction.
What You'll Build
When you run the finished server and fire off requests, every log line is a structured JSON object you can search and filter. Sensitive data like passwords and credit card numbers is automatically masked before it reaches any output.
By the end of this project, you'll have:
- A running Express "Order API" where every request produces structured JSON logs with timestamps, severity levels, and service metadata you can pipe to any monitoring tool.
- Color-coded console output for quick human scanning during development, alongside a persistent log file you can search with a single command to find exactly what happened during a request.
- Per-request correlation IDs that let you pick any log line, grab its ID, and instantly see the full lifecycle of that request across dozens of interleaved logs.
- Automatic path-based redaction that masks passwords, tokens, and credit card numbers before they reach any transport, keeping your logs safe to ship to external services.
- Secret Mission: Wire up AsyncLocalStorage so correlation context flows automatically through deeply nested async calls without passing the logger manually.
Are there any prerequisites?
This project assumes you're comfortable with Express basics (routes, middleware, req/res) and JavaScript fundamentals (classes, objects, callbacks). You don't need any prior logging library experience.
Before We Start
Before we get hands-on, let's ground ourselves in what you're about to build. You'll create a production-grade structured logger from scratch and integrate it with an Express API, so you can explain exactly why tools like Pino make the design decisions they do.
Set Up the Express Order API
Every Express app starts the same way: a few routes, some console.log calls sprinkled in to see what's happening, and a server listening on a port. It works fine when you're the only developer and there's one request at a time.
In this step, you'll scaffold a minimal Order API with naive console.log statements throughout. By the end, you'll have a working baseline that exposes exactly why console.log falls apart in production.
In this step, get ready to:
- Initialize a Node.js project and install Express.
- Create an Order API with three endpoints using console.log for logging.
- Run the server and observe what console.log actually gives you.
Initialize the project and install Express
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir node-logger-system
cd node-logger-system
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The -y flag tells npm to accept all defaults and skip the interactive prompts. It creates a package.json file that tracks your project's metadata and dependencies.
- Install Express by running:
npm install express@5.2.1
You should see output confirming the package was added to your project.
Seeing permission errors or EACCES?
This usually means your terminal does not have write access to the folder. Close the terminal, reopen it, and confirm you are inside the node-logger-system folder with pwd.
Help me fix npm permission errors
- Open package.json in VS Code and replace its entire contents with the following:
{
"name": "node-logger-system",
"version": "1.0.0",
"description": "Production-grade logger built from scratch with Node.js",
"main": "src/app.js",
"scripts": {
"dev": "node --watch src/app.js",
"start": "node src/app.js"
},
"dependencies": {
"express": "5.2.1"
}
}
What does this configure?
- The dev script uses node --watch which automatically restarts your server whenever you save a file.
- The start script runs the server without file watching, for production use.
- The main field points to src/app.js where your Express server will live.
- Save package.json.
- Create the src directory by running:
mkdir src
- Confirm your project structure by running:
ls
You should see node_modules, package-lock.json, package.json, and src listed in the output.
Create the Express server with three routes
- Create src/app.js in VS Code and paste in the following code to set up Express and your first route:
const express = require('express');
const app = express();
app.use(express.json());
// In-memory store for orders
const orders = [];
// GET all orders
app.get('/orders', (req, res) => {
console.log('Fetching all orders');
res.json({ orders });
});
What does this code do?
- express.json() is middleware that parses incoming JSON request bodies so you can read req.body.
- The orders array acts as a simple in-memory database that resets every time the server restarts.
- The GET route returns all orders as JSON and logs a plain string with console.log.
- Add the following code below the GET route to handle creating orders and retrieving a single order:
// POST a new order
app.post('/orders', (req, res) => {
const { item, quantity } = req.body;
if (!item || !quantity) {
console.log('Invalid order request', req.body);
return res.status(400).json({ error: 'item and quantity are required' });
}
const order = { id: orders.length + 1, item, quantity, createdAt: new Date().toISOString() };
orders.push(order);
console.log('Order created', order);
res.status(201).json({ order });
});
// GET a single order by ID
app.get('/orders/:id', (req, res) => {
const order = orders.find(o => o.id === parseInt(req.params.id));
if (!order) {
console.log('Order not found', req.params.id);
return res.status(404).json({ error: 'Order not found' });
}
console.log('Order retrieved', order);
res.json({ order });
});
// Start the server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server started on port ${PORT}`);
});
What does this code do?
- The POST route validates that item and quantity exist in the request body, then creates an order object with a timestamp.
- The GET /orders/:id route finds a specific order by its numeric ID and returns 404 if it does not exist.
- Every route handler uses console.log to announce what just happened. Notice there are no timestamps, no severity levels, and no structure.
- Save src/app.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both package.json and src/app.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete package.json:
{
"name": "node-logger-system",
"version": "1.0.0",
"description": "Production-grade logger built from scratch with Node.js",
"main": "src/app.js",
"scripts": {
"dev": "node --watch src/app.js",
"start": "node src/app.js"
},
"dependencies": {
"express": "5.2.1"
}
}
For src/app.js, scroll up and confirm both code chunks are present in order:
- Chunk 1: the require, app setup, orders array, and the GET /orders route.
- Chunk 2: the POST /orders route, the GET /orders/:id route, and the app.listen call at the bottom.
Run the server and make test requests
- Start the development server by running:
npm run dev
You should see Server started on port 3000 printed in the terminal.
Why node --watch?
The --watch flag tells Node.js to monitor your files and automatically restart the server whenever you save a change. This means you can keep editing code without manually stopping and restarting.
Seeing "Cannot find module" or "ENOENT"?
Make sure your file is saved at src/app.js inside the node-logger-system folder. The path must match the dev script in package.json exactly.
Help me fix the Cannot find module error
Now open a second terminal window to send requests while the server keeps running.
- Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) in VS Code to open the command palette.
- Type Terminal: Create New Terminal and press Enter.
Before you send your first request, what do you think the response will look like for a brand-new server with no orders?
- Test the GET /orders endpoint by running:
curl http://localhost:3000/orders
You should see {"orders":[]} returned. Switch back to your first terminal and notice the plain text Fetching all orders printed by console.log.
- Create an order by running:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d "{\"item\": \"Keyboard\", \"quantity\": 2}"
The response returns the new order with an id and createdAt timestamp. In your first terminal, you see Order created followed by the order object.
- Retrieve that order by running:
curl http://localhost:3000/orders/1
The response returns the order you just created. Your first terminal shows Order retrieved with the order details.
What's wrong with this console.log output?
Look at the logs in your first terminal. Every line is plain text with no timestamps, no severity levels, and no consistent structure.
- You cannot filter by severity (which logs are errors vs info?).
- You cannot search by field (which request created this order?).
- You cannot pipe this output to any monitoring tool because it is not machine-readable.
This is exactly the gap you will close in the next step by building a structured JSON logger.
Your Express API is up and running. Next up, you'll replace those bare console.log calls with a structured JSON logger that gives every line a timestamp, a severity level, and searchable fields.
Build a Structured JSON Logger
In the previous step, you scattered console.log statements throughout your Express routes to simulate how many developers log in early projects.
The problem is that console.log output has no timestamps, no severity levels, and no structure. You cannot filter by severity, search by field, or pipe these logs to any monitoring tool. In this step, you will replace it with a Logger class that outputs structured JSON with every field a production system needs.
In this step, get ready to:
- Create a Logger class with configurable minimum log level and default metadata.
- Replace all console.log calls with structured logger methods.
- Verify that log level filtering suppresses lower-priority messages.
Create the Logger class
Structured logging means every log entry is a JSON object with consistent fields. Instead of a free-form string, each entry carries a severity level, a timestamp, a message, and any additional context as named keys.
- Create a new file called src/logger.js in your src folder.
- Add the level priority map and Logger class foundation by pasting the following code:
// Map level names to numeric priorities for comparison
const LOG_LEVELS = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
class Logger {
constructor(options = {}) {
// Set minimum severity level (only log at this level or higher)
this.level = options.level || 'info';
// Fields that appear in every log entry
this.defaultMeta = options.defaultMeta || {};
// Placeholders for future features (redaction, transports)
this.redactPaths = options.redact || [];
this.transports = [];
}
// Check if a given level meets the minimum threshold
_shouldLog(level) {
return LOG_LEVELS[level] <= LOG_LEVELS[this.level];
}
What does this code do?
LOG_LEVELS assigns a numeric priority to each severity. Lower numbers mean higher urgency. The constructor stores the minimum level the logger will output, plus any default fields (like a service name) that should appear on every entry.
_shouldLog compares an incoming entry's priority against the configured minimum. If you set the minimum to info (priority 2), only entries at priority 2 or lower (info, warn, error) pass through. Debug (priority 3) is filtered out.
- Below the _shouldLog method, add the core logging engine and convenience methods:
// Build and output a structured JSON log entry
_log(level, message, meta = {}) {
if (!this._shouldLog(level)) return;
const entry = {
level,
time: new Date().toISOString(),
msg: message,
...this.defaultMeta,
...meta,
};
console.log(JSON.stringify(entry));
}
// Convenience methods for each severity level
error(message, meta) { this._log('error', message, meta); }
warn(message, meta) { this._log('warn', message, meta); }
info(message, meta) { this._log('info', message, meta); }
debug(message, meta) { this._log('debug', message, meta); }
}
module.exports = { Logger, LOG_LEVELS };
What does this code do?
_log is the engine that all log calls pass through. It first checks _shouldLog to decide whether the entry meets the minimum level, then builds a JSON object with the level, an ISO timestamp, the message, all default metadata, and any extra fields you pass in.
The convenience methods (error, warn, info, debug) are shortcuts so callers write logger.info('...') instead of logger._log('info', '...').
- Save src/logger.js.
- Confirm the Logger loads without errors by running this in your terminal:
node -e "const { Logger } = require('./src/logger'); const log = new Logger(); log.info('test');"
You should see a single JSON string like {"level":"info","time":"2026-08-04T...","msg":"test"} printed to the terminal.
Seeing a syntax error?
Make sure you closed the class with a } before the module.exports line. Also confirm the file is saved in src/logger.js (inside the src folder, not the project root).
help me fix a syntax error in my Logger class
✔️ Awesome, I've got everything!
Great. Double check you have saved src/logger.js and move on.
ⓧ I'd like to double check the full code
Your src/logger.js file should contain these two sections in order:
- The LOG_LEVELS object, the Logger class opening with its constructor and _shouldLog method (from chunk 1 above).
- The _log method, four convenience methods, the class closing brace, and the module.exports line (from chunk 2 above).
Scroll up and confirm both chunks are present and in order.
Replace console.log with the Logger
Now that the Logger class exists, you will wire it into your Express app and swap out every console.log for the appropriate severity method.
- In src/app.js, replace the first line (const express = require('express');) with the following block that imports both Express and the Logger, then creates a configured instance:
const express = require('express');
const { Logger } = require('./logger');
// Create logger with minimum level and default fields for every entry
const logger = new Logger({
level: process.env.LOG_LEVEL || 'info',
defaultMeta: {
service: 'order-api',
environment: process.env.NODE_ENV || 'development',
},
});
What does this code do?
You instantiate a Logger with a minimum level of info (so debug messages are suppressed) and two default metadata fields: service and environment. These fields appear automatically on every log entry without you needing to pass them each time.
- In the GET /orders route, find this line:
console.log('Fetching all orders');
- Replace it with:
logger.info('Fetching all orders', { count: orders.length });
- In the POST /orders route, find:
console.log('Invalid order request', req.body);
- Replace it with:
logger.warn('Invalid order request', { body: req.body });
- Still in the POST /orders route, find:
console.log('Order created', order);
- Replace it with:
logger.info('Order created', { order });
- In the GET /orders/:id route, find:
console.log('Order not found', req.params.id);
- Replace it with:
logger.warn('Order not found', { orderId: req.params.id });
- Still in GET /orders/:id, find:
console.log('Order retrieved', order);
- Replace it with:
logger.info('Order retrieved', { order });
- Finally, at the bottom of the file, find:
console.log(`Server started on port ${PORT}`);
- Replace it with:
logger.info(`Server started on port ${PORT}`);
- Save src/app.js.
- Start the server by running:
npm run dev
Before you test, predict what the terminal output will look like now. Will it still be plain text, or something different?
- In a second terminal, send a test request by running:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d "{\"item\": \"Keyboard\", \"quantity\": 2}"
In the server terminal, you now see structured JSON like {"level":"info","time":"2026-08-04T...","msg":"Order created","service":"order-api","environment":"development","order":{"id":1,...}} instead of bare text.
Still seeing plain console.log output?
The --watch flag auto-restarts on file changes, but only if the file saved successfully. Confirm there are no leftover console.log calls in src/app.js and that you saved the file.
Also check the import path is ./logger (not ./src/logger) since app.js is already inside the src folder.
help me debug why my Logger isn't producing JSON output
✔️ Awesome, I've got everything!
Great. Double check you have saved src/app.js and move on.
ⓧ I'd like to double check the full code
Your src/app.js should now have:
- The import block at the top (require('express') and require('./logger')) plus the Logger instantiation with level, defaultMeta, service, and environment.
- The three route handlers using logger.info and logger.warn instead of console.log. No console.log calls should remain.
- The server listen callback using logger.info for the startup message.
Scroll up and confirm each replacement matches the before/after pairs shown above.
Test log level filtering
The whole point of log levels is that you can suppress noise in production while keeping verbose output available for debugging. Your logger's minimum level is currently info, so any debug call should be silently discarded.
- In src/app.js, find the GET /orders route handler.
- Add this line directly above the existing logger.info call:
logger.debug('Debug: checking orders array', { orders });
- Save src/app.js.
Before you send the request, predict: will you see this debug line in the terminal output, or not?
- Send a GET request by running:
curl http://localhost:3000/orders
You only see the info-level "Fetching all orders" entry. The debug line is gone because debug (priority 3) is below the minimum info threshold (priority 2).
- Now temporarily change the logger's level in src/app.js. Find:
level: process.env.LOG_LEVEL || 'info',
- Replace 'info' with 'debug' so the line reads:
level: process.env.LOG_LEVEL || 'debug',
- Save src/app.js and run the same curl again:
curl http://localhost:3000/orders
This time you see both the debug entry AND the info entry in the terminal. The numeric priority comparison is working exactly as designed.
- Change the level back to 'info' and remove the logger.debug test line from the GET /orders route before continuing.
- Save src/app.js.
Why this pattern matters
Production loggers like Pino use the same numeric-priority approach. In production you set the level to warn or error so only critical issues surface. When debugging a live issue, you lower the level to debug without changing any log call sites.
Your logger now outputs machine-parseable JSON with timestamps and severity levels. Next up, you will split this output into multiple destinations so humans and machines each get the format they prefer.
Add Multiple Transports
Your logger produces structured JSON, which is exactly what monitoring tools and log aggregation systems need.
But there are two problems. First, raw JSON is painful for humans to scan during development. Second, if your terminal session closes, every log line vanishes forever. This step solves both by splitting output into two transports: a colorized human-readable format for the console, and raw JSON appended to a persistent file on disk.
In this step, get ready to:
- Add a transport system to the Logger class so output destinations are pluggable.
- Build a ConsoleTransport that prints color-coded, human-scannable lines.
- Build a FileTransport that persists JSON log lines to disk.
Add a transport system to the Logger class
A transport is any object with a write(entry) method. The logger doesn't care whether the transport writes to a console, a file, a network socket, or a database. It just iterates its list of transports and hands each one the log entry.
- In src/logger.js, find the _shouldLog method inside the Logger class.
- Add the following addTransport method directly above _shouldLog:
addTransport(transport) {
this.transports.push(transport);
}
- Now find the existing _log method. It currently looks like this:
_log(level, message, meta = {}) {
if (!this._shouldLog(level)) return;
const entry = {
level,
time: new Date().toISOString(),
msg: message,
...this.defaultMeta,
...meta,
};
console.log(JSON.stringify(entry));
}
- Replace the entire _log method with this updated version:
_log(level, message, meta = {}) {
if (!this._shouldLog(level)) return;
const entry = {
level,
time: new Date().toISOString(),
msg: message,
...this.defaultMeta,
...meta,
};
// Fall back to console.log if no transports configured
if (this.transports.length === 0) {
console.log(JSON.stringify(entry));
return;
}
// Send the entry to every registered transport
for (const transport of this.transports) {
transport.write(entry);
}
}
What does this code do?
- addTransport(transport) pushes any object with a write() method onto the logger's transports list.
- The modified _log builds the structured entry as before, but now hands it to each transport instead of calling console.log directly.
- The fallback ensures the logger still works even when no transports are registered. This makes the upgrade backward-compatible.
- Save src/logger.js.
- Check your terminal where npm run dev is running. The server auto-restarts and you should still see the same JSON output as before (the fallback is active since no transports are registered yet).
Server crashed on restart?
- Check that your addTransport method is inside the Logger class (between the constructor and _shouldLog).
- Make sure you didn't accidentally delete the closing brace of the Logger class.
- help me debug a crash in my Logger class after adding the transport system
Create the ConsoleTransport class
The ConsoleTransport formats log entries for human eyes. Instead of raw JSON, it prints a short timestamp, a color-coded level, and the message.
- In src/logger.js, add the following ConsoleTransport class below the Logger class (after its closing brace):
class ConsoleTransport {
constructor(options = {}) {
this.colorize = options.colorize !== false;
}
write(entry) {
if (this.colorize) {
// Destructure to separate formatting fields from metadata
const { level, time, msg, ...meta } = entry;
const timestamp = time.split('T')[1].split('.')[0];
const color = this._getColor(level);
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
console.log(`${color}[${timestamp}] ${level.toUpperCase()}\x1b[0m: ${msg}${metaStr}`);
} else {
console.log(JSON.stringify(entry));
}
}
_getColor(level) {
// ANSI escape codes for terminal colors
const colors = {
error: '\x1b[31m',
warn: '\x1b[33m',
info: '\x1b[36m',
debug: '\x1b[90m',
};
return colors[level] || '\x1b[0m';
}
}
What does this code do?
- write(entry) is the method the Logger calls on each transport. ConsoleTransport formats the entry for human readability.
- ANSI escape codes (\x1b[31m for red, \x1b[33m for yellow, etc.) tell the terminal to color the text. \x1b[0m resets the color back to normal after the level.
- The rest-spread (...meta) collects all fields that aren't level, time, or msg. This keeps the output compact while still showing contextual data.
- Save src/logger.js. The server restarts but still shows JSON output (the ConsoleTransport exists but isn't wired up yet).
Create the FileTransport class and wire up both transports
The FileTransport appends each log entry as a JSON line to a file on disk. This gives you a persistent, searchable record that survives terminal restarts.
- At the very top of src/logger.js (above the LOG_LEVELS object), add these two require statements:
const fs = require('node:fs');
const path = require('node:path');
- Now add the FileTransport class below the ConsoleTransport class:
class FileTransport {
constructor(options = {}) {
// Default to a 'logs' directory in the project root
this.dir = options.dir || path.join(process.cwd(), 'logs');
this.filename = options.filename || 'app.log';
// Create the logs directory if it doesn't exist
if (!fs.existsSync(this.dir)) {
fs.mkdirSync(this.dir, { recursive: true });
}
}
write(entry) {
const filePath = path.join(this.dir, this.filename);
// Append one JSON object per line (newline-delimited JSON)
const line = JSON.stringify(entry) + '\n';
fs.appendFileSync(filePath, line);
}
}
What does this code do?
- The constructor creates a logs/ directory automatically using fs.mkdirSync with { recursive: true }. This means no manual folder creation is needed.
- write(entry) appends each entry as a single JSON line followed by a newline character. This format (newline-delimited JSON) lets you search the file line by line.
- fs.appendFileSync is synchronous, meaning it blocks the event loop briefly on each write. Production loggers like Pino offload this to a worker thread. For learning purposes, the synchronous version keeps the code simple and the trade-off visible.
- Finally, update the module.exports line at the bottom of src/logger.js. Find this line:
module.exports = { Logger, LOG_LEVELS };
- Replace it with:
module.exports = { Logger, ConsoleTransport, FileTransport, LOG_LEVELS };
- Save src/logger.js.
Now wire up both transports in your application.
- In src/app.js, find the import line at the top:
const { Logger } = require('./logger');
- Replace it with:
const { Logger, ConsoleTransport, FileTransport } = require('./logger');
- Find the line where you create the logger (the closing }); of the new Logger({...}) call). Add these two lines directly below it:
logger.addTransport(new ConsoleTransport({ colorize: true }));
logger.addTransport(new FileTransport({ filename: 'app.log' }));
Why two transports?
Each transport serves a different audience. The ConsoleTransport gives you instant, scannable feedback during development. The FileTransport creates a persistent audit trail you can search later.
This is the same pattern production loggers use. Pino sends JSON to stdout and lets you pipe it to any destination. You're building the same idea, just routing explicitly inside the Logger.
- Save src/app.js.
Before you check the terminal, predict what the output will look like. Will it still be raw JSON, or something different?
- Check your terminal. You should see the server startup message in color-coded format like:
[14:30:22] INFO: Server started on port 3000 {"service":"order-api","environment":"development"}
The INFO text appears in cyan instead of plain white. That's the ConsoleTransport working.
- Now send a test request from a second terminal to verify both transports are active:
curl http://localhost:3000/orders
- Check the terminal where the server is running. You should see a cyan-colored line like:
[14:30:45] INFO: Fetching all orders {"service":"order-api","environment":"development","count":0}
- Now verify the log file was created by running:
cat logs/app.log
You should see JSON objects, one per line. Each line contains the same structured data (level, time, msg, service, environment) but in raw machine-parseable JSON format.
Not seeing color output?
- Make sure the import line in src/app.js includes both ConsoleTransport and FileTransport in the destructured import.
- Check that the two logger.addTransport(...) lines come after the new Logger({...}) call but before const app = express().
- If you see Cannot find module errors, verify that module.exports in src/logger.js includes ConsoleTransport and FileTransport.
- help me fix my transport setup so logs appear in color
No logs/app.log file?
- Confirm you added const fs = require('node:fs'); and const path = require('node:path'); at the very top of src/logger.js (above LOG_LEVELS).
- Check that FileTransport is exported in module.exports and imported in src/app.js.
- help me figure out why my FileTransport isn't creating the log file
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Your src/logger.js should contain these sections in order. Scroll up and confirm each is present:
- The fs and path require statements at the top.
- The LOG_LEVELS object (unchanged).
- The Logger class with constructor, addTransport, _shouldLog, the modified _log (with transport iteration and fallback), and the four convenience methods.
- The ConsoleTransport class with write() and _getColor().
- The FileTransport class with directory creation in the constructor and write() using appendFileSync.
- module.exports = { Logger, ConsoleTransport, FileTransport, LOG_LEVELS };
Your src/app.js should have:
- The updated import: const { Logger, ConsoleTransport, FileTransport } = require('./logger');
- Two logger.addTransport(...) lines immediately after the new Logger({...}) call.
- Everything else (routes, app.listen) remains unchanged.
Your logger now writes to two destinations simultaneously. Humans get color-coded output they can scan at a glance, while machines get a persistent JSON file they can search and parse. Next up, you'll tackle a harder problem: when multiple requests arrive at the same time, how do you tell which log line belongs to which request?
Add Request Correlation IDs
Your logger now outputs structured JSON with timestamps, levels, and metadata. But fire two requests at the same time and the log lines interleave.
You cannot tell which "Order created" log belongs to which incoming request. Correlation IDs solve this by tagging every log line from a single request with the same unique identifier.
In this step, get ready to:
- Add a child() method to the Logger class that inherits parent config and adds request-specific metadata.
- Create Express middleware that generates a correlation ID for every incoming request.
- Wire the middleware into your API so every log line carries a requestId you can filter on.
Add a child() method to the Logger class
A child logger inherits everything from its parent (level, transports, default metadata) but adds extra fields that appear on every log call made through that child. This is how you attach a requestId to every log line without passing it manually each time.
- In src/logger.js, find the addTransport method and add the following child method directly below it:
child(bindings) {
const childLogger = Object.create(this);
childLogger.defaultMeta = { ...this.defaultMeta, ...bindings };
return childLogger;
}
What does this code do?
Object.create(this) creates a new object whose prototype is the parent logger. The child automatically inherits all methods (info, warn, error) and the transport array without copying them.
The only thing overridden is defaultMeta. The child merges the parent's metadata with the new bindings, so every log call through the child automatically includes those extra fields. This is the same zero-copy pattern that Pino uses internally.
- Save src/logger.js.
Why no visible change yet?
The child() method exists on the Logger now, but nothing calls it yet. The next substep creates middleware that uses it to stamp every request with a unique ID.
Create the request logger middleware
Express middleware sits between the incoming request and your route handlers. This middleware generates a unique correlation ID, creates a child logger carrying that ID, and attaches it to req.log so every handler can use it.
- Create a new file called src/middleware.js and add the following code:
const crypto = require('node:crypto');
// Middleware that attaches a correlation ID to every request
function requestLogger(logger) {
return (req, res, next) => {
// Use existing request ID header or generate a new one
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
const startTime = Date.now();
// Create a child logger scoped to this request
req.log = logger.child({ requestId });
req.log.info('Request received', {
method: req.method,
url: req.url,
body: req.body,
});
next();
};
}
module.exports = { requestLogger };
What does this code do?
- crypto.randomUUID() generates a unique ID for each request. If the caller already sent an x-request-id header, the middleware reuses it (useful for distributed tracing across services).
- logger.child({ requestId }) creates a child logger that automatically includes the requestId field on every log call.
- req.log attaches the child logger to the request object so any downstream handler can call req.log.info(...) and the correlation ID is included automatically.
- Save src/middleware.js.
Now wire the middleware into your Express app so it runs on every request.
- In src/app.js, find this line near the top:
const { Logger, ConsoleTransport, FileTransport } = require('./logger');
- Add this import directly below it:
const { requestLogger } = require('./middleware');
- Then find this line:
app.use(express.json());
- Add the middleware call directly below it:
app.use(requestLogger(logger));
- Save src/app.js.
- If your server is still running from npm run dev, it restarts automatically. If not, start it again by running:
npm run dev
- Send a test request from a second terminal by running:
curl http://localhost:3000/orders
You should see a color-coded "Request received" log line in your server terminal that includes a requestId field with a UUID like a1b2c3d4-e5f6-.... The middleware is working.
Not seeing a requestId in the log?
- Make sure app.use(requestLogger(logger)) comes AFTER app.use(express.json()) but BEFORE your route definitions.
- Check that src/middleware.js exports requestLogger (not a default export). The curly braces in the import matter.
- Confirm the child() method is inside the Logger class in src/logger.js, not outside it.
Ask for help: My middleware isn't logging a requestId. What should I check?
Add response tracking and update route handlers
The middleware logs when a request arrives, but you also want to know when it finishes and how long it took. You will add a response listener and then switch all route handlers to use req.log so every log line carries the correlation ID.
- In src/middleware.js, find the line that says next(); and add the following code directly above it:
res.on('finish', () => {
const duration = Date.now() - startTime;
// Choose log level based on response status code
const level = res.statusCode >= 500 ? 'error'
: res.statusCode >= 400 ? 'warn'
: 'info';
req.log[level]('Request completed', {
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration: `${duration}ms`,
});
});
What does this code do?
The finish event fires after Express sends the response. At that point the status code is set, so you can calculate the total duration and pick a log level: error for 5xx, warn for 4xx, info for everything else.
Because this uses the same req.log child logger, the "Request completed" line automatically carries the same requestId as "Request received". That pair lets you trace the full lifecycle of any request.
- Save src/middleware.js.
Now update the route handlers so their log calls also carry the correlation ID.
- In src/app.js, find every logger.info, logger.warn, and logger.error call inside the route handlers. Here is an example of what to look for:
logger.info('Fetching all orders', { count: orders.length });
- Replace logger with req.log in each one, so it becomes:
req.log.info('Fetching all orders', { count: orders.length });
- Apply the same change to all five log calls inside route handlers. The only logger.info you should leave unchanged is the Server started on port line at the bottom (it runs at startup, not inside a request).
Quick find-and-replace
Press Cmd+Shift+H (macOS) or Ctrl+Shift+H (Windows) in VS Code. Search for logger.info and replace with req.log.info. Repeat for logger.warn to req.log.warn. Skip the server startup line.
- Save src/app.js.
Before you run the final test, think about what you expect to see. You are sending two POST requests rapidly. Each request now gets its own requestId. What should the log file show for each request?
- Send two rapid requests by running:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d "{\"item\": \"Keyboard\", \"quantity\": 2}" & curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d "{\"item\": \"Mouse\", \"quantity\": 1}"
- Now inspect the log file by running:
findstr "requestId" logs\app.log
Every line now has a requestId field. Pick one of the IDs from the output and filter for it:
findstr "paste-one-request-id-here" logs\app.log
You should see exactly 3 log lines for that single request: "Request received", the route handler log ("Order created"), and "Request completed". Logs from the other request are cleanly separated because they carry a different ID.
Seeing logs without a requestId?
- If only the middleware logs have a requestId but route handler logs do not, you missed a logger to req.log replacement. Double check all five log calls inside route handlers.
- If findstr returns nothing, make sure you are searching the right file. The log file path is logs\app.log relative to your project root.
- On macOS or Linux, use grep instead of findstr.
Ask for help: My log lines are missing the requestId field. How do I debug this?
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: src/logger.js, src/middleware.js, and src/app.js.
ⓧ I'd like to double check the full code
Scroll up through this step and confirm each of these pieces is in your code, in order:
- src/logger.js: The child(bindings) method sits between addTransport and _shouldLog.
- src/middleware.js: The file imports node:crypto, defines requestLogger(logger) with req.log.info('Request received', ...), a res.on('finish', ...) handler, and a next() call. Exports { requestLogger }.
- src/app.js: Imports { requestLogger } from ./middleware. Calls app.use(requestLogger(logger)) after express.json(). All route handler log calls use req.log (not logger), except the startup line at the bottom.
Every log line from your API now carries a unique requestId that ties together the full lifecycle of a single request. Next up, you will tackle a security problem that this correlation logging actually makes worse: your middleware is now logging full request bodies, which means sensitive data like passwords ends up in your log file.
Add Sensitive Data Redaction
Your middleware from the last step logs full request bodies to help trace issues. This is great for debugging, but it creates a hidden security risk.
If a request contains sensitive fields like passwords or credit card numbers, those values end up in plain text in your log file. Any developer with file access, or any log aggregation system, can read them. This step ensures sensitive fields are masked before they reach any transport.
In this step, get ready to:
- Expose the security hole by sending sensitive data through your existing logger.
- Build a path-based redaction system that deep-clones log entries and masks specified fields.
- Integrate redaction into the logging pipeline so sensitive data never reaches any transport.
Send sensitive data to expose the security hole
Before building any fix, you need to see the problem with your own eyes. Your middleware currently logs the full request body, which means anything the client sends ends up in logs/app.log.
- Make sure your server is running with npm run dev.
- Send a POST request that includes a password and credit card number by running this command in a second terminal:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d "{\"item\": \"Laptop\", \"quantity\": 1, \"password\": \"secret123\", \"creditCard\": \"4111-1111-1111-1111\"}"
Before you check the logs, what do you think will appear for the password and creditCard fields?
- Open logs/app.log in VS Code and look at the most recent log entries.
You will see "password":"secret123" and "creditCard":"4111-1111-1111-1111" sitting in plain text in your log file. This is exactly the gap you are about to close.
Why is this dangerous?
Production applications ship logs to centralized systems like Datadog, Splunk, or CloudWatch. Once sensitive data hits those systems, it is stored, indexed, and searchable by anyone with access.
Production loggers like Pino solve this with a redact option that masks fields before serialization. You are about to build the same pattern from scratch.
Add path-based redaction to the Logger class
The redaction system accepts an array of dot-notation paths (like body.password) and replaces matching values with [REDACTED] before the entry reaches any transport.
- In src/logger.js, find the _shouldLog method inside the Logger class.
- Add the following _redact method directly below _shouldLog:
_redact(obj) {
// Skip redaction if no paths are configured
if (this.redactPaths.length === 0) return obj;
// Deep clone to avoid mutating the original entry
const redacted = JSON.parse(JSON.stringify(obj));
for (const redactPath of this.redactPaths) {
this._redactPath(redacted, redactPath.split('.'));
}
return redacted;
}
What does _redact do?
- It returns the entry unchanged if no redact paths are configured (fast exit for loggers without redaction).
- JSON.parse(JSON.stringify(obj)) deep-clones the entry so the original objects in your route handlers are never mutated.
- It loops over each configured path and delegates to _redactPath to walk the nested structure and mask the target field.
- Add the _redactPath method directly below _redact:
_redactPath(obj, parts) {
if (!obj || typeof obj !== 'object') return;
// Wildcard matches any key at this level
if (parts[0] === '*') {
for (const key of Object.keys(obj)) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
this._redactPath(obj[key], parts.slice(1));
}
}
return;
}
// Final segment reached - replace the value
if (parts.length === 1) {
if (parts[0] in obj) {
obj[parts[0]] = '[REDACTED]';
}
return;
}
// Traverse deeper into nested objects
if (obj[parts[0]] && typeof obj[parts[0]] === 'object') {
this._redactPath(obj[parts[0]], parts.slice(1));
}
}
What does _redactPath do?
- It recursively traverses nested objects following each segment of the dot-notation path.
- When it reaches the final segment and the key exists, it replaces the value with [REDACTED].
- The * wildcard matches any key at that level, letting you redact fields inside arrays of objects or dynamic keys without listing each one.
- Save src/logger.js.
Seeing a syntax error on save?
- Make sure both methods are inside the Logger class body (between the opening { after class Logger and its closing }).
- Check that each method has matching curly braces. The _redactPath method has multiple if blocks that each need their own closing brace.
Why am I getting a syntax error after adding redaction methods?
Integrate redaction into the logging pipeline
The redaction methods exist but are not called yet. You need to wire them into _log() and tell the logger which paths to redact.
- In src/logger.js, find the _log method. Locate these lines after the entry object is built:
if (this.transports.length === 0) {
console.log(JSON.stringify(entry));
return;
}
for (const transport of this.transports) {
transport.write(entry);
}
- Replace those lines with the following, which calls redaction before passing the entry to transports:
// Redact sensitive fields before any transport sees the data
const redacted = this._redact(entry);
if (this.transports.length === 0) {
console.log(JSON.stringify(redacted));
return;
}
for (const transport of this.transports) {
transport.write(redacted);
}
What changed?
One new line: const redacted = this._redact(entry). Every reference to entry after that point now uses redacted instead. The raw entry stays untouched so your HTTP responses still contain the original values.
- Save src/logger.js.
- Now open src/app.js and find the Logger instantiation. Locate this block:
const logger = new Logger({
level: process.env.LOG_LEVEL || 'info',
defaultMeta: {
service: 'order-api',
environment: process.env.NODE_ENV || 'development',
},
});
- Replace it with the following, which adds the redact option specifying which paths to mask:
const logger = new Logger({
level: process.env.LOG_LEVEL || 'info',
defaultMeta: {
service: 'order-api',
environment: process.env.NODE_ENV || 'development',
},
// Paths to redact from log entries
redact: ['body.password', 'body.creditCard', 'body.token'],
});
What do these paths mean?
Each path is a dot-notation string relative to the log entry object. When the middleware logs { body: { password: 'secret123' } }, the redactor walks body then password and replaces the value.
The body.token path handles auth tokens that might appear in future request bodies.
- Save src/app.js.
- Delete logs/app.log so you start with a clean log file for verification. The FileTransport recreates it automatically on the next log write.
What do you expect to see for the password and creditCard fields this time?
- Send the same request again to test redaction:
curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d "{\"item\": \"Laptop\", \"quantity\": 1, \"password\": \"secret123\", \"creditCard\": \"4111-1111-1111-1111\"}"
In your terminal's console output, you should see the color-coded log lines with [REDACTED] where the password and credit card values used to be. The item and quantity fields remain visible.
- Open logs/app.log in VS Code to confirm the same redaction appears in the file transport.
You should see entries containing "password":"[REDACTED]" and "creditCard":"[REDACTED]" while "item":"Laptop" and "quantity":1 remain intact.
- Check the HTTP response from the curl command. It should still contain the original unredacted data in the order object, proving redaction only affects logs.
Why does the response still show the original data?
The _redact method deep-clones the entry before masking. The original objects in your route handler are never mutated. This separation is critical in production: you log safely while still returning correct responses to the client.
Still seeing plain text passwords in the logs?
- Make sure you saved both src/logger.js and src/app.js. The --watch flag only restarts when it detects saved changes.
- Check that your redact array paths match the structure of your log entry. The middleware logs the body under the key body, so the path must be body.password, not just password.
- Verify that const redacted = this._redact(entry) appears AFTER the entry object is built, and that you pass redacted (not entry) to both JSON.stringify and transport.write.
Why is my redaction not working even though I added the paths?
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/logger.js and src/app.js.
ⓧ I'd like to double check the full code
Your src/logger.js is too long to show in one block. Scroll up and confirm these pieces are present, in order:
- The require statements for node:fs and node:path at the top.
- The LOG_LEVELS object mapping level names to numeric priorities.
- The Logger class with: constructor (including this.redactPaths = options.redact || []), addTransport, child, _shouldLog, _redact, _redactPath, _log (calling this._redact(entry) and passing redacted to transports), and the four convenience methods.
- The ConsoleTransport class with colorized output.
- The FileTransport class that appends JSON to disk.
- The module.exports line exporting Logger, ConsoleTransport, FileTransport, and LOG_LEVELS.
For src/app.js, confirm the Logger instantiation now includes the redact option:
const logger = new Logger({
level: process.env.LOG_LEVEL || 'info',
defaultMeta: {
service: 'order-api',
environment: process.env.NODE_ENV || 'development',
},
redact: ['body.password', 'body.creditCard', 'body.token'],
});
Your logger now automatically strips sensitive data from every log line before it reaches the console or the file. Any new field you want to protect is a single string added to the redact array. Next up, tackle the Secret Mission and wire up automatic context propagation so your correlation IDs flow through deeply nested async calls without passing the logger manually.
Secret mission
AsyncLocalStorage Context Propagation
Right now, every function that needs to log with the correlation ID must receive req.log as an argument. In real applications with deep call stacks, this is impractical. Wire up AsyncLocalStorage so context flows automatically through async operations without passing the logger manually.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The node-logger-system project folder on your Desktop.
- The logs/app.log file inside the project (grows with each request you send).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the logger.
- The logs/app.log file will grow each time you send requests to the server. This is fine for local development.
- The server only runs when you start it with npm run dev. It does not consume resources when stopped.
Pause - I'll come back to this later
Shut down the running server to free up the terminal. Your project files stay on disk for when you return.
- In the terminal where the server is running, press Ctrl+C to stop the process.
- Your project folder and log file remain on your Desktop. Nothing consumes memory or CPU while the server is stopped.
Delete - I don't want to use this again
Remove all project files from your machine. This frees disk space and removes the log file entirely.
- In the terminal where the server is running, press Ctrl+C to stop the process.
- Delete the entire project folder by running:
macOS
cd ~/Desktop
rm -rf node-logger-system
Windows
cd ~\Desktop
rmdir /s /q node-logger-system
This removes the project folder, all source files, the logs/ directory, and the node_modules/ folder in one step.
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-grade logging system from scratch using nothing but Node.js built-in modules and Express.
You've learned how to:
- Build a structured JSON logger with configurable log levels and a transport system that writes color-coded output to the console and machine-parseable JSON to a persistent file simultaneously.
- Add per-request correlation IDs via Express middleware and child loggers, enabling you to trace a single request across interleaved traffic in a shared log file.
- Implement path-based sensitive data redaction that automatically masks passwords, tokens, and credit cards before they reach any transport.
- Secret Mission: Wired up AsyncLocalStorage for automatic context propagation through async call stacks without passing the logger manually.
Ready to quiz yourself?