Registro estructurado en Server y Edge
Emita registros estructurados y correlacionados que sobrevivan a los arranques en frío serverless y a las limitaciones de Edge.
Registro estructurado en Server y Edge es una lección gratuita de Next.js 15 Fullstack (App Router + Server Actions) en CoddyKit. Esta es la lección 3 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de Next.js 15 Fullstack (App Router + Server Actions), y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de Next.js 15 Fullstack (App Router + Server Actions) incluye 4 lecciones en total.
Partes de esta lección aún no han sido traducidas y se muestran en inglés.
Why Structured Logging Matters in Next.js 15
Traditional console.log outputs unstructured strings. In production Next.js 15 apps running across serverless functions and Edge runtimes, these strings are nearly useless: no correlation between requests, no machine-parseable fields, and cold starts discard buffered output before it flushes.
Structured logging solves this by emitting JSON objects with consistent fields on every log line:
- requestId — ties every log to one HTTP request
- timestamp — ISO-8601, always UTC
- level —
info/warn/error - service — which route or function emitted the log
- message — human-readable summary
- context — arbitrary structured payload
Observability platforms (Datadog, Axiom, Grafana Loki) ingest these fields automatically, letting you filter and correlate across thousands of concurrent requests in seconds.
The Core Logger Interface
Before integrating with any runtime, define a minimal, portable logger interface. This keeps your business logic decoupled from the concrete logging implementation and makes testing trivial.
Create lib/logger/types.ts with the shape every logger must satisfy:
// lib/logger/types.ts
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface LogContext {
requestId?: string;
userId?: string;
route?: string;
durationMs?: number;
[key: string]: unknown;
}
export interface Logger {
debug(message: string, context?: LogContext): void;
info(message: string, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
error(message: string, context?: LogContext & { err?: unknown }): void;
}
export interface LogEntry {
timestamp: string;
level: LogLevel;
service: string;
message: string;
context: LogContext;
}Building a JSON Logger for the Node.js Runtime
Next.js 15 Server Components, Route Handlers, and Server Actions run in the Node.js runtime by default. Here you have access to process.stdout and can safely emit multi-line JSON.
The key discipline: write one JSON object per line (NDJSON / JSON Lines format). Log aggregators split on newlines, so multi-line output corrupts ingestion.
Notice how err is serialised manually — JSON.stringify silently drops Error properties like stack and message.
// lib/logger/node-logger.ts
import type { Logger, LogContext, LogEntry, LogLevel } from './types';
function serializeError(err: unknown): Record<string, unknown> {
if (err instanceof Error) {
return { name: err.name, message: err.message, stack: err.stack };
}
return { raw: String(err) };
}
function createEntry(
level: LogLevel,
service: string,
message: string,
context: LogContext = {}
): LogEntry {
return {
timestamp: new Date().toISOString(),
level,
service,
message,
context,
};
}
export function createNodeLogger(service: string): Logger {
const write = (entry: LogEntry) =>
process.stdout.write(JSON.stringify(entry) + '\n');
return {
debug: (msg, ctx) => write(createEntry('debug', service, msg, ctx)),
info: (msg, ctx) => write(createEntry('info', service, msg, ctx)),
warn: (msg, ctx) => write(createEntry('warn', service, msg, ctx)),
error: (msg, ctx) => {
const { err, ...rest } = ctx ?? {};
write(createEntry('error', service, msg, {
...rest,
...(err !== undefined ? { error: serializeError(err) } : {}),
}));
},
};
}Edge-Compatible Logger: Surviving the Constraint
The Edge Runtime (used by Middleware and Route Handlers with export const runtime = 'edge') strips out most Node.js APIs. You cannot use process.stdout.write or fs.
What you can use:
console.log/console.error— always available- The
Fetch API— to forward logs to an external endpoint crypto.randomUUID()— for request IDs
The trick: still emit JSON strings via console.log. Vercel Edge logs are line-buffered and forward to your log drain as plain text, so one JSON per line is safe.
// lib/logger/edge-logger.ts
import type { Logger, LogContext, LogEntry, LogLevel } from './types';
function createEntry(
level: LogLevel,
service: string,
message: string,
context: LogContext = {}
): LogEntry {
return {
timestamp: new Date().toISOString(),
level,
service,
message,
context,
};
}
export function createEdgeLogger(service: string): Logger {
const emit = (entry: LogEntry) => {
// console.log is the only safe output channel in Edge Runtime
const line = JSON.stringify(entry);
if (entry.level === 'error') {
console.error(line);
} else {
console.log(line);
}
};
return {
debug: (msg, ctx) => emit(createEntry('debug', service, msg, ctx)),
info: (msg, ctx) => emit(createEntry('info', service, msg, ctx)),
warn: (msg, ctx) => emit(createEntry('warn', service, msg, ctx)),
error: (msg, ctx) => emit(createEntry('error', service, msg, ctx)),
};
}Request Correlation with AsyncLocalStorage
The hardest problem in serverless logging is correlation: attaching the same requestId to every log emitted during one request, even deep inside utility functions, without passing the ID as a parameter everywhere.
Node.js AsyncLocalStorage solves this. It creates a per-request context store that propagates automatically through async chains — await, Promise.then, timers — without any explicit threading.
Next.js 15 supports this natively in the Node.js runtime. Create a store module:
// lib/logger/request-store.ts
import { AsyncLocalStorage } from 'async_hooks';
export interface RequestStore {
requestId: string;
userId?: string;
startTime: number;
}
// One singleton for the process lifetime
export const requestStore = new AsyncLocalStorage<RequestStore>();
export function getRequestContext(): Partial<RequestStore> {
return requestStore.getStore() ?? {};
}
// Wrap any async work in this to bind a store
export function runWithRequestContext<T>(
store: RequestStore,
fn: () => Promise<T>
): Promise<T> {
return requestStore.run(store, fn);
}Context-Aware Logger Using the Store
Now wire the AsyncLocalStorage store into the logger. Every log call automatically reads requestId and userId from the running context — no prop-drilling needed.
Update lib/logger/node-logger.ts to merge the ambient store before writing:
// lib/logger/context-logger.ts
import { createNodeLogger } from './node-logger';
import { getRequestContext } from './request-store';
import type { Logger, LogContext } from './types';
export function createContextLogger(service: string): Logger {
const base = createNodeLogger(service);
function enrich(ctx: LogContext = {}): LogContext {
const { requestId, userId, startTime } = getRequestContext();
return {
...(requestId ? { requestId } : {}),
...(userId ? { userId } : {}),
...(startTime
? { elapsedMs: Date.now() - startTime }
: {}),
...ctx, // caller can override ambient values if needed
};
}
return {
debug: (msg, ctx) => base.debug(msg, enrich(ctx)),
info: (msg, ctx) => base.info(msg, enrich(ctx)),
warn: (msg, ctx) => base.warn(msg, enrich(ctx)),
error: (msg, ctx) => base.error(msg, enrich(ctx)),
};
}
// Singleton used everywhere in Node.js routes
export const logger = createContextLogger('nextjs-app');Injecting Context in Middleware
The ideal place to assign a requestId is Next.js Middleware — it intercepts every request before routing. You can generate an ID, attach it to a request header (x-request-id), and pass it downstream to both Server Components and API routes.
Note: Middleware runs in the Edge Runtime. Use crypto.randomUUID() (not uuid package) and createEdgeLogger.
// middleware.ts (project root)
import { NextRequest, NextResponse } from 'next/server';
import { createEdgeLogger } from '@/lib/logger/edge-logger';
const log = createEdgeLogger('middleware');
export function middleware(req: NextRequest) {
// Honour an upstream gateway's ID if present
const requestId =
req.headers.get('x-request-id') ?? crypto.randomUUID();
const start = Date.now();
log.info('request started', {
requestId,
method: req.method,
path: req.nextUrl.pathname,
});
const response = NextResponse.next();
// Forward the ID so Route Handlers and Server Actions can read it
response.headers.set('x-request-id', requestId);
log.info('request forwarded', {
requestId,
durationMs: Date.now() - start,
});
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Seeding AsyncLocalStorage in a Route Handler
In Node.js Route Handlers, read the x-request-id header (set by Middleware) and seed the AsyncLocalStorage store. Everything called within runWithRequestContext — including nested awaits and utility functions — inherits this context automatically.
// app/api/orders/route.ts
import { type NextRequest, NextResponse } from 'next/server';
import { runWithRequestContext } from '@/lib/logger/request-store';
import { logger } from '@/lib/logger/context-logger';
import { fetchOrders } from '@/lib/orders';
export async function GET(req: NextRequest) {
const requestId =
req.headers.get('x-request-id') ?? crypto.randomUUID();
return runWithRequestContext(
{ requestId, startTime: Date.now() },
async () => {
logger.info('fetching orders');
// up arrow automatically includes requestId + elapsedMs
try {
const orders = await fetchOrders();
logger.info('orders fetched', { count: orders.length });
return NextResponse.json(orders);
} catch (err) {
logger.error('failed to fetch orders', { err });
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
);
}Logging Inside Server Actions
Server Actions in Next.js 15 run in the Node.js runtime on the server, so AsyncLocalStorage works here too. However, there is a subtle constraint: the action is invoked by the React renderer, not a raw HTTP handler, so you must seed the store yourself at the top of the action.
A common pattern is a wrapper utility that seeds the store and provides the logger, keeping each action's body clean:
// lib/actions/with-logging.ts
import { headers } from 'next/headers';
import { runWithRequestContext } from '@/lib/logger/request-store';
import { logger } from '@/lib/logger/context-logger';
type ActionFn<TArgs extends unknown[], TResult> =
(...args: TArgs) => Promise<TResult>;
export function withLogging<TArgs extends unknown[], TResult>(
name: string,
fn: ActionFn<TArgs, TResult>
): ActionFn<TArgs, TResult> {
return async (...args) => {
const headersList = await headers();
const requestId =
headersList.get('x-request-id') ?? crypto.randomUUID();
return runWithRequestContext(
{ requestId, startTime: Date.now() },
async () => {
logger.info('action started: ' + name, { action: name });
try {
const result = await fn(...args);
logger.info('action completed: ' + name);
return result;
} catch (err) {
logger.error('action failed: ' + name, { err });
throw err;
}
}
);
};
}
// Usage in a Server Action:
// export const submitOrder = withLogging('submitOrder', async (data) => { ... });Sampling and Log Levels in Production
In a high-traffic Next.js app, logging every debug statement is expensive — both in compute time and in ingestion costs. Use two techniques together:
- Level gating: check
process.env.LOG_LEVELand skip levels below the threshold. Production typically runs atinfo; debug is enabled only during incidents. - Sampling: for noisy
infopaths (e.g., health checks), log only a percentage of requests to reduce volume without losing signal entirely.
A standalone demonstration of the core sampling + level-gating logic:
// Standalone demo — runs without any framework
const LEVELS = ['debug', 'info', 'warn', 'error'];
const MIN_LEVEL = process.env['LOG_LEVEL'] || 'info';
function shouldLog(level) {
return LEVELS.indexOf(level) >= LEVELS.indexOf(MIN_LEVEL);
}
function sample(rate) {
// rate = 0.1 means log 10% of the time
return Math.random() < rate;
}
function log(level, message, sampleRate = 1) {
if (!shouldLog(level)) return;
if (!sample(sampleRate)) return;
console.log(JSON.stringify({ level, message, ts: new Date().toISOString() }));
}
// Simulated high-frequency health-check path — logs only ~10%
for (let i = 0; i < 20; i++) {
log('info', '/api/health called', 0.1);
}
// Errors always log regardless of sample rate
log('error', 'Database connection failed');Forwarding Logs to an External Sink
Serverless functions are ephemeral — stdout is only reliable if your platform captures it (Vercel does; bare AWS Lambda does not by default). A log drain / remote sink (Axiom, Better Stack, Datadog) guarantees durability.
From the Edge Runtime you can forward logs using fetch with waitUntil so the HTTP response returns immediately while the log POST happens in the background. From Node.js Route Handlers, use after() (Next.js 15) for the same non-blocking guarantee.
// lib/logger/axiom-drain.ts
// Edge-compatible log drain using fetch
const AXIOM_DATASET = process.env['AXIOM_DATASET'] ?? '';
const AXIOM_TOKEN = process.env['AXIOM_API_TOKEN'] ?? '';
export async function sendToAxiom(
entries: object[]
): Promise<void> {
if (!AXIOM_DATASET || !AXIOM_TOKEN) return; // skip in dev
await fetch(
'https://api.axiom.co/v1/datasets/' + AXIOM_DATASET + '/ingest',
{
method: 'POST',
headers: {
'Content-Type': 'application/x-ndjson',
Authorization: 'Bearer ' + AXIOM_TOKEN,
},
// NDJSON: one JSON object per line
body: entries.map((e) => JSON.stringify(e)).join('\n'),
}
);
}
// In a Middleware or Edge Route Handler:
// context.waitUntil(sendToAxiom([entry]));
//
// In a Node.js Route Handler (Next.js 15):
// import { after } from 'next/server';
// after(() => sendToAxiom([entry]));Quick Check: Correlation Across Runtimes
You need every log line from a single user request — spanning Middleware (Edge), a Route Handler (Node.js), and a nested Server Action — to share the same requestId. Which approach is correct?
Lesson Recap: Structured Logging Across Server and Edge
In this lesson you built a complete, production-grade structured logging pipeline for Next.js 15:
- Portable interface (
Logger,LogContext) keeps business logic decoupled from the logging backend. - Runtime-specific implementations:
createNodeLoggerwrites NDJSON toprocess.stdout;createEdgeLoggerusesconsole.log— the only safe output channel in the Edge Runtime. - Correlation via
AsyncLocalStorage: seed a store once (in a Route Handler or Server Action wrapper) and every nestedawaitinheritsrequestId,userId, and elapsed time automatically. - Middleware as the request-ID origin: generate or honour an upstream
x-request-idheader in Middleware, forward it downstream, and seed the Node.js store on arrival. - Production discipline: gate log levels with
LOG_LEVELenv var, sample noisy paths, serialiseErrorobjects explicitly, and forward logs to a durable external sink withwaitUntilorafter()to avoid blocking responses.
These patterns together give you correlated, machine-parseable observability that survives cold starts, concurrent requests, and the constraints of both the Node.js and Edge runtimes.
Aprende TypeScript con un tutor de IA — gratis
Escribe y ejecuta código real en tu navegador, obtén ayuda instantánea de un tutor de IA disponible 24/7 y continúa donde lo dejaste en la web o en la aplicación.
- Cursos
- 22
- Lecciones
- 88
Preguntas frecuentes
¿La lección «Registro estructurado en Server y Edge» es gratis?
Sí — el texto completo de «Registro estructurado en Server y Edge» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de Next.js 15 Fullstack (App Router + Server Actions), actualiza a CoddyKit PRO. El curso de Next.js 15 Fullstack (App Router + Server Actions) incluye 4 lecciones en total.
¿Qué aprenderé en «Registro estructurado en Server y Edge»?
Emita registros estructurados y correlacionados que sobrevivan a los arranques en frío serverless y a las limitaciones de Edge. Practicas Next.js 15 Fullstack (App Router + Server Actions) con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.
¿Necesito experiencia previa para empezar Next.js 15 Fullstack (App Router + Server Actions)?
No se requiere experiencia previa. Next.js 15 Fullstack (App Router + Server Actions) en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 3 de 4.
¿Cuánto tiempo toma la lección «Registro estructurado en Server y Edge»?
La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.
¿Puedo escribir y ejecutar código en esta lección de Next.js 15 Fullstack (App Router + Server Actions)?
Sí. Cada lección de Next.js 15 Fullstack (App Router + Server Actions) incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.
Todas las lecciones de este curso
- Tracing de OpenTelemetry con instrumentation.ts
- Límites de error granulares con error.tsx y global-error
- Registro estructurado en Server y Edge
- Captura de fallos de Server Actions y telemetría