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.