Перейти к содержимому
GitHubXDiscord

Инспектор

Каждый рантайм ведёт короткий структурированный лог того, что только что произошло — что сработало, что было пропущено, что бросило исключение. Этот лог и есть инспектор. На нём построены useInspect, useInspectHistory, <InspectorView>, мост Redux DevTools, мост BroadcastChannel и инструмент replay. Эта страница покрывает содержимое снимка, два React-хука, кольцевой буфер и то, как не тащить инспектор в продакшен-бандл.

Каждый запуск триггера производит один TriggerInspectSnapshot. Форма стабильна и дружелюбна к JSON — снимок можно безопасно отправлять через мост postMessage или сохранять на диск:

type TriggerInspectSnapshot = {
  readonly triggerId: string;
  readonly runId: string;
  readonly eventName: string;
  readonly status: 'fired' | 'skipped' | 'errored' | 'aborted';
  readonly reason?: string;                   // e.g. 'missing-required: settings'
  readonly durationMs: number;
  readonly executedActions: readonly string[]; // action names called by the handler
  readonly snapshotKeys: readonly string[];   // condition names visible at fire-time
};

Четыре статуса взаимно исключающие:

СтатусКогда рантайм записывает
firedОбработчик отработал до конца (sync) или его промис зарезолвился (async).
skippedrequired-условие не зарегистрировано, триггер выключен или middleware отменил срабатывание. reason уточняет, что именно.
erroredОбработчик или одно из его действий бросило исключение.
abortedПри take-latest более новый запуск вытеснил этот.

durationMs — это wall-time от момента, когда диспатч выбрал триггер, до возврата обработчика. executedActions перечисляет имена действий, которые реально были вызваны, — удобно для проверки в тестах, что debounce действительно подавил второй вызов.

В каждом рантайме снимки попадают в кольцевой буфер фиксированного размера (по умолчанию 50). Когда буфер заполняется, самая старая запись перезаписывается. Структура данных не требует аллокаций на горячем пути: записи — O(1), а массив newest-first буфер материализует только когда кто-то вызывает getInspectorBuffer().

Размер настраивается при создании рантайма:

src/main.tsx
import { createRuntime } from '@triggery/core';

const runtime = createRuntime({
  inspectorBufferSize: 200, // default: 50
});

Больший буфер не стоит ничего на срабатывание и пару KB памяти суммарно — увеличь его, если расследуешь флейк-сценарий и нужно больше истории.

useInspect(trigger) — последний запуск одного триггера

Заголовок раздела «useInspect(trigger) — последний запуск одного триггера»

useInspect возвращает самый свежий снимок одного триггера или undefined, если он ещё не запускался.

src/dev/MessageTriggerStatus.tsx
import { useInspect } from '@triggery/react';
import { messageTrigger } from '../triggers/message.trigger';

export function MessageTriggerStatus() {
  const last = useInspect(messageTrigger);
  if (!last) return <p>no runs yet</p>;
  return (
    <p>
      {last.eventName}{last.status} in {last.durationMs.toFixed(1)} ms
    </p>
  );
}

Хук не подписывается. Он читает trigger.inspect() на каждом рендере — этого достаточно для панели, перерисовывающейся по действиям пользователя, но мало для live-таила. Для обновлений по факту срабатываний используй useInspectHistory.

useInspectHistory(limit) — live-таил недавних запусков

Заголовок раздела «useInspectHistory(limit) — live-таил недавних запусков»

useInspectHistory подписывается на инспектор рантайма и возвращает последние limit снимков, newest-first. Компонент перерисовывается каждый раз, когда записан новый запуск.

src/dev/RecentRuns.tsx
import { useInspectHistory } from '@triggery/react';

