Пошук уроків, статей та іншого контенту
Побудуєте структуроване логування серверних подій, помилок і важливих дій користувачів.
У production логування допомагає відповісти на запитання:
який запит спричинив помилку;
для якого користувача виконувалася дія;
коли саме сталася проблема;
скільки разів виникає певна помилка;
які серверні події відбуваються найчастіше.
Звичайний текстовий лог складно шукати та аналізувати:
Order creation failed for user 42Структурований лог зберігає ті самі дані у форматі JSON:
{
"timestamp": "2026-08-17T10:15:30.000Z",
"level": "error",
"event": "order.create.failed",
"requestId": "8f2c...",
"userId": "42",
"error": {
"name": "Error",
"message": "Database unavailable",
"stack": "..."
}
}Такий формат легко обробляють системи збору логів і моніторингу.
Кожен запис має описувати одну подію:
request.received;
order.created;
user.login.failed;
database.query.failed.
Не варто формувати довгі повідомлення, у яких змішані кілька подій.
Найчастіше достатньо таких рівнів:
debug — деталі для локальної розробки;
info — нормальні важливі події;
warn — потенційні проблеми або очікувані помилки;
error — помилки, які потребують уваги.
У production рівень debug зазвичай вимикають, щоб не створювати зайвий обсяг даних.
Корисними полями є:
requestId — ідентифікатор конкретного HTTP-запиту;
userId — ідентифікатор користувача, якщо він відомий;
event — стабільний код події;
durationMs — тривалість операції;
resourceId — ідентифікатор об’єкта, наприклад замовлення;
error — нормалізована інформація про помилку.
Назви подій краще робити стабільними й машинно читабельними. Наприклад, order.create.failed зручніше фільтрувати, ніж довільний текст повідомлення.
У серверному коді Next.js можна використовувати console, але передавати в нього потрібно JSON, а не довільні рядки.
Створімо файл src/lib/logger.ts:
type LogLevel = "debug" | "info" | "warn" | "error";
type LogContext = Record<string, unknown>;
const sensitiveKeys = new Set([
"password",
"token",
"accessToken",
"refreshToken",
"authorization",
"cookie",
"secret",
]);
function sanitize(value: unknown, key?: string): unknown {
if (key && sensitiveKeys.has(key)) {
return "[REDACTED]";
}
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack,
};
}
if (Array.isArray(value)) {
return value.map((item) => sanitize(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
sanitize(entryValue, entryKey),
]),
);
}
return value;
}
function writeLog(
level: LogLevel,
event: string,
context: LogContext = {},
): void {
const record = {
timestamp: new Date().toISOString(),
level,
event,
...sanitize(context),
};
const output = JSON.stringify(record);
if (level === "error") {
console.error(output);
} else if (level === "warn") {
console.warn(output);
} else {
console.log(output);
}
}
export const logger = {
debug(event: string, context?: LogContext): void {
if (process.env.NODE_ENV !== "production") {
writeLog("debug", event, context);
}
},
info(event: string, context?: LogContext): void {
writeLog("info", event, context);
},
warn(event: string, context?: LogContext): void {
writeLog("warn", event, context);
},
error(event: string, context?: LogContext): void {
writeLog("error", event, context);
},
};У цьому logger:
кожен запис містить час і рівень;
подія має окреме поле event;
об’єкти Error перетворюються на JSON;
відомі секретні поля замінюються на [REDACTED];
debug не записується в production;
помилки виводяться через console.error.
Використання:
logger.info("user.login.succeeded", {
userId: "user_123",
});
logger.warn("user.login.failed", {
email: "user@example.com",
reason: "invalid_credentials",
});
logger.error("payment.charge.failed", {
userId: "user_123",
error,
});Не додавайте до логів пароль, токени, повні дані банківських карток або інші секрети. Навіть якщо logger має базове очищення, краще взагалі не передавати такі дані в контекст.
Розглянемо Route Handler, який створює замовлення. Він:
отримує або створює requestId;
записує факт отримання запиту;
перевіряє вхідні дані;
записує успішну дію користувача;
окремо записує очікувані та неочікувані помилки.
Файл app/api/orders/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { logger } from "@/lib/logger";
type CreateOrderBody = {
productId: string;
quantity: number;
};
function getRequestId(request: NextRequest): string {
return request.headers.get("x-request-id") ?? crypto.randomUUID();
}
function isCreateOrderBody(value: unknown): value is CreateOrderBody {
if (!value || typeof value !== "object") {
return false;
}
const body = value as Record<string, unknown>;
return (
typeof body.productId === "string" &&
body.productId.length > 0 &&
typeof body.quantity === "number" &&
Number.isInteger(body.quantity) &&
body.quantity > 0 &&
body.quantity <= 100
);
}
export async function POST(request: NextRequest) {
const requestId = getRequestId(request);
const startedAt = Date.now();
logger.info("order.create.requested", {
requestId,
method: request.method,
path: request.nextUrl.pathname,
});
try {
const body: unknown = await request.json();
if (!isCreateOrderBody(body)) {
logger.warn("order.create.rejected", {
requestId,
reason: "invalid_input",
});
return NextResponse.json(
{ error: "Invalid request body" },
{
status: 400,
headers: { "x-request-id": requestId },
},
);
}
// У реальному застосунку userId потрібно отримувати із серверної сесії.
const userId = request.headers.get("x-user-id") ?? "anonymous";
// Тут зазвичай викликається сервіс або база даних.
const order = {
id: crypto.randomUUID(),
productId: body.productId,
quantity: body.quantity,
userId,
};
logger.info("order.created", {
requestId,
userId,
orderId: order.id,
productId: order.productId,
quantity: order.quantity,
durationMs: Date.now() - startedAt,
});
return NextResponse.json(
{ order },
{
status: 201,
headers: { "x-request-id": requestId },
},
);
} catch (error) {
logger.error("order.create.failed", {
requestId,
durationMs: Date.now() - startedAt,
error,
});
return NextResponse.json(
{ error: "Internal server error" },
{
status: 500,
headers: { "x-request-id": requestId },
},
);
}
}Тестовий запит:
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-H "x-user-id: user_42" \
-H "x-request-id: request_abc123" \
-d '{"productId":"keyboard-1","quantity":2}'На сервері буде записано подію приблизно такого вигляду:
{
"timestamp": "2026-08-17T10:15:30.000Z",
"level": "info",
"event": "order.created",
"requestId": "request_abc123",
"userId": "user_42",
"orderId": "b7c...",
"productId": "keyboard-1",
"quantity": 2,
"durationMs": 18
}Зверніть увагу: заголовок x-user-id у прикладі потрібен лише для демонстрації. У справжньому застосунку ідентифікатор користувача має надходити із перевіреної серверної сесії або іншого механізму автентифікації. Не можна довіряти довільному ідентифікатору, надісланому клієнтом.
requestId дозволяє пов’язати між собою кілька записів:
request.received
database.query.started
database.query.failed
response.sentЯкщо всі вони містять однаковий requestId, можна відновити послідовність подій для одного запиту.
Якщо запит прийшов із зовнішньої системи та вже містить x-request-id, його можна використати. Якщо заголовка немає, сервер створює новий ідентифікатор.
Важливо повертати цей ідентифікатор у відповіді. Тоді користувач або служба підтримки зможуть передати його розробнику:
Помилка під час оплати. Ідентифікатор запиту: request_abc123За цим значенням запис можна швидко знайти у production-логах.
Помилку потрібно логувати на сервері, але не розкривати її внутрішні деталі клієнту.
Невдалий варіант:
return NextResponse.json({
error: error instanceof Error ? error.stack : String(error),
});Stack trace може містити внутрішні шляхи, назви сервісів або інші технічні деталі.
Кращий варіант:
logger.error("profile.update.failed", {
requestId,
userId,
error,
});
return NextResponse.json(
{ error: "Unable to update profile" },
{ status: 500 },
);Клієнт отримує безпечне загальне повідомлення, а команда розробників має деталі в серверному журналі.
Не кожна помилка означає несправність системи.
Наприклад:
неправильні дані форми — очікувана помилка, зазвичай warn;
відсутній ресурс — очікувана ситуація, залежно від сценарію info або warn;
недоступна база даних — error;
помилка в коді — error.
Приклад:
if (!isCreateOrderBody(body)) {
logger.warn("order.create.rejected", {
requestId,
reason: "invalid_input",
});
return NextResponse.json(
{ error: "Invalid request body" },
{ status: 400 },
);
}Не слід записувати всі відповіді зі статусом 4xx як критичні помилки. Інакше важливі проблеми загубляться серед звичайних помилок користувацького введення.
Логуйте не кожен рух користувача, а значущі дії, наприклад:
успішний вхід;
невдалу спробу входу;
зміну email;
створення або скасування замовлення;
зміну ролі;
видалення даних;
запуск фінансової операції.
Для таких подій корисно зберігати:
logger.info("account.email.changed", {
requestId,
userId,
durationMs: Date.now() - startedAt,
});Не потрібно записувати стару й нову email-адреси без необхідності. Навіть звичайні персональні дані можуть мати обмеження щодо зберігання та доступу.
Для дій, пов’язаних із безпекою, варто записувати причину відмови, але не секрети:
logger.warn("user.login.failed", {
requestId,
userId: null,
reason: "invalid_credentials",
});Пароль у такий лог додавати не можна.
Логування серверних подій потрібно виконувати в серверному коді:
Route Handlers;
Server Actions;
серверних функціях;
middleware, якщо подія стосується middleware;
обробниках помилок на сервері.
Логи з Client Components через console.log з’являться у консолі браузера користувача, а не в централізованих production-логах сервера. Це не замінює серверне логування і може розкрити зайві дані користувачу.
Якщо серверна функція викликає інший сервіс, передавайте requestId далі, щоб події можна було пов’язати між системами:
await fetch("https://payments.example/charge", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": requestId,
},
body: JSON.stringify({
orderId,
amount,
}),
});У цьому прикладі не передається платіжний токен у лог. Він використовується лише для виклику сервісу та має оброблятися за правилами безпеки.
console у productionУ середовищі запуску Next.js записи console.log, console.warn і console.error потрапляють у стандартні потоки процесу. Далі платформа розгортання може:
зберігати їх;
передавати в систему збору логів;
додавати власні поля;
обмежувати строк зберігання;
обмежувати максимальний розмір або кількість записів.
Тому logger має писати компактні записи в один рядок. Не покладайтеся на формат конкретного хостингу: основні поля події повинні бути присутні безпосередньо у вашому JSON.
Зайві логи ускладнюють пошук реальних проблем і можуть збільшити вартість зберігання.
Перед додаванням запису запитайте:
чи допоможе ця подія діагностувати проблему;
чи потрібно зберігати її для аудиту;
чи можна відфільтрувати її за стабільним event;
чи не містить вона персональних або секретних даних;
чи не записується одна й та сама подія кілька разів.
Не варто записувати весь об’єкт запиту:
logger.info("request.received", {
request,
});Такий об’єкт може містити cookies, токени, заголовки та великі дані. Краще вибрати потрібні поля:
logger.info("request.received", {
requestId,
method: request.method,
path: request.nextUrl.pathname,
});Небезпечно:
logger.info("auth.request", {
email,
password,
accessToken,
});Безпечніше:
logger.info("auth.request", {
email,
hasAccessToken: Boolean(accessToken),
});Але email також варто записувати лише тоді, коли він справді потрібен для діагностики.
Незручно:
console.log(`Order ${orderId} failed for user ${userId}`);Краще:
logger.error("order.create.failed", {
orderId,
userId,
error,
});Стабільний код події дозволяє створювати фільтри й метрики без аналізу тексту.
requestIdЯкщо частина подій не містить ідентифікатора запиту, пов’язати їх між собою буде складно. Передавайте requestId у всі логи одного сценарію.
ErrorНевдалий варіант:
logger.error("database.failed", {
error: "Database request failed",
});Це втрачає stack trace. Якщо він доступний, передавайте сам об’єкт:
logger.error("database.failed", {
error,
});Не повертайте клієнту stack, SQL-запит або повідомлення внутрішнього сервісу. Технічні деталі мають залишатися в серверному журналі.
Структурований лог — це JSON із полями події, рівня та контексту.
Для подій використовуйте стабільні назви на кшталт order.created або order.create.failed.
Додавайте requestId, щоб пов’язувати логи одного запиту.
Логуйте важливі дії користувача, серверні події та помилки.
Не записуйте паролі, токени, cookies та непотрібні персональні дані.
Очікувані помилки введення не слід змішувати з неочікуваними помилками сервера.
У production клієнту повертайте загальне повідомлення, а деталі зберігайте в серверному журналі.
console можна використовувати як основу, якщо записи мають стабільний JSON-формат і містять лише потрібні дані.