Метрика — это регулярно измеряемое число с метками и временем. Приложение публикует текущие значения, Prometheus периодически их забирает и хранит как временные ряды, а Grafana строит панели и помогает увидеть тренд. Ни Grafana, ни Prometheus сами по себе не исправляют утечку.
Разбираем: Prometheus и Grafana
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Лаборатория отдаёт Prometheus text exposition на `/api/metrics`: память главного процесса и memory child, Event Loop utilization/delay, активные запуски и ошибки. В optional Docker Compose Prometheus scrape-ит endpoint каждые пять секунд, а Grafana автоматически получает datasource и готовый dashboard.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Metric
Числовое измерение во времени; gauge меняется в обе стороны, counter обычно только растёт.
Time series
Последовательность samples одного metric name и уникального набора labels.
Scrape
HTTP-запрос Prometheus к metrics endpoint для получения текущих samples.
Cardinality
Число уникальных label combinations. userId/requestId в labels способны взорвать стоимость хранения.
SLI
Измеримый показатель качества сервиса, например доля успешных запросов или latency.
Alert
Правило над временным рядом, которое срабатывает при устойчивом условии, а не обязательно на одиночном sample.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Приложение измеряет
process.memoryUsage и perf_hooks дают runtime signals, а бизнес-код увеличивает counters.
- 02Endpoint публикует
`/api/metrics` возвращает HELP/TYPE и samples в Prometheus text format.
- 03Prometheus scrape-ит
Через равные интервалы он сохраняет значения с timestamp в локальную TSDB.
- 04PromQL вычисляет
rate(counter[window]), max_over_time и сравнения превращают samples в диагностические сигналы.
- 05Grafana визуализирует
Provisioned dashboard показывает память main/child, Event Loop delay, ELU и частоту запусков.
- 06Alert ведёт к расследованию
Устойчивый рост открывает runbook: workload, logs, snapshot безопасной реплики, retainer path, исправление.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Heap slope важнее одного числа
Высокий стабильный heap может быть нормой; подозрителен новый растущий baseline после сопоставимых циклов нагрузки и GC.
Event Loop delay и CPU не взаимозаменяемы
Высокий delay может дать синхронный I/O или пауза GC, а CPU процесса включает работу вне главного Event Loop. Коррелируйте сигналы.
Counter читают через rate/increase
Абсолютное число запусков растёт с uptime. Для текущей интенсивности используйте rate на окне; restart counter учитывается Prometheus.
Labels должны быть ограниченными
mode или outcome имеют малую cardinality. URL с id, email, stack trace и requestId оставляйте логам/traces.
Локальный dashboard — не вся production-система
Для реального сервиса добавьте HTTP RED metrics, DB/queue pool saturation, cgroup memory, restarts/OOMKill, alert routing и retention policy.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
import { monitorEventLoopDelay } from 'node:perf_hooks';
const delay = monitorEventLoopDelay({ resolution: 20 });
delay.enable();
// Prometheus scrape:
// GET /api/metrics
// node_loop_lab_process_resident_memory_bytes 123456789
// node_loop_lab_event_loop_delay_p95_seconds 0.012
console.log(delay.percentile(95) / 1e6, 'ms');Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
import {
monitorEventLoopDelay,
performance,
} from 'node:perf_hooks';
const sleep = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
function blockMainThread(durationMs) {
const startedAt = performance.now();
let iterations = 0;
// Намеренная блокировка для учебного сценария. В production так делать нельзя.
while (performance.now() - startedAt < durationMs) {
iterations += Math.sqrt((iterations % 10_000) + 1);
}
return Math.round(iterations);
}
async function observabilitySignals(emit) {
const histogram = monitorEventLoopDelay({ resolution: 10 });
histogram.enable();
const eluBefore = performance.eventLoopUtilization();
const memoryBefore = process.memoryUsage();
emit(
'metrics',
'sample',
`До нагрузки: RSS=${Math.round(memoryBefore.rss / 1024 / 1024)} MB, heapUsed=${Math.round(
memoryBefore.heapUsed / 1024 / 1024,
)} MB`,
);
emit(
'prometheus',
'info',
'Prometheus получает эти process/runtime/memory-lab ряды через GET /api/metrics',
);
// Даём monitorEventLoopDelay установить свой sampling timer до блокировки.
await sleep(35);
emit(
'event-loop',
'warning',
'Создаём короткую контролируемую блокировку, чтобы метрика delay получила сигнал',
);
blockMainThread(140);
await sleep(35);
const elu = performance.eventLoopUtilization(eluBefore);
const p95Ms = Number.isFinite(histogram.percentile(95))
? histogram.percentile(95) / 1e6
: 0;
const maxMs = Number.isFinite(histogram.max) ? histogram.max / 1e6 : 0;
histogram.disable();
emit(
'metrics',
'result',
`Event Loop: p95 delay=${p95Ms.toFixed(1)} мс, max=${maxMs.toFixed(
1,
)} мс, ELU=${(elu.utilization * 100).toFixed(1)}%`,
);
emit(
'grafana',
'result',
'Grafana не измеряет процесс сама: она строит панели по временным рядам, которые собрал Prometheus',
);
}Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
userId в Prometheus label ломает monitoring
Команда хочет быстро находить медленных пользователей и добавляет userId и requestId в labels HTTP histogram.
Каждая новая комбинация label создаёт time series. Prometheus тратит память на миллионы рядов, scrape и запросы Grafana замедляются, а dashboard становится частью инцидента.
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const startedAt = performance.now();
return next.handle().pipe(finalize(() => {
httpDuration.observe({
method: request.method,
path: request.url,
userId: request.user.id,
requestId: request.id,
}, (performance.now() - startedAt) / 1_000);
}));
}
}userId, requestId и raw URL имеют практически неограниченную cardinality. Metrics backend предназначен для агрегированных dimensions, а не для поиска единичного запроса.
@Injectable()
export class MetricsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const http = context.switchToHttp();
const request = http.getRequest();
const response = http.getResponse();
const controller = context.getClass().name;
const handler = context.getHandler().name;
const startedAt = performance.now();
return next.handle().pipe(finalize(() => {
const duration = (performance.now() - startedAt) / 1_000;
httpDuration.observe({
method: request.method,
controller,
handler,
statusClass: `${Math.floor(response.statusCode / 100)}xx`,
}, duration);
logger.info({
requestId: request.id,
userId: request.user?.id,
traceId: spanContext.traceId,
duration,
});
}));
}
}Метрики используют bounded labels, а high-cardinality identity уходит в structured logs и distributed traces. Между системами остаётся traceId.
Что делают непривычные вызовы из обоих фрагментов кода.
NestInterceptor- Nest-компонент, оборачивающий выполнение выбранного controller handler до и после его вызова.
ExecutionContext- Даёт interceptor доступ к controller, handler и транспортному контексту текущего запроса.
next.handle()- Запускает дальнейшую Nest pipeline и возвращает RxJS Observable с результатом handler.
finalize(callback)- RxJS-оператор вызывает callback и при успехе, и при ошибке Observable — удобное место для записи duration.
histogram.observe(labels, value)- Добавляет измерение в Prometheus histogram. Каждая уникальная комбинация labels создаёт отдельный time series.
context.getClass() / getHandler()- Возвращают выбранные Nest controller class и method. Их имена образуют ограниченный набор metric labels.
logger.info(fields, message)- Пишет structured log: high-cardinality requestId и userId остаются доступными для поиска, но не создают Prometheus series.
Популярные заблуждения
Миф слева, корректная модель справа.
Grafana собирает метрики приложения.
В этой схеме приложение публикует, Prometheus собирает и хранит, Grafana запрашивает и визуализирует.
Alert должен сработать при первом высоком sample.
Одиночный spike часто нормален; обычно задают окно, порог, duration и runbook.
RSS можно сложить с heapUsed и external.
RSS уже является общей резидентной картиной, а компоненты частично перекрываются.
В label полезно положить как можно больше контекста.
Неограниченные значения создают новые time series и могут перегрузить monitoring раньше приложения.
Красивый dashboard доказывает отсутствие утечки.
Он помогает заметить симптом. Причину подтверждают контролируемой нагрузкой, profiles/snapshots и retainer path.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Как отличить полезно выросший bounded cache от memory leak по временным рядам?
- Почему requestId нельзя использовать как Prometheus label?
- Какой runbook должен открываться при росте heap и Event Loop delay?
- Каких HTTP-метрик пока не хватает этой учебной лаборатории для настоящего SLO?