Пошук уроків, статей та іншого контенту
Організуєте логи у форматі JSON, додасте контекст запитів і підготуєте їх до аналізу.
Звичайний текстовий лог зручний для розробника, але його складно аналізувати автоматично:
User 42 opened profile in 87msСтруктурований лог описує ту саму подію як JSON:
{
"timestamp": "2026-09-01T10:15:00.000Z",
"level": "info",
"event": "user_profile_opened",
"userId": 42,
"durationMs": 87,
"requestId": "8f6d..."
}Такий формат має кілька переваг:
кожен рядок є окремою JSON-подією;
поля можна фільтрувати та агрегувати;
числові значення залишаються числами;
можна пов’язати логи одного HTTP-запиту за requestId;
система моніторингу може автоматично аналізувати level, statusCode, durationMs та інші поля.
Лог має описувати подію, а не лише містити повідомлення для людини.
Перед реалізацією варто визначити стабільний набір полів:
timestamp — час події в ISO-форматі;
level — рівень логу: info, warn, error, debug;
message — текстовий опис, якщо він потрібен;
context — частина застосунку, яка створила лог;
requestId — ідентифікатор HTTP-запиту;
event — машинне ім’я події;
додаткові поля, пов’язані з подією.
Наприклад:
{
"timestamp": "2026-09-01T10:15:00.000Z",
"level": "info",
"context": "UsersService",
"requestId": "f3c8d3a6-6b2c-4d75-9a3d-83e5a9d5f521",
"event": "user_loaded",
"userId": 42
}Назва події user_loaded є стабільною і придатною для фільтрації. Натомість повідомлення на кшталт Користувача 42 завантажено складніше надійно аналізувати програмно.
NestJS дозволяє передати застосунку власну реалізацію LoggerService. Логер нижче:
записує події в stdout;
формує один JSON-об’єкт на рядок;
підтримує контекст NestJS;
додає requestId до кожного логу поточного запиту;
обробляє помилки окремо від звичайних повідомлень.
Для збереження requestId протягом асинхронного HTTP-запиту використаємо вбудований у Node.js AsyncLocalStorage.
Створіть файл request-context.service.ts:
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestStore {
requestId: string;
}
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestStore>();
run<T>(store: RequestStore, callback: () => T): T {
return this.storage.run(store, callback);
}
getRequestId(): string | undefined {
return this.storage.getStore()?.requestId;
}
}Метод run створює контекст для поточного асинхронного ланцюжка. Згодом сервіс логування зможе отримати з нього ідентифікатор запиту без передавання requestId через кожен метод.
Створіть файл json-logger.service.ts:
import { Injectable, LoggerService } from '@nestjs/common';
import { RequestContextService } from './request-context.service';
type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'verbose';
@Injectable()
export class JsonLoggerService implements LoggerService {
constructor(
private readonly requestContext: RequestContextService,
) {}
log(message: unknown, context?: string): void {
this.write('info', message, context);
}
warn(message: unknown, context?: string): void {
this.write('warn', message, context);
}
error(message: unknown, trace?: string, context?: string): void {
this.write('error', message, context, trace);
}
debug(message: unknown, context?: string): void {
this.write('debug', message, context);
}
verbose(message: unknown, context?: string): void {
this.write('verbose', message, context);
}
setContext(): void {
// Контекст передається другим або третім аргументом методів логера.
}
private write(
level: LogLevel,
message: unknown,
context?: string,
trace?: string,
): void {
const event: Record<string, unknown> = {
timestamp: new Date().toISOString(),
level,
};
const requestId = this.requestContext.getRequestId();
if (requestId) {
event.requestId = requestId;
}
if (context) {
event.context = context;
}
if (message instanceof Error) {
event.message = message.message;
event.error = {
name: message.name,
stack: message.stack,
};
} else if (
typeof message === 'object' &&
message !== null &&
!Array.isArray(message)
) {
Object.assign(event, message);
} else {
event.message = String(message);
}
if (trace) {
event.trace = trace;
}
process.stdout.write(`${JSON.stringify(event)}\n`);
}
}Для структурованих подій логер об’єднує властивості об’єкта з основним об’єктом логу:
this.logger.log(
{
event: 'user_loaded',
userId: 42,
},
UsersService.name,
);Результатом буде подія приблизно такого вигляду:
{
"timestamp": "2026-09-01T10:15:00.000Z",
"level": "info",
"requestId": "f3c8d3a6-6b2c-4d75-9a3d-83e5a9d5f521",
"context": "UsersService",
"event": "user_loaded",
"userId": 42
}requestId до HTTP-запитівІдентифікатор запиту допомагає знайти всі події, пов’язані з одним зверненням до API.
Створіть файл request-logging.middleware.ts:
import {
Injectable,
Logger,
NestMiddleware,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Request, Response, NextFunction } from 'express';
import { RequestContextService } from './request-context.service';
@Injectable()
export class RequestLoggingMiddleware implements NestMiddleware {
private readonly logger = new Logger(
RequestLoggingMiddleware.name,
);
constructor(
private readonly requestContext: RequestContextService,
) {}
use(
request: Request,
response: Response,
next: NextFunction,
): void {
const requestId =
this.getRequestId(request) ?? randomUUID();
const startedAt = Date.now();
response.setHeader('x-request-id', requestId);
this.requestContext.run({ requestId }, () => {
response.on('finish', () => {
this.logger.log({
event: 'http_request_completed',
method: request.method,
path: request.originalUrl ?? request.url,
statusCode: response.statusCode,
durationMs: Date.now() - startedAt,
});
});
next();
});
}
private getRequestId(request: Request): string | undefined {
const value = request.header('x-request-id');
if (!value || value.length > 100) {
return undefined;
}
return value;
}
}Middleware виконує кілька завдань:
Читає x-request-id із вхідного запиту.
Створює новий ідентифікатор, якщо заголовок відсутній.
Додає цей ідентифікатор до відповіді.
Відкриває асинхронний контекст для поточного запиту.
Записує метод, шлях, статус і тривалість запиту після його завершення.
Використання вхідного requestId корисне, коли між сервісами передається один ідентифікатор кореляції. Обмеження довжини заголовка не дозволяє безконтрольно записувати великі значення в логи.
Додайте сервіси та middleware до модуля застосунку:
import {
MiddlewareConsumer,
Module,
NestModule,
} from '@nestjs/common';
import { AppController } from './app.controller';
import { JsonLoggerService } from './json-logger.service';
import { RequestContextService } from './request-context.service';
import { RequestLoggingMiddleware } from './request-logging.middleware';
@Module({
controllers: [AppController],
providers: [
JsonLoggerService,
RequestContextService,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(RequestLoggingMiddleware)
.forRoutes('*');
}
}У main.ts передайте NestJS власний логер:
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { JsonLoggerService } from './json-logger.service';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
});
const jsonLogger = app.get(JsonLoggerService);
app.useLogger(jsonLogger);
app.enableShutdownHooks();
await app.listen(3000);
new Logger('Bootstrap').log({
event: 'application_started',
port: 3000,
});
}
void bootstrap();Після виклику app.useLogger екземпляри NestJS Logger, створені в контролерах і сервісах, використовуватимуть передану реалізацію.
Створимо простий контролер:
import { Controller, Get, Logger } from '@nestjs/common';
@Controller()
export class AppController {
private readonly logger = new Logger(AppController.name);
@Get('health')
getHealth(): { status: string } {
this.logger.log({
event: 'health_check',
status: 'ok',
});
return {
status: 'ok',
};
}
}Після запиту GET /health у стандартний потік буде записано події на кшталт:
{
"timestamp": "2026-09-01T10:15:00.000Z",
"level": "info",
"requestId": "f3c8d3a6-6b2c-4d75-9a3d-83e5a9d5f521",
"context": "AppController",
"event": "health_check",
"status": "ok"
}{
"timestamp": "2026-09-01T10:15:00.010Z",
"level": "info",
"requestId": "f3c8d3a6-6b2c-4d75-9a3d-83e5a9d5f521",
"context": "RequestLoggingMiddleware",
"event": "http_request_completed",
"method": "GET",
"path": "/health",
"statusCode": 200,
"durationMs": 10
}Обидва записи мають однаковий requestId, тому їх можна об’єднати під час аналізу.
Рівень має відповідати важливості події:
info — нормальні бізнес-події та завершені операції;
warn — незвичайна ситуація, яка не зупинила обробку;
error — помилка операції або виняток;
debug — деталі для налагодження;
verbose — дуже докладна діагностична інформація.
Наприклад:
this.logger.warn({
event: 'rate_limit_near',
userId,
remainingRequests: 2,
});
this.logger.error(
{
event: 'payment_failed',
paymentId,
reason: 'provider_timeout',
},
undefined,
'PaymentsService',
);Для помилок важливо зберігати технічний контекст:
назву помилки;
повідомлення;
стек викликів;
ідентифікатори сутностей, пов’язаних з операцією.
Водночас не слід записувати в логи пароль, токени доступу, повні дані банківських карток та інші секрети.
Оскільки кожен лог є окремим JSON-об’єктом, його можна обробляти стандартними інструментами командного рядка.
Наприклад, вибрати лише ідентифікатори та статуси HTTP-запитів:
cat application.log | jq 'select(.event == "http_request_completed") | {requestId, statusCode, durationMs}'Знайти всі помилки:
cat application.log | jq 'select(.level == "error")'Вибрати повний ланцюжок подій конкретного запиту:
cat application.log | jq 'select(.requestId == "f3c8d3a6-6b2c-4d75-9a3d-83e5a9d5f521")'Стабільні поля event, level, requestId і statusCode дають змогу будувати фільтри та метрики без аналізу довільного тексту.
Краще:
this.logger.log({
event: 'order_created',
orderId,
userId,
});Гірше:
this.logger.log(`Замовлення ${orderId} створено користувачем ${userId}`);Назви полів і подій мають залишатися однаковими в усіх частинах застосунку.
Не потрібно записувати весь HTTP-запит, об’єкт користувача або результат запиту до бази даних. Обирайте лише поля, необхідні для діагностики:
this.logger.log({
event: 'user_loaded',
userId: user.id,
durationMs,
});Лог має допомагати зрозуміти перебіг операції, але не замінює базу даних або систему аудиту. Записуйте ідентифікатори та короткий контекст, а не повний стан усіх об’єктів.
У контейнеризованих застосунках логер зазвичай має писати в stdout і stderr, а не самостійно керувати файлами. Збір, зберігання та ротація логів можуть виконуватися зовнішньою інфраструктурою.
Поля requestId, request_id і correlationId можуть означати одне й те саме, але ускладнюють пошук. Виберіть одну назву та використовуйте її послідовно.
Не варто робити так:
this.logger.log(JSON.stringify({
event: 'user_loaded',
userId: 42,
}));У такому випадку поле message міститиме JSON-рядок, а не окремі поля події. Передавайте об’єкт без попереднього виклику JSON.stringify.
requestId в асинхронному кодіЯкщо ідентифікатор запиту передавати вручну через багато методів, його легко загубити. Контекст через AsyncLocalStorage дозволяє отримувати його з логера в межах поточного запиту.
Перевіряйте поля перед логуванням. Особливо небезпечно записувати:
authorization та access-токени;
паролі;
cookies;
персональні дані без необхідності;
платіжні реквізити.
debugДокладні логи можуть створювати великий обсяг даних і містити чутливу інформацію. У production-середовищі рівні логування варто контролювати конфігурацією застосунку або середовища.
Структурований лог — це JSON-подія зі стабільними полями.
Для машинного аналізу використовуйте окремі поля event, level, statusCode і durationMs.
requestId дозволяє пов’язати логи контролерів, сервісів і middleware одного HTTP-запиту.
AsyncLocalStorage зберігає контекст запиту в асинхронному коді.
Власний LoggerService у NestJS може централізовано формувати всі лог-події.
Не записуйте в логи секрети та зайві великі об’єкти.