Observability: логи, спаны, метрики, OpenTelemetry
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Observability: логи, спаны, метрики, OpenTelemetry
Сцена · три часа ночи
Тебя разбудил алерт: сервис отдаёт 500. Ты открываешь ноутбук и задаёшь три вопроса подряд.
Первый: что вообще происходит? Сколько запросов в секунду, какая доля падает, растёт ли латентность. Это вопрос к числам, агрегированным по времени. Отвечают на него метрики.
Второй: где именно оно происходит? Запрос прошёл через шесть шагов, и один из них ест четыре секунды. Какой. Это вопрос к одному конкретному запросу, разложенному по шагам. Отвечают на него трейсы.
Третий: почему? Вот этот запрос, вот этот пользователь, вот этот параметр, и вот текст ошибки. Это вопрос к событию. Отвечают на него логи.
Три вопроса, три разных инструмента. Их часто называют тремя столпами observability, и путать их дорого: искать причину падения по графикам это как искать опечатку по гистограмме длин слов.
Хорошая новость: в Effect все три встроены в рантайм и не требуют ни глобальных синглтонов, ни явного протаскивания контекста. Логи это эффект. Спан это комбинатор. Метрика это значение, которое ты объявляешь один раз и обновляешь из любого места. Плохая новость: пока ты не подключил экспортёр, всё это красиво копится в памяти и никуда не летит. Этим и займёмся.
Карта урока · что заберёшь домой
Восемь разделов:
- Раздел 1, логи как эффект.
Effect.log, уровни,Causeв логе, аннотации, log-спаны. - Раздел 2,
Loggerкак значение в контексте.Logger.consoleJson,Logger.consolePretty,Logger.layer, свой логгер, минимальный уровень. - Раздел 3, трейсинг.
Effect.withSpan,Effect.annotateCurrentSpan, вложенность,Effect.fn. - Раздел 4, шесть типов метрик: counter, gauge, histogram, summary, frequency, timer.
- Раздел 5, метрики поверх эффекта:
Effect.track,trackDuration,trackSuccesses,trackErrors, атрибуты. - Раздел 6, экспорт наружу: OTLP, Prometheus, встроенные метрики рантайма.
- Раздел 7, свой рендер поверх
Metric.snapshot, когда встроенного формата не хватает. - Раздел 8, что мерить: RED, USE, и на что вешать алерт.
К концу урока ты добавляешь в Pulse полноценную телеметрию: структурные логи с аннотациями, дерево спанов на цикл пробинга, четыре метрики с тегами и два способа отдать их наружу.
Раздел 1 · Логи как эффект
Шесть уровней и одна функция
Effect.log это обычный эффект: он ничего не печатает сам, он объявляет намерение записать сообщение. Что с этим намерением сделает рантайм, решает подключённый Logger.
import { Effect } from 'effect';
const program = Effect.gen(function* () {
yield* Effect.logTrace('самый подробный уровень');
yield* Effect.logDebug('детали для разработчика');
yield* Effect.logInfo('обычное событие');
yield* Effect.log('то же самое, что logInfo');
yield* Effect.logWarning('подозрительно, но живём');
yield* Effect.logError('ошибка, которую мы ожидали');
yield* Effect.logFatal('всё, дальше некуда');
});
По умолчанию рантайм печатает от Info и выше: Trace и Debug молчат. Порог живёт в ссылке контекста References.MinimumLogLevel, и это важная деталь: он не глобальный, а действует на кусок программы.
import { Effect, References } from 'effect';
const noisy = Effect.logDebug('видно только внутри');
const program = Effect.gen(function* () {
yield* noisy; // молчит
yield* noisy.pipe(Effect.provideService(References.MinimumLogLevel, 'Debug')); // печатает
});
Уровень тут это обычная строка: 'Trace', 'Debug', 'Info', 'Warn', 'Error', 'Fatal', плюс граничные 'All' и 'None'. Отдельных значений-объектов больше нет, модуль LogLevel остался только ради сравнений и порядка.
Это отличается от привычного logger.setLevel('debug') глобально. Ты можешь включить Debug для одного подозрительного сервиса и оставить Info для остального приложения, и это не гонка и не глобальное состояние.
Ошибка в логе это Cause, а не строка
Effect.logError принимает не только строку. Если передать ему Cause, логгер напечатает полное дерево причины: типизированная ошибка, дефект, прерывание, параллельные ветки. Мы разбирали Cause в 03 · Tagged-ошибки, и вот где он окупается второй раз:
import { Cause, Effect } from 'effect';
const probe = Effect.fail(new Error('connection refused'));
const program = probe.pipe(
Effect.tapCause((cause) => Effect.logError('проба упала', cause)),
Effect.catch(() => Effect.succeed(null)),
);
Effect.tapCause не глотает ошибку, он подсматривает причину и пропускает её дальше. Пара tapCause плюс logError это рабочая идиома: логируем в точке, где знаем контекст, а обрабатываем там, где знаем, что делать.
Аннотации: контекст без протаскивания
Самая частая беда самописного логирования это ручное протаскивание requestId через двадцать функций. Effect.annotateLogs вешает пару ключ-значение на весь кусок программы, и каждая запись внутри получает её автоматически:
import { Effect } from 'effect';
const step = Effect.log('шаг');
const program = Effect.gen(function* () {
yield* step;
yield* step;
}).pipe(Effect.annotateLogs({ requestId: 'req-42', target: 'example.com' }));
Обе записи выйдут с обеими аннотациями. Внутри step про них ничего не известно, и это правильно: функция не должна знать, в каком запросе её вызвали.
Аннотации вкладываются. Внешний слой ставит service, средний ставит requestId, внутренний ставит attempt, и в логе оказываются все три.
Log-спан это не трейс-спан
У Effect.withLogSpan обманчивое имя. Это не спан трейсинга, это метка длительности внутри лога:
import { Effect, Logger } from 'effect';
const program = Effect.gen(function* () {
yield* Effect.sleep('50 millis');
yield* Effect.log('готово');
}).pipe(Effect.withLogSpan('probe'), Effect.provide(Logger.layer([Logger.consoleJson])));
Вывод:
{"message":"готово","level":"INFO","timestamp":"2026-07-29T08:14:49.936Z","annotations":{},"spans":{"probe":51},"fiberId":"#0"}
spans: {"probe": 51} это “с момента входа в withLogSpan('probe') прошёл 51 миллисекунда”. Дёшево, никуда не экспортируется, живёт только в строке лога. Настоящие спаны, с traceId и родителями, делает Effect.withSpan, и это Раздел 3.
Запомни разницу сразу, иначе будешь долго искать, почему withLogSpan не появляется в Jaeger.
Раздел 2 · Logger как сервис
Готовые логгеры
Logger в Effect это значение, которое ставится в контекст Layer-ом, ровно как всё остальное из 04 · Services и Layer. Четыре готовых варианта покрывают почти все случаи:
| Логгер | Формат | Когда брать |
|---|---|---|
Logger.consolePretty() | цветной человекочитаемый | локальная разработка |
Logger.consoleJson | одна JSON-строка на запись | прод, сбор в Loki или ELK |
Logger.consoleLogFmt | key=value key=value | прод, если коллектор любит logfmt |
Logger.consoleStructured | объект, а не строка | когда сам решаешь, куда писать |
Подключение идёт через Logger.layer, который принимает массив логгеров:
import { Effect, Logger } from 'effect';
const program = Effect.log('привет').pipe(Effect.provide(Logger.layer([Logger.consoleJson])));
По умолчанию Logger.layer заменяет весь набор логгеров тем, что ты передал. Если нужно добавить приёмник к уже существующим, передай опцию: Logger.layer([myLogger], { mergeWithExisting: true }). Это ответ сразу на два старых вопроса, “как подменить логгер” и “как добавить второй”.
В реальном приложении логгер живёт не тут, а в MainLive рядом с остальными слоями, и меняется одной строкой при сборке для прода:
import { Layer, Logger } from 'effect';
const LoggerLive = Logger.layer([
process.env.NODE_ENV === 'production' ? Logger.consoleJson : Logger.consolePretty(),
]);
const MainLive = Layer.mergeAll(
LoggerLive,
// ...остальные сервисы
);
Свой логгер за десять строк
Logger.make принимает функцию, которая получает всё, что рантайм знает о записи, и делает с этим что угодно. Набор полей короткий: message, logLevel, cause, fiber, date.
import { Effect, Logger, References } from 'effect';
const CompactLogger = Logger.make(({ logLevel, message, fiber, date }) => {
const now = date.getTime();
const annotations = fiber.getRef(References.CurrentLogAnnotations);
const ann = Object.entries(annotations)
.map(([key, value]) => `${key}=${String(value)}`)
.join(' ');
const spans = fiber.getRef(References.CurrentLogSpans);
const sp = spans.map(([label, started]) => `${label}=${now - started}ms`).join(' ');
globalThis.console.log(`${logLevel.toUpperCase()} ${String(message)} ${ann} ${sp}`);
});
const LoggerLive = Logger.layer([CompactLogger]);
Две детали, на которых спотыкаются:
- аннотаций и спанов нет в аргументе. Они лежат в контексте текущего файбера, и достаёт их
fiber.getRef. Логично: они принадлежат не записи, а куску программы, в котором запись случилась. CurrentLogAnnotationsэто обычный объект-запись, аCurrentLogSpansэто массив пар[метка, момент старта]. Длительность считаешь сам вычитанием, никакого форматтера не нужно.
Запуск:
const program = Effect.gen(function* () {
yield* Effect.logDebug('низкий уровень');
yield* Effect.logError('упало');
}).pipe(
Effect.annotateLogs('target', 'example.com'),
Effect.withLogSpan('probe'),
Effect.provideService(References.MinimumLogLevel, 'Debug'),
Effect.provide(LoggerLive),
);
// DEBUG низкий уровень target=example.com probe=0ms
// ERROR упало target=example.com probe=1ms
Батчинг записей
Логгер, который делает сетевой вызов на каждую строку, убьёт латентность. Logger.batched копит записи в окне и отдаёт пачкой:
import { Effect, Logger } from 'effect';
const BatchedJson = Logger.batched(Logger.formatJson, {
window: '2 seconds',
flush: (messages) => Effect.promise(() => sendToCollector(messages)),
});
export const LoggerLive = Logger.layer([BatchedJson]);
Две записи подряд уедут одним вызовом sendToCollector. Читается это так: Logger.batched возвращает эффект, который создаёт логгер и требует Scope (внутри крутится фоновый файбер сброса). Разворачивать его вручную не надо: Logger.layer принимает не только логгеры, но и эффекты, отдающие логгер, и сам привязывает фоновый файбер к времени жизни слоя.
Обрати внимание на пару имён: Logger.formatJson собирает JSON-строку и отдаёт её наружу, а Logger.consoleJson это тот же формат, но сразу печатающий в консоль. Приставка format значит “верни результат”, приставка console значит “выведи сам”. Для батчинга и для записи в файл (Logger.toFile) нужен именно format-вариант.
Раздел 3 · Трейсинг: withSpan и дерево вызовов
Один комбинатор
Спан в Effect это один комбинатор поверх любого эффекта:
import { Effect } from 'effect';
const probe = (url: string) =>
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan('http.url', url);
yield* Effect.sleep('10 millis');
return 200;
}).pipe(Effect.withSpan('probe'));
const cycle = probe('https://example.com').pipe(Effect.withSpan('cycle'));
Вложенность выстраивается сама: probe внутри cycle становится дочерним спаном, никакого явного parent передавать не надо. Рантайм знает текущий спан через тот же файбер-контекст, что и аннотации логов.
Effect.annotateCurrentSpan(key, value) вешает атрибут на ближайший спан. Атрибуты это то, по чему потом ищут в Jaeger: http.url, http.status_code, user.id, retry.attempt.
Атрибуты можно задать и при создании:
const probe = (url: string) =>
effect.pipe(Effect.withSpan('probe', { attributes: { 'http.url': url } }));
Разница простая: в опциях удобно то, что известно заранее, через annotateCurrentSpan то, что выяснилось по ходу (код ответа, номер попытки).
Как это выглядит на выходе
Вот что уедет в коллектор после одного цикла (полная настройка экспорта в Разделе 6):
name: 'probe',
spanId: '7efd2f5ab5211c54',
traceId: '29bc7d92a3745c9632344371e1cd04e1',
parentSpanId: '213e2658c1ed3b1e',
duration: 11239.083,
attributes: { 'http.url': 'https://example.com' }
name: 'cycle',
spanId: '213e2658c1ed3b1e',
traceId: '29bc7d92a3745c9632344371e1cd04e1',
parentSpanId: undefined,
duration: 11784.125,
attributes: {}
Один traceId на оба спана, у probe в родителях стоит cycle, у cycle родителя нет. Длительность в микросекундах. Именно это дерево ты увидишь в Jaeger или Grafana Tempo.
Effect.fn: спан и функция за один заход
Оборачивать каждую функцию в withSpan руками скучно. Effect.fn(name) делает генератор-функцию, у которой уже есть спан с этим именем:
import { Effect } from 'effect';
const checkTarget = Effect.fn('checkTarget')(function* (url: string) {
yield* Effect.annotateCurrentSpan('url', url);
yield* Effect.sleep('1 millis');
return url.length;
});
Плюс сверху: Effect.fn подхватывает место объявления, и в спане появляется ссылка на строку исходника. Когда трейс приходит из чужого модуля, это экономит минуты.
Есть парная Effect.fnUntraced для случаев, когда спан не нужен, а генератор-синтаксис нужен.
Когда чем пользоваться:
Effect.fn('name'), публичные функции сервиса, границы модулей, всё, что хочется видеть в трейсе;Effect.withSpan, когда оборачиваешь кусок внутри функции, а не функцию целиком;- ничего, для мелких чистых хелперов. Спан на каждую строчку это шум и лишние байты в коллекторе.
Раздел 4 · Метрики: шесть типов
Метрика в Effect это значение, а не вызов. Ты объявляешь её один раз на уровне модуля, а дальше обновляешь откуда угодно: рантайм держит реестр в контексте, ключом служит имя плюс набор атрибутов.
Counter, только вверх
import { Effect, Metric } from 'effect';
const probeTotal = Metric.counter('pulse_probe_total', {
description: 'Сколько проб выполнено',
incremental: true,
});
const bump = Metric.update(probeTotal, 1);
incremental: true запрещает уменьшение: любые отрицательные обновления игнорируются. Для счётчика запросов это то, что нужно, иначе однажды кто-то вычтет и график поедет.
Есть вариант на bigint ({ bigint: true }) для счётчиков, которые реально переполнят number. На практике редкость.
Gauge, вверх и вниз
const inFlight = Metric.gauge('pulse_in_flight', {
description: 'Сколько проб выполняется прямо сейчас',
});
const program = Effect.gen(function* () {
yield* Metric.update(inFlight, 3); // поставить абсолютное значение
yield* Metric.modify(inFlight, -1); // сдвинуть на дельту, станет 2
});
Gauge это “мгновенное значение”: сколько соединений открыто, сколько задач в очереди, сколько памяти занято. Счётчик отвечает на “сколько всего было”, gauge на “сколько сейчас”.
Пара update плюс modify работает у всех метрик, и разница у них ровно одна: update задаёт значение, modify прибавляет дельту. У счётчика оба прибавляют, у gauge первый ставит абсолют, а второй сдвигает.
Histogram, распределение
import { Metric } from 'effect';
const latency = Metric.histogram('pulse_latency_ms', {
description: 'Латентность пробы',
boundaries: Metric.exponentialBoundaries({ start: 10, factor: 2, count: 8 }),
});
const record = Metric.update(latency, 42);
Гистограмма раскладывает значения по корзинам и позволяет считать перцентили на стороне Prometheus. Границы это просто массив чисел, и два хелпера собирают его за тебя:
Metric.linearBoundaries({ start: 0, width: 100, count: 5 }), равномерные корзины;Metric.exponentialBoundaries({ start: 10, factor: 2, count: 8 }), растущие: 10, 20, 40, 80, 160, 320, 640, 1280.
Отдельного модуля MetricBoundaries больше нет, и границы можно писать руками: { boundaries: [10, 50, 100, 500] } это полностью рабочий вариант.
Для латентности почти всегда берут экспоненциальные: разница между 10 и 20 миллисекундами важна, между 1010 и 1020 нет.
Выбор границ это не мелочь. Если все твои запросы укладываются в 5 мс, а первая корзина 10 мс, гистограмма покажет ровную стену и ничего не расскажет.
Summary, перцентили на месте
import { Metric } from 'effect';
const latencySummary = Metric.summary('pulse_latency_summary', {
maxAge: '5 minutes',
maxSize: 1000,
quantiles: [0.5, 0.9, 0.99],
});
Summary считает квантили сам, на скользящем окне (maxAge) и с ограничением по размеру выборки (maxSize). Плюс: не надо угадывать границы. Минус: квантили нельзя корректно складывать между инстансами, поэтому в распределённой системе histogram обычно удобнее.
Обрати внимание, что maxAge принимает строку вроде '5 minutes': везде, где Effect ждёт Duration, строку он разберёт сам, и Duration.minutes(5) писать не обязательно.
Frequency, счётчики по строковому ключу
const statusFreq = Metric.frequency('pulse_status', { description: 'коды ответов' });
const record = Metric.update(statusFreq, '200');
Frequency это набор счётчиков, ключ появляется сам при первом обновлении. Идеально для кодов ответа, имён ошибок, типов событий. Осторожно с кардинальностью: не клади туда идентификаторы пользователей.
Timer, гистограмма длительностей
const duration = Metric.timer('pulse_probe_duration');
Metric.timer это гистограмма, которая на входе принимает Duration, а не число. Отдельный тип нужен ровно для того, чтобы не перепутать секунды с миллисекундами. Как её кормить, в следующем разделе.
Раздел 5 · Метрики поверх эффекта
Ручное Metric.update в каждой ветке кода быстро надоедает и легко забывается на пути ошибки. Effect даёт комбинаторы, которые вешают метрику на эффект целиком.
Важная деталь v4: эти комбинаторы живут в модуле Effect, а не в Metric. Метрика отвечает за хранение значения, эффект за то, когда его обновить.
import { Effect, Metric } from 'effect';
const probe = Effect.succeed(200);
// длительность в timer, автоматически
const a = probe.pipe(Effect.trackDuration(durationTimer));
// значение успеха в гистограмму
const b = probe.pipe(Effect.trackSuccesses(latency, (code) => code));
// счётчик ошибок, только на неудачном пути
const c = probe.pipe(Effect.trackErrors(errorsTotal, () => 1));
// метрика с фиксированным входом: любой прогон это плюс единица
const d = probe.pipe(Effect.track(Metric.withConstantInput(errorsTotal, 1)));
Семейство целиком: Effect.track (на любой исход), Effect.trackSuccesses, Effect.trackErrors, Effect.trackDefects и Effect.trackDuration. Второй аргумент везде необязателен: без него метрика получает само значение (успех, ошибку, длительность), с ним ты сначала переводишь его в нужный вход.
trackDuration важнее остальных: он замеряет и успех, и провал, и прерывание, и тебе не надо помнить про try/finally.
Атрибуты вместо тегов
То, что в v3 называлось тегом, в v4 называется атрибутом, и это не только переименование: атрибуты задаются целым набором, а не по одному. Метка, по которой потом режут графики, всё та же: target, region, method. Два способа её поставить.
Способ первый, значение известно в момент объявления. Тогда его пишут прямо в конструктор:
const apiProbes = Metric.counter('pulse_probe_total', {
description: 'Сколько проб выполнено',
attributes: { target: 'example.com' },
});
Или навешивают поверх готовой метрики, получая её отдельный “срез”:
const taggedCounter = Metric.withAttributes(probeTotal, { target: 'example.com' });
const bump = Metric.update(taggedCounter, 1);
Способ второй, значение вычисляется в рантайме. Тогда атрибуты кладут в контекст, и их подхватят все метрики внутри куска программы:
const bump = Metric.update(probeTotal, 1).pipe(
Effect.provideService(Metric.CurrentMetricAttributes, { target: url }),
);
Обернул обработчик запроса один раз, и все метрики внутри получили route и method. Тот же механизм, что и у аннотаций логов из Раздела 1: значение живёт в контексте файбера, а не протаскивается аргументом.
Ловушка тут одна, и она про кардинальность, а не про типы. Каждая комбинация значений атрибутов это отдельный ряд в реестре. Metric.withAttributes возвращает новое значение метрики, и если вызывать его в цикле с новым адресом, ты честно наплодишь рядов ровно столько, сколько было адресов. Для сотни целей это нормально, для идентификатора пользователя это авария.
Посмотреть значение прямо в тесте
Metric.value(metric) возвращает текущее состояние. Это делает метрики тестируемыми без всякого коллектора:
import { Effect, Metric } from 'effect';
const test = Effect.gen(function* () {
yield* Metric.update(probeTotal, 1);
const state = yield* Metric.value(probeTotal);
// state.count === 1
});
Для гистограммы состояние содержит count, sum, min, max и buckets. Проверять в тесте sum и count обычно достаточно.
Одна практическая мелочь. Реестр метрик это тоже ссылка в контексте, Metric.MetricRegistry. Если тесты в одном файле мешают друг другу счётчиками, дай каждому свой реестр: Effect.provideService(program, Metric.MetricRegistry, new Map()). Изоляция ценой одной строки.
Раздел 6 · Экспорт наружу
Пока метрики и спаны живут в памяти процесса. Чтобы они куда-то полетели, нужен экспортёр. Хорошая новость: OTLP-экспортёры в v4 встроены в сам effect, в модули effect/unstable/observability. Ставить @opentelemetry/* пакеты не нужно вообще.
Логи, метрики и спаны одним слоем
import { Layer } from 'effect';
import { FetchHttpClient } from 'effect/unstable/http';
import { Otlp } from 'effect/unstable/observability';
export const TelemetryLive = Otlp.layerJson({
baseUrl: 'http://localhost:4318',
resource: { serviceName: 'pulse', serviceVersion: '1.0.0' },
}).pipe(Layer.provide(FetchHttpClient.layer));
Всё. Ни одной строчки в бизнес-коде менять не надо: Effect.withSpan, который ты уже расставил, начинает отдавать данные, а Effect.log и метрики уезжают туда же. Слой сам разложит их по трём адресам под baseUrl: /v1/logs, /v1/metrics, /v1/traces.
Единственное требование слоя это HttpClient, потому что отправка идёт обычным HTTP-запросом. Отсюда и FetchHttpClient.layer в цепочке, о нём подробно в 19 · HttpClient и API-обвязка.
resource.serviceName это то имя, под которым сервис появится в Jaeger. Не забудь его: сервисы с именем unknown_service в общем коллекторе искать невесело.
Три полезные вариации:
- нужен только трейсинг, без логов и метрик, бери
OtlpTracer.layerвместо общегоOtlp. Есть парныеOtlpMetrics.layerиOtlpLogger.layer; Otlp.layerProtobufвместоlayerJson, если коллектор настроен на protobuf. Опции те же;Otlp.layerFromConfig()читает адрес и заголовки из стандартных переменных окружения OpenTelemetry, и тогда в коде не остаётся ни одного хардкода.
Метрики в Prometheus
Prometheus не принимает данные, он приходит и забирает их сам. Поэтому вместо экспортёра ему нужен эндпоинт, и на это есть готовый слой:
import { PrometheusMetrics } from 'effect/unstable/observability';
export const MetricsRoute = PrometheusMetrics.layerHttp({ path: '/metrics' });
Слой добавляет GET /metrics в твой HttpRouter (тот самый из 12 · Production-обвязка) и рендерит весь реестр в текстовый формат Prometheus. Реальный вывод после одной пробы:
# HELP pulse_probe_total Всего проб выполнено
# TYPE pulse_probe_total counter
pulse_probe_total{target="example.com"} 1
# TYPE pulse_latency_ms histogram
pulse_latency_ms_count 1
pulse_latency_ms_sum 130
pulse_latency_ms_bucket{le="10"} 0
pulse_latency_ms_bucket{le="80"} 0
pulse_latency_ms_bucket{le="160"} 1
pulse_latency_ms_bucket{le="+Inf"} 1
Видно всё: атрибут target доехал как метка, гистограмма разложилась по корзинам le, счётчик на единице. Строка # HELP берётся из description, так что заполнять его стоит.
Если HTTP-сервера рядом нет, а строку получить надо (в тесте, в CLI-команде, в дампе на диск), есть PrometheusMetrics.format(): это Effect<string> и ничего больше.
Встроенные метрики рантайма
Effect умеет считать файберы сам, но в v4 это выключено по умолчанию: сбор стоит денег, а нужен не всем. Включается слоем Metric.enableRuntimeMetricsLayer (или точечно, комбинатором Metric.enableRuntimeMetrics на куске программы). После этого в снапшоте появляются:
child_fibers_started, сколько файберов запущено всего;child_fibers_active, сколько живо прямо сейчас;child_fiber_successesиchild_fiber_failures.
child_fibers_active, который растёт и не падает, это прямой признак утечки файберов: где-то forkDetach без присмотра. Полезный график, поставь его на дашборд рядом с памятью.
Готовой гистограммы времени жизни файбера в v4 нет. Если она нужна, заведи свой Metric.timer и повесь Effect.trackDuration на те эффекты, время которых тебе действительно интересно. Это честнее: раньше мерились все файберы подряд, включая служебные.
Раздел 7 · Свой рендер поверх Metric.snapshot
Готовый PrometheusMetrics.format() закрывает обычный случай. Но иногда формат нужен другой: свой JSON для внутренней панели, дамп в лог по SIGUSR2, компактная строка для healthcheck. Тогда берут сырой снапшот, и тридцати строк хватает на всё.
import { Effect, Metric } from 'effect';
const renderPrometheus = Effect.gen(function* () {
const metrics = yield* Metric.snapshot;
return metrics
.map((metric) => {
const labels = Object.entries(metric.attributes ?? {})
.map(([key, value]) => `${key}="${value}"`)
.join(',');
const suffix = labels === '' ? '' : `{${labels}}`;
const { id, state } = metric;
switch (metric.type) {
case 'Counter':
return `${id}${suffix} ${state.count}`;
case 'Gauge':
return `${id}${suffix} ${state.value}`;
case 'Histogram':
case 'Summary':
return `${id}_sum${suffix} ${state.sum}\n${id}_count${suffix} ${state.count}`;
case 'Frequency':
return [...state.occurrences]
.map(([key, count]) => `${id}{key="${key}"} ${count}`)
.join('\n');
}
})
.join('\n');
});
Живой вывод:
pulse_probe_total{target="example.com"} 1
pulse_in_flight 2
pulse_status_codes{key="200"} 1
Снапшот в v4 стал плоским и приятным: у каждой записи есть id (имя метрики), type (строка 'Counter' | 'Gauge' | 'Histogram' | 'Summary' | 'Frequency'), description, attributes и state. Отдельных модулей MetricKey и MetricState больше нет, а строковый type работает как размеченное объединение: в ветке case 'Counter' компилятор уже знает, что в state лежит count.
Приятный побочный эффект такой формы: switch без default заставляет обработать все пять типов, иначе TypeScript пожалуется на возможный undefined.
Раздел 8 · Что мерить и на что алертить
Инструменты у тебя теперь есть. Осталось не утонуть.
RED для сервисов, USE для ресурсов
Два готовых набора, которые закрывают девяносто процентов случаев.
RED для всего, что обслуживает запросы:
- Rate, сколько запросов в секунду. Счётчик.
- Errors, сколько из них упало. Счётчик с тегом причины.
- Duration, сколько заняло. Гистограмма.
USE для всего, что является ресурсом (пул соединений, очередь, диск):
- Utilization, какая доля занята. Gauge.
- Saturation, сколько ждёт в очереди. Gauge.
- Errors, сколько отказов. Счётчик.
Для Pulse это переводится буквально: pulse_probe_total (rate), pulse_probe_errors_total с тегом причины (errors), pulse_latency_ms (duration), pulse_in_flight (utilization пула проб), pulse_queue_size (saturation).
Алерт ставят на симптом, а не на причину
Классическая ошибка это алерт “CPU выше 80 процентов”. Ночью тебя разбудит нагруженный, но полностью работающий сервис.
Правило простое: алерт вешают на то, что чувствует пользователь. Доля ошибок выше одного процента за пять минут. Девяносто девятый перцентиль латентности выше секунды. Очередь растёт полчаса подряд. А загрузка CPU это то, на что ты смотришь после того, как алерт уже сработал, чтобы понять причину.
Три столпа и три бюджета
Логи стоят дорого в объёме, метрики дёшевы, трейсы дороги по кардинальности. Отсюда практика:
- логировать события с контекстом, а не каждый шаг цикла;
- метрики держать с низкой кардинальностью, никаких идентификаторов в тегах;
- трейсы сэмплировать, но ошибочные брать целиком.
Pulse · вклад этого урока
К этому уроку у Pulse есть цикл пробинга, ретраи, батчинг записи и SSE-эндпоинт. Добавляем телеметрию.
Файл src/telemetry.ts:
import { Layer, Metric } from 'effect';
import { FetchHttpClient } from 'effect/unstable/http';
import { Otlp, PrometheusMetrics } from 'effect/unstable/observability';
export const TelemetryLive = Layer.mergeAll(
Otlp.layerJson({
baseUrl: 'http://localhost:4318',
resource: { serviceName: 'pulse', serviceVersion: '1.0.0' },
}).pipe(Layer.provide(FetchHttpClient.layer)),
PrometheusMetrics.layerHttp({ path: '/metrics' }),
Metric.enableRuntimeMetricsLayer,
);
export const probeTotal = Metric.counter('pulse_probe_total', {
description: 'Всего проб выполнено',
incremental: true,
});
export const probeErrors = Metric.counter('pulse_probe_errors_total', {
description: 'Пробы, завершившиеся ошибкой',
incremental: true,
});
export const probeLatency = Metric.histogram('pulse_probe_latency_ms', {
description: 'Латентность пробы в миллисекундах',
boundaries: Metric.exponentialBoundaries({ start: 10, factor: 2, count: 8 }),
});
export const probeDuration = Metric.timer('pulse_probe_duration');
export const statusCodes = Metric.frequency('pulse_status_codes', {
description: 'Распределение кодов ответа',
});
export const breakersOpen = Metric.gauge('pulse_breakers_open', {
description: 'Сколько circuit breaker сейчас открыто',
});
Инструментированная проба:
import { Effect, Metric } from 'effect';
const probe = Effect.fn('probe')(function* (target: Target) {
yield* Effect.annotateCurrentSpan('http.url', target.url);
const response = yield* httpService.get(target.url);
yield* Effect.annotateCurrentSpan('http.status_code', response.status);
yield* Metric.update(statusCodes, String(response.status));
yield* Metric.update(probeLatency, response.elapsedMs);
return response;
});
const instrumented = (target: Target) =>
probe(target).pipe(
Effect.trackDuration(probeDuration),
Effect.trackErrors(probeErrors, () => 1),
Effect.tap(() => Metric.update(probeTotal, 1)),
Effect.tapCause((cause) => Effect.logError('проба упала', cause)),
Effect.provideService(Metric.CurrentMetricAttributes, { target: target.name }),
Effect.annotateLogs({ target: target.name }),
);
Цикл сверху получает свой спан, и в трейсе появляется дерево: cycle содержит по одному probe на адрес, а внутри probe видно ретраи, потому что Effect.retry из 11 · Runtime и Schedule уже внутри спана.
Сборка MainLive меняется на одну строку:
const MainLive = Layer.mergeAll(
TelemetryLive,
LoggerLive,
HttpService.layer,
Storage.layer,
);
Что это дало в цифрах. До урока при жалобе “Pulse тормозит” ты шёл читать JSONL и считать глазами. Теперь на дашборде видно долю ошибок по каждому адресу, девяносто девятый перцентиль латентности и число открытых breaker-ов, а по клику на всплеск открывается конкретный трейс с адресом и кодом ответа в атрибутах.
Финал · чек-лист
ДЗ
Все задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-18.
Дальше
Телеметрия это первый из блоков “обвязки”, которых не хватало Pulse для взрослого прода. Дальше по разделу:
- 19 · HttpClient и API-обвязка. Там
HttpClientсо своими ретраями и таймаутами, и его инструментирование ложится ровно на то, что ты собрал сегодня. - 20 · Config, секреты и platform. Порт Prometheus и адрес коллектора не должны быть захардкожены, и в следующем уроке они переедут в
Config.
Полезно перечитать:
- 06-node · 06 Производительность и телеметрия, тот же OpenTelemetry, но на голом Node. Сравни объём кода, который нужен там и здесь.
- 09-backend · 18 Паттерны устойчивости, про то, какие сигналы стоит выводить наружу у сервиса с ретраями и circuit breaker.