Пошук уроків, статей та іншого контенту
Додайте JSON-логи, кореляційні ідентифікатори та контекст запитів для ефективного пошуку подій у системах збору логів.
Звичайний текстовий лог зручний для читання людиною:
2026-08-19T10:15:22.341Z Request completed in 42 msАле системам збору логів складно надійно витягнути з такого повідомлення HTTP-метод, статус, ідентифікатор користувача або тривалість запиту.
Структурований лог — це запис зі стабільною структурою, найчастіше JSON:
{"timestamp":"2026-08-19T10:15:22.341Z","level":"info","message":"Request completed","requestId":"6f2...","method":"GET","path":"/users","statusCode":200,"durationMs":42}Кожен запис містить окремі поля, за якими можна:
фільтрувати події;
групувати логи;
будувати графіки й сповіщення;
знаходити всі події одного запиту;
порівнювати тривалість операцій.
Зазвичай один JSON-об’єкт записують в один рядок. Такий формат називають JSON Lines або NDJSON.
Назви полів мають бути стабільними в усіх частинах застосунку. Мінімальний набір може містити:
timestamp — час події в ISO-форматі;
level — рівень події, наприклад info, warn, error;
message — короткий опис події;
requestId — ідентифікатор HTTP-запиту;
method і path — HTTP-метод і шлях;
statusCode — код відповіді;
durationMs — тривалість операції;
error — структуровані дані про помилку.
Повідомлення має описувати подію, а змінні значення краще передавати окремими полями:
logger.info('User loaded', {
userId: 42,
durationMs: 18
});Замість:
logger.info(`User 42 loaded in 18 ms`);У першому випадку система логування може окремо фільтрувати userId або будувати статистику за durationMs.
Найчастіше використовують такі рівні:
debug — деталі для розробки й діагностики;
info — штатні важливі події;
warn — нетипові ситуації, які не зупинили операцію;
error — помилки, що потребують уваги.
Рівні варто використовувати послідовно. Наприклад, успішне завершення запиту — це info, а невдале підключення до зовнішнього сервісу — error.
Один HTTP-запит може пройти через кілька функцій, сервісів і асинхронних операцій. Щоб знайти всі пов’язані події, кожен запит отримує кореляційний ідентифікатор.
У Node.js його часто передають у заголовку X-Request-Id:
X-Request-Id: 6f3f5e2e-8f7b-4e9a-9b0e-7e4c5b3b9a20Алгоритм обробки запиту:
Прочитати X-Request-Id із вхідного запиту.
Перевірити, що значення має дозволений формат.
Якщо заголовка немає або він некоректний — створити новий ідентифікатор.
Записувати цей ідентифікатор у кожен лог запиту.
Повернути його клієнту у відповіді.
Не варто без перевірки приймати довільні довгі значення із заголовка: це може створити зайве навантаження на логи або ускладнити їх пошук.
Передавати requestId вручну через кожен виклик незручно:
loadUser(userId, requestId);
saveAuditRecord(event, requestId);
sendNotification(notification, requestId);У Node.js для збереження даних у межах асинхронного ланцюжка можна використати AsyncLocalStorage з вбудованого модуля node:async_hooks.
Він дозволяє:
створити контекст на початку запиту;
отримати цей контекст у глибокій функції;
зберегти його через await, таймери та інші стандартні асинхронні операції.
Контекст не слід зберігати у глобальній змінній. Глобальна змінна буде спільною для всіх одночасних запитів, тому ідентифікатори можуть змішатися.
Нижче наведено повністю runnable-приклад без зовнішніх залежностей. Він створює HTTP-сервер, додає кореляційний ідентифікатор, зберігає його в AsyncLocalStorage і виводить JSON-логи в stdout.
const http = require('node:http');
const { randomUUID } = require('node:crypto');
const { AsyncLocalStorage } = require('node:async_hooks');
const requestContext = new AsyncLocalStorage();
function getRequestId(request) {
const header = request.headers['x-request-id'];
const value = Array.isArray(header) ? header[0] : header;
// Приймаємо лише короткі ідентифікатори з безпечними символами.
if (typeof value === 'string' && /^[a-zA-Z0-9._-]{1,128}$/.test(value)) {
return value;
}
return randomUUID();
}
function serializeError(error) {
if (!(error instanceof Error)) {
return { value: String(error) };
}
return {
name: error.name,
message: error.message,
stack: error.stack
};
}
function log(level, message, fields = {}) {
const context = requestContext.getStore() || {};
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...context,
...fields
};
process.stdout.write(`${JSON.stringify(entry)}\n`);
}
const logger = {
debug(message, fields) {
log('debug', message, fields);
},
info(message, fields) {
log('info', message, fields);
},
warn(message, fields) {
log('warn', message, fields);
},
error(message, fields) {
log('error', message, fields);
}
};
function delay(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function handleRequest(request, response) {
const url = new URL(
request.url,
`http://${request.headers.host || 'localhost'}`
);
if (url.pathname === '/fail') {
await delay(20);
throw new Error('Демонстраційна помилка');
}
if (url.pathname === '/hello') {
await delay(30);
logger.info('Business operation completed', {
operation: 'hello'
});
response.statusCode = 200;
response.setHeader('content-type', 'application/json; charset=utf-8');
response.end(JSON.stringify({ message: 'Привіт!' }));
return;
}
response.statusCode = 404;
response.setHeader('content-type', 'application/json; charset=utf-8');
response.end(JSON.stringify({ error: 'Not found' }));
}
const server = http.createServer((request, response) => {
const requestId = getRequestId(request);
const startedAt = Date.now();
response.setHeader('x-request-id', requestId);
requestContext.run(
{
requestId
},
async () => {
const url = new URL(
request.url,
`http://${request.headers.host || 'localhost'}`
);
logger.info('Request started', {
method: request.method,
path: url.pathname
});
try {
await handleRequest(request, response);
} catch (error) {
response.statusCode = 500;
response.setHeader('content-type', 'application/json; charset=utf-8');
response.end(JSON.stringify({ error: 'Internal Server Error' }));
logger.error('Request failed', {
error: serializeError(error)
});
} finally {
logger.info('Request completed', {
method: request.method,
path: url.pathname,
statusCode: response.statusCode,
durationMs: Date.now() - startedAt
});
}
}
);
});
server.listen(3000, () => {
logger.info('Server started', {
port: 3000
});
});Запустіть сервер:
node server.jsЗробіть запит із власним ідентифікатором:
curl -H "X-Request-Id: checkout-123" http://localhost:3000/helloУ виводі сервера кожен лог цього запиту матиме однакове поле:
{"timestamp":"...","level":"info","message":"Request started","requestId":"checkout-123","method":"GET","path":"/hello"}
{"timestamp":"...","level":"info","message":"Business operation completed","requestId":"checkout-123","operation":"hello"}
{"timestamp":"...","level":"info","message":"Request completed","requestId":"checkout-123","method":"GET","path":"/hello","statusCode":200,"durationMs":31}Помилку можна перевірити так:
curl -H "X-Request-Id: failed-request" http://localhost:3000/failУ записі з рівнем error буде вкладене поле error із назвою, повідомленням і стеком помилки.
Об’єкт Error не серіалізується в JSON так, як очікується:
JSON.stringify(new Error('Помилка'));Результатом буде порожній об’єкт, оскільки властивості message і stack не є звичайними enumerable-властивостями.
Тому помилку потрібно перетворити на звичайний об’єкт:
function serializeError(error) {
return {
name: error.name,
message: error.message,
stack: error.stack
};
}Не потрібно логувати помилку лише як рядок:
logger.error('Request failed', {
error: String(error)
});Так буде втрачено окремі поля, за якими система збору логів могла б фільтрувати тип помилки або аналізувати стек.
Структуровані логи часто зберігаються довго й доступні багатьом працівникам. Не додавайте до них:
паролі;
токени доступу;
значення Authorization;
повні номери банківських карток;
секретні ключі;
зайві персональні дані.
Якщо діагностична інформація необхідна, використовуйте маскування або обмежений набір полів. Наприклад, замість повного токена можна записати його короткий технічний ідентифікатор, якщо це справді потрібно для пошуку.
Також не варто записувати весь об’єкт запиту без фільтрації: у заголовках і тілі можуть міститися секрети.
У системі збору логів пошук за requestId дає змогу відновити послідовність подій:
запит надійшов до сервера;
викликалася бізнес-операція;
сталася помилка або сформувалася відповідь;
запит завершився із певним статусом.
Наприклад, для діагностики запиту failed-request достатньо відфільтрувати всі записи, де:
requestId = "failed-request"Важливо використовувати однакове ім’я поля всюди. Якщо в одному місці це requestId, в іншому request_id, а в третьому correlationId, пошук стає складнішим.
Якщо частина повідомлень має формат JSON, а частина — довільний текст, система збору логів може не розпізнати всі записи.
Для машинного аналізу використовуйте один формат, наприклад JSON Lines.
Якщо requestId додається тільки до початкового логу, а помилки або бізнес-події його не містять, пов’язати записи буде складно.
Контекст має додаватися автоматично до кожного запису в межах запиту.
Такий підхід небезпечний:
let currentRequestId;Під час паралельної обробки запитів значення буде перезаписуватися. Для асинхронного контексту використовуйте AsyncLocalStorage.
Великий об’єкт може містити секрети, зайві дані або циклічні посилання, через які JSON.stringify завершиться помилкою.
Вибирайте поля явно та серіалізуйте помилки окремою функцією.
Поля duration, time, elapsed і durationMs можуть означати одне й те саме. Виберіть один варіант і використовуйте його в усьому застосунку.
Структурований лог — це JSON-об’єкт зі стабільними полями.
Один логічний запис бажано виводити одним рядком.
Кореляційний ідентифікатор пов’язує всі події одного запиту.
AsyncLocalStorage дає змогу зберігати контекст через асинхронні виклики.
Ідентифікатор потрібно перевіряти, приймати або генерувати на початку запиту та додавати до відповіді.
Об’єкти Error потрібно явно перетворювати перед JSON-серіалізацією.
Секрети й зайві персональні дані не повинні потрапляти в логи.
Послідовні назви полів роблять пошук і аналіз подій передбачуваними.