Building a Logger
From function to class
The previous two lessons built a log function with JSON output and level filtering. This lesson wraps it in a reusable class with a clean API.
The Logger class
// src/logger.ts
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 } as const;
type Level = keyof typeof LEVELS;
export class Logger {
private minLevel: number;
private baseContext: Record<string, unknown>;
constructor(options: { level?: Level; context?: Record<string, unknown> } = {}) {
this.minLevel = LEVELS[options.level ?? (process.env.LOG_LEVEL as Level) ?? "info"];
this.baseContext = options.context ?? {};
}
private log(level: Level, message: string, context: Record<string, unknown> = {}): void {
if (LEVELS[level] < this.minLevel) return;
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...this.baseContext,
...context,
};
const output = JSON.stringify(entry);
if (LEVELS[level] >= LEVELS.error) {
process.stderr.write(output + "\n");
} else {
process.stdout.write(output + "\n");
}
}
debug(message: string, context?: Record<string, unknown>): void {
this.log("debug", message, context);
}
info(message: string, context?: Record<string, unknown>): void {
this.log("info", message, context);
}
warn(message: string, context?: Record<string, unknown>): void {
this.log("warn", message, context);
}
error(message: string, context?: Record<string, unknown>): void {
this.log("error", message, context);
}
fatal(message: string, context?: Record<string, unknown>): void {
this.log("fatal", message, context);
}
child(context: Record<string, unknown>): Logger {
return new Logger({
level: Object.entries(LEVELS).find(([, v]) => v === this.minLevel)?.[0] as Level,
context: { ...this.baseContext, ...context },
});
}
} Using the logger
import { Logger } from "./logger.js";
const logger = new Logger();
logger.info("server started", { port: 3000 });
// {"timestamp":"...","level":"info","message":"server started","port":3000}
logger.debug("executing query", { sql: "SELECT * FROM books" });
// Hidden in production (LOG_LEVEL=info), visible in development
logger.error("database connection failed", { error: "SQLITE_CANTOPEN" });
// {"timestamp":"...","level":"error","message":"database connection failed","error":"SQLITE_CANTOPEN"} stdout vs stderr
The logger writes info/debug/warn to process.stdout and error/fatal to process.stderr. This is a Unix convention: normal output goes to stdout, errors go to stderr. Docker and log aggregation tools can capture them separately.
[!NOTE] The Deploying with Docker course configured Docker to capture container stdout/stderr. This logger writes to the correct stream automatically — Docker captures both, and log tools can distinguish errors from normal logs.
Base context
The baseContext is included in every log entry. Useful for process-wide information:
const logger = new Logger({
context: {
service: "book-catalog",
environment: process.env.NODE_ENV ?? "development",
version: process.env.APP_VERSION ?? "unknown",
},
});
logger.info("server started", { port: 3000 });
// {"timestamp":"...","level":"info","message":"server started","service":"book-catalog","environment":"production","version":"1.2.3","port":3000} Every log entry includes the service name, environment, and version — without passing them explicitly.
Child loggers (preview)
The child() method creates a new logger with additional context. This is crucial for request-scoped logging (covered in Section 4):
const requestLogger = logger.child({ requestId: "req_a1b2c3" });
requestLogger.info("request started");
// {"timestamp":"...","level":"info","message":"request started","service":"book-catalog","requestId":"req_a1b2c3"} The child inherits the parent’s base context (service) and adds its own (requestId).
Replacing console.log in the app
// src/app.ts
import { Logger } from "./logger.js";
export const logger = new Logger({
context: { service: "book-catalog" },
});
export const app = setup({
onRequest: ({ request }) => {
logger.info("request received", {
method: request.method,
path: new URL(request.url).pathname,
});
return {};
},
onError: ({ error }) => {
logger.error("unhandled error", { error: error.message });
return Response.json({ error: "Internal error" }, { status: 500 });
},
routes: [
/* ... */
],
}); Exercises
Exercise 1: Build the Logger class. Create an instance. Log at each level. Verify level filtering works.
Exercise 2: Add base context (service, environment). Verify every entry includes it.
Exercise 3: Create a child logger with a request ID. Verify the child’s entries include both the parent’s context and the child’s context.
Why does the logger write errors to stderr instead of stdout?