跨服务器与 Edge 的结构化日志记录
输出具有关联性的结构化日志,使其能够经受无服务器冷启动和 Edge 限制。
跨服务器与 Edge 的结构化日志记录 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
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.
常见问题解答
「跨服务器与 Edge 的结构化日志记录」课时是免费的吗?
是的 — 「跨服务器与 Edge 的结构化日志记录」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
「跨服务器与 Edge 的结构化日志记录」这节课中我会学到什么?
输出具有关联性的结构化日志,使其能够经受无服务器冷启动和 Edge 限制。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。
「跨服务器与 Edge 的结构化日志记录」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?
能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 使用 instrumentation.ts 进行 OpenTelemetry 追踪
- 细粒度 error.tsx 与全局错误边界
- 跨服务器与 Edge 的结构化日志记录
- 捕获服务器操作失败与遥测数据