Skip to main content
Back to Blog
Tutorials
3 min read
December 22, 2024

How to Build a Logging and Monitoring System in Next.js

Build a structured logging and monitoring system in Next.js with Pino, custom transports, request tracing, and error aggregation.

Ryel Banfield

Founder & Lead Developer

Structured logging and monitoring help you understand what your app is doing in production. Here is how to set it up properly.

Install

pnpm add pino pino-pretty

Structured Logger

// lib/logger.ts
import pino from "pino";

const isDev = process.env.NODE_ENV === "development";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? (isDev ? "debug" : "info"),
  ...(isDev && {
    transport: {
      target: "pino-pretty",
      options: { colorize: true, translateTime: "HH:MM:ss" },
    },
  }),
  formatters: {
    level: (label) => ({ level: label }),
  },
  base: {
    env: process.env.NODE_ENV,
    service: "web",
    version: process.env.NEXT_PUBLIC_APP_VERSION ?? "unknown",
  },
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "body.password",
      "body.token",
      "body.creditCard",
    ],
    censor: "[REDACTED]",
  },
});

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

Request Context with AsyncLocalStorage

// lib/request-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import crypto from "node:crypto";

interface RequestContext {
  requestId: string;
  userId?: string;
  path: string;
  method: string;
  startTime: number;
}

export const requestContextStorage = new AsyncLocalStorage<RequestContext>();

export function getRequestContext(): RequestContext | undefined {
  return requestContextStorage.getStore();
}

export function createRequestContext(path: string, method: string, userId?: string): RequestContext {
  return {
    requestId: crypto.randomUUID(),
    userId,
    path,
    method,
    startTime: Date.now(),
  };
}

Request Logging Middleware

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const requestId = crypto.randomUUID();
  const response = NextResponse.next();

  // Attach request ID to headers for downstream use
  response.headers.set("x-request-id", requestId);

  // Log via console in middleware (Edge runtime doesn't support Pino)
  console.log(
    JSON.stringify({
      level: "info",
      msg: "request",
      requestId,
      method: request.method,
      path: request.nextUrl.pathname,
      userAgent: request.headers.get("user-agent"),
    })
  );

  return response;
}

export const config = {
  matcher: ["/api/:path*"],
};

API Route Logger Helper

// lib/api-logger.ts
import { NextRequest } from "next/server";
import { logger } from "./logger";

export function createApiLogger(request: NextRequest) {
  const requestId =
    request.headers.get("x-request-id") ?? crypto.randomUUID();

  return logger.child({
    requestId,
    method: request.method,
    path: request.nextUrl.pathname,
    ip: request.headers.get("x-forwarded-for") ?? "unknown",
  });
}

export function logDuration(start: number, label: string) {
  const duration = Date.now() - start;
  logger.info({ duration, label }, `${label} completed in ${duration}ms`);
  return duration;
}

Usage in API Routes

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createApiLogger, logDuration } from "@/lib/api-logger";

export async function GET(request: NextRequest) {
  const log = createApiLogger(request);
  const start = Date.now();

  log.info("Fetching users");

  try {
    // const users = await db.select().from(usersTable);
    const users: unknown[] = [];

    log.info({ count: users.length }, "Users fetched successfully");
    logDuration(start, "GET /api/users");

    return NextResponse.json(users);
  } catch (error) {
    log.error({ error }, "Failed to fetch users");
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

Error Aggregation

// lib/error-tracker.ts
import { logger } from "./logger";

interface ErrorEntry {
  message: string;
  count: number;
  firstSeen: number;
  lastSeen: number;
  stack?: string;
}

const errorMap = new Map<string, ErrorEntry>();
const FLUSH_INTERVAL = 60_000; // 1 minute

export function trackError(error: Error, context?: Record<string, unknown>) {
  const key = `${error.name}:${error.message}`;

  const existing = errorMap.get(key);
  if (existing) {
    existing.count++;
    existing.lastSeen = Date.now();
  } else {
    errorMap.set(key, {
      message: error.message,
      count: 1,
      firstSeen: Date.now(),
      lastSeen: Date.now(),
      stack: error.stack,
    });
  }

  logger.error(
    { error: { name: error.name, message: error.message, stack: error.stack }, ...context },
    `Error: ${error.message}`
  );
}

// Periodically flush aggregated errors
if (typeof setInterval !== "undefined") {
  setInterval(() => {
    if (errorMap.size === 0) return;

    const errors = Array.from(errorMap.entries()).map(([key, entry]) => ({
      key,
      ...entry,
    }));

    logger.warn({ aggregatedErrors: errors, totalUnique: errors.length }, "Error summary");

    errorMap.clear();
  }, FLUSH_INTERVAL);
}

Health Check Endpoint

// app/api/health/route.ts
import { NextResponse } from "next/server";
import { logger } from "@/lib/logger";

interface HealthCheck {
  status: "healthy" | "degraded" | "unhealthy";
  uptime: number;
  timestamp: string;
  checks: Record<string, { status: string; latency?: number }>;
}

const startTime = Date.now();

export async function GET() {
  const checks: HealthCheck["checks"] = {};

  // Database check
  try {
    const dbStart = Date.now();
    // await db.execute(sql`SELECT 1`);
    checks.database = { status: "ok", latency: Date.now() - dbStart };
  } catch {
    checks.database = { status: "error" };
  }

  // Redis check
  try {
    const redisStart = Date.now();
    // await redis.ping();
    checks.redis = { status: "ok", latency: Date.now() - redisStart };
  } catch {
    checks.redis = { status: "error" };
  }

  const allOk = Object.values(checks).every((c) => c.status === "ok");
  const health: HealthCheck = {
    status: allOk ? "healthy" : "degraded",
    uptime: Math.floor((Date.now() - startTime) / 1000),
    timestamp: new Date().toISOString(),
    checks,
  };

  logger.info(health, "Health check");

  return NextResponse.json(health, {
    status: allOk ? 200 : 503,
  });
}

Need Production Observability?

We set up monitoring, logging, and alerting systems for production applications. Contact us to improve your observability.

loggingmonitoringobservabilityPinoNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles