В архитектуре Next.js 15 с App Router обработка единого пользовательского запроса распределяется по множеству гетерогенных сред выполнения: клиентский браузер, пограничный слой (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) вложенных спанов:
[ Browser: Client Navigation ] -> traceparent: 00-7a8b9c...-1111-01
│
▼
[ Edge Middleware: Auth & Geo Routing ] -> duration: 4ms
│
▼
[ Node.js Server: Root RSC Render (/dashboard) ] -> duration: 185ms
│
├─► [ Nested Span: Server Action (fetchUserData) ] -> duration: 62ms
│ │
│ └─► [ DB Span: pg.query (SELECT * FROM users) ] -> duration: 18ms
│
└─► [ HTTP Client Span: fetch(api.billing.internal) ] -> duration: 95ms
│ (W3C Header Propagation)
▼
[ Remote Microservice: Go/gRPC Invoicing ] -> duration: 80msСоздание точки входа register() с изоляцией по проверке process.env.NEXT_RUNTIME === 'nodejs'
Подключение BatchSpanProcessor, экспортера OTLP gRPC и фильтрация лишней авто-инструментации (fs, dns)
Настройка проброса заголовков traceparent через propagation.inject при сетевых вызовах fetch()
Оборачивание критических транзакций в withTracedSpan с записью бизнес-атрибутов и статусов
Сбор спанов OpenTelemetry Collector и визуализация графа вызовов с квантилями задержек
Почему прямой статический импорт @opentelemetry/sdk-node внутри файла instrumentation.ts приводит к ошибке сборки в Next.js 15?
await fetch() или обращениями к Prisma выполняются последовательно из-за отсутствия вызова Promise.all().Реализация сквозной трассировки требует модульного разделения: файла instrumentation.ts с динамической проверкой NEXT_RUNTIME, отдельного файла конфигурации NodeSDK с авто-инструментацией библиотек и утилиты-обертки для создания кастомных спанов в Server Actions.
/**
* Корневая точка входа инструментации Next.js 15.
* Вызывается автоматически один раз при старте сервера перед загрузкой маршрутов.
*/
export async function register() {
// Строгая проверка среды: тяжелый NodeSDK OpenTelemetry импортируется ТОЛЬКО в среде Node.js.
// Попытка импортировать его в Edge Runtime приведет к краху сборки.
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeNodeTelemetry } = await import('./instrumentation.node');
initializeNodeTelemetry();
}
}Клиент отправляет HTTP-запрос. В заголовки внедряется W3C заголовок traceparent для продолжения трейса.
Легковесный спан фиксирует проверку сессионной cookie, роутинг и пробрасывает заголовки в upstream.
Запуск корневого спана страницы. NodeSDK извлекает контекст из входящих заголовков через W3C propagator.
Авто-инструментация pg/Prisma создает дочерние спаны для каждого запроса к базе данных с записью SQL-кода.
Фоновый BatchSpanProcessor отправляет накопленные спаны по gRPC в Jaeger или Datadog Agent.
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-запросов, DNS, fs и БД.
*/
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({
// Отключаем шумные спаны чтения локальной файловой системы Next.js чанков
'@opentelemetry/instrumentation-fs': { enabled: false },
'@opentelemetry/instrumentation-dns': { enabled: false },
'@opentelemetry/instrumentation-http': {
enabled: true,
ignoreIncomingRequestHook: (req) => {
// Игнорируем healthcheck запросы Kubernetes зондов
return req.url === '/api/healthz' || req.url === '/api/metrics';
},
},
}),
],
});
sdk.start();
console.log('[OpenTelemetry] NodeSDK successfully started with OTLP gRPC exporter.');
// Корректное завершение работы OTel SDK при получении сигналов остановки контейнера
const shutdown = async () => {
try {
await sdk.shutdown();
console.log('[OpenTelemetry] SDK terminated and flushed buffers gracefully.');
} catch (err) {
console.error('[OpenTelemetry] Error during shutdown:', 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');
}
/**
* Production-ready утилита для создания дочерних спанов в 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) => {
// Добавление кастомных атрибутов согласно спецификации OpenTelemetry
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 };
}
);
}Какая архитектурная конфигурация спан-процессора и сэмплера является обязательной для высоконагруженных production-сервисов на Next.js?
Если внутри instrumentation.ts написать import { NodeSDK } from '@opentelemetry/sdk-node', Webpack/Turbopack попытается скомпилировать этот код для сборки Edge Runtime Middleware. Сборка упадет с фатальной ошибкой об отсутствии модулей 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-коллектору. Это катастрофически деградирует пропускную способность (RPS) приложения. Для продакшена обязателен только BatchSpanProcessor с буферизацией в памяти.
Установка сэмплера AlwaysOnSampler при потоке в тысячи запросов в секунду генерирует сотни гигабайт телеметрии, перегружает CPU контейнера сериализацией Protobuf и приводит к огромным счетам за трафик и хранение в Datadog. Используйте ParentBasedSampler в связке с TraceIdRatioBasedSampler (10-20% сэмплирования) или Head/Tail sampling на уровне внешнего OTel Collector.