Part of OpenTelemetry Track
OpenTelemetry
NextJS
Logging
SigNoz
JavaScript
June 23, 202513 min read

Structured Logging in NextJS with OpenTelemetry

Author:

Yuvraj Singh JadonYuvraj Singh Jadon

Traces tell you what happened and when. Logs tell you why. When something breaks, logs are often your first clue—and if they’re correlated with traces, they can cut debugging time down from hours to minutes.

In this section, we’ll wire up end-to-end structured logging across both server and browser environments in your Next.js app, complete with trace correlation and SigNoz integration.

Why You Need More Than console.log

Traditional logging is fine for local development but production needs more:

  • Structured, searchable logs
  • Logs tied to specific user actions or trace spans
  • Server + browser visibility
  • Centralized analysis and alerting

With OpenTelemetry + SigNoz, you can:

  • See errors and logs in one place
  • Correlate logs with spans (traceId, spanId)
  • Analyze structured metadata (userId, URL, duration, etc.)
  • Monitor logs from both the client and server

Server-Side Logging with Trace Context

Step 1: Install Required Packages

npm install @opentelemetry/api-logs @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-http pino

Dependency Breakdown:

  • @opentelemetry/api-logs: Core logging API
  • @opentelemetry/sdk-logs: SDK for log processing and export
  • @opentelemetry/exporter-logs-otlp-http: OTLP HTTP exporter for logs
  • pino: High-performance structured logging library

Step 2: Create the Logs Exporter

This sends structured logs to the OpenTelemetry collector:

// lib/logs-exporter.ts

import { logs } from "@opentelemetry/api-logs";
import {
  LoggerProvider,
  BatchLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";
import type { LogEntry } from "./logger";

let isInitialized = false;
let loggerProvider: LoggerProvider | null = null;

export function initializeLogsExporter() {
  if (isInitialized || typeof window !== "undefined") {
    return; // Only initialize on server side
  }

  try {
    // Create resource with service information
    const resource = new Resource({
      [ATTR_SERVICE_NAME]: "nextjs-observability-demo",
      [ATTR_SERVICE_VERSION]: "1.0.0",
    });

    // Create OTLP exporter
    const logExporter = new OTLPLogExporter({
      url: "http://localhost:4318/v1/logs",
      headers: {},
    });

    // Create logger provider
    loggerProvider = new LoggerProvider({
      resource: resource as any,
    });

    // Add batch processor
    loggerProvider.addLogRecordProcessor(
      new BatchLogRecordProcessor(logExporter, {
        maxExportBatchSize: 10,
        scheduledDelayMillis: 5000, // Export every 5 seconds
        exportTimeoutMillis: 30000,
        maxQueueSize: 100,
      })
    );

    // Set global logger provider
    logs.setGlobalLoggerProvider(loggerProvider);
    isInitialized = true;
    console.log(" OpenTelemetry logs exporter initialized");
  } catch (error) {
    console.error("❌ Failed to initialize logs exporter:", error);
  }
}

export function exportLogEntry(entry: LogEntry) {
  if (!isInitialized || !loggerProvider || typeof window !== "undefined") {
    return;
  }

  try {
    const logger = loggerProvider.getLogger("nextjs-observability-demo");

    // Build attributes object
    const attributes: Record<string, any> = {
      ...entry.context,
      "log.level": entry.level,
      "service.name": "nextjs-observability-demo",
    };

    // Add error details if present
    if (entry.error) {
      attributes["error.name"] = entry.error.name;
      attributes["error.message"] = entry.error.message;
      attributes["error.stack"] = entry.error.stack;
    }

    // Convert our log entry to OpenTelemetry log record
    const logRecord = {
      timestamp: Date.now(),
      observedTimestamp: Date.now(),
      severityNumber: getSeverityNumber(entry.level),
      severityText: entry.level.toUpperCase(),
      body: entry.message,
      attributes,
    };

    logger.emit(logRecord);
  } catch (error) {
    console.error("Failed to export log entry:", error);
  }
}

function getSeverityNumber(level: string): number {
  switch (level) {
    case "debug":
      return 5; // DEBUG
    case "info":
      return 9; // INFO
    case "warn":
      return 13; // WARN
    case "error":
      return 17; // ERROR
    default:
      return 9; // Default to INFO
  }
}

export function shutdownLogsExporter(): Promise<void> {
  if (loggerProvider) {
    return loggerProvider.shutdown();
  }
  return Promise.resolve();
}

Step 3: Unified Logger

This is your standard logging API across server and client:

// lib/logger.ts

import { trace } from "@opentelemetry/api";
import { exportLogEntry } from "./logs-exporter";

export interface LogContext {
  traceId?: string;
  spanId?: string;
  userId?: string;
  requestId?: string;
  sessionId?: string;
  [key: string]: any;
}

export interface LogEntry {
  timestamp: string;
  level: "debug" | "info" | "warn" | "error";
  message: string;
  context?: LogContext;
  error?: Error;
}

class Logger {
  private getTraceContext(): { traceId?: string; spanId?: string } {
    try {
      const span = trace.getActiveSpan();
      if (span) {
        const spanContext = span.spanContext();
        return {
          traceId: spanContext.traceId,
          spanId: spanContext.spanId,
        };
      }
    } catch (error) {
      // Ignore trace context errors
    }
    return {};
  }

  private log(
    level: LogEntry["level"],
    message: string,
    context?: LogContext,
    error?: Error
  ) {
    const traceContext = this.getTraceContext();
    const entry: LogEntry = {
      timestamp: new Date().toISOString(),
      level,
      message,
      context: {
        ...traceContext,
        ...context,
      },
      error,
    };

    // Always log to console
    const logMethod =
      level === "error"
        ? console.error
        : level === "warn"
        ? console.warn
        : level === "debug"
        ? console.debug
        : console.log;

    if (error) {
      logMethod(`[${level.toUpperCase()}] ${message}`, entry.context, error);
    } else {
      logMethod(`[${level.toUpperCase()}] ${message}`, entry.context);
    }

    // Export to OpenTelemetry collector (server-side only)
    if (typeof window === "undefined") {
      try {
        exportLogEntry(entry);
      } catch (exportError) {
        console.error("Failed to export log to OTel:", exportError);
      }
    }

    return entry;
  }

  debug(message: string, context?: LogContext) {
    return this.log("debug", message, context);
  }

  info(message: string, context?: LogContext) {
    return this.log("info", message, context);
  }

  warn(message: string, context?: LogContext) {
    return this.log("warn", message, context);
  }

  error(message: string, error?: Error, context?: LogContext) {
    return this.log("error", message, context, error);
  }

  // Convenience methods for common patterns

  request(
    method: string,
    url: string,
    statusCode: number,
    duration: number,
    context?: LogContext
  ) {
    return this.info(`${method} ${url} ${statusCode}`, {
      ...context,
      httpMethod: method,
      httpUrl: url,
      httpStatusCode: statusCode,
      duration,
      type: "request",
    });
  }

  externalCall(
    service: string,
    method: string,
    url: string,
    duration: number,
    success: boolean,
    context?: LogContext
  ) {
    const level = success ? "info" : "error";
    return this.log(level, `External call to ${service}: ${method} ${url}`, {
      ...context,
      externalService: service,
      httpMethod: method,
      httpUrl: url,
      duration,
      success,
      type: "external_call",
    });
  }

  performance(operation: string, duration: number, context?: LogContext) {
    const level = duration > 1000 ? "warn" : "info";
    return this.log(level, `Performance: ${operation} took ${duration}ms`, {
      ...context,
      operation,
      duration,
      type: "performance",
    });
  }

  business(event: string, context?: LogContext) {
    return this.info(`Business event: ${event}`, {
      ...context,
      event,
      type: "business",
    });
  }

  security(event: string, context?: LogContext) {
    return this.warn(`Security event: ${event}`, {
      ...context,
      event,
      type: "security",
    });
  }
}

export const logger = new Logger();

// Convenience functions for quick logging
export const log = {
  debug: (message: string, context?: LogContext) =>
    logger.debug(message, context),
  info: (message: string, context?: LogContext) =>
    logger.info(message, context),
  warn: (message: string, context?: LogContext) =>
    logger.warn(message, context),
  error: (message: string, error?: Error, context?: LogContext) =>
    logger.error(message, error, context),
};

  • Logs include traceId, spanId, timestamp
  • Supports levels: debug, info, warn, error
  • Auto-exports to collector (on server only)

Step 4: High-Performance Pino Logger (Optional)

Use pino for faster logs in production:

// lib/pino-logger.ts

import pino from "pino";
import { trace } from "@opentelemetry/api";
import type { LogContext } from "./logger";

// Pino configuration for different environments
const createPinoConfig = (environment: string = "development") => {
  const baseConfig = {
    name: "nextjs-observability-demo",
    level: environment === "production" ? "info" : "debug",
    timestamp: pino.stdTimeFunctions.isoTime,
    formatters: {
      level: (label: string) => ({ level: label }),
    },
    mixin: () => {
      // Automatically inject trace context into every log
      const span = trace.getActiveSpan();
      const spanContext = span?.spanContext();
      return {
        traceId: spanContext?.traceId,
        spanId: spanContext?.spanId,
        environment,
      };
    },
  };
  return baseConfig;
};

// Create Pino logger instance
export const pinoLogger = pino(createPinoConfig(process.env.NODE_ENV));

// Enhanced Pino logger with additional context methods
export class PinoLogger {
  private logger: pino.Logger;

  constructor(logger: pino.Logger = pinoLogger) {
    this.logger = logger;
  }

  private enrichContext(context: LogContext = {}): LogContext {
    const span = trace.getActiveSpan();
    const spanContext = span?.spanContext();
    return {
      traceId: spanContext?.traceId,
      spanId: spanContext?.spanId,
      timestamp: new Date().toISOString(),
      ...context,
    };
  }

  debug(message: string, context?: LogContext) {
    this.logger.debug(this.enrichContext(context), message);
  }

  info(message: string, context?: LogContext) {
    this.logger.info(this.enrichContext(context), message);
  }

  warn(message: string, context?: LogContext) {
    this.logger.warn(this.enrichContext(context), message);
  }

  error(message: string, error?: Error, context?: LogContext) {
    const enrichedContext = this.enrichContext(context);
    if (error) {
      enrichedContext.error = {
        name: error.name,
        message: error.message,
        stack: error.stack,
      };
    }
    this.logger.error(enrichedContext, message);
  }

  // Specialized logging methods

  logRequest(req: any, context?: LogContext) {
    this.info("HTTP Request", {
      ...context,
      method: req.method,
      url: req.url,
      userAgent: req.headers?.["user-agent"],
      ip: req.headers?.["x-forwarded-for"] || req.connection?.remoteAddress,
    });
  }

  logDbOperation(
    operation: string,
    table: string,
    duration: number,
    context?: LogContext
  ) {
    this.info("Database Operation", {
      ...context,
      operation,
      table,
      duration: `${duration}ms`,
    });
  }

  logExternalCall(
    url: string,
    method: string,
    statusCode: number,
    duration: number,
    context?: LogContext
  ) {
    this.info("External API Call", {
      ...context,
      url,
      method,
      statusCode,
      duration: `${duration}ms`,
    });
  }
}

export const serverLogger = new PinoLogger(pinoLogger);

Client-Side Logging with Auto-Export

Step 5: Create Browser Logger

// lib/browser-logger.ts
"use client";

import { trace } from "@opentelemetry/api";
import type { LogContext } from "./logger";

export interface BrowserLogEntry {
  level: "debug" | "info" | "warn" | "error";
  message: string;
  context?: LogContext;
  timestamp: string;
  url?: string;
  userAgent?: string;
  error?: {
    name: string;
    message: string;
    stack?: string;
  };
}

class BrowserLogger {
  private logs: BrowserLogEntry[] = [];
  private maxLogs = 1000;
  private flushInterval = 10000; // 10 seconds
  private collectorUrl = "/api/logs";

  constructor() {
    if (typeof window !== "undefined") {
      this.setupGlobalErrorHandlers();
      this.startPeriodicFlush();
    }
  }

  private enrichContext(context: LogContext = {}): LogContext {
    const span = trace.getActiveSpan();
    const spanContext = span?.spanContext();

    return {
      traceId: spanContext?.traceId || "no-trace",
      spanId: spanContext?.spanId || "no-span",
      url: window.location.href,
      userAgent: navigator.userAgent,
      sessionId: this.getSessionId(),
      ...context,
    };
  }

  private getSessionId(): string {
    let sessionId = sessionStorage.getItem("session-id");
    if (!sessionId) {
      sessionId = crypto.randomUUID();
      sessionStorage.setItem("session-id", sessionId);
    }
    return sessionId;
  }

  private createLogEntry(
    level: BrowserLogEntry["level"],
    message: string,
    context?: LogContext,
    error?: Error
  ): BrowserLogEntry {
    const entry: BrowserLogEntry = {
      level,
      message,
      context: this.enrichContext(context),
      timestamp: new Date().toISOString(),
      url: window.location.href,
      userAgent: navigator.userAgent,
    };

    if (error) {
      entry.error = {
        name: error.name,
        message: error.message,
        stack: error.stack,
      };
    }

    return entry;
  }

  debug(message: string, context?: LogContext) {
    const entry = this.createLogEntry("debug", message, context);
    this.addLog(entry);
    console.debug(`[BROWSER DEBUG] ${message}`, entry.context);
  }

  info(message: string, context?: LogContext) {
    const entry = this.createLogEntry("info", message, context);
    this.addLog(entry);
    console.info(`[BROWSER INFO] ${message}`, entry.context);
  }

  warn(message: string, context?: LogContext) {
    const entry = this.createLogEntry("warn", message, context);
    this.addLog(entry);
    console.warn(`[BROWSER WARN] ${message}`, entry.context);
  }

  error(message: string, error?: Error, context?: LogContext) {
    const entry = this.createLogEntry("error", message, context, error);
    this.addLog(entry);
    console.error(`[BROWSER ERROR] ${message}`, entry.context, error);
  }

  // Specialized logging methods

  logNavigation(from: string, to: string) {
    this.info("Navigation", {
      event: "navigation",
      from,
      to,
      timing: performance.now(),
    });
  }

  logUserInteraction(action: string, element?: string, context?: LogContext) {
    this.info("User Interaction", {
      ...context,
      event: "user_interaction",
      action,
      element,
      timing: performance.now(),
    });
  }

  logApiCall(
    url: string,
    method: string,
    status: number,
    duration: number,
    context?: LogContext
  ) {
    const level = status >= 400 ? "error" : status >= 300 ? "warn" : "info";
    this[level](`API Call: ${method} ${url}`, {
      ...context,
      event: "api_call",
      url,
      method,
      status,
      duration,
    });
  }

  private addLog(entry: BrowserLogEntry) {
    this.logs.push(entry);
    // Keep only the most recent logs
    if (this.logs.length > this.maxLogs) {
      this.logs = this.logs.slice(-this.maxLogs);
    }
  }

  private setupGlobalErrorHandlers() {
    // Unhandled JavaScript errors
    window.addEventListener("error", (event) => {
      this.error("Unhandled Error", event.error, {
        event: "unhandled_error",
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
      });
    });

    // Unhandled promise rejections
    window.addEventListener("unhandledrejection", (event) => {
      this.error("Unhandled Promise Rejection", event.reason, {
        event: "unhandled_rejection",
        reason: event.reason?.toString(),
      });
    });
  }

  private startPeriodicFlush() {
    setInterval(() => {
      this.flush();
    }, this.flushInterval);

    // Flush on page unload
    window.addEventListener("beforeunload", () => {
      this.flush();
    });
  }

  async flush() {
    if (this.logs.length === 0) return;

    const logsToSend = [...this.logs];
    this.logs = [];

    try {
      const response = await fetch(this.collectorUrl, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(logsToSend),
      });

      if (!response.ok) {
        console.error("Failed to send logs to server:", response.statusText);
        // Re-add logs if send failed
        this.logs.unshift(...logsToSend);
      }
    } catch (error) {
      console.error("Error sending logs:", error);
      // Re-add logs if send failed
      this.logs.unshift(...logsToSend);
    }
  }

  getLogs(): BrowserLogEntry[] {
    return [...this.logs];
  }

  clearLogs() {
    this.logs = [];
  }
}

