Раздел 25 · Effect-TS

Observability: логи, спаны, метрики, OpenTelemetry

senior~100 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Observability: логи, спаны, метрики, OpenTelemetry

Сцена · три часа ночи

Тебя разбудил алерт: сервис отдаёт 500. Ты открываешь ноутбук и задаёшь три вопроса подряд.

Первый: что вообще происходит? Сколько запросов в секунду, какая доля падает, растёт ли латентность. Это вопрос к числам, агрегированным по времени. Отвечают на него метрики.

Второй: где именно оно происходит? Запрос прошёл через шесть шагов, и один из них ест четыре секунды. Какой. Это вопрос к одному конкретному запросу, разложенному по шагам. Отвечают на него трейсы.

Третий: почему? Вот этот запрос, вот этот пользователь, вот этот параметр, и вот текст ошибки. Это вопрос к событию. Отвечают на него логи.

Три вопроса, три разных инструмента. Их часто называют тремя столпами observability, и путать их дорого: искать причину падения по графикам это как искать опечатку по гистограмме длин слов.

Хорошая новость: в Effect все три встроены в рантайм и не требуют ни глобальных синглтонов, ни явного протаскивания контекста. Логи это эффект. Спан это комбинатор. Метрика это значение, которое ты объявляешь один раз и обновляешь из любого места. Плохая новость: пока ты не подключил экспортёр, всё это красиво копится в памяти и никуда не летит. Этим и займёмся.

Карта урока · что заберёшь домой

Восемь разделов:

  1. Раздел 1, логи как эффект. Effect.log, уровни, Cause в логе, аннотации, log-спаны.
  2. Раздел 2, Logger как значение в контексте. Logger.consoleJson, Logger.consolePretty, Logger.layer, свой логгер, минимальный уровень.
  3. Раздел 3, трейсинг. Effect.withSpan, Effect.annotateCurrentSpan, вложенность, Effect.fn.
  4. Раздел 4, шесть типов метрик: counter, gauge, histogram, summary, frequency, timer.
  5. Раздел 5, метрики поверх эффекта: Effect.track, trackDuration, trackSuccesses, trackErrors, атрибуты.
  6. Раздел 6, экспорт наружу: OTLP, Prometheus, встроенные метрики рантайма.
  7. Раздел 7, свой рендер поверх Metric.snapshot, когда встроенного формата не хватает.
  8. Раздел 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.consoleLogFmtkey=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.

Полезно перечитать: