DEV Community

Yuvraj Singh Jadon
Yuvraj Singh Jadon

Posted on • Originally published at signoz.io

Structured Logging in NextJS with OpenTelemetry

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 
Enter fullscreen mode Exit fullscreen mode

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(); } 
Enter fullscreen mode Exit fullscreen mode

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), }; 
Enter fullscreen mode Exit fullscreen mode
  • 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); 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode
  • 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 } ); } } 
Enter fullscreen mode Exit fullscreen mode

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(); } 
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)