Node.js logging
Logging is essential for almost every application. A good logger should include levels, timestamps, and file output - features you’d expect to be built into Node.js by default. Yet, surprisingly, Node.js still lacks a robust built-in solution.
As a result, many developers immediately turn to NPM modules, adding yet another dependency to their projects. This leads to an unnecessary import in nearly every file - a small annoyance that adds up over time.
What if you could enhance Node.js’s built-in console to handle structured logging without extra dependencies? In this post, I’ll show you how.
Default logs
The legendary console.log
works simply as possible - it just outputs to the console what you have passed to it:
// src/main.ts
console.log("This is default console.log");
console.log("test", 2025, true, { data: [] });
$ npx tsx src/main.ts
This is default console.log
test 2025 true { data: [] }
You may also know about other methods of the global console
object, such as info
, warn
, error
, debug
:
// src/main.ts
console.info("info message");
console.warn("warn message");
console.error("error message");
console.debug("debug message");
$ npx tsx src/main.ts
info message
warn message
error message
debug message
By default, these methods behave almost identically to console.log
- they print plain text without additional formatting. The only technical difference is that log
, info
, and debug
write to stdout, while warn
and error
write to stderr. However, this distinction is rarely visible in most development environments, so we’ll focus on enhancing their usability.
Modification
Let's modify the global behavior of these methods step by step. For convenience, I create a separate file for this:
$ touch src/utils/logger.ts
Define constants and imports
import fs from "fs";
import path from "path";
const LOG_EMOJI = {info: "💬", warn: "⚠️", error: "🛑", debug: "🔨"};
const LOGS_DIR = path.join(path.dirname("."), "logs");
We will use the Emoji symbol to indicate each of the levels. Using emoji for logs is simpler than ANSI color codes because they’re instantly readable (e.g., ❌ Error vs. \x1b[31mError\x1b[0m
), work cross-platform (unlike ANSI colors, which require terminal support), add semantic meaning, and enhance visual scanning.
We also define the folder where the log files will be saved. Just the ./logs
folder in the root of the project is suitable for this. Feel free to change some of this to your liking.
Write helpers
Log files need a naming strategy that balances convenience and maintainability. While a single fixed filename would work initially, it would eventually grow too large, slowing down access and making debugging harder. A simple solution is to rotate logs daily by including the current date in the filename (e.g., 05-26.log). This approach:
- Prevents unbounded file growth
- Organizes logs chronologically for easy retrieval
- Requires no manual cleanup
Here’s the helper function to implement this:
const padZero = (value: number) => String(value).padStart(2, "0");
function getLogFilePath() {
const now = new Date();
const mm = padZero(now.getMonth() + 1); // Month (01-12)
const dd = padZero(now.getDate()); // Day (01-31)
const filename = `${mm}-${dd}.log`;
return path.join(LOGS_DIR, filename);
}
Next, we need a time prefix for our logs in the form of a string. I'll just take the string representation of the date in the current locale. Again, feel free to change it to your liking:
function getDateString() {
return new Date().toLocaleString();
}
And finally, we will assemble a helper to write the log to a file:
function logToFile(prefix: string, ...args: unknown[]) {
fs.appendFileSync(getLogFilePath(), `${prefix} ${args.join(" ")}\n`);
}
Write modification
Now we can write our modifier:
function modifyConsoleLogs() {
const originalInfo = console.info;
const originalWarn = console.warn;
const originalError = console.error;
const originalDebug = console.debug;
console.info = (...args: unknown[]) => {
const prefix = `${getDateString()} ${LOG_EMOJI.info}`;
originalInfo(prefix, ...args);
logToFile(prefix, ...args);
};
console.warn = (...args: unknown[]) => {
const prefix = `${getDateString()} ${LOG_EMOJI.warn}`;
originalWarn(prefix, ...args);
logToFile(prefix, ...args);
};
console.error = (...args: unknown[]) => {
const prefix = `${getDateString()} ${LOG_EMOJI.error}`;
originalError(prefix, ...args);
logToFile(prefix, ...args);
};
console.debug = (...args: unknown[]) => {
// Skip debug logs in production
if (process.env.NODE_ENV === "production") return;
const prefix = `${getDateString()} ${LOG_EMOJI.debug}`;
originalDebug(prefix, ...args);
logToFile(prefix, ...args);
};
};
We store the original console methods to prevent infinite recursion - without this, our overridden methods would call themselves indefinitely (e.g., console.error
triggering itself). The constants preserve the native functions for safe reuse.
Then each modified method prepends a timestamp and corresponding emoji, logs to the console, and saves to a file. Debug logs are skipped in production (NODE_ENV
=== 'production'), keeping development-only logs clean.
Combine into single function call
Finally, we can combine all stuff into a single initialization function for our new logger to call it when the application starts:
/**
* Initializes the logger by:
* 1. Creating logs directory if it doesn't exist
* 2. Modifying console methods to add enhanced logging
*/
export const initLogger = () => {
if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
modifyConsoleLogs();
};
The result
Let's run our logger:
// src/main.ts
import { initLogger } from "./utils/logger";
const main = async () => {
try {
initLogger();
console.log("This is default console.log");
console.info("This is modified console.info");
console.warn("This is modified console.warn");
console.error("This is modified console.error");
console.debug("This is modified console.debug");
} catch (error) {
console.error(error);
}
};
main();
$ npx tsx src/main.ts
This is default console.log
5/26/2025, 12:25:07 PM 💬 This is modified console.info
5/26/2025, 12:25:07 PM ⚠️ This is modified console.warn
5/26/2025, 12:25:07 PM 🛑 This is modified console.error
5/26/2025, 12:25:07 PM 🔨 This is modified console.debug
As we can see, logging to the console has become much more pleasant and informative visually, while console.log
works as before. Let's check the saving to a file:
$ tree logs
logs
└── 05-26.log
1 directory, 1 file
$ cat logs/05-26.log
5/26/2025, 12:28:36 PM 💬 This is modified console.info
5/26/2025, 12:28:36 PM ⚠️ This is modified console.warn
5/26/2025, 12:28:36 PM 🛑 This is modified console.error
5/26/2025, 12:28:36 PM 🔨 This is modified console.debug
Now, every new day, the logs will be saved in the appropriate file.
Full code
// src/utils/logger.ts
import fs from "fs";
import path from "path";
const LOG_EMOJI = { info: "💬", warn: "⚠️", error: "🛑", debug: "🔨" };
const LOGS_DIR = path.join(path.dirname("."), "logs");
/**
* Initializes the logger by:
* 1. Creating logs directory if it doesn't exist
* 2. Modifying console methods to add enhanced logging
*/
export const initLogger = () => {
if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
modifyConsoleLogs();
};
function modifyConsoleLogs() {
const originalInfo = console.info;
const originalWarn = console.warn;
const originalError = console.error;
const originalDebug = console.debug;
console.info = (...args: unknown[]) => {
const prefix = `${getDateString()} ${LOG_EMOJI.info}`;
originalInfo(prefix, ...args);
logToFile(prefix, ...args);
};
console.warn = (...args: unknown[]) => {
const prefix = `${getDateString()} ${LOG_EMOJI.warn}`;
originalWarn(prefix, ...args);
logToFile(prefix, ...args);
};
console.error = (...args: unknown[]) => {
const prefix = `${getDateString()} ${LOG_EMOJI.error}`;
originalError(prefix, ...args);
logToFile(prefix, ...args);
};
console.debug = (...args: unknown[]) => {
// Skip debug logs in production
if (process.env.NODE_ENV === "production") return;
const prefix = `${getDateString()} ${LOG_EMOJI.debug}`;
originalDebug(prefix, ...args);
logToFile(prefix, ...args);
};
};
const padZero = (value: number) => String(value).padStart(2, "0");
function getLogFilePath() {
const now = new Date();
const mm = padZero(now.getMonth() + 1); // Month (01-12)
const dd = padZero(now.getDate()); // Day (01-31)
const filename = `${mm}-${dd}.log`;
return path.join(LOGS_DIR, filename);
}
function getDateString() {
return new Date().toLocaleString();
}
function logToFile(prefix: string, ...args: unknown[]) {
fs.appendFileSync(getLogFilePath(), `${prefix} ${args.join(" ")}\n`);
}