Skip to content

Logging & Observability

Structured logging with Winston and Loki for centralized log aggregation.

Architecture

┌─────────────────────────────────────────────────────────────┐
│                      Browser                                 │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              logger-client.ts                        │    │
│  │  console.log + POST /api/logs                       │    │
│  └──────────────────────┬──────────────────────────────┘    │
└─────────────────────────┼───────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                   Next.js Server                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              /api/logs/route.ts                      │    │
│  │  Receives client logs, enriches, forwards            │    │
│  └──────────────────────┬──────────────────────────────┘    │
│                          │                                   │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              logger.ts (Winston)                     │    │
│  │  Console transport + Loki transport                  │    │
│  └──────────────────────┬──────────────────────────────┘    │
└─────────────────────────┼───────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                      Loki                                    │
│              (Log aggregation)                               │
└─────────────────────────────────────────────────────────────┘

Server-Side Logger

lib/logger.ts

import winston from "winston";
import LokiTransport from "winston-loki";

const isServer = typeof window === "undefined";

const logger = isServer
  ? winston.createLogger({
      level: process.env.LOG_LEVEL || "info",
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.errors({ stack: true }),
        winston.format.json()
      ),
      defaultMeta: {
        service: "coordinator-web",
        team: "coordinator",
        environment: process.env.NODE_ENV
      },
      transports: [
        // Console output (colorized for development)
        new winston.transports.Console({
          format: winston.format.combine(
            winston.format.colorize(),
            winston.format.simple()
          )
        }),
        // Loki for log aggregation
        ...(process.env.LOKI_URL
          ? [
              new LokiTransport({
                host: process.env.LOKI_URL,
                labels: {
                  service: "coordinator-web",
                  team: "coordinator"
                },
                json: true,
                format: winston.format.json(),
                replaceTimestamp: true,
                onConnectionError: (err) => 
                  console.error("Loki connection error:", err)
              })
            ]
          : [])
      ]
    })
  : null;

export { logger };

Usage

import { logger } from "@/lib/logger";

// Log levels
logger?.info("User logged in", { userId: "123" });
logger?.warn("Rate limit approaching", { remaining: 10 });
logger?.error("Database connection failed", { error: err.message });
logger?.debug("Query executed", { query, duration: 50 });

Client-Side Logger

lib/logger-client.ts

type LogLevel = "debug" | "info" | "warn" | "error";

interface LogMeta {
  [key: string]: unknown;
}

class ClientLogger {
  private async sendToServer(
    level: LogLevel,
    message: string,
    meta?: LogMeta
  ) {
    // Debug logs only go to console
    if (level === "debug") return;

    try {
      await fetch("/api/logs", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          level,
          message,
          meta: {
            ...meta,
            timestamp: new Date().toISOString(),
            url: window.location.href
          }
        })
      });
    } catch (error) {
      console.error("Failed to send log:", error);
    }
  }

  debug(message: string, meta?: LogMeta) {
    console.debug(message, meta);
  }

  info(message: string, meta?: LogMeta) {
    console.info(message, meta);
    this.sendToServer("info", message, meta);
  }

  warn(message: string, meta?: LogMeta) {
    console.warn(message, meta);
    this.sendToServer("warn", message, meta);
  }

  error(message: string, meta?: LogMeta) {
    console.error(message, meta);
    this.sendToServer("error", message, meta);
  }
}

export const logger = new ClientLogger();

Usage

import { logger } from "@/lib/logger-client";

// In components/hooks
logger.info("Session started", { sessionId: "123" });
logger.error("Upload failed", { filename: "video.mp4", error: err.message });

Log API Endpoint

app/api/logs/route.ts

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

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { level, message, meta } = body;

    // Validate level
    if (!["debug", "info", "warn", "error"].includes(level)) {
      return NextResponse.json(
        { error: "Invalid log level" },
        { status: 400 }
      );
    }

    // Enrich with request metadata
    const enrichedMeta = {
      ...meta,
      source: "client",
      userAgent: request.headers.get("user-agent"),
      ip: request.headers.get("x-forwarded-for") || 
          request.headers.get("x-real-ip")
    };

    // Dynamic import to avoid client-side bundling
    const { logger } = await import("@/lib/logger");

    // Log at appropriate level
    logger?.[level as keyof typeof logger]?.(message, enrichedMeta);

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error("Log endpoint error:", error);
    return NextResponse.json(
      { error: "Failed to process log" },
      { status: 500 }
    );
  }
}

Log Structure

Standard Fields

Field Description
timestamp ISO 8601 timestamp
level Log level (debug, info, warn, error)
message Log message
service Service name (coordinator-web)
team Team name (coordinator)
environment NODE_ENV value

Client Log Fields

Field Description
source Always "client"
url Page URL where log originated
userAgent Browser user agent
ip Client IP address

Custom Metadata

Pass any additional context:

logger.info("Device registered", {
  deviceId: "device-123",
  deviceType: "camera",
  organizationId: "org-456"
});

Loki Labels

Labels for querying in Grafana/Loki:

{service="coordinator-web", team="coordinator"}

Query examples:

{service="coordinator-web"} |= "error"
{service="coordinator-web"} | json | level="error"
{service="coordinator-web"} | json | source="client"

Next.js Configuration

External packages for server-side only:

next.config.ts

const nextConfig = {
  serverExternalPackages: ["winston", "winston-loki", "snappy"]
};

export default nextConfig;

Best Practices

Do Log

  • User actions (login, create session, etc.)
  • API errors with context
  • Performance metrics
  • State transitions
logger.info("Session created", { 
  sessionId, 
  userId, 
  duration: Date.now() - startTime 
});

Don't Log

  • Sensitive data (passwords, tokens)
  • PII without consent
  • High-frequency events (every frame)
  • Debug logs in production
// BAD
logger.info("User signed in", { password: user.password });

// GOOD
logger.info("User signed in", { userId: user.id });

Error Logging

Always include stack trace for errors:

try {
  await riskyOperation();
} catch (error) {
  logger.error("Operation failed", {
    error: error.message,
    stack: error.stack,
    context: { operationId, userId }
  });
}

Environment Variables

Variable Purpose Required
LOKI_URL Loki endpoint URL No (falls back to console)
LOG_LEVEL Minimum log level No (default: info)
NODE_ENV Environment name Yes