Сервису нужны три вида телеметрии: структурированные логи, чтобы разобрать конкретный запрос; метрики, чтобы видеть тренды и ставить алерты; трассировки, чтобы проследить запрос через несколько сервисов. Минимальный набор метрик — четыре золотых сигнала из книги Google SRE: задержка, трафик, ошибки и насыщение. Алерт должен срабатывать на симптом, который видят пользователи, с порогом и выдержкой времени, а не на любую странность.
После материала вы сможете
- настроить структурированные JSON-логи на pino с уровнями, маскированием секретов и
reqIdв каждой строке - передать correlation id между двумя сервисами в заголовке
X-Request-Idи собрать по нему все строки одного запроса - отдать метрики RED на
/metricsи посчитать долю ошибок и p95 запросами PromQL - описать правило алерта с порогом и
forи проверить, что оно переходит вfiring
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)Express 5.3.0pino 10.4.0pino-http 11.0.0@prometheus-io/client 0.16.1Prometheus 3.15.0Docker Compose 5.3
Сверено с документацией: 11 октября 2026 г.
Содержание
- Логи, метрики, трассировки: что есть что
- Что измерять: четыре золотых сигнала, RED и USE
- Структурированные логи на pino: уровни и маскирование
- Correlation id: одна нить через все сервисы
- Health и readiness: живой ли процесс и готов ли он к трафику
- Метрики Prometheus: /metrics и запросы PromQL
- Алерты: пороги, выдержка и шум
- Grafana, OpenTelemetry и упражнения
Логи, метрики, трассировки: что есть что
Пользователь пишет в поддержку «заявка не отправилась». Без телеметрии команда гадает: упал сервер, медленная база или ошибка во фронтенде. Мониторинг отвечает на два вопроса, которые глава 6 книги Google SRE «Monitoring Distributed Systems» формулирует так: что сломалось (симптом) и почему (причина). Для ответа используют три сигнала — так их называет и OpenTelemetry.
Лог
- Что это
- Запись о событии: время, уровень, сообщение и поля
- На какой вопрос отвечает
- Что именно случилось с этим запросом?
- Типичный инструмент
- pino, Loki, Elasticsearch
Метрика
- Что это
- Число во времени: счётчик, текущее значение, гистограмма
- На какой вопрос отвечает
- Сколько, как быстро, растёт ли проблема?
- Типичный инструмент
- Prometheus, Grafana
Трассировка (trace)
- Что это
- Путь одного запроса через сервисы, разбитый на участки (span)
- На какой вопрос отвечает
- Где именно запрос провёл время?
- Типичный инструмент
- OpenTelemetry, Jaeger, Tempo
Сигналы дополняют друг друга. Метрика дешёвая: одно число на интервал, сколько бы ни было запросов, поэтому на метриках строят графики и алерты. Лог дорогой — строка на каждое событие, — зато в нём контекст: какой заказ, какая ошибка, какой стек вызовов. Типичный путь разбора: алерт по метрике «ошибок стало 25%» → фильтр логов по времени и уровню error → request id конкретного запроса → все строки этого запроса во всех сервисах.
Книга SRE различает мониторинг «белого ящика» — метрики и логи изнутри системы — и «чёрного ящика»: внешнюю проверку так, как систему видит пользователь. Google использует много белого ящика и немного, но обязательно, чёрного: внешняя проверка ловит то, что уже сломалось сейчас, а внутренние метрики показывают надвигающиеся проблемы и ошибки, скрытые повторными попытками.
Что измерять: четыре золотых сигнала, RED и USE
Книга Google SRE (глава 6, раздел «The Four Golden Signals») прямо говорит: если можете измерять только четыре метрики пользовательской системы, измеряйте задержку, трафик, ошибки и насыщение. Если по каждому сигналу есть алерт, сервис «как минимум прилично покрыт мониторингом».
Задержка (latency)
- Определение по SRE book
- Время обслуживания запроса; задержку успешных и неуспешных запросов считают раздельно
- Как измерить в HTTP API на Node.js
- Гистограмма
http_request_duration_secondsс меткойstatus_code, p95/p99
Трафик (traffic)
- Определение по SRE book
- Нагрузка на систему в её единицах — для веб-сервиса обычно HTTP-запросы в секунду
- Как измерить в HTTP API на Node.js
rate()от счётчика запросов
Ошибки (errors)
- Определение по SRE book
- Доля неуспешных запросов: явно (500), неявно (200 с неверным содержимым) или по политике (дольше обещанного)
- Как измерить в HTTP API на Node.js
- Доля ответов
5xxот всех ответов
Насыщение (saturation)
- Определение по SRE book
- Насколько сервис «заполнен»: самый ограниченный ресурс — память, CPU, очередь, диск
- Как измерить в HTTP API на Node.js
- Память процесса, задержка event loop, длина очереди задач, свободное место
Важная деталь из той же главы — «хвост». Средняя задержка 100 мс может прятать 1% запросов по 5 секунд, а среднее по CPU — один перегруженный сервер среди свободных. Поэтому задержку собирают гистограммой — числом запросов по корзинам длительности — и смотрят перцентили: p95 означает, что 95% запросов быстрее этого значения. Медленная ошибка, напоминают авторы, ещё хуже быстрой.
Есть две короткие схемы, которые часто путают с золотыми сигналами. Метод USE Брендана Грегга — для ресурсов: для каждого ресурса (CPU, диск, сеть) проверить Utilization (занятость), Saturation (очередь невыполненной работы) и Errors. Метод RED Тома Уилки (Grafana Labs) — для сервисов: Rate (запросов в секунду), Errors (сколько из них падает), Duration (распределение длительности). Сам автор RED советует использовать их вместе: RED показывает, как себя чувствуют пользователи, USE — как себя чувствуют машины.
Структурированные логи на pino: уровни и маскирование
Строка console.log("order failed", id) удобна человеку в терминале, но бесполезна для поиска по тысячам строк. Структурированный лог — это одна JSON-строка на событие с постоянными полями: время, уровень, сервис, request id, сообщение и данные. Такие строки системы сбора логов (Loki, Elasticsearch, облачные логи) разбирают без регулярных выражений и фильтруют по полям. Pino по умолчанию пишет именно JSON в стандартный вывод — а в контейнере stdout и есть правильное место для логов.
Пример этого урока — два маленьких сервиса на Express 5: orders отдаёт заказ и для этого ходит в stock за остатком. Один и тот же код запускается дважды с разными переменными окружения. Начнём с проекта и логгера.
mkdir orders-observability && cd orders-observability
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node src/server.js"
npm install --save-exact express@5.3.0 pino@10.4.0 pino-http@11.0.0 @prometheus-io/client@0.16.1@prometheus-io/client — это бывший prom-client: с версии 0.16 он стал подпроектом Prometheus и переименован.import { pino } from "pino";
export const logger = pino({
name: process.env.SERVICE_NAME ?? "orders-api",
level: process.env.LOG_LEVEL ?? "info",
// ISO time and a level name instead of a number: easier to search
timestamp: pino.stdTimeFunctions.isoTime,
formatters: { level: (label) => ({ level: label }) },
// secrets and personal data never reach the log
redact: {
paths: ["password", "*.password", "*.token", "*.phone"],
censor: "[REDACTED]",
},
});fatal
- Число
- 60
- Когда использовать
- Процесс не может продолжать работу и сейчас завершится
error
- Число
- 50
- Когда использовать
- Запрос или задача не выполнены: ответ 5xx, исключение
warn
- Число
- 40
- Когда использовать
- Странно, но обработано: ответ 4xx, повторная попытка, близко к лимиту
info
- Число
- 30
- Когда использовать
- Значимые события: сервер запущен, запрос завершён, заказ создан
debug
- Число
- 20
- Когда использовать
- Подробности для разработки; в продакшене обычно выключен
trace
- Число
- 10
- Когда использовать
- Максимальная детализация; включают точечно и ненадолго
Уровень по умолчанию — info: строки ниже него pino не формирует вовсе, это экономит ресурсы. Здесь уровень берётся из LOG_LEVEL, чтобы включить debug без правки кода. Опция formatters.level пишет уровень словом вместо числа; в документации pino есть оговорка — при нескольких транспортах сразу числовое поле level нужно сохранить, но у нас один вывод.
Опция redact заменяет значения по путям на [REDACTED]. Путь *.phone закрывает поле phone на один уровень вложенности, а поле верхнего уровня нужно указать отдельно — поэтому в списке есть и password, и *.password. В проверке строка logger.info({ lead: { name: "Test", phone: "+70000000000" }, password: "x" }) превратилась в "phone":"[REDACTED]" и "password":"[REDACTED]".
Correlation id: одна нить через все сервисы
Запрос пользователя проходит через несколько сервисов, и каждый пишет свои логи. Чтобы собрать их вместе, запросу дают идентификатор — correlation id, или request id — и передают его во все вызовы дальше по цепочке. Здесь для этого служит заголовок X-Request-Id: если он пришёл и выглядит как идентификатор, используем его, иначе создаём новый UUID и возвращаем клиенту в ответе.
import { randomUUID } from "node:crypto";
import express from "express";
import { pinoHttp } from "pino-http";
import { logger } from "./logger.js";
import { metricsMiddleware, register } from "./metrics.js";
// accept an incoming id only if it looks like an id: no junk or forged log lines
const REQUEST_ID = /^[A-Za-z0-9-]{8,64}$/;
const QUIET = new Set(["/metrics", "/healthz", "/readyz"]);
export const state = { ready: false };
export function createApp({ stockUrl, failRate = 0 } = {}) {
const app = express();
app.use(
pinoHttp({
logger,
genReqId(req, res) {
const incoming = req.headers["x-request-id"];
const id = typeof incoming === "string" && REQUEST_ID.test(incoming) ? incoming : randomUUID();
res.setHeader("X-Request-Id", id);
return id;
},
customLogLevel(req, res, err) {
if (err || res.statusCode >= 500) return "error";
if (res.statusCode >= 400) return "warn";
return "info";
},
autoLogging: { ignore: (req) => QUIET.has(req.url) },
// req.log carries only reqId; full req/res go to one summary line per request
quietReqLogger: true,
serializers: {
req: (req) => ({ method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode }),
},
}),
);
app.use(metricsMiddleware);
// liveness: the process is alive and serves requests
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
// readiness: this instance can take traffic (started, not shutting down)
app.get("/readyz", (req, res) => {
if (!state.ready) return res.status(503).json({ status: "not ready" });
res.json({ status: "ready" });
});
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType);
res.send(await register.metrics());
});
// "stock": a slow dependency that sometimes fails
app.get("/api/stock/:sku", async (req, res) => {
await new Promise((r) => setTimeout(r, 20 + Math.random() * 80));
if (Math.random() < failRate) throw new Error("stock db timeout");
res.json({ sku: req.params.sku, available: 7 });
});
// "orders": calls stock and forwards the same X-Request-Id
app.get("/api/orders/:id", async (req, res) => {
const r = await fetch(`${stockUrl}/api/stock/${encodeURIComponent(req.params.id)}`, {
headers: { "x-request-id": req.id },
signal: AbortSignal.timeout(2000),
});
if (!r.ok) throw new Error(`stock service responded ${r.status}`);
const stock = await r.json();
req.log.info({ orderId: req.params.id, available: stock.available }, "order loaded");
res.json({ id: req.params.id, status: "new", stock });
});
app.use((req, res) => res.status(404).json({ error: "not_found" }));
// Express 5 forwards errors from async handlers here by itself
app.use((err, req, res, next) => {
res.err = err; // pino-http adds the error with its stack to the summary line
res.status(500).json({ error: "internal_error", requestId: req.id });
});
return app;
}genReqId— функция pino-http для идентификатора запроса; без неё он использует порядковый номер, а документация pino-http предупреждает, что для нескольких экземпляров приложения это плохой вариант- Входящий id проверяется регулярным выражением: в лог не попадут перевод строки, кавычки и мегабайтная строка от злоумышленника
quietReqLogger: true— вreq.logостаётся толькоreqId, а полныйreqиresпопадают в одну итоговую строку запросаcustomLogLevelвыбирает уровень итоговой строки: 5xx —error, 4xx —warn, остальное —infoautoLogging.ignoreубирает из логов/metricsи проверки здоровья — их дёргают каждые несколько секундordersпередаётreq.idв заголовкеx-request-idпри вызовеstock— так id переходит границу сервиса
Обработчик ошибок кладёт исключение в res.err — pino-http проверяет это поле и пишет ошибку со стеком в итоговую строку запроса, так что на один упавший запрос приходится одна строка уровня error, а не две. Клиент получает только requestId без технических деталей: по нему поддержка найдёт запрос в логах. Ниже — реальный вывод: один запрос, два сервиса, общий reqId, и сразу видно, что первопричина — в stock.
$ FAIL_RATE=0.3 docker compose up -d --build --wait
$ curl -s -H "X-Request-Id: check-7f3a-0004" localhost:3000/api/orders/1042
{"error":"internal_error","requestId":"check-7f3a-0004"}
$ docker compose logs --no-log-prefix orders stock | grep check-7f3a-0004
{"level":"error","time":"2026-10-12T01:31:14.981Z","pid":1,"hostname":"e383b1a6fba4","name":"stock-api","reqId":"check-7f3a-0004","req":{"method":"GET","url":"/api/stock/1042"},"res":{"statusCode":500},"err":{"type":"Error","message":"stock db timeout","stack":"Error: stock db timeout\n at file:///app/src/app.js:56:41"},"responseTime":44,"msg":"request errored"}
{"level":"error","time":"2026-10-12T01:31:14.982Z","pid":1,"hostname":"b41fddb93326","name":"orders-api","reqId":"check-7f3a-0004","req":{"method":"GET","url":"/api/orders/1042"},"res":{"statusCode":500},"err":{"type":"Error","message":"stock service responded 500","stack":"Error: stock service responded 500\n at file:///app/src/app.js:66:22\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)"},"responseTime":46,"msg":"request errored"}reqId.Health и readiness: живой ли процесс и готов ли он к трафику
Оркестратору (Kubernetes, Docker, балансировщику) нужно знать о каждом экземпляре сервиса две разные вещи. Документация Kubernetes разделяет их на отдельные проверки, и эти же имена прижились для HTTP-эндпоинтов.
Liveness
- Вопрос
- Процесс жив и не завис?
- Что делает оркестратор при провале
- Перезапускает контейнер
- В примере
GET /healthz→ 200
Readiness
- Вопрос
- Экземпляр готов принимать запросы?
- Что делает оркестратор при провале
- Перестаёт слать трафик, но не убивает
- В примере
GET /readyz→ 200 или 503
Startup
- Вопрос
- Медленный старт уже закончился?
- Что делает оркестратор при провале
- Откладывает остальные проверки
- В примере
- Не используется: сервис стартует мгновенно
import { createApp, state } from "./app.js";
import { logger } from "./logger.js";
const port = Number(process.env.PORT ?? 3000);
const app = createApp({
stockUrl: process.env.STOCK_URL ?? `http://localhost:${port}`,
failRate: Number(process.env.FAIL_RATE ?? 0),
});
const server = app.listen(port, () => {
state.ready = true;
logger.info({ port }, "server started");
});
function shutdown(signal) {
logger.info({ signal }, "shutting down");
state.ready = false; // the balancer stops sending traffic, in-flight requests finish
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);При SIGTERM (его шлёт docker stop и Kubernetes при выкатке) сервис сначала объявляет себя неготовым, затем server.close() дожидается текущих запросов и только потом процесс выходит. Если что-то зависло, таймер через 10 секунд завершит процесс принудительно. В проверке остановленный контейнер записал в лог "signal":"SIGTERM","msg":"shutting down" и вышел с кодом 0.
Чёрный ящик снаружи тоже нужен: внешний uptime-сервис, который раз в минуту запрашивает публичный адрес и проверяет код 200. Он ловит то, чего не видит сервис изнутри, — истёкший SSL-сертификат, ошибку DNS, упавший балансировщик. Книга SRE замечает, что для цели доступности 99,9% проверять код ответа чаще одного-двух раз в минуту, вероятно, излишне.
Метрики Prometheus: /metrics и запросы PromQL
Prometheus работает по модели pull: раз в интервал он сам приходит на /metrics каждого сервиса, забирает текстовый список чисел и сохраняет их как временные ряды. Клиент для Node.js собирает стандартные метрики процесса — память, CPU, задержку event loop, сборку мусора — и даёт типы Counter, Gauge, Histogram и Summary.
import { Histogram, collectDefaultMetrics, register } from "@prometheus-io/client";
// memory, CPU, event loop lag, GC: Node.js process metrics
collectDefaultMetrics();
// one histogram gives all three RED signals:
// _count = requests (rate), _count{status_code=~"5.."} = errors, _bucket = duration
export const httpDuration = new Histogram({
name: "http_request_duration_seconds",
help: "HTTP request duration in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
export function metricsMiddleware(req, res, next) {
const end = httpDuration.startTimer();
res.on("finish", () => {
// route template, not the raw URL: /api/orders/:id, not /api/orders/1042
const route = req.route ? req.baseUrl + req.route.path : "unmatched";
end({ method: req.method, route, status_code: String(res.statusCode) });
});
next();
}
export { register };Имена следуют правилам Prometheus: базовые единицы (секунды, а не миллисекунды) и суффикс единицы в имени. Метка route берёт шаблон маршрута, а не реальный путь. Это принципиально: каждая уникальная комбинация меток — отдельный временной ряд, и документация Prometheus прямо запрещает класть в метки значения с высокой кардинальностью — id пользователей, email, необработанные URL. Запросы, не попавшие ни в один маршрут, получают route="unmatched".
Теперь упакуем сервис в образ и поднимем рядом Prometheus. Docker Compose запускает три контейнера: orders, stock и prom/prometheus. Проверка здоровья в Compose вызывает /readyz через встроенный в Node.js fetch, поэтому в образе не нужны curl или wget.
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]services:
orders:
build: .
environment:
SERVICE_NAME: orders-api
STOCK_URL: http://stock:3000
ports:
- "3000:3000"
healthcheck: &ready
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/readyz').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3
depends_on:
stock:
condition: service_healthy
stock:
build: .
environment:
SERVICE_NAME: stock-api
FAIL_RATE: ${FAIL_RATE:-0}
healthcheck: *ready
prometheus:
image: prom/prometheus:v3.15.0
ports:
- "9090:9090"
volumes:
- ./prometheus:/etc/prometheus:ro
depends_on:
- orders
- stockglobal:
scrape_interval: 5s # demo value; the default is 1m
evaluation_interval: 5s
rule_files:
- alerts.yml
scrape_configs:
- job_name: node-api
static_configs:
- targets: ["orders:3000", "stock:3000"]$ curl -s localhost:3000/healthz; curl -s localhost:3000/readyz
{"status":"ok"}{"status":"ready"}
$ curl -s localhost:3000/metrics | grep 'route="/api/orders/:id"' | grep -E 'le="0.1"|_sum|_count'
http_request_duration_seconds_bucket{le="0.1",method="GET",route="/api/orders/:id",status_code="200"} 0
http_request_duration_seconds_sum{method="GET",route="/api/orders/:id",status_code="200"} 0.118051033
http_request_duration_seconds_count{method="GET",route="/api/orders/:id",status_code="200"} 1/metrics: из сорока с лишним строк гистограммы показаны три.Сгенерируем нагрузку с ошибками: переменная FAIL_RATE=0.3 заставляет stock падать примерно на трети запросов. Затем спросим Prometheus через HTTP API — те же выражения можно вставить в веб-интерфейс на localhost:9090.
# two minutes of load: requests hit orders, which calls stock
end=$((SECONDS+120)); while [ $SECONDS -lt $end ]; do
curl -s -o /dev/null localhost:3000/api/orders/$RANDOM
done$ curl -s -G localhost:9090/api/v1/query --data-urlencode \
'query=sum by (instance) (rate(http_request_duration_seconds_count{status_code=~"5.."}[1m])) / sum by (instance) (rate(http_request_duration_seconds_count[1m]))'
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"instance":"stock:3000"},"value":[1791768977.267,"0.24901960784313726"]},{"metric":{"instance":"orders:3000"},"value":[1791768977.267,"0.24754420432220037"]}]}}
$ curl -s -G localhost:9090/api/v1/query --data-urlencode \
'query=histogram_quantile(0.95, sum by (instance, le) (rate(http_request_duration_seconds_bucket{route=~"/api/.*"}[5m])))'
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"instance":"stock:3000"},"value":[1791768977.334,"0.09608341223427372"]},{"metric":{"instance":"orders:3000"},"value":[1791768977.334,"0.09826923076923078"]}]}}Трафик, запросов/с
- Запрос PromQL
sum by (instance) (rate(http_request_duration_seconds_count[1m]))
Доля ошибок
- Запрос PromQL
sum(rate(http_request_duration_seconds_count{status_code=~"5.."}[1m])) / sum(rate(http_request_duration_seconds_count[1m]))
Задержка p95, с
- Запрос PromQL
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
Насыщение: задержка event loop
- Запрос PromQL
nodejs_eventloop_lag_p99_seconds
Цель скрапинга доступна
- Запрос PromQL
up{job="node-api"}
rate() считает среднюю скорость роста счётчика в секунду за окно и сам учитывает сброс счётчика при перезапуске сервиса. Доля ошибок здесь считается по всем ответам, включая /metrics и проверки здоровья; в рабочем дашборде их обычно исключают фильтром по route.
Алерты: пороги, выдержка и шум
Правило алерта в Prometheus — выражение PromQL плюс условие. Пока выражение возвращает хоть один ряд, алерт активен для этого ряда. Поле for задаёт выдержку: сначала алерт находится в состоянии pending и переходит в firing, только если условие держится всё это время. Так одиночный всплеск не будит дежурного.
groups:
- name: node-api
rules:
- alert: InstanceDown
expr: up{job="node-api"} == 0
for: 1m
labels:
severity: page
annotations:
summary: "{{ $labels.instance }} does not answer scrapes"
- alert: HighErrorRate
expr: |
sum by (instance) (rate(http_request_duration_seconds_count{status_code=~"5.."}[1m]))
/ sum by (instance) (rate(http_request_duration_seconds_count[1m]))
> 0.05
for: 1m
labels:
severity: page
annotations:
summary: "{{ $labels.instance }}: more than 5% of responses are 5xx"
description: "Error ratio is {{ $value | humanizePercentage }}"
- alert: SlowRequestsP95
expr: |
histogram_quantile(0.95,
sum by (instance, le) (rate(http_request_duration_seconds_bucket{route=~"/api/.*"}[5m]))
) > 0.5
for: 10m
labels:
severity: ticket
annotations:
summary: "{{ $labels.instance }}: p95 latency above 500 ms"Три правила закрывают разные сценарии: InstanceDown — Prometheus не может забрать метрики (сервис упал или недоступен), HighErrorRate — больше 5% ответов с ошибкой, SlowRequestsP95 — медленно, но работает, поэтому severity: ticket (задача в очередь), а не звонок. Окна в 1 минуту выбраны для демо, чтобы алерт сработал быстро; в рабочей системе окна и for обычно длиннее. Синтаксис проверяется командой promtool check config из того же образа.
$ curl -s localhost:9090/api/v1/alerts
{"status":"success","data":{"alerts":[{"labels":{"alertname":"HighErrorRate","instance":"stock:3000","severity":"page"},"annotations":{"description":"Error ratio is 24.9%","summary":"stock:3000: more than 5% of responses are 5xx"},"state":"firing","activeAt":"2026-10-12T01:34:25.655482362Z","value":"2.4901960784313726e-01"},{"labels":{"alertname":"HighErrorRate","instance":"orders:3000","severity":"page"},"annotations":{"description":"Error ratio is 24.75%","summary":"orders:3000: more than 5% of responses are 5xx"},"state":"firing","activeAt":"2026-10-12T01:34:25.655482362Z","value":"2.4754420432220037e-01"}]}}FAIL_RATE=0.3.Сработал ли алерт по ошибкам
После нагрузки
HighErrorRateперешёл вfiringдля обоих сервисов, в аннотации — доля ошибок около 25%.Сработает ли алерт по падению
docker compose stop stock— через минуту/api/v1/alertsпоказываетInstanceDownдляstock:3000в состоянииfiring, аordersотвечает 500.Сбросится ли он сам
Когда нагрузка прекратилась,
HighErrorRateисчез из списка: без запросов выражение не возвращает рядов.
Prometheus только вычисляет правила. Рассылкой — в почту, мессенджер, систему дежурств — занимается отдельный компонент Alertmanager: он группирует алерты, глушит повторы и умеет подавлять следствия, когда известна причина.
Обратите внимание: HighErrorRate сработал дважды — для stock и для orders, хотя проблема одна. Руководство Prometheus по алертам советует будить по ошибкам, видимым пользователю, как можно выше по стеку и не дублировать страницы из нижних слоёв. Книга SRE добавляет вопросы, которые стоит задать перед каждым новым правилом:
- Правило ловит срочную ситуацию, которую видят или вот-вот увидят пользователи, и её больше ничто не ловит
- Этот алерт нельзя спокойно проигнорировать, зная, что он безвреден
- На алерт можно и нужно реагировать сейчас, а не утром — иначе это задача, а не звонок
- В аннотации есть понятное описание и ссылка на инструкцию (runbook)
- Реакция требует размышления; если она механическая — её надо автоматизировать
Grafana, OpenTelemetry и упражнения
Веб-интерфейс Prometheus подходит для разовых запросов, а для постоянных дашбордов обычно берут Grafana: она подключает Prometheus как источник данных, и те же выражения PromQL становятся графиками. В документации Grafana есть пошаговое руководство «Get started with Grafana and Prometheus». Логи из stdout контейнеров собирают отдельным агентом в Loki или Elasticsearch, чтобы искать по reqId сразу по всем сервисам, а не через docker compose logs.
OpenTelemetry — открытый стандарт и набор SDK для генерации и экспорта трассировок, метрик и логов; хранилищем он не является и отдаёт данные в Prometheus, Jaeger или коммерческие системы. Когда сервисов больше двух-трёх, его трассировки заменяют самодельный X-Request-Id: каждый участок запроса становится span с длительностью, а контекст передаётся в заголовке traceparent. Документация отдельно отмечает, что библиотека логов OpenTelemetry для Node.js ещё в разработке, поэтому связка «pino для логов + OpenTelemetry для трасс» остаётся разумным выбором.
Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/logger.js, src/metrics.js, src/app.js, src/server.js (локальный запуск)
временный проект, Node 24.18.0, express 5.3.0, pino 10.4.0, pino-http 11.0.0, @prometheus-io/client 0.16.1 (версии из npm view); PORT=3317 FAIL_RATE=0.5: 200/500 по ответам, X-Request-Id возвращается, чужой id принимается, некорректный (с пробелом и кавычкой) заменяется UUID; одна строка error со стеком на упавший запрос; /metrics с route-шаблонами и route="unmatched" для 404; redact: phone и password → [REDACTED]
- запущено, работает
Dockerfile, compose.yaml, prometheus/prometheus.yml, prometheus/alerts.yml
promtool check config из prom/prometheus:v3.15.0 — SUCCESS, 3 rules; docker compose up -d --build --wait (Compose 5.3.1, проект kb-monitoring): все сервисы healthy, обе цели up; 2 мин нагрузки (~1100–1440 запросов) с FAIL_RATE=0.3 → запросы PromQL через /api/v1/query вернули ~8,9 req/s, долю ошибок ~0,25, p95 ~0,096–0,098 с; /api/v1/alerts: HighErrorRate firing для обоих экземпляров; docker compose stop stock → через 75 с InstanceDown firing, orders отвечает 500; SIGTERM → «shutting down», код выхода 0; общий reqId в логах orders и stock. Стек остановлен через down --rmi local
- запущено, работает
команды установки (npm init / npm pkg set / npm install --save-exact)
выполнены в чистой папке, package.json с type=module, scripts.start и точными версиями
- запущено, работает
решение упражнения modify (HighErrorRate только для orders)
фрагмент вставлен в группу правил, promtool check rules — SUCCESS, 1 rule; срабатывание с окном 5m не прогонялось
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.