Пошук уроків, статей та іншого контенту
Інструментуйте застосунок за допомогою OpenTelemetry для збору трас, метрик і контексту розподілених запитів.
OpenTelemetry, або OTel, — це набір API, SDK і інструментів для збору телеметрії застосунків:
трас — послідовності операцій під час обробки запиту;
метрик — числових показників стану застосунку;
логів — повідомлень про події та помилки;
контексту — даних, які дають змогу пов’язати операції між різними сервісами.
OpenTelemetry не є системою зберігання даних. Він збирає телеметрію та передає її до бекенда спостережуваності, наприклад Jaeger, Grafana, Prometheus або сумісного з OTLP сервісу.
У Node.js зазвичай використовують:
@opentelemetry/api — API для створення власних спанів і метрик;
@opentelemetry/sdk-node — SDK для запуску OpenTelemetry у Node.js;
@opentelemetry/auto-instrumentations-node — автоматичну інструментацію популярних модулів;
експортери для передавання даних у зовнішні системи.
Трейс описує повний шлях одного запиту через систему.
Спан — окрема операція в межах трейсу. Наприклад:
HTTP GET /orders
├── запит до PostgreSQL
├── запит до сервісу платежів
└── формування відповідіКожен спан має:
ім’я;
час початку та завершення;
атрибути;
події;
статус;
батьківський спан.
Спани утворюють дерево. Вхідний HTTP-запит зазвичай стає кореневим спаном, а операції бази даних або вихідні HTTP-запити — його дочірніми спанами.
Автоматична інструментація підключається до модулів Node.js і створює спани без змін у бізнес-коді. Вона може інструментувати, зокрема:
http і https;
express;
pg;
mysql;
redis;
клієнти інших популярних бібліотек.
SDK потрібно ініціалізувати до імпорту модулів, які потрібно інструментувати. У CommonJS це означає, що файл запуску спочатку підключає OpenTelemetry, а вже потім — застосунок.
Для прикладу використаємо HTTP-сервер Node.js, автоматичну інструментацію, OTLP-експортер для трас і Prometheus-експортер для метрик.
npm install @opentelemetry/api \
@opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-prometheusУ production-застосунку версії залежностей потрібно зафіксувати у package-lock.json або іншому lock-файлі.
Створимо файл instrumentation.js:
const { NodeSDK } = require('@opentelemetry/sdk-node');
const {
getNodeAutoInstrumentations,
} = require('@opentelemetry/auto-instrumentations-node');
const {
OTLPTraceExporter,
} = require('@opentelemetry/exporter-trace-otlp-http');
const {
PrometheusExporter,
} = require('@opentelemetry/exporter-prometheus');
const traceExporter = new OTLPTraceExporter({
// Адреса OTLP Collector або іншого сумісного бекенда
url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||
'http://localhost:4318/v1/traces',
});
const metricReader = new PrometheusExporter({
port: Number(process.env.METRICS_PORT || 9464),
});
const sdk = new NodeSDK({
serviceName: process.env.OTEL_SERVICE_NAME || 'orders-service',
traceExporter,
metricReader,
instrumentations: [
getNodeAutoInstrumentations(),
],
});
sdk.start();
const shutdown = async () => {
try {
await sdk.shutdown();
console.log('OpenTelemetry SDK завершив роботу');
} catch (error) {
console.error('Помилка завершення OpenTelemetry SDK', error);
} finally {
process.exit(0);
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);У цьому прикладі:
спани передаються через OTLP HTTP;
метрики доступні на endpoint /metrics порту 9464;
автоматично підключаються інструментації, доступні в пакеті;
ім’я сервісу встановлюється через OTEL_SERVICE_NAME.
Значення http://localhost:4318/v1/traces передбачає, що локально доступний OTLP-сумісний приймач. Якщо його немає, застосунок може працювати, але експортувати спани буде нікуди.
Автоматична інструментація не знає деталей бізнес-операцій. Для цього додають власні спани та метрики через @opentelemetry/api.
Файл app.js:
const http = require('node:http');
const {
trace,
metrics,
SpanStatusCode,
} = require('@opentelemetry/api');
const tracer = trace.getTracer('orders-service', '1.0.0');
const meter = metrics.getMeter('orders-service', '1.0.0');
const ordersCreated = meter.createCounter('orders_created_total', {
description: 'Кількість створених замовлень',
});
const requestDuration = meter.createHistogram('request_duration_ms', {
description: 'Тривалість обробки HTTP-запитів у мілісекундах',
unit: 'ms',
});
const sleep = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function createOrder() {
return tracer.startActiveSpan('order.create', async (span) => {
try {
span.setAttribute('order.type', 'standard');
await sleep(20);
const order = {
id: `order-${Date.now()}`,
status: 'created',
};
ordersCreated.add(1, {
status: order.status,
});
span.addEvent('order.created', {
'order.id': order.id,
});
return order;
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
throw error;
} finally {
span.end();
}
});
}
const server = http.createServer(async (request, response) => {
const startedAt = performance.now();
try {
if (request.method === 'GET' && request.url === '/health') {
response.writeHead(200, {
'content-type': 'application/json',
});
response.end(JSON.stringify({ status: 'ok' }));
return;
}
if (request.method === 'POST' && request.url === '/orders') {
const order = await createOrder();
response.writeHead(201, {
'content-type': 'application/json',
});
response.end(JSON.stringify(order));
return;
}
response.writeHead(404, {
'content-type': 'application/json',
});
response.end(JSON.stringify({ error: 'Not found' }));
} catch (error) {
const activeSpan = trace.getActiveSpan();
if (activeSpan) {
activeSpan.recordException(error);
activeSpan.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
}
response.writeHead(500, {
'content-type': 'application/json',
});
response.end(JSON.stringify({ error: 'Internal server error' }));
} finally {
requestDuration.record(performance.now() - startedAt, {
method: request.method,
route: request.url,
});
}
});
const port = Number(process.env.PORT || 3000);
server.listen(port, () => {
console.log(`HTTP-сервер запущено на порту ${port}`);
});Файл main.js:
require('./instrumentation');
require('./app');Запуск:
node main.jsПеревірка застосунку:
curl -X POST http://localhost:3000/orders
curl http://localhost:9464/metricsУ першому запиті автоматична інструментація створить HTTP-спан, а order.create буде його дочірнім спаном. У другому запиті Prometheus-експортер поверне зібрані метрики.
Атрибути — це структуровані властивості операції:
span.setAttribute('user.id', user.id);
span.setAttribute('order.amount', order.amount);
span.setAttribute('order.currency', order.currency);Атрибути мають бути:
стабільними;
придатними для фільтрації;
достатньо загальними для групування.
Не слід додавати до атрибутів:
паролі;
токени;
повні номери банківських карток;
персональні дані без обґрунтованої потреби;
великі об’єкти або повний текст HTTP-запитів.
Значення атрибутів повинні мати прості типи: рядок, число, boolean або масив таких значень.
Подія описує важливий момент у життєвому циклі спана:
span.addEvent('payment.authorized', {
'payment.provider': 'example-provider',
});Події зручні для фактів, які не потребують окремого спана. Якщо операція має власну тривалість або вкладені операції, для неї краще створити дочірній спан.
Помилку потрібно записати у спан і встановити статус ERROR:
try {
await operation();
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
throw error;
} finally {
span.end();
}recordException додає інформацію про виняток, але сам по собі не змінює статус спана. Тому для помилок також потрібно викликати setStatus.
Спан потрібно завершувати навіть у разі помилки. Найзручніше робити це у блоці finally.
OpenTelemetry пов’язує поточний спан із контекстом виконання Node.js. Завдяки цьому дочірні операції можуть автоматично отримувати батьківський спан.
Метод startActiveSpan створює спан і робить його активним на час виконання callback-функції:
return tracer.startActiveSpan('operation.name', async (span) => {
try {
return await operation();
} finally {
span.end();
}
});Усередині callback-функції:
const activeSpan = trace.getActiveSpan();повертає поточний активний спан.
Контекст зберігається між асинхронними операціями Node.js, зокрема під час роботи з Promise та async/await. За це відповідає механізм контексту OpenTelemetry, реалізований для Node.js через асинхронний контекст виконання.
Не потрібно передавати span кожній функції як аргумент лише для створення вкладених спанів:
// Такий підхід зазвичай зайвий
await loadUser(userId, span);Краще використовувати активний контекст:
async function loadUser(userId) {
return tracer.startActiveSpan('user.load', async (span) => {
try {
span.setAttribute('user.id', userId);
return await repository.findUser(userId);
} finally {
span.end();
}
});
}Водночас контекст не слід вважати заміною явним аргументам бізнес-логіки. Він потрібен саме для телеметрії, а не для передавання доменних даних.
У розподіленій системі один користувацький запит може пройти через декілька сервісів:
клієнт → API Gateway → orders-service → payments-serviceЩоб побачити цей шлях як один трейс, сервіс-ініціатор передає контекст у HTTP-заголовках. Типовий стандарт OpenTelemetry — W3C Trace Context.
Основний заголовок має вигляд:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01У ньому містяться:
версія формату;
ідентифікатор трейсу;
ідентифікатор поточного спана;
прапорці трасування.
Автоматична інструментація:
витягує контекст із вхідного HTTP-запиту;
робить його активним;
створює спан поточного запиту;
додає контекст до вихідних HTTP-запитів.
Тому для підтримуваних HTTP-клієнтів зазвичай не потрібно вручну формувати traceparent.
Наприклад, якщо сервіс виконує вихідний запит через http.request, автоматична інструментація Node.js може створити клієнтський спан і передати контекст наступному сервісу:
const http = require('node:http');
function requestPaymentService(orderId) {
return new Promise((resolve, reject) => {
const request = http.request(
{
hostname: 'payments-service',
port: 3000,
path: `/payments/${orderId}`,
method: 'GET',
},
(response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('end', () => {
resolve({
statusCode: response.statusCode,
body,
});
});
},
);
request.on('error', reject);
request.end();
});
}Важливо, щоб автоматична інструментація була завантажена до імпорту node:http у прикладному коді.
Ручна пропагація потрібна, якщо транспорт не підтримується автоматично або застосунок має власний протокол.
Для цього використовують propagation.inject і propagation.extract:
const {
context,
propagation,
} = require('@opentelemetry/api');
function addTraceHeaders(headers = {}) {
propagation.inject(context.active(), headers);
return headers;
}
const headers = addTraceHeaders({
'content-type': 'application/json',
});inject записує поточний контекст у carrier — об’єкт заголовків, повідомлення черги або іншу структуру транспорту.
На стороні отримувача контекст потрібно витягнути:
const {
context,
propagation,
trace,
} = require('@opentelemetry/api');
function handleMessage(message, headers) {
const parentContext = propagation.extract(
context.active(),
headers,
);
return context.with(parentContext, () => {
const tracer = trace.getTracer('orders-service');
return tracer.startActiveSpan('message.process', (span) => {
try {
return processMessage(message);
} finally {
span.end();
}
});
});
}Назви полів у headers залежать від конкретного транспорту. Для HTTP це зазвичай об’єкт заголовків, а для черг повідомлень — metadata повідомлення.
Метрики мають інший життєвий цикл, ніж спани. Замість окремого запису кожної операції вони агрегують велику кількість вимірювань.
Основні типи:
Counter — значення, яке лише збільшується;
UpDownCounter — значення, яке може збільшуватися та зменшуватися;
Histogram — розподіл значень, наприклад тривалості запитів;
Observable Gauge — значення, яке зчитується під час збору метрик.
Приклади:
const meter = metrics.getMeter('orders-service');
const ordersCreated = meter.createCounter('orders_created_total');
const activeJobs = meter.createUpDownCounter('active_jobs');
const requestDuration = meter.createHistogram('request_duration_ms');
ordersCreated.add(1, { status: 'created' });
activeJobs.add(1);
activeJobs.add(-1);
requestDuration.record(125, {
method: 'POST',
route: '/orders',
});Атрибути метрик утворюють окремі часові ряди. Якщо значення атрибута має дуже багато варіантів, кількість часових рядів швидко зростає.
Небезпечні атрибути:
requestDuration.record(125, {
userId: 'a-unique-value-for-every-user',
requestId: 'a-unique-value-for-every-request',
});Краще використовувати обмежені набори значень:
requestDuration.record(125, {
method: 'POST',
route: '/orders',
status: '2xx',
});Ідентифікатори користувачів, замовлень і запитів доречніші у спанах, але навіть там їх потрібно додавати з урахуванням вимог до приватності.
OpenTelemetry розділяє:
створення телеметрії через API;
обробку телеметрії SDK;
її експортування;
зберігання та візуалізацію у зовнішній системі.
Для трас у прикладі використовується OTLP HTTP exporter. Найчастіше застосунок передає дані до OpenTelemetry Collector, а Collector уже маршрутизує їх до Jaeger, Tempo або іншого бекенда.
Для конфігурації зазвичай використовують змінні середовища:
OTEL_SERVICE_NAME=orders-service \
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces \
PORT=3000 \
node main.jsНазва сервісу важлива: за нею бекенд групує спани, отримані від одного застосунку.
Prometheus-експортер працює інакше: він відкриває HTTP endpoint, який система моніторингу періодично опитує. У прикладі це:
http://localhost:9464/metricsІнформація про сервіс, середовище та версію застосунку називається ресурсом. Ці дані додаються до всієї телеметрії сервісу.
Мінімальний набір:
service.name;
service.version;
deployment.environment.
service.name має бути стабільним між перезапусками. Не слід включати в нього ідентифікатор конкретного контейнера або випадкове значення.
Назву сервісу можна встановити змінною середовища:
OTEL_SERVICE_NAME=orders-service node main.jsІдентифікатор середовища також можна передати стандартними налаштуваннями OpenTelemetry:
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production node main.jsУ великій системі зберігати кожен спан може бути дорого. Sampling визначає, які спани експортувати.
Поширені стратегії:
зберігати всі трейси під час локальної розробки;
зберігати частину успішних трейcів у production;
зберігати всі помилкові або повільні трейси;
використовувати рішення Collector, якщо sampling має бути узгодженим між сервісами.
Sampling потрібно налаштовувати обережно. Якщо кожен сервіс незалежно відкидає трейси, розподілений трейс може стати неповним.
Незалежно від того, чи буде спан експортовано, ідентифікатори контексту можуть використовуватися для кореляції запитів. Однак конкретна поведінка залежить від sampler і конфігурації SDK.
OpenTelemetry потрібно запускати до завантаження інструментованих модулів:
require('./instrumentation');
require('./app');Неправильний порядок:
require('./app');
require('./instrumentation');У такому випадку модулі можуть бути завантажені до того, як SDK встигне підключити автоматичну інструментацію.
Під час завершення процесу потрібно викликати:
await sdk.shutdown();Це дає SDK можливість передати буферизовану телеметрію. Без коректного завершення останні спани або метрики можуть не потрапити до експортера.
Якщо SDK запускається після імпорту http, express або іншої бібліотеки, автоматична інструментація може не спрацювати.
Ініціалізуйте SDK у найпершому файлі запуску.
Помилка до span.end() залишає спан незавершеним.
Використовуйте try...finally:
tracer.startActiveSpan('operation', async (span) => {
try {
return await operation();
} finally {
span.end();
}
});Не додавайте до кожного спана великі об’єкти, секрети або повне тіло запиту. Це збільшує обсяг телеметрії та може спричинити витік конфіденційних даних.
requestId, випадкові UUID і довільні URL як атрибути метрик можуть створити тисячі або мільйони часових рядів.
Для метрик використовуйте нормалізовані маршрути та обмежені набори значень.
Якщо сервіс створює новий контекст замість використання вхідного, трейс розривається на кілька незалежних частин.
Для HTTP і підтримуваних бібліотек покладайтеся на автоматичну пропагацію. Для власних транспортів використовуйте propagation.extract та propagation.inject.
console.log як заміни спанамЛоги не містять автоматично дерева операцій, тривалості та контексту розподіленого запиту. Логування може доповнювати трасування, але не замінює його.
service.nameБез стабільної назви сервісу важко відрізнити телеметрію різних компонентів системи.
Встановлюйте OTEL_SERVICE_NAME для кожного сервісу.
OpenTelemetry у Node.js збирає траси, метрики та контекст розподілених запитів.
@opentelemetry/sdk-node запускає SDK, а getNodeAutoInstrumentations() додає спани для підтримуваних бібліотек.
SDK потрібно ініціалізувати до імпорту прикладного коду.
Власні операції інструментують через tracer.startActiveSpan.
Помилки потрібно записувати через recordException, встановлювати статус ERROR і завершувати спан.
Метрики створюють через meter.createCounter, createHistogram та інші інструменти API.
W3C Trace Context дає змогу пов’язувати запити між сервісами.
Для власних транспортів контекст передають через propagation.inject і propagation.extract.
Атрибути потрібно добирати з урахуванням приватності та кардинальності.
Перед завершенням процесу слід викликати sdk.shutdown().