hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Logging and Observability with @hectoday/http

Why Logging

  • console.log Is Not Enough
  • What to Log
  • Project Setup

Structured Logging

  • JSON Logs
  • Log Levels
  • Building a Logger

Request Logging

  • Request IDs
  • Request-Response Logging
  • Error Logging

Context and Correlation

  • Log Context
  • Child Loggers
  • Correlating Across Services

What to Observe

  • Business Event Logging
  • Performance Logging
  • Health and Metrics

Production

  • Log Output and Transport
  • Sensitive Data
  • Checklist and Capstone

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?

← Log Levels Request IDs →

© 2026 hectoday. All rights reserved.