Skip to content

Custom Logger

Wrap a structured logging library (pino) behind a simple factory function so calling code never touches the library directly. The factory auto-detects the calling module via stack walking, maps pino levels to Google Cloud Logging severity, and supports child loggers for hierarchical context.

When to use

Use this logger factory to abstract your logging backend behind a simple API. It provides structured JSON logging in production and pretty output in development, maps severity levels to Google Cloud Logging conventions, and lets you swap the backend without changing call sites — replacing ad-hoc console.log calls with structured, filterable output.

The pattern

Basic usage

Import the factory and create a logger with initial context. Every log call includes the context fields plus an auto-detected module path:

javascript
import createLogger from "./logger.js";

const log = createLogger({ service: "api" });

log.info("Server started");
// { service: "api", module: "server/start",
//   severity: "INFO", message: "Server started" }

log.info({ port: 3000 }, "Listening on port");
// { service: "api", module: "server/start", port: 3000,
//   severity: "INFO", message: "Listening on port" }

log.error({ err }, "Failed to connect");
// { service: "api", module: "server/start",
//   err: { message: "...", stack: "..." },
//   severity: "ERROR", message: "Failed to connect" }

Child loggers

Create child loggers to add context for a subsystem. Each child inherits the parent's fields and appends its own:

javascript
const log = createLogger({ service: "server" });

const authLog = log.child({ component: "auth" });
authLog.info("Initializing auth");
// { service: "server", component: "auth",
//   module: "server/auth",
//   severity: "INFO", message: "Initializing auth" }

const dbLog = log.child({ component: "db" });
dbLog.debug({ query }, "Executing query");
// { service: "server", component: "db",
//   module: "db/index", query: "SELECT ...",
//   severity: "DEBUG", message: "Executing query" }

Implementing the logger wrapper

The Logger class wraps a pino instance and exposes only the standard log methods and child(). The createLogger factory picks the right pino configuration and returns a Logger:

javascript
import pino from "pino";

class Logger {
  #pino;
  
  constructor(pinoInstance, context = {}) {
    const module = getCallerModule();
    if (module) {
      context.module = module;
    }
    this.#pino = pinoInstance.child(context);
  }
  
  child(context = {}) {
    return new Logger(this.#pino, context);
  }
  
  trace(...args) { this.#pino.trace(...args); }
  debug(...args) { this.#pino.debug(...args); }
  info(...args) { this.#pino.info(...args); }
  warn(...args) { this.#pino.warn(...args); }
  error(...args) { this.#pino.error(...args); }
  fatal(...args) { this.#pino.fatal(...args); }
  
  bindings() {
    return this.#pino.bindings();
  }
}

const defaultPino = pino(getPinoOptions());

function createLogger(context = {}, options = {}) {
  if (options.destination || options.level) {
    const customPino = pino(
      { ...DEFAULT_PINO_OPTIONS, level: options.level },
      options.destination,
    );
    return new Logger(customPino, context);
  }
  return new Logger(defaultPino, context);
}

export default createLogger;

The private #pino field means calling code cannot bypass the wrapper. The options.destination parameter lets tests inject a capture stream without touching the production transport.

Google Cloud severity mapping

Map pino's numeric levels to Google Cloud Logging severity names so Cloud Logging displays the correct severity badge in its UI:

javascript
const PINO_TO_SEVERITY = new Map([
  [10, "DEBUG"],    // trace
  [20, "DEBUG"],    // debug
  [30, "INFO"],     // info
  [40, "WARNING"],  // warn
  [50, "ERROR"],    // error
  [60, "CRITICAL"], // fatal
]);

const DEFAULT_PINO_OPTIONS = {
  name: SERVICE_NAME,
  level: DEFAULT_LEVEL,
  messageKey: "message",
  formatters: {
    level(label, number) {
      const severity =
        PINO_TO_SEVERITY.get(number) ?? "DEFAULT";
      return { level: label, severity };
    },
  },
};

Setting messageKey to "message" makes Cloud Logging promote the message field in its log explorer. The formatters.level function adds a severity field that Cloud Logging uses for filtering and alerting.

Pretty printing in development

Use pino-pretty during development for human-readable output, and raw structured JSON in production for log aggregators:

javascript
const PRODUCTION =
  process.env.NODE_ENV === "production";

function getPinoOptions() {
  const transport = PRODUCTION ? undefined : {
    target: "pino-pretty",
    options: {
      colorize: true,
      translateTime: "SYS:standard",
      ignore: "pid,hostname",
    },
  };
  
  return {
    ...DEFAULT_PINO_OPTIONS,
    ...(transport && { transport }),
  };
}

Auto-detecting the calling module

Walk the error stack to find the first frame outside the logger module. Convert the file path to a project-relative module path:

javascript
import { relative, join, parse } from "path";
import { fileURLToPath } from "url";

const THIS_MODULE = toModulePath(__filename);

function getCallerModule() {
  try {
    const stack = new Error().stack;
    const FILE_PATTERN = /file:\/\/(.*):\d+:\d+/;
    
    for (const line of stack.split("\n")) {
      const match = line.match(FILE_PATTERN);
      if (!match) continue;
      
      const filePath = fileURLToPath(
        `file://${match[1]}`,
      );
      const modulePath = toModulePath(filePath);
      
      if (modulePath !== THIS_MODULE) {
        return modulePath;
      }
    }
    return undefined;
  } catch {
    return undefined;
  }
}

function toModulePath(filePath) {
  const rel = relative(PROJECT_ROOT, filePath);
  const { dir, name } = parse(rel);
  return join(dir, name);
}

Every createLogger() and child() call captures the caller's module path automatically. A logger created in server/auth.js gets module: "server/auth" without the caller specifying it.

Testing with a capture stream

Pass a writable stream as destination to capture log output in tests without touching stdout:

javascript
import { Writable } from "node:stream";
import createLogger from "./logger.js";

const lines = [];
const stream = new Writable({
  write(chunk, encoding, callback) {
    lines.push(JSON.parse(chunk.toString()));
    callback();
  },
});

const log = createLogger(
  {},
  { destination: stream, level: "trace" },
);
log.info("test message");

assert.equal(lines.length, 1);
assert.equal(lines[0].message, "test message");
assert.equal(lines[0].severity, "INFO");

Trade-offs

Benefits:

  • Structured JSON in production, human-readable in development
  • Google Cloud severity mapping works out of the box
  • Auto-detected module paths — no manual labeling
  • Child loggers inherit context without re-specifying it
  • Implementation is swappable — call sites use createLogger, not pino directly

Costs:

  • Pino dependency — adds a package where console would suffice for simple projects
  • Stack walking — minor overhead per createLogger/child call (not per log call)
  • Global default instance — the shared pino instance persists for the process lifetime

When console is enough:

  • Prototypes and scripts where structured output is unnecessary
  • Client-side code where bundle size matters more than log structure
  • Projects that will never need log aggregation or severity filtering