export function RecentRuns() {
  const history = useInspectHistory(50);
  return (
    <table>
      <thead>
        <tr><th>trigger</th><th>event</th><th>status</th><th>ms</th></tr>
      </thead>
      <tbody>
        {history.map((s) => (
          <tr key={s.runId} data-status={s.status}>
            <td>{s.triggerId}</td>
            <td>{s.eventName}</td>
            <td>{s.status}{s.reason ? ` (${s.reason})` : ''}</td>
            <td>{s.durationMs.toFixed(1)}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Хук дешёвый — подписка это членство в одном Set; единственная React-работа происходит на реальном срабатывании. Аргумент limit обрезает видимый срез; сам буфер помнит до inspectorBufferSize записей.

Три паттерна, по нарастанию вовлечённости.

@triggery/devtools-panel поставляется с готовой стилизованной панелью, работающей из коробки. Размести её под провайдером рантайма в dev — и получишь рабочий инспектор:

src/App.tsx
import { InspectorView } from '@triggery/devtools-panel';

export function App() {
  return (
    <>
      <YourRealApp />
      {import.meta.env.DEV && <InspectorView limit={50} />}
    </>
  );
}

Это inline-стили, без CSS-импортов, без внешних зависимостей кроме @triggery/react. Полезно для быстрого подключи-и-забудь.

Когда хочется полноценного product-style devtool — поиск, фильтры по статусам, drill-down по триггерам — собирай его на useInspectHistory и куске локального состояния:

src/dev/DebugPanel.tsx
import { useInspectHistory } from '@triggery/react';
import { useMemo, useState } from 'react';

type StatusFilter = 'all' | 'fired' | 'skipped' | 'errored' | 'aborted';

export function DebugPanel() {
  const all = useInspectHistory(200);
  const [status, setStatus] = useState<StatusFilter>('all');
  const [search, setSearch] = useState('');

  const visible = useMemo(
    () =>
      all.filter(
        (s) =>
          (status === 'all' || s.status === status) &&
          (!search ||
            s.triggerId.includes(search) ||
            s.eventName.includes(search)),
      ),
    [all, status, search],
  );

  return (
    <aside className="debug-panel">
      <header>
        <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="filter…" />
        <select value={status} onChange={(e) => setStatus(e.target.value as StatusFilter)}>
          <option value="all">all</option>
          <option value="fired">fired</option>
          <option value="skipped">skipped</option>
          <option value="errored">errored</option>
          <option value="aborted">aborted</option>
        </select>
        <span>{visible.length} / {all.length}</span>
      </header>
      <ul>
        {visible.map((s) => (
          <li key={s.runId} data-status={s.status}>
            <strong>{s.triggerId}</strong> · {s.eventName} · {s.durationMs.toFixed(1)} ms
            {s.reason && <em> — {s.reason}</em>}
          </li>
        ))}
      </ul>
    </aside>
  );
}

Этого хватает на 90% задач отладки триггеров. Оставшиеся 10% — сравнить два запуска, переиграть последовательность — для этого есть standalone replay-инструмент.

По умолчанию инспектор включён в dev и выключен в prod. Проверка смотрит на process.env.NODE_ENV (его Vite, Webpack, Rollup, esbuild и Next подменяют на этапе сборки). В production-сборке рантайм подменяет реальный инспектор на no-op:

  • record() — no-op — никаких аллокаций, никаких записей в Map, никакого fan-out по слушателям.
  • subscribe() возвращает токен, обратный вызов которого никогда не выполняется.
  • getInspectorBuffer() возвращает замороженный общий пустой массив.
  • useInspect(trigger) возвращает undefined.
  • useInspectHistory() возвращает пустой массив на каждом рендере.

Горячий путь экономит ~30–40% пропускной способности на тривиальном диспатче. Рантайм отдаёт булеан для тулинга, которому нужно знать:

runtime.inspectorEnabled; // true в dev, false в prod (с дефолтами)

Иногда инспектор нужен в проде — скрытый support-сценарий, флаг ?debug=1 для бета-когорты, breadcrumb для Sentry. Передай inspector: true в createRuntime:

src/main.tsx
import { createRuntime } from '@triggery/core';

const runtime = createRuntime({
  inspector: true, // always on, regardless of NODE_ENV
});

Или передай объект, чтобы переопределить пер-окружение, — удобно, когда хочется, чтобы одна ветвь A/B-теста писала:

const runtime = createRuntime({
  inspector: {
    dev: true,  // unchanged from the auto default
    prod: location.search.includes('debug=1'),
  },
});

Возможные значения:

ЗначениеПоведение
undefined (по умолчанию)DEV on, PROD off — автоматически.
trueВсегда on.
falseВсегда off.
{ dev?: boolean; prod?: boolean }Переопределение пер-окружение. Незаданные поля откатываются к авто-дефолту.

Когда инспектор off, подписанные на него devtools (@triggery/devtools-redux, @triggery/devtools-bridge, useInspectHistory) выдают однократный DEV-warning, чтобы не пришлось гадать, почему панель пуста.

Дефолтный кольцевой буфер — это то, что нужно в 99% случаев. Для специализированного тулинга — мультирантаймового агрегатора, кастомного формата replay, рекордера сессий — можешь собрать свой и подключить его через middleware. Публичный фабричный API повторяет внутренний API рантайма:

import type { InspectorImpl, TriggerInspectSnapshot } from '@triggery/core';

function createSentryBreadcrumbInspector(): InspectorImpl {
  return {
    record(snapshot) {
      Sentry.addBreadcrumb({
        category: 'triggery',
        type: snapshot.status === 'errored' ? 'error' : 'info',
        message: `${snapshot.triggerId} ${snapshot.eventName}`,
        data: snapshot,
      });
    },
    getBuffer() { return []; },
    getLastForTrigger() { return undefined; },
    subscribe() { return () => {}; },
    clear() {},
  };
}

Подключи через middleware, чьи onActionEnd / onSkip / onError вызывают твой кастомный record. Встроенный инспектор остаётся активным для useInspectHistory; твой добавляет ещё один побочный канал. См. страницу Middleware — там полный жизненный цикл.

  • @triggery/devtools-panel<InspectorView>, <TriggerSnapshotView> для drop-in UI.
  • @triggery/devtools-redux — отображает запуски Triggery в Redux DevTools (они выглядят как диспатченные actions).
  • @triggery/devtools-bridge — postMessage-мост в отдельное окно; полезно для отладки SSR и iframe.

Все три подписываются на один и тот же инспектор. Подключай сколько хочешь — буфер делает fan-out каждому слушателю.