서버와 Edge 전반의 구조화된 로깅
서버리스 콜드 스타트와 Edge 제약에서도 유지되는 상관관계 기반 구조화 로그를 생성하는 방법을 배웁니다.
서버와 Edge 전반의 구조화된 로깅은(는) CoddyKit의 무료 Next.js 15 Fullstack (App Router + Server Actions) 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 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 전반의 구조화된 로깅” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Next.js 15 Fullstack (App Router + Server Actions) 강의 전체를 잠금 해제할 수 있습니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.
“서버와 Edge 전반의 구조화된 로깅”에서 뭘 배우나요?
서버리스 콜드 스타트와 Edge 제약에서도 유지되는 상관관계 기반 구조화 로그를 생성하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Next.js 15 Fullstack (App Router + Server Actions)을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Next.js 15 Fullstack (App Router + Server Actions)을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Next.js 15 Fullstack (App Router + Server Actions)은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.
“서버와 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 전반의 구조화된 로깅
- 서버 액션 실패와 원격 측정 데이터 수집