В архітектурі Next.js 15 з App Router обробка єдиного клієнтського HTTP-запиту розподіляється між різними гетерогенними середовищами виконання: браузером клієнта, пограничним шаром (Edge Middleware), серверними компонентами React (RSC) у Node.js, Server Actions, пулами підключень до реляційних баз даних (PostgreSQL, Prisma) та зовнішніми мікросервісами. У такому поліморфному середовищі традиційне ізольоване логування (console.log, розрізнені файли логів на диску) не здатне виявити причини деградації затримок (Latency Bottlenecks), розподілених дедлоків та каскадних відмов.
Фундаментом розподіленого трасування є специфікація W3C Trace Context. Контекст поширюється крізь мережеві межі за допомогою двох стандартизованих HTTP-заголовків:
traceparent: містить версію специфікації, 16-байтний глобальний ідентифікатор трейсу (trace_id), 8-байтний ідентифікатор батьківського спану (parent_id) та прапорці керування (trace_flags, зокрема біт семплювання). Формат: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01.tracestate: список пар ключ-значення для передачі специфічних метаданих моніторингових систем (Datadog, Dynatrace, New Relic) без модифікації заголовка traceparent.У Next.js 15 канонічною точкою входу для ініціалізації спостережуваності на рівні середовища є файл instrumentation.ts, розташований у корені проєкту (або в каталозі src/). Функція register(), експортована з цього файлу, викликається сервером Next.js одноразово під час холодного старту інстансу до завантаження та компіляції маршрутів. Головне архітектурне обмеження полягає в ізоляції середовищ: instrumentation.ts виконується як у середовищі Node.js, так і в Edge Runtime. Спроба статичного імпорту важких залежностей (@opentelemetry/sdk-node, C++ біндінгів gRPC) в Edge Runtime призводить до фатальної помилки компіляції (Module not found) або аварійного завершення через недоступність базових API Node.js (fs, net).
Топологія виконання розподіленого трейсу під час проходження запиту крізь стек Next.js 15 та бази даних формує орієнтований ациклічний граф (DAG) вкладених спанів:
[ Браузер: Клієнтська навігація ] -> traceparent: 00-7a8b9c...-1111-01
│
▼
[ Edge Middleware: Auth & Geo Routing ] -> тривалість: 4ms
│
▼
[ Сервер Node.js: Root RSC Render (/dashboard) ] -> тривалість: 185ms
│
├─► [ Вкладений спан: Server Action (fetchUserData) ] -> тривалість: 62ms
│ │
│ └─► [ DB Спан: pg.query (SELECT * FROM users) ] -> тривалість: 18ms
│
└─► [ HTTP Client Спан: fetch(api.billing.internal) ] -> тривалість: 95ms
│ (Прокидання W3C заголовків)
▼
[ Віддалений мікросервіс: Go/gRPC Invoicing ] -> тривалість: 80msawait fetch() або звертаються до Prisma замість паралельного виконання через Promise.all().Побудова надійної розподіленої спостережуваності вимагає чіткого модульного поділу: кореневого шлюзу instrumentation.ts із динамічною перевіркою NEXT_RUNTIME, окремого конфігураційного файлу instrumentation.node.ts із налаштуванням NodeSDK та авто-інструментації, а також типізованих утиліт для створення користувацьких спанів у Server Actions.
/**
* Коренева точка входу інструментації Next.js 15.
* Викликається один раз під час запуску сервера перед завантаженням маршрутів.
*/
export async function register() {
// Сувора перевірка рантайму: важкі модулі NodeSDK повинні імпортуватися ТІЛЬКИ в середовищі Node.js.
// Спроба виконати цей імпорт в Edge Runtime призведе до аварійного завершення збірки.
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeNodeTelemetry } = await import('./instrumentation.node');
initializeNodeTelemetry();
}
}Клієнт ініціює перехід або fetch-запит. У заголовки вбудовується W3C traceparent для продовження контексту.
Легковаговий спан перевіряє сесію користувача, виконує гео-роутинг та прокидає заголовки далі в upstream.
Стартує кореневий спан сторінки. NodeSDK витягує контекст із вхідних заголовків за допомогою зареєстрованого W3C пропагатора.
Авто-інструментація pg/Prisma створює дочірні спани для кожного SQL-запиту, фіксуючи санітизовані запити та затримки.
Фоновий BatchSpanProcessor передає накопичені спани протоколом gRPC до локального OpenTelemetry Collector або агента.
Нижче наведено конфігурацію NodeSDK корпоративного рівня з експортом OTLP через gRPC, адаптивним семплюванням, фільтрацією шуму та типізованим хелпером withTracedSpan для Server Actions.
import { NodeSDK } from '@opentelemetry/sdk-node';
import { Resource } from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { BatchSpanProcessor, TraceIdRatioBasedSampler, ParentBasedSampler } from '@opentelemetry/sdk-trace-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { trace, context, SpanStatusCode, Span, propagation, type Tracer } from '@opentelemetry/api';
/**
* Ініціалізує OpenTelemetry NodeSDK для середовища Node.js.
* Налаштовує буферизацію спанів до OTLP колектора (Jaeger / Datadog Agent)
* та вмикає авто-інструментацію для HTTP, Prisma, PostgreSQL та Redis.
*/
export function initializeNodeTelemetry(): void {
// Адаптивне семплювання: зберігаємо рішення батьківського запиту або семплюємо 20% вхідного трафіку
const sampleRatio = parseFloat(process.env.OTEL_TRACE_SAMPLE_RATIO || '0.2');
const sampler = new ParentBasedSampler({
root: new TraceIdRatioBasedSampler(sampleRatio),
});
// OTLP експортер через високопродуктивний бінарний gRPC
const traceExporter = new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317',
headers: process.env.OTEL_EXPORTER_OTLP_HEADERS
? JSON.parse(process.env.OTEL_EXPORTER_OTLP_HEADERS)
: {},
});
const sdk = new NodeSDK({
resource: new Resource({
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || 'nextjs-enterprise-app',
[ATTR_SERVICE_VERSION]: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
'deployment.environment': process.env.NODE_ENV || 'production',
'host.name': process.env.HOSTNAME || 'k8s-pod',
}),
sampler,
spanProcessor: new BatchSpanProcessor(traceExporter, {
maxQueueSize: 2048,
scheduledDelayMillis: 2000,
exportTimeoutMillis: 5000,
maxExportBatchSize: 512,
}),
instrumentations: [
getNodeAutoInstrumentations({
// Вимикаємо спани fs та dns, які створюють надлишковий шум під час завантаження чанків Next.js
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-http': {
enabled: true,
ignoreIncomingRequestHook: (req) => {
// Ігноруємо високочастотні запити проб життєздатності Kubernetes
return req.url === '/api/healthz' || req.url === '/api/metrics';
},
},
}),
],
});
sdk.start();
console.log('[OpenTelemetry] NodeSDK успішно запущено з OTLP gRPC експортером.');
// Коректне завершення роботи SDK зі скиданням буферів перед вимкненням контейнера
const shutdown = async () => {
try {
await sdk.shutdown();
console.log('[OpenTelemetry] SDK коректно зупинено, буфери скинуто.');
} catch (err) {
console.error('[OpenTelemetry] Помилка під час зупинки SDK:', err);
}
};
process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);
}
/**
* Повертає типізований екземпляр трасувальника з прив'язкою до версії застосунку.
*/
export function getTracer(name = 'nextjs-custom-tracer'): Tracer {
return trace.getTracer(name, process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0');
}
/**
* Продакшен-хелпер для трасування асинхронних Server Actions та RSC операцій.
* Керує життєвим циклом спану, записує помилки та гарантує закриття спану.
*/
export async function withTracedSpan<T>(
name: string,
attributes: Record<string, string | number | boolean>,
fn: (span: Span) => Promise<T>
): Promise<T> {
const tracer = getTracer();
return tracer.startActiveSpan(name, async (span) => {
for (const [key, value] of Object.entries(attributes)) {
span.setAttribute(key, value);
}
try {
const result = await fn(span);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : 'Unknown execution error',
});
throw error;
} finally {
span.end();
}
});
}
/**
* Допоміжна утиліта для ін'єкції поточного W3C TraceContext у вихідні HTTP-заголовки.
*/
export function injectTraceContext(headers: Headers = new Headers()): Headers {
const output: Record<string, string> = {};
propagation.inject(context.active(), output);
for (const [key, val] of Object.entries(output)) {
headers.set(key, val);
}
return headers;
}Усередині Server Actions спани огортають транзакційні операції та передають контекст трасування під час викликів мікросервісів:
'use server';
import { withTracedSpan, injectTraceContext } from '@/instrumentation.node';
interface CheckoutPayload {
cartId: string;
userId: string;
totalAmount: number;
}
export async function processOrderAction(payload: CheckoutPayload) {
return withTracedSpan(
'action.checkout.processOrder',
{
'business.user_id': payload.userId,
'business.cart_id': payload.cartId,
'business.amount': payload.totalAmount,
},
async (span) => {
span.addEvent('order.validation.start');
if (payload.totalAmount <= 0) {
throw new Error('Invalid order amount');
}
// Виклик зовнішнього білінгового мікросервісу з передачею W3C Trace Context
const paymentHeaders = injectTraceContext(new Headers({ 'Content-Type': 'application/json' }));
const response = await fetch('https://billing.internal.enterprise/v1/charge', {
method: 'POST',
headers: paymentHeaders,
body: JSON.stringify({
userId: payload.userId,
amount: payload.totalAmount,
}),
});
if (!response.ok) {
throw new Error(`Payment processing failed with status ${response.status}`);
}
const chargeResult = await response.json();
span.setAttribute('payment.transaction_id', chargeResult.transactionId);
span.addEvent('order.completed');
return { success: true, orderId: chargeResult.orderId };
}
);
}Виклик import { NodeSDK } from '@opentelemetry/sdk-node' на верхньому рівні instrumentation.ts змушує Webpack або Turbopack компілювати модуль для Edge Runtime. Збірка зазнає фатального краху через відсутність модулів child_process, net або tls. Завжди ізолюйте ініціалізацію через динамічний await import('./instrumentation.node') всередині умови if (process.env.NEXT_RUNTIME === 'nodejs').
Якщо необроблений виняток перериває виконання функції до виклику span.end(), спан залишається відкритим у пам'яті буфера на невизначений час. Такі спани або відкидаються, або надсилаються з нескінченною тривалістю, спотворюючи графіки затримок у дашбордах APM. Завжди огортайте логіку спану в блок try...catch...finally та викликайте span.end() у секції finally.
Конфігурація SimpleSpanProcessor замість BatchSpanProcessor змушує Node.js синхронно відправляти кожен створений спан мережею на OTLP колектор одразу після завершення. Цей послідовний мережевий ввід/вивід блокує єдиний потік Event Loop і критично знижує пропускну здатність (RPS). У продакшені обов'язково використовуйте BatchSpanProcessor із буферизацією в оперативній пам'яті.
Використання AlwaysOnSampler у системах із тисячами запитів на секунду генерує гігабайти телеметрії, перевантажує CPU контейнера серіалізацією Protobuf та спричиняє величезні рахунки за зберігання даних у Datadog. Застосовуйте ParentBasedSampler разом із TraceIdRatioBasedSampler (10–20% вибірки) або делегуйте tail-sampling зовнішньому OpenTelemetry Collector.
Оголошення хука register() з ізоляцією середовища для запобігання помилкам збірки Edge Runtime.
Налаштування BatchSpanProcessor, експортера gRPC OTLP та приглушення надлишкових спанів файлової системи і DNS.
Забезпечення передачі заголовків traceparent у вихідних викликах fetch() за допомогою propagation.inject().
Інкапсуляція критичних операцій утилітою withTracedSpan із додаванням бізнес-атрибутів та статусів.
Перевірка надходження трейсів до Jaeger або Datadog, аналіз графіків тривалості та процентилів p99.