structured-logging

Structured logging patterns with Pino for Next.js applications. Covers log levels, context enrichment, child loggers, and production best practices.

Structured Logging Skill

Structured logging patterns with Pino for Next.js applications.

Reference Files:

Project Configuration

The logger is configured in lib/logger.ts:

import pino from "pino";

const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  transport:
    process.env.NODE_ENV === "development"
      ? {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "SYS:standard",
            ignore: "pid,hostname",
          },
        }
      : undefined,
  formatters: {
    level: (label) => ({ level: label.toUpperCase() }),
  },
  timestamp: pino.stdTimeFunctions.isoTime,
});

export function createLogger(context: Record<string, unknown>) {
  return logger.child(context);
}

export default logger;

Quick Start

Basic Logging

import logger from "~/lib/logger";

// Simple message
logger.info("Server started");

// With context object
logger.info({ port: 3000 }, "Server started");

// Error logging
logger.error({ error, userId }, "Failed to process request");

Module-Specific Logger

import { createLogger } from "~/lib/logger";

const logger = createLogger({ module: "user-service" });

// All logs include { module: "user-service" }
logger.info({ userId: "123" }, "User created");
// Output: { module: "user-service", userId: "123", msg: "User created" }

Log Levels

LevelWhen to UseExample
fatalApp crash, unrecoverableDatabase connection lost
errorOperation failedUser creation failed
warnUnexpected but recoverableRate limit approaching
infoNormal operationsUser logged in
debugDevelopment detailsRequest payload
traceFine-grained debuggingFunction entry/exit

Usage

logger.fatal({ error }, "Database connection lost, shutting down");
logger.error({ userId, errorCode: error.code }, "Failed to update user");
logger.warn({ requestCount, limit }, "Rate limit 80% reached");
logger.info({ userId }, "User logged in successfully");
logger.debug({ payload }, "Processing request");
logger.trace({ functionName: "processData" }, "Entering function");

Key Patterns

Always Log Context Objects First

// ✅ Good - context object first, then message
logger.info({ userId, action: "login" }, "User authenticated");

// ❌ Bad - no context
logger.info("User authenticated");

// ❌ Bad - string interpolation
logger.info(`User ${userId} authenticated`);

Error Logging

import { categorizeServiceError, ServiceError } from "~/lib/firebase/errors";

try {
  await updateUser(userId, data);
} catch (error) {
  const serviceError = categorizeServiceError(error, "User");

  logger.error(
    {
      userId,
      errorCode: serviceError.code,
      isRetryable: serviceError.isRetryable,
      operation: "updateUser",
    },
    "Failed to update user",
  );

  return [serviceError, null];
}

Database Operations

const logger = createLogger({ module: "user-db" });

export async function getUserById(id: string) {
  logger.debug({ userId: id }, "Fetching user");

  try {
    const user = await db.collection("users").doc(id).get();

    if (!user.exists) {
      logger.warn({ userId: id }, "User not found");
      return [ServiceError.notFound("User"), null];
    }

    logger.info({ userId: id }, "User fetched successfully");
    return [null, transformUser(user)];
  } catch (error) {
    const serviceError = categorizeServiceError(error, "User");
    logger.error(
      {
        userId: id,
        errorCode: serviceError.code,
        isRetryable: serviceError.isRetryable,
      },
      "Database error fetching user",
    );
    return [serviceError, null];
  }
}

Server Actions

const logger = createLogger({ module: "user-actions" });

export async function updateProfile(data: ProfileData): ActionResponse {
  const { userId } = await auth();

  if (!userId) {
    logger.warn({ action: "updateProfile" }, "Unauthorized access attempt");
    return { success: false, error: "Unauthorized" };
  }

  logger.info({ userId, action: "updateProfile" }, "Starting profile update");

  const [error] = await updateUserProfile(userId, data);

  if (error) {
    logger.error(
      {
        userId,
        errorCode: error.code,
        action: "updateProfile",
      },
      "Profile update failed",
    );
    return { success: false, error: error.message };
  }

  logger.info(
    { userId, action: "updateProfile" },
    "Profile updated successfully",
  );
  return { success: true, data: null };
}

Environment Configuration

# .env.local
LOG_LEVEL=debug  # Development: see all logs

# .env.production
LOG_LEVEL=info   # Production: info and above

File Locations

PurposeLocation
Logger setuplib/logger.ts
Feature loggersCreate in feature modules
Log level configdata/env/server.ts

Sensitive Data Protection

Never log secrets, credentials, or personally identifiable information (PII).

What NOT to Log

// ❌ NEVER log these
logger.info({ password, token, apiKey }, "User login");
logger.info({ creditCard: card.number }, "Payment processed");
logger.info({ ssn, dateOfBirth, fullAddress }, "User profile loaded");
logger.debug({ cookie: req.headers.cookie }, "Request received");
logger.info({ authorization: req.headers.authorization }, "API call");

Safe Logging Patterns

// ✅ Log identifiers, not values
logger.info({ userId, email: maskEmail(email) }, "User login");
logger.info({ cardLast4: card.number.slice(-4) }, "Payment processed");
logger.debug({ hasAuthHeader: !!req.headers.authorization }, "API call");

// ✅ Log metadata, not content
logger.info({ bodySize: JSON.stringify(body).length }, "Request received");
logger.info({ fieldCount: Object.keys(formData).length }, "Form submitted");

Sensitive Fields Checklist

CategoryFields to NEVER log
Authpassword, token, apiKey, secret, refreshToken, sessionId, cookie, authorization header
PIISSN, date of birth, full address, phone number (log last 4 digits max)
Financialcredit card number, bank account, CVV, routing number
Healthmedical records, diagnoses, insurance IDs

Masking Helper

export function maskEmail(email: string): string {
  const [local, domain] = email.split("@");
  return `${local[0]}***@${domain}`;
}

export function maskId(id: string): string {
  return id.length > 8 ? `${id.slice(0, 4)}...${id.slice(-4)}` : "***";
}

Framework-Level Protection

Consider adding a Pino redaction config to automatically strip sensitive fields:

const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  redact: {
    paths: [
      "password",
      "token",
      "apiKey",
      "*.password",
      "*.token",
      "*.apiKey",
      "authorization",
      "cookie",
      "creditCard",
      "ssn",
    ],
    censor: "[REDACTED]",
  },
});

Related Skills

  • firebase-firestore - Database logging patterns
  • server-actions - Action logging patterns
  • t3-env-validation - LOG_LEVEL configuration