export const browserLogger = new BrowserLogger();

// Convenience exports
export const browserLog = {
  debug: (message: string, context?: LogContext) =>
    browserLogger.debug(message, context),
  info: (message: string, context?: LogContext) =>
    browserLogger.info(message, context),
  warn: (message: string, context?: LogContext) =>
    browserLogger.warn(message, context),
  error: (message: string, error?: Error, context?: LogContext) =>
    browserLogger.error(message, error, context),
  navigation: (from: string, to: string) =>
    browserLogger.logNavigation(from, to),
  userInteraction: (
    action: string,
    element?: string,
    context?: LogContext
  ) => browserLogger.logUserInteraction(action, element, context),
  apiCall: (
    url: string,
    method: string,
    status: number,
    duration: number,
    context?: LogContext
  ) => browserLogger.logApiCall(url, method, status, duration, context),
  flush: () => browserLogger.flush(),
  getLogs: () => browserLogger.getLogs(),
  clearLogs: () => browserLogger.clearLogs(),
};
j
  • Buffers logs in memory
  • Adds browser metadata (URL, userAgent, sessionId)
  • Automatically flushes logs to server every 10s

Step 6: API Route to Receive Logs

// app/api/logs/route.ts

import { NextRequest, NextResponse } from "next/server";
import { logger } from "@/lib/logger";
import { initializeLogsExporter } from "@/lib/logs-exporter";

// Ensure logs exporter is initialized for API routes
initializeLogsExporter();

export async function POST(request: NextRequest) {
  try {
    const logs = await request.json();

    if (!Array.isArray(logs)) {
      return NextResponse.json(
        { error: "Invalid logs format" },
        { status: 400 }
      );
    }

    console.log(`🔄 Processing ${logs.length} browser logs...`);

    // Process each log entry from the browser
    for (const log of logs) {
      const { level, message, context, error } = log;

      // Add browser-specific context
      const enrichedContext = {
        ...context,
        source: "browser",
        userAgent: request.headers.get("user-agent"),
        referer: request.headers.get("referer"),
      };

      // Forward to server logger (which also exports to OTel)
      switch (level) {
        case "debug":
          logger.debug(message, enrichedContext);
          break;
        case "info":
          logger.info(message, enrichedContext);
          break;
        case "warn":
          logger.warn(message, enrichedContext);
          break;
        case "error":
          const errorObj = error ? new Error(error.message) : undefined;
          if (errorObj && error.stack) {
            errorObj.stack = error.stack;
          }
          logger.error(message, errorObj, enrichedContext);
          break;
        default:
          logger.info(message, enrichedContext);
      }
    }

    return NextResponse.json({
      success: true,
      processed: logs.length,
    });

  } catch (error) {
    console.error("❌ Failed to process browser logs:", error);
    logger.error("Failed to process browser logs", error as Error);
    return NextResponse.json(
      { error: "Failed to process logs" },
      { status: 500 }
    );
  }
}

This route receives browser logs and sends them to the collector with trace context.

Instrumentation Hook

Step 7: Add to instrumentation.ts

import { registerOTel } from "@vercel/otel";
import { initializeLogsExporter } from "./lib/logs-exporter";

export function register() {
  registerOTel({
    serviceName: "nextjs-observability-demo",
    instrumentationConfig: {
      fetch: {
        // Propagate context to all external URLs to enable proper tracing
        propagateContextUrls: [
          /jsonplaceholder\.typicode\.com/,
          /httpbin\.org/,
          /api\.openweathermap\.org/,
          // Add more external domains as needed
        ],
        // Don't ignore any URLs - we want to trace all external calls
        ignoreUrls: [],
        // Enable resource naming for better span identification
        resourceNameTemplate: "{http.method} {http.host}{http.target}",
      },
    },
  });

  // Initialize logs exporter for OpenTelemetry
  initializeLogsExporter();
}

Logs Demo Page

Step 8: Create logs-demo/page.tsx

Create a page that lets you trigger logs from browser and server:

  • Simulate API calls, slow requests, errors
  • Generate and view structured logs
  • Verify export to collector and SigNoz

View Logs in SigNoz

Step 9: Explore Your Logs

  • Go to SigNoz → Logs

  • Filter by source: source="browser" or source="server"

    SigNoz logs interface showing structured logs with trace correlation and filtering options
    Filter logs by source (browser/server) and view structured log entries with trace context in SigNoz
  • Click any log to view trace metadata

    Navigate between logs and traces seamlessly in SigNoz
    Navigate between logs and traces seamlessly in SigNoz
  • Correlate with full request trace via traceId

    SigNoz trace details view showing the correlation between logs and distributed traces
    Click 'Inspect in Trace' to jump from logs to the full distributed trace view with all spans and timing information

The real power of working with logs and traces in SigNoz comes from correlation - click "Inspect in Trace" or the trace ID to jump directly to the corresponding trace when checking a log or "Go to related logs" from any trace details page.

Next: Production Deployment and Scaling

With instrumentation, metrics, and logging in place, you're ready for production. In the next article, we'll cover deploying your instrumented Next.js app, choosing between collector vs direct exporter setups, implementing smart sampling strategies, and setting up production-grade alerting.

Was this page helpful?