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:
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
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 |