Инструменты и отладка: LSP, диагностики в CI, layerinfo, Cause, файбер-дамп
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Инструменты и отладка: LSP, диагностики в CI, layerinfo, Cause, файбер-дамп
Сцена · ошибка, из-за которой люди бросают Effect
Забыл подать слой, и компилятор говорит вот это:
Type 'Effect<void, never, Storage>' is not assignable to type 'Effect<void, never, never>'.
Type 'Storage' is not assignable to type 'never'.
На реальном проекте, где в канале требований пять сервисов, а в ошибке ещё и дженерики Layer, такое сообщение растягивается на сорок строк, и в нём тонет ровно одно полезное слово.
С правильно настроенными инструментами то же самое выглядит так:
missing.ts(21,14): error effect(missingEffectContext):
This Effect requires a service that is missing from the expected Effect context: `Storage`.
Одна строка, имя сервиса, номер строки. Разница между “три часа злости” и “тридцать секунд”.
Этот урок про то, как получить второй вариант: настроить LSP, вынести диагностики в CI, научиться читать Cause и находить утёкшие файберы. Тем и закрывается раздел.
Карта урока · что заберёшь домой
Восемь разделов:
- Раздел 1, как читать ошибки типов Effect: три канала и порядок действий.
- Раздел 2,
@effect/language-service, что он ловит. - Раздел 3, те же диагностики в CI.
- Раздел 4,
layerinfo, разбор графа слоёв. - Раздел 5,
Causeна отладке:pretty,tapCause, уровни логов. - Раздел 6, метрики рантайма, кто из файберов ещё живой.
- Раздел 7, профилирование:
timed, спаны, DevTools. - Раздел 8, чек-лист настройки проекта на Effect.
Раздел 1 · Как читать ошибки типов
Почти все длинные ошибки в Effect это несовпадение по одному из трёх каналов. Порядок разбора всегда один.
Шаг 1: определи канал. Смотри на позицию несовпадения в Effect<A, E, R>:
- расходится первый параметр, значит дело в типе успеха. Обычно забытый
yield*или лишняя обёрткаEffect<Effect<...>>; - расходится второй, значит ошибка. Обычно необработанный вариант в
catchTagsили ошибка, которую не ждали в сигнатуре; - расходится третий, значит требования. Это девять случаев из десяти: не подан слой.
Шаг 2: ищи is not assignable to type 'never'. Эта фраза почти всегда значит “ты обещал, что требований (или ошибок) нет, а они есть”. Имя слева от is not assignable это и есть недостающий сервис или необработанная ошибка.
Шаг 3: читай снизу вверх. TypeScript печатает от общего к частному, а полезное лежит в самой глубокой строке. Первая строка почти всегда бесполезна.
Шаг 4: сузь место. Если ошибка на длинном pipe, разрежь его на два и посмотри, на каком шаге тип поехал. Помогает и явная аннотация промежуточной переменной: компилятор начнёт ругаться там, где действительно проблема, а не в конце цепочки.
Дальше можно этого всего не делать, потому что за тебя это делает плагин.
Раздел 2 · LSP-плагин
@effect/language-service подключается в tsconfig.json и работает прямо в редакторе:
{
"compilerOptions": {
"plugins": [{ "name": "@effect/language-service" }]
}
}
В нашем проекте он уже стоит, посмотри tsconfig.json. В VS Code дополнительно нужно выбрать TypeScript из рабочей директории (TypeScript: Select TypeScript Version плюс Use Workspace Version), иначе редактор возьмёт свой встроенный компилятор и плагин не подхватится.
Что он даёт, кроме внятных сообщений. Возьмём файл с четырьмя типичными ошибками:
export const floating = Effect.gen(function* () {
Effect.log('это никогда не выполнится'); // забыли yield*
yield* Effect.void;
});
export const uselessCatch = Effect.succeed(1).pipe(Effect.catch(() => Effect.succeed(0)));
export const chained = Effect.void.pipe(
Effect.provide(ConfigLive),
Effect.provide(Layer.empty),
);
Реальный вывод:
bad.ts(9,3): error effect(floatingEffect): This Effect value is neither yielded nor used in an assignment.
bad.ts(20,52): message effect(catchUnfailableEffect): The previous Effect does not fail, so this error-handling branch will never run.
bad.ts(23,41): warning effect(multipleEffectProvide): This expression chains multiple `Effect.provide` calls.
Первая находка это самая дорогая ошибка новичка: эффект создан, но не запущен. Ни компилятор, ни тесты её не видят, потому что типы сходятся, а поведение молча теряется. Плагин ловит её за миллисекунды.
Категории диагностик, которые стоит знать:
| Что ловит | Примеры |
|---|---|
| Корректность | floatingEffect, missingEffectContext, missingEffectError, missingStarInYieldEffectGen, classSelfMismatch |
| Антипаттерны | catchUnfailableEffect, multipleEffectProvide, leakingRequirements, globalErrorInEffectFailure, layerMergeAllWithDependencies |
| Устаревшее | outdatedApi, duplicatePackage, effectGenUsesAdapter |
Отдельно отмечу duplicatePackage: две версии effect в дереве зависимостей дают спецэффекты вида “сервис не находится, хотя он подан”, потому что теги из разных копий пакета это разные теги. Диагностика находит это сразу, а без неё уходит вечер.
Раньше эта проблема была куда острее: экосистема состояла из десятка отдельно версионируемых пакетов (@effect/platform, @effect/cli, @effect/schema и так далее), и разъехаться они могли на ровном месте, при обычном install. Сейчас почти всё живёт в самом effect, отдельными пакетами остались только привязанные к рантайму (@effect/platform-node, @effect/sql-pg и подобные), и версия у всей экосистемы одна. Дубликат теперь означает буквально “в дереве две копии effect”, и лечится это одной строкой в resolutions.
Кроме диагностик плагин умеет рефакторинги (превратить pipe в Effect.gen и обратно, обернуть в Effect.gen), подсказки по типам в hover и автодополнения. Это приятно, но диагностики важнее.
Раздел 3 · Диагностики в CI
Плагин TypeScript живёт только в редакторе: tsc о нём ничего не знает. Поэтому у пакета есть CLI:
npx effect-language-service diagnostics --project tsconfig.json --format text
bad.ts(9,3): error effect(floatingEffect): This Effect value is neither yielded nor used in an assignment.
Checked 1 files out of 1 files.
1 errors, 1 warnings and 1 messages.
Форматы вывода: text, pretty (с цветом и контекстом), json (для своих обработок) и github-actions, который печатает workflow-команды, и тогда GitHub сам подсвечивает проблемные строки прямо в диффе PR.
Флаг --strict делает предупреждения ошибками, то есть влияет на код возврата. Дальше это обычный шаг пайплайна:
- name: Effect diagnostics
run: npx effect-language-service diagnostics --project tsconfig.json --format github-actions --strict
Есть и второй путь: effect-language-service patch патчит локальный typescript, и тогда диагностики появляются в обычном tsc, без отдельного шага. Проверить состояние можно командой check:
INFO: node_modules/typescript/lib/typescript.js patched with version 0.86.2
INFO: node_modules/typescript/lib/_tsc.js patched with version 0.86.2
Патч удобен (одна команда typecheck проверяет всё), но у него есть цена: он меняет файлы в node_modules, а значит его нужно накатывать после каждого pnpm install, обычно в postinstall. Отдельный шаг в CI честнее и не зависит от порядка установки. Обратная операция это unpatch.
Раздел 4 · layerinfo, разбор графа слоёв
Самая мутная часть любого проекта на Effect это сборка MainLive. Кто кому что предоставляет, где provide, где provideMerge, почему сервис не находится. Для этого есть отдельная команда:
npx effect-language-service layerinfo --file src/main.ts --name MainLive
MainLive
./missing.ts:23:14
Layer<Storage | Logger, never, never>
Provides (2):
- Logger
- Storage
Suggested Composition:
export const MainLive = LoggerLive.pipe(
Layer.provideMerge(StorageLive)
)
Три вещи разом: что слой в итоге предоставляет, что требует, и как его правильно собрать. Последнее особенно полезно: описываешь все слои через Layer.mergeAll(...), запускаешь команду и получаешь корректную композицию с правильными provideMerge.
Практический сценарий: сервис не находится в рантайме, хотя визуально всё подано. Смотришь layerinfo и видишь, что слой не только предоставляет, но и требует что-то, чего в сборке нет. Это тот же граф, что мы разбирали в 04 · Services и Layer, только показанный инструментом, а не выведенный головой.
Раздел 5 · Cause на отладке
В рантайме главный источник правды это Cause из 03 · Tagged-ошибки. Он умеет печататься по-человечески:
import { Cause, Effect } from 'effect';
const exit = yield* program.pipe(Effect.exit);
if (exit._tag === 'Failure') {
console.log(Cause.pretty(exit.cause));
}
Error: внутренняя поломка
at <anonymous> (src/probe.ts:5:29)
at failing (src/probe.ts:10:43)
Cause.pretty разворачивает всё дерево: типизированную ошибку, дефект, прерывание, параллельные ветки. И, что ценно, стек включает логические кадры Effect, а не только физические кадры JavaScript. Отсюда практическое следствие: спаны из 18 · Observability улучшают не только трейсинг, но и стектрейсы, потому что имя спана попадает в кадр.
Три приёма, которые закрывают большинство сессий отладки.
Подсмотреть, не глотая. Effect.tapCause((cause) => Effect.logError('упало', cause)) пишет причину в лог и пропускает ошибку дальше. Ставится в подозрительном месте, снимается после.
Поднять уровень логов точечно. Минимальный уровень это обычная ссылка из References, поэтому меняется он тем же способом, что любой сервис: Effect.provideService(References.MinimumLogLevel, LogLevel.Trace) на конкретном куске программы, а не глобально. Отладочные логи включаются в одном сервисе, остальное приложение не шумит.
Кстати, вот вам и общий принцип v4: то, что раньше пряталось за специальными комбинаторами вида withXxx, стало обычными ссылками в контексте. Настроек стало не меньше, но способ их менять теперь один на все случаи.
Различать ошибку и дефект. Если в Cause лежит Die, это не ожидаемая ситуация, а баг: где-то бросили исключение мимо канала E. Чинить надо не обработчик, а место броска.
Раздел 6 · Кто из файберов ещё живой
Классическая беда долгоживущего процесса: память растёт, число живых файберов ползёт вверх, а кто именно течёт, непонятно.
Первый инструмент это метрики рантайма. Effect умеет считать файберы сам, надо только попросить:
import { Layer, Metric } from 'effect';
export const MainLive = Layer.mergeAll(
// ...остальные слои
Metric.enableRuntimeMetricsLayer,
);
После этого в общем реестре метрик появляются счётчики стартов и завершений файберов, и разница между ними это число живых. Смотреть на них можно там же, где на остальные метрики Pulse, через Metric.snapshot или экспорт в Prometheus (18 · Observability). Растущая без потолка разница это и есть утечка.
Если нужен точечный замер, а не постоянный сбор, тот же переключатель есть в форме комбинатора: Metric.enableRuntimeMetrics(effect) включает подсчёт только на конкретном куске программы, disableRuntimeMetrics наоборот выключает.
Метрика говорит “течёт”, но не говорит “где”. Дальше помогает второй приём: аннотации на файберах. Если каждый форк в подозрительном месте помечен спаном или лог-аннотацией, в логах видно, какая именно ветка не завершается.
Отдельного наблюдателя за деревом файберов, который раньше жил в модуле Supervisor и умел выдавать список живых потомков, в v4 нет. Идея заменилась на две более честные: числа берутся из метрик, а конкретика из трассировки. Это дешевле в рантайме и лучше ложится на прод, где смотреть надо не в консоль отладчика, а в дашборд.
Что до причины утечки, она почти всегда одна и та же: файбер форкнули без привязки к Scope. Лечится заменой Effect.forkDetach (файбер живёт сам по себе) на Effect.forkScoped (файбер умрёт вместе со скоупом) из 05 · Resources. Кстати, forkDetach это то, что раньше называлось forkDaemon, а обычный Effect.fork теперь Effect.forkChild: в v4 у всех трёх в имени написано, к чему привязана жизнь файбера.
Раздел 7 · Профилирование
Что и где занимает время отвечают спаны из 18 · Observability. Это первый инструмент, а не последний: дерево спанов сразу показывает, какой шаг съедает время, и дальше вопрос сужается.
Точечный замер делается Effect.timed:
const [duration, value] = yield* work.pipe(Effect.timed);
// Duration(11ms 720917ns), 42
Возвращается пара из Duration и результата. Для повторяющегося замера заведи Metric.timer(...) и обновляй его результатом timed, чтобы значения копились в гистограмме, а не терялись в логе.
DevTools. Модуль DevTools из effect/unstable/devtools подключает рантайм к внешнему инспектору: живое дерево спанов и метрик по WebSocket. Ставится одной строкой в сборку слоёв и снимается так же. Раньше он жил в отдельном пакете @effect/experimental; теперь это unstable-модуль ядра, и отдельно ставить нечего.
Отдельно про то, чего делать не надо. console.time внутри Effect меряет не то: между началом и концом файбер мог быть приостановлен, и в интервал попадёт чужое ожидание. Effect.timed меряет по тем же часам, что и весь рантайм, и потому корректен, включая тесты на TestClock.
Раздел 8 · Чек-лист настройки проекта
Если заводишь новый проект на Effect, вот минимальный набор, который окупается в первую неделю:
@effect/language-serviceвtsconfig.json, и в редакторе выбран TypeScript из рабочей директории.- Шаг диагностик в CI с
--strict(илиpatchвpostinstall, если предпочитаешь одинtypecheck). strict: trueиnoUncheckedIndexedAccess: trueвtsconfig.json. Без них половина гарантий Effect не работает:OptionиResultтеряют смысл, еслиundefinedпролезает мимо типов.exactOptionalPropertyTypesполезен, но включай его сразу, а не на проекте с историей.- Одна версия
effectв дереве зависимостей. Проверяетсяpnpm why effect, а нарушение ловит диагностикаduplicatePackage. Помни, что версия у всей экосистемы общая:effectи, скажем,@effect/platform-nodeдолжны стоять одной и той же. - Логгер в проде
Logger.consoleJson, в разработкеLogger.consolePretty(урок 18). - Обработчик верхнего уровня, который печатает
Cause.prettyи отдаёт разные коды выхода на ошибку, дефект и прерывание.
Pulse · вклад этого урока
Инфраструктурный урок, поэтому и вклад инфраструктурный.
В tsconfig.json появляется плагин, в CI шаг диагностик с --strict, в package.json скрипт pnpm diagnostics. Первый же прогон по всей кодовой базе Pulse обычно находит два-три floatingEffect (забытые yield* в местах, где ничего не сломалось видимым образом) и один multipleEffectProvide в тестах.
MainLive приводится к композиции, которую предложил layerinfo. Заодно выясняется, что один слой подавался дважды, из-за чего сервис создавался в двух экземплярах, а MemoMap из 04 · Services и Layer их не схлопывал, потому что слои были разными значениями.
Появляется точка входа с человеческим обработчиком отказов:
const main = program.pipe(
Effect.tapCause((cause) => Effect.logError(Cause.pretty(cause))),
Effect.catchCause((cause) =>
Effect.sync(() => {
process.exitCode = Cause.hasInterruptsOnly(cause) ? 0 : Cause.hasDies(cause) ? 2 : 1;
}),
),
);
Прерывание по Ctrl+C это не ошибка, поэтому код 0. Дефект это баг, поэтому отдельный код 2, по которому оркестратор поймёт, что перезапуск не поможет.
Имена предикатов тут во множественном числе не случайно: Cause в v4 плоский, внутри лежит массив причин, и вопрос звучит как “есть ли среди них дефекты”. Ловцы называются по тому же принципу: Effect.catch для ожидаемых ошибок, Effect.catchCause для всей причины целиком, Effect.catchDefect для дефектов.
И подкоманда pulse fibers, печатающая снимок метрик рантайма: сколько файберов запущено, сколько завершено, сколько живо прямо сейчас. Ей же ловится регрессия, если кто-то добавит форк без привязки к scope.
Финал · чек-лист
ДЗ
Все задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-25.
Финал раздела
Двадцать пять уроков позади. Пройдись по тому, что теперь есть у тебя на руках.
Ядро. Effect<A, E, R> и три канала, Schema на границе, tagged-ошибки, Layer как граф зависимостей, Scope и finalizer-ы, файберы и координация, транзакции, Stream, батчинг, рантайм и Schedule.
Прод-обвязка. Телеметрия (логи, спаны, метрики, экспорт), HttpClient и HttpApi с middleware и OpenAPI, конфигурация и секреты, файлы и процессы через платформенные сервисы ядра.
Данные. Schema вглубь: рекурсия, накопление ошибок, асинхронная валидация, эволюция форматов, property-тесты из схемы, схема как контракт для языковой модели. Приёмники, пачки, DLQ.
Инструменты. Стандартная библиотека и, наконец, настроенный редактор с CI.
Сквозной проект Pulse прошёл весь путь: от скрипта, который дёргает адрес, до сервиса с одним графом зависимостей, двумя точками входа, телеметрией, авторизацией, конфигурацией, конвейером записи и человеческой диагностикой.
Главная мысль, которую стоит унести, та же, что была в конце 13 · Testing и code style. Effect это инструмент, а не идеология. Каждый примитив, который ты закрыл, решает одну конкретную задачу. Когда следующий проект её поставит, ты вспомнишь нужное имя, прочитаешь сигнатуру за минуту и встроишь за день. А когда не поставит, спокойно напишешь обычный async и не будешь чувствовать себя предателем.
Дальше
- 26 · Архитектура бэкенда, капстоун раздела: разбор боевого платёжного шлюза на Effect, где сегодняшние примитивы складываются в целый сервис с очередью на Postgres, outbox и воркером.
- 14 · DDD-типы и следующие три урока, если ты пропустил блок про functional DDD:
Decider, Event Store на@effect/sql-pg, CQRS-проекции. Они самодостаточны и хорошо ложатся поверх всего, что было. - 02-cs · io-monad, воркшоп, где ты собираешь
IOруками. После этой серии он читается совсем иначе. - 29-testing, раздел про тестирование целиком. Половина приёмов оттуда на Effect делается в две строки, и это хорошая проверка того, насколько идеи усвоились.
В репозитории курса лежат examples/our-pulse/, examples/our-db/effect/ и examples/ddd-hotel/, три разных по духу проекта на Effect. Открывай любой и читай как код коллеги: теперь ты знаешь там каждую конструкцию.