nanoeffects 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/testing.js +1 -1
- package/dist/trace.d.ts +189 -42
- package/dist/trace.js +2 -2
- package/dist/tracer-v7m-Qah_.js +2 -0
- package/docs/{debugging-compared.md → debugging-cases.md} +3 -17
- package/docs/debugging-loops.md +79 -0
- package/docs/package.md +1 -1
- package/docs/ru/{debugging-compared.md → debugging-cases.md} +3 -17
- package/docs/ru/debugging-loops.md +79 -0
- package/docs/ru/package.md +1 -1
- package/docs/ru/tracing.md +106 -4
- package/docs/tracing.md +106 -4
- package/llms-full.txt +190 -23
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/dist/tracer-mMDm5yE7.js +0 -2
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
- [Обрыв цикла во время работы](#обрыв-цикла-во-время-работы)
|
|
17
17
|
- [Падение теста при цикле](#падение-теста-при-цикле)
|
|
18
18
|
- [Экспорт графа и payload](#экспорт-графа-и-payload)
|
|
19
|
+
- [Подписки, которые не запускались](#подписки-которые-не-запускались)
|
|
19
20
|
- [Как находится цикл](#как-находится-цикл)
|
|
20
21
|
- [Ограничения](#ограничения)
|
|
21
22
|
|
|
@@ -278,6 +279,84 @@ console.log(JSON.stringify(data));
|
|
|
278
279
|
|
|
279
280
|
Payload может быть экземпляром класса, `Map`, объектом с циклической ссылкой или `Error`, которые обычный `JSON.stringify` не примет. Экспорт превращает их в обычный JSON: у `Error` остаются имя, сообщение, стек и причина, цикл становится `"[Circular]"`, `Map` — `{ "$map": [...] }`, а экземпляр класса сохраняет собственные поля и имя класса в `$class`. Геттеры не вызываются, а слишком глубокое или слишком большое значение обрезается.
|
|
280
281
|
|
|
282
|
+
## Подписки, которые не запускались
|
|
283
|
+
|
|
284
|
+
Граф одного запуска показывает только то, что сделал этот запуск. Он не скажет, какие подписки есть в приложении, но ни один запуск до них не дошёл: мёртвый код или сценарий, который никто не тестирует. Чтобы их найти, объедините графы нескольких запусков и сравните результат с подписками шины.
|
|
285
|
+
|
|
286
|
+
Что нужно заранее: шина использует `tracePlugin` и `graphLogger` (см. [Поиск цикла](#поиск-цикла)), а у каждой подписки есть имя функции или `traceId`. У анонимной подписки нет узла в графе, поэтому о ней не сообщается.
|
|
287
|
+
|
|
288
|
+
1. Каждый запуск пишет свой граф в файл.
|
|
289
|
+
2. Скрипт читает файлы и объединяет их через `mergeGraphs`.
|
|
290
|
+
3. `findOrphans` сравнивает объединённый граф с `describeBus`.
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
// bus.js
|
|
294
|
+
import { createBus } from "nanoeffects";
|
|
295
|
+
import { createTracer, graphLogger, tracePlugin } from "nanoeffects/trace";
|
|
296
|
+
|
|
297
|
+
// graphLogger — карта того, что выполнилось
|
|
298
|
+
export const graph = graphLogger({ name: "cart" });
|
|
299
|
+
export const bus = createBus({ trace: createTracer(graph) }).use(tracePlugin());
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
// handlers.js
|
|
304
|
+
import { bus } from "./bus";
|
|
305
|
+
|
|
306
|
+
// подписка названа по функции; findOrphans сопоставляет это имя
|
|
307
|
+
bus.on("cart/added", function recalcTotal() {});
|
|
308
|
+
bus.on("cart/cleared", function resetTotal() {});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
// graph-setup.js (setup-файл вашего тест-раннера)
|
|
313
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
314
|
+
import { afterAll } from "vitest";
|
|
315
|
+
import { graph } from "./bus";
|
|
316
|
+
|
|
317
|
+
afterAll(() => {
|
|
318
|
+
mkdirSync("graphs", { recursive: true });
|
|
319
|
+
|
|
320
|
+
// toJSON — граф этого запуска как обычные данные
|
|
321
|
+
const data = graph.toJSON();
|
|
322
|
+
writeFileSync(`graphs/${process.pid}.json`, JSON.stringify(data));
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
// orphans.js (запустите после тестов)
|
|
328
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
329
|
+
import { describeBus, findOrphans, mergeGraphs } from "nanoeffects/trace";
|
|
330
|
+
import { bus } from "./bus";
|
|
331
|
+
import "./handlers";
|
|
332
|
+
|
|
333
|
+
const files = readdirSync("graphs");
|
|
334
|
+
const runs = files.map((file) => JSON.parse(readFileSync(`graphs/${file}`, "utf8")));
|
|
335
|
+
|
|
336
|
+
// mergeGraphs — счётчики складываются, поэтому подписка, выполнявшаяся в любом запуске, становится узлом
|
|
337
|
+
const all = mergeGraphs(...runs);
|
|
338
|
+
|
|
339
|
+
// describeBus — подписки, которые держит шина, по событиям
|
|
340
|
+
const described = describeBus(bus);
|
|
341
|
+
|
|
342
|
+
// findOrphans — подписки, которые объединённый граф ни разу не видел
|
|
343
|
+
console.log(findOrphans(described, all));
|
|
344
|
+
// [{ event: "cart/cleared", listener: "resetTotal", kind: "unemitted" }]
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
У каждого элемента есть `kind`:
|
|
348
|
+
|
|
349
|
+
| `kind` | Что значит | Что проверить |
|
|
350
|
+
| ------ | ---------- | ------------- |
|
|
351
|
+
| `unemitted` | Ни в одном запуске никто не отправил это событие. | Нужно ли ещё событие? Не хватает ли сценария? |
|
|
352
|
+
| `unrun` | Событие отправлялось, а эта подписка не запускалась. | Условие или [политика](policies.md), из-за которых она не запускается. |
|
|
353
|
+
|
|
354
|
+
Читайте список как подсказку, а не как ошибку. При малом числе запусков в нём много ложных записей, и сигналом он становится, когда запуски покрывают основные сценарии. Ограничения:
|
|
355
|
+
|
|
356
|
+
- Две подписки с одним именем дают один узел, поэтому вторая никогда не попадёт в список. Дайте каждой своё имя или `traceId`.
|
|
357
|
+
- Объединяйте запуски одной сборки. Переименованная подписка попадёт в список.
|
|
358
|
+
- `mergeGraphs` не находит цикл, который замыкается только при объединении рёбер разных запусков. Смотрите `graph.cycles()` в каждом запуске.
|
|
359
|
+
|
|
281
360
|
## Как находится цикл
|
|
282
361
|
|
|
283
362
|
`graphLogger` находит циклы двумя способами. Они дают два вида находок в `graph.cycles()`, которые различает поле `source`:
|
package/docs/ru/package.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
| `nanoeffects/svelte` | `sveltePlugin`: добавляет `eventStore` на шину (нужен Svelte 5 или новее) | компоненты Svelte |
|
|
27
27
|
| `nanoeffects/vue` | `vuePlugin`: добавляет `watchEmitter` и `useEventRef` на шину (нужен Vue 3.3 или новее) | компоненты Vue |
|
|
28
28
|
| `nanoeffects/dev` | `devPlugin` (в production ничего не делает), `getLostSignalsCount` и `getLostSignals` для тестов | нужно находить утечки подписок и потерянные сигналы при разработке и в тестах |
|
|
29
|
-
| `nanoeffects/trace` | `createTracer`, логгеры (`graphLogger` находит циклы
|
|
29
|
+
| `nanoeffects/trace` | `createTracer`, логгеры (`graphLogger` находит циклы событий; `mergeGraphs` и `findOrphans` находят подписки, до которых не дошёл ни один запуск) и `tracePlugin` (имена для трассировки) | нужно видеть, что выполнилось и почему |
|
|
30
30
|
| `nanoeffects/testing` | `createMarbleBus`, `fakeTimersClock`: шина для тестов, где отправки и фазы записаны диаграммами | вы тестируете `latest`, `debounce`, `queue`, `retry` или `timeout` на поддельных таймерах |
|
|
31
31
|
| `nanoeffects/guard` | `loopGuardPlugin`: отклоняет emit, который превысил бы предел цепочки | цикл не должен ронять приложение |
|
|
32
32
|
|
package/docs/ru/tracing.md
CHANGED
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
- [Встроенные логгеры](#встроенные-логгеры)
|
|
21
21
|
- [Сокращение объёма в production](#сокращение-объёма-в-production)
|
|
22
22
|
- [Последние события на случай инцидента](#последние-события-на-случай-инцидента)
|
|
23
|
+
- [Время и незавершённые задачи](#время-и-незавершённые-задачи)
|
|
24
|
+
- [Поиск причины ошибки](#поиск-причины-ошибки)
|
|
23
25
|
- [Настройка Redux DevTools](#настройка-redux-devtools)
|
|
24
26
|
- [Поиск циклов через `graphLogger`](#поиск-циклов-через-graphlogger)
|
|
25
27
|
- [Безопасные данные для инструмента](#безопасные-данные-для-инструмента)
|
|
26
28
|
- [Собственный логгер](#собственный-логгер)
|
|
29
|
+
- [Остановка на событии](#остановка-на-событии)
|
|
27
30
|
- [Удаление трассировки из сборки](#удаление-трассировки-из-сборки)
|
|
28
31
|
|
|
29
32
|
## Подключение трассировщика
|
|
@@ -254,7 +257,7 @@ console.log(describeBus(bus));
|
|
|
254
257
|
|
|
255
258
|
**Когда.** Вы отлаживаете в разработке и хотите перейти от строки лога к коду.
|
|
256
259
|
|
|
257
|
-
Передайте `source: true` в `tracePlugin`. Он читает место каждого вызова `bus.on` и `bus.once` один раз, при регистрации, и никогда при `emit`. События обработчика получают `source` (`file:line:column`), `consoleLogger({ source: true })` печатает его, а `describeBus(bus, { source: true })` его перечисляет. Обработчик без имени тоже попадает в трассировку, под типом своего
|
|
260
|
+
Передайте `source: true` в `tracePlugin`. Он читает место каждого вызова `bus.on` и `bus.once` один раз, при регистрации, и никогда при `emit`. События обработчика получают `source` (`file:line:column`), `consoleLogger({ source: true })` печатает его, а `describeBus(bus, { source: true })` его перечисляет. Обработчик без имени тоже попадает в трассировку, под типом своего события и номером: `name/changed#1`, `name/changed#2`, в порядке регистрации. Два встроенных обработчика одного события — это две записи, а место остаётся в `source`. Добавление или перестановка вызовов `bus.on` меняет номера, поэтому дайте `traceId` обработчику, чьё имя должно остаться прежним между сборками.
|
|
258
261
|
|
|
259
262
|
```js
|
|
260
263
|
import { createBus } from "nanoeffects";
|
|
@@ -282,7 +285,7 @@ console.log(describeBus(bus, { source: true }));
|
|
|
282
285
|
|
|
283
286
|
```
|
|
284
287
|
[nanoeffects] saveName · run start (file:///app/src/user.js:13:5)
|
|
285
|
-
[nanoeffects] name/changed · run start (file:///app/src/user.js:14:5)
|
|
288
|
+
[nanoeffects] name/changed#1 · run start (file:///app/src/user.js:14:5)
|
|
286
289
|
{ "name/changed": ["saveName (file:///app/src/user.js:13:5)", "<anonymous> (file:///app/src/user.js:14:5)"] }
|
|
287
290
|
```
|
|
288
291
|
|
|
@@ -334,7 +337,7 @@ const bus = createBus({
|
|
|
334
337
|
| `reduxDevtools()` | Отправляет emit в Redux DevTools, а с `policies` — стадии обработчиков как `type/effect/phase`; state — `{ running }` |
|
|
335
338
|
| `consoleLogger()` | Печатает по одной строке на фазу, с отступом по глубине задачи |
|
|
336
339
|
| `performanceLogger()` | Измерения User Timing для панели Performance браузера |
|
|
337
|
-
| `graphLogger()` | Строит карту того, какое событие к какому ведёт, находит циклы во время работы приложения и записывает карту как Mermaid, Graphviz или JSON ([см. ниже](#поиск-циклов-через-graphlogger)) |
|
|
340
|
+
| `graphLogger()` | Строит карту того, какое событие к какому ведёт, находит циклы во время работы приложения и записывает карту как Mermaid, Graphviz или JSON ([см. ниже](#поиск-циклов-через-graphlogger)); [`mergeGraphs` и `findOrphans`](debugging-loops.md#подписки-которые-не-запускались) объединяют запуски и находят подписки, которые не запускались |
|
|
338
341
|
| `createTraceRecorder()` | Хранит события в `recorder.events`: все — для тестов, либо с `{ capacity }` последние N ([см. ниже](#последние-события-на-случай-инцидента)) |
|
|
339
342
|
|
|
340
343
|
### Сокращение объёма в production
|
|
@@ -395,12 +398,91 @@ window.addEventListener("error", () => {
|
|
|
395
398
|
Что нужно знать, прежде чем отправлять события:
|
|
396
399
|
|
|
397
400
|
- **`payload` и `error` могут содержать приватные данные**: то, что ввёл пользователь, токены, личный текст. Рекордер хранит их как есть и не вычищает. Выбирайте, что отправлять, как это делает `withoutPayload`.
|
|
398
|
-
- **У события нет времени.** Вы получаете порядок, а не длительность.
|
|
401
|
+
- **У события нет времени.** Вы получаете порядок, а не длительность. Время считает [`timingLogger`](#время-и-незавершённые-задачи).
|
|
399
402
|
- **Старый `parentId` может указывать на событие, которое вытеснено.** Когда вы строите дерево из событий, считайте неизвестный `parentId` корнем.
|
|
400
403
|
- **`recorder.events` с `capacity` — снимок.** Каждое чтение строит новый массив, поэтому читайте его один раз в нужный момент. Без `capacity` это один живой массив.
|
|
401
404
|
- **Низкий `sample` разрежает буфер.** `createTracer(recorder, { sample: 0.05 })` записывает 5% корневых задач, поэтому к моменту инцидента в буфере может быть мало. См. [Сокращение объёма в production](#сокращение-объёма-в-production).
|
|
402
405
|
- **`capacity`, не являющийся положительным целым** (`0`, отрицательное число, `NaN`), означает «без ограничения».
|
|
403
406
|
|
|
407
|
+
### Время и незавершённые задачи
|
|
408
|
+
|
|
409
|
+
У события нет времени, и по рекордеру нельзя узнать, какой обработчик медленный, сколько жил запрос до `cancel` и осталось ли что-то после размонтирования. `timingLogger` проставляет время на каждое событие при получении и связывает `start` с первой завершающей фазой (`done`, `error`, `cancel` или `drop`) той же задачи.
|
|
410
|
+
|
|
411
|
+
Подайте его в трассировщик вместе с рекордером: `createTracer` принимает массив логгеров.
|
|
412
|
+
|
|
413
|
+
<!-- docs-check: new -->
|
|
414
|
+
|
|
415
|
+
```js
|
|
416
|
+
// bus.js
|
|
417
|
+
import { createBus } from "nanoeffects";
|
|
418
|
+
import { createTraceRecorder, createTracer, timingLogger } from "nanoeffects/trace";
|
|
419
|
+
|
|
420
|
+
// createTraceRecorder: события в порядке прихода
|
|
421
|
+
export const recorder = createTraceRecorder();
|
|
422
|
+
|
|
423
|
+
// timingLogger: время каждого события и длительность задач
|
|
424
|
+
export const timing = timingLogger();
|
|
425
|
+
|
|
426
|
+
// createTracer: каждое событие получают оба логгера
|
|
427
|
+
const trace = createTracer([recorder, timing]);
|
|
428
|
+
|
|
429
|
+
export const bus = createBus({ trace });
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```js
|
|
433
|
+
// check.js
|
|
434
|
+
import { timing } from "./bus";
|
|
435
|
+
|
|
436
|
+
// durations: задачи, которые завершились, с фазой конца и длительностью в мс
|
|
437
|
+
const slow = timing.durations.filter((task) => task.ms > 500);
|
|
438
|
+
console.log(slow);
|
|
439
|
+
|
|
440
|
+
// pending: задачи, которые начались и не закончились
|
|
441
|
+
const leftovers = timing.pending();
|
|
442
|
+
if (leftovers.length > 0) {
|
|
443
|
+
throw new Error(`${leftovers.length} tasks are still open`);
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Что нужно знать:
|
|
448
|
+
|
|
449
|
+
- **Часы.** Время берётся из `performance.now()`, а где его нет, из `Date.now()`. В тесте передайте свои часы: `timingLogger({ now })`.
|
|
450
|
+
- **`capacity` режет только `durations`.** `timingLogger({ capacity: 200 })` хранит последние 200 завершённых задач. `pending()` не обрезается: утечка видна как растущий список.
|
|
451
|
+
- **`clear()`** очищает оба списка. Вызывайте его между тестами.
|
|
452
|
+
- **Логгер видит то, что пропустил трассировщик.** Задача, чей `start` отфильтрован или не попал в `sample`, не получит пару, и её завершение игнорируется.
|
|
453
|
+
|
|
454
|
+
### Поиск причины ошибки
|
|
455
|
+
|
|
456
|
+
Ошибка приходит одним событием, и не видно, что к ней привело. `chainOf` строит путь от корня до задачи по `parentId`. Передайте ему рекордер: тогда в путь попадут и подписчики, которые не шлют событий (обычный обработчик, который вызывает `ctx.emit`).
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
// why.js
|
|
460
|
+
import { chainOf } from "nanoeffects/trace";
|
|
461
|
+
import { recorder } from "./bus";
|
|
462
|
+
|
|
463
|
+
const failed = recorder.events.find((event) => event.phase === "error");
|
|
464
|
+
|
|
465
|
+
// chainOf: путь от корня до упавшей задачи
|
|
466
|
+
const path = chainOf(recorder, failed.id);
|
|
467
|
+
|
|
468
|
+
const text = path.map((step) => step.type).join(" → ");
|
|
469
|
+
console.log(text); // queryChanged → searchLoaded
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
У шага есть `type`, а также `effect` и `phase` (у подписчика без событий их нет), `source` и `traceGroupId`, когда они известны. Если передать вместо рекордера массив событий (снимок или файл), в пути будут только задачи с событиями.
|
|
473
|
+
|
|
474
|
+
Чтобы видеть путь сразу в консоли, включите `chain` в `consoleLogger`:
|
|
475
|
+
|
|
476
|
+
```js
|
|
477
|
+
import { consoleLogger, createTracer } from "nanoeffects/trace";
|
|
478
|
+
|
|
479
|
+
// consoleLogger: строка ошибки заканчивается путём от корня
|
|
480
|
+
const trace = createTracer(consoleLogger({ chain: true }));
|
|
481
|
+
// [nanoeffects] render · run error (queryChanged → render)
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
`chain` помнит последние 1000 задач. По умолчанию он выключен.
|
|
485
|
+
|
|
404
486
|
### Настройка Redux DevTools
|
|
405
487
|
|
|
406
488
|
<!-- docs-check: new -->
|
|
@@ -532,6 +614,26 @@ const errorsOnly = (): TraceLogger => (event) => {
|
|
|
532
614
|
};
|
|
533
615
|
```
|
|
534
616
|
|
|
617
|
+
### Остановка на событии
|
|
618
|
+
|
|
619
|
+
Нужно остановиться в отладчике, когда приходит определённое событие. Для этого не нужен API: это пять строк в собственном логгере.
|
|
620
|
+
|
|
621
|
+
```js
|
|
622
|
+
import { createTracer } from "nanoeffects/trace";
|
|
623
|
+
|
|
624
|
+
// Логгер: останавливается в отладчике на начале задачи с этим type
|
|
625
|
+
const stopOn = (type) => (event) => {
|
|
626
|
+
if (event.type === type && event.phase === "start") {
|
|
627
|
+
debugger; // браузер остановится здесь, если открыты DevTools
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// createTracer: подайте логгер, как любой другой
|
|
632
|
+
const trace = createTracer(stopOn("search"));
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
В отладчике посмотрите `event.payload` и поднимитесь по стеку: синхронная часть цепочки видна в нём целиком.
|
|
636
|
+
|
|
535
637
|
## Удаление трассировки из сборки
|
|
536
638
|
|
|
537
639
|
Без трассировщика хуки почти ничего не стоят. `nanoeffects/trace` попадает в бандл, только если его импортируют.
|
package/docs/tracing.md
CHANGED
|
@@ -20,10 +20,13 @@ Tracing is optional. A bus works the same without it, and it costs almost nothin
|
|
|
20
20
|
- [Use built-in loggers](#use-built-in-loggers)
|
|
21
21
|
- [Cut volume in production](#cut-volume-in-production)
|
|
22
22
|
- [Keep the last events for an incident](#keep-the-last-events-for-an-incident)
|
|
23
|
+
- [Measure time and find open tasks](#measure-time-and-find-open-tasks)
|
|
24
|
+
- [Find the cause of an error](#find-the-cause-of-an-error)
|
|
23
25
|
- [Configure Redux DevTools](#configure-redux-devtools)
|
|
24
26
|
- [Find loops with `graphLogger`](#find-loops-with-graphlogger)
|
|
25
27
|
- [Send safe data to a tool](#send-safe-data-to-a-tool)
|
|
26
28
|
- [Write your own logger](#write-your-own-logger)
|
|
29
|
+
- [Stop on an event](#stop-on-an-event)
|
|
27
30
|
- [Drop tracing from a build](#drop-tracing-from-a-build)
|
|
28
31
|
|
|
29
32
|
## Attach a tracer
|
|
@@ -254,7 +257,7 @@ console.log(describeBus(bus));
|
|
|
254
257
|
|
|
255
258
|
**When.** You debug in development and want to jump from a log line to the code.
|
|
256
259
|
|
|
257
|
-
Pass `source: true` to `tracePlugin`. It reads the place of each `bus.on` and `bus.once` call once, when the listener is registered, and never on `emit`. The events of the listener get a `source` (`file:line:column`), `consoleLogger({ source: true })` prints it, and `describeBus(bus, { source: true })` lists it. An anonymous listener is reported too, under the type of its event.
|
|
260
|
+
Pass `source: true` to `tracePlugin`. It reads the place of each `bus.on` and `bus.once` call once, when the listener is registered, and never on `emit`. The events of the listener get a `source` (`file:line:column`), `consoleLogger({ source: true })` prints it, and `describeBus(bus, { source: true })` lists it. An anonymous listener is reported too, under the type of its event and a number: `name/changed#1`, `name/changed#2`, in the order of registration. Two inline listeners on one event are two entries, and the place stays in `source`. Adding or reordering `bus.on` calls renumbers them, so give a `traceId` to a listener whose name must stay the same between builds.
|
|
258
261
|
|
|
259
262
|
```js
|
|
260
263
|
import { createBus } from "nanoeffects";
|
|
@@ -282,7 +285,7 @@ console.log(describeBus(bus, { source: true }));
|
|
|
282
285
|
|
|
283
286
|
```
|
|
284
287
|
[nanoeffects] saveName · run start (file:///app/src/user.js:13:5)
|
|
285
|
-
[nanoeffects] name/changed · run start (file:///app/src/user.js:14:5)
|
|
288
|
+
[nanoeffects] name/changed#1 · run start (file:///app/src/user.js:14:5)
|
|
286
289
|
{ "name/changed": ["saveName (file:///app/src/user.js:13:5)", "<anonymous> (file:///app/src/user.js:14:5)"] }
|
|
287
290
|
```
|
|
288
291
|
|
|
@@ -335,7 +338,7 @@ const bus = createBus({
|
|
|
335
338
|
| `consoleLogger()` | Prints one line per phase, indented by task depth |
|
|
336
339
|
| `performanceLogger()` | User Timing measures for the browser Performance panel |
|
|
337
340
|
| `createTraceRecorder()` | Keeps events in `recorder.events`: all of them for tests, or with `{ capacity }` the last N ([see below](#keep-the-last-events-for-an-incident)) |
|
|
338
|
-
| `graphLogger()` | Builds the map of which event leads to which, finds loops while the app runs, and writes the map as Mermaid, Graphviz or JSON ([see below](#find-loops-with-graphlogger)) |
|
|
341
|
+
| `graphLogger()` | Builds the map of which event leads to which, finds loops while the app runs, and writes the map as Mermaid, Graphviz or JSON ([see below](#find-loops-with-graphlogger)); [`mergeGraphs` and `findOrphans`](debugging-loops.md#find-listeners-that-never-ran) join runs and find listeners that never ran |
|
|
339
342
|
|
|
340
343
|
### Cut volume in production
|
|
341
344
|
|
|
@@ -395,12 +398,91 @@ window.addEventListener("error", () => {
|
|
|
395
398
|
What to know before you send the events:
|
|
396
399
|
|
|
397
400
|
- **`payload` and `error` can hold private data**: what a user typed, tokens, personal text. The recorder keeps them as they are and does not redact. Choose what to send, as `withoutPayload` does.
|
|
398
|
-
- **There is no time on an event.** You get the order, not the duration.
|
|
401
|
+
- **There is no time on an event.** You get the order, not the duration. [`timingLogger`](#measure-time-and-find-open-tasks) measures time.
|
|
399
402
|
- **An old `parentId` can point at an event that was pushed out.** When you build a tree from the events, treat an unknown `parentId` as a root.
|
|
400
403
|
- **`recorder.events` is a snapshot with a `capacity`.** Each read builds a new array, so read it once at the moment you need it. Without a `capacity` it is one live array.
|
|
401
404
|
- **A low `sample` thins the buffer.** `createTracer(recorder, { sample: 0.05 })` records 5% of root tasks, so the buffer may hold little when the incident comes. See [Cut volume in production](#cut-volume-in-production).
|
|
402
405
|
- **A `capacity` that is not a positive integer** (`0`, a negative number, `NaN`) means no limit.
|
|
403
406
|
|
|
407
|
+
### Measure time and find open tasks
|
|
408
|
+
|
|
409
|
+
An event has no time, so the recorder cannot tell you which handler is slow, how long a request lived before `cancel`, or whether a test left something running. `timingLogger` stamps the time on each event when it gets it and pairs `start` with the first closing phase (`done`, `error`, `cancel` or `drop`) of the same task.
|
|
410
|
+
|
|
411
|
+
Pass it to the tracer next to the recorder: `createTracer` takes an array of loggers.
|
|
412
|
+
|
|
413
|
+
<!-- docs-check: new -->
|
|
414
|
+
|
|
415
|
+
```js
|
|
416
|
+
// bus.js
|
|
417
|
+
import { createBus } from "nanoeffects";
|
|
418
|
+
import { createTraceRecorder, createTracer, timingLogger } from "nanoeffects/trace";
|
|
419
|
+
|
|
420
|
+
// createTraceRecorder: events in the order they came
|
|
421
|
+
export const recorder = createTraceRecorder();
|
|
422
|
+
|
|
423
|
+
// timingLogger: the time of each event and the duration of tasks
|
|
424
|
+
export const timing = timingLogger();
|
|
425
|
+
|
|
426
|
+
// createTracer: both loggers get every event
|
|
427
|
+
const trace = createTracer([recorder, timing]);
|
|
428
|
+
|
|
429
|
+
export const bus = createBus({ trace });
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```js
|
|
433
|
+
// check.js
|
|
434
|
+
import { timing } from "./bus";
|
|
435
|
+
|
|
436
|
+
// durations: the tasks that ended, with the closing phase and the time in ms
|
|
437
|
+
const slow = timing.durations.filter((task) => task.ms > 500);
|
|
438
|
+
console.log(slow);
|
|
439
|
+
|
|
440
|
+
// pending: the tasks that started and have not ended
|
|
441
|
+
const leftovers = timing.pending();
|
|
442
|
+
if (leftovers.length > 0) {
|
|
443
|
+
throw new Error(`${leftovers.length} tasks are still open`);
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
What to know:
|
|
448
|
+
|
|
449
|
+
- **The clock.** Time comes from `performance.now()`, or `Date.now()` where there is none. In a test pass your own clock: `timingLogger({ now })`.
|
|
450
|
+
- **`capacity` cuts only `durations`.** `timingLogger({ capacity: 200 })` keeps the last 200 ended tasks. `pending()` is never cut: a leak shows up as a growing list.
|
|
451
|
+
- **`clear()`** empties both lists. Call it between tests.
|
|
452
|
+
- **The logger sees what the tracer lets through.** A task whose `start` was filtered out or not sampled gets no pair, and its ending is ignored.
|
|
453
|
+
|
|
454
|
+
### Find the cause of an error
|
|
455
|
+
|
|
456
|
+
An error arrives as one event, and you cannot see what led to it. `chainOf` builds the path from the root to a task by `parentId`. Give it the recorder: then the listeners that send no event (a plain handler that calls `ctx.emit`) are in the path too.
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
// why.js
|
|
460
|
+
import { chainOf } from "nanoeffects/trace";
|
|
461
|
+
import { recorder } from "./bus";
|
|
462
|
+
|
|
463
|
+
const failed = recorder.events.find((event) => event.phase === "error");
|
|
464
|
+
|
|
465
|
+
// chainOf: the path from the root to the failed task
|
|
466
|
+
const path = chainOf(recorder, failed.id);
|
|
467
|
+
|
|
468
|
+
const text = path.map((step) => step.type).join(" → ");
|
|
469
|
+
console.log(text); // queryChanged → searchLoaded
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
A step has `type`, and `effect` and `phase` (a listener with no events has neither), `source` and `traceGroupId` when known. If you pass a list of events (a snapshot, a file) instead of the recorder, the path holds only the tasks that have events.
|
|
473
|
+
|
|
474
|
+
To see the path right in the console, turn on `chain` in `consoleLogger`:
|
|
475
|
+
|
|
476
|
+
```js
|
|
477
|
+
import { consoleLogger, createTracer } from "nanoeffects/trace";
|
|
478
|
+
|
|
479
|
+
// consoleLogger: the line of an error ends with the path from the root
|
|
480
|
+
const trace = createTracer(consoleLogger({ chain: true }));
|
|
481
|
+
// [nanoeffects] render · run error (queryChanged → render)
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
`chain` remembers the last 1000 tasks. It is off by default.
|
|
485
|
+
|
|
404
486
|
### Configure Redux DevTools
|
|
405
487
|
|
|
406
488
|
<!-- docs-check: new -->
|
|
@@ -532,6 +614,26 @@ const errorsOnly = (): TraceLogger => (event) => {
|
|
|
532
614
|
};
|
|
533
615
|
```
|
|
534
616
|
|
|
617
|
+
### Stop on an event
|
|
618
|
+
|
|
619
|
+
You want the debugger to stop when a certain event comes. No API is needed for that: it is five lines in a logger of your own.
|
|
620
|
+
|
|
621
|
+
```js
|
|
622
|
+
import { createTracer } from "nanoeffects/trace";
|
|
623
|
+
|
|
624
|
+
// A logger that stops in the debugger at the start of a task with this type
|
|
625
|
+
const stopOn = (type) => (event) => {
|
|
626
|
+
if (event.type === type && event.phase === "start") {
|
|
627
|
+
debugger; // the browser stops here when DevTools is open
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// createTracer: pass the logger like any other
|
|
632
|
+
const trace = createTracer(stopOn("search"));
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
In the debugger read `event.payload` and walk up the stack: the synchronous part of the chain is all there.
|
|
636
|
+
|
|
535
637
|
## Drop tracing from a build
|
|
536
638
|
|
|
537
639
|
Without a tracer the hooks cost almost nothing. `nanoeffects/trace` is only bundled when imported.
|