Skip to content
GitHubXDiscord

Inspector

Every runtime records a short, structured log of what just happened — what fired, what was skipped, what threw. That log is the inspector. It’s the data source behind useInspect, useInspectHistory, <InspectorView>, the Redux DevTools bridge, the BroadcastChannel bridge and the replay tool. This page covers what’s in a snapshot, the two React hooks, the ring buffer, and how to keep it out of your production bundle.

Each run of a trigger produces one TriggerInspectSnapshot. The shape is stable and JSON-friendly — safe to ship across a postMessage bridge or persist to disk:

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
};

The four statuses are mutually exclusive:

StatusWhen the runtime records it
firedThe handler ran to completion (sync) or its promise settled (async).
skippedA required condition wasn’t registered, the trigger was disabled, or a middleware cancelled the fire. reason tells you which.
erroredThe handler threw, or one of its actions threw.
abortedUnder take-latest, a newer run superseded this one.

durationMs is the wall time from “dispatch picked this trigger” to “handler returned”. executedActions lists the action names actually invoked — useful for asserting in tests that a debounce really did suppress the second call.

Per runtime, snapshots go into a ring buffer of fixed size (default 50). When the buffer fills up, the oldest entry is overwritten. The data structure is allocation-free on the hot path: writes are O(1), and the buffer materialises a newest-first array only when someone calls getInspectorBuffer().

You configure the size when you create the runtime:

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

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

A larger buffer costs nothing per fire and a few KB of memory total — bump it if you’re investigating a flaky scenario and need more history.

useInspect(trigger) — the latest run of one trigger

Section titled “useInspect(trigger) — the latest run of one trigger”

useInspect returns the most recent snapshot for a single trigger, or undefined if it hasn’t run yet.

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>
  );
}

The hook doesn’t subscribe. It reads trigger.inspect() on each render — fine for a panel that re-renders on user interaction, not enough for a live tail. Use useInspectHistory when you want updates as fires happen.

useInspectHistory(limit) — live tail of recent runs

Section titled “useInspectHistory(limit) — live tail of recent runs”

useInspectHistory subscribes to the runtime’s inspector and returns the last limit snapshots, newest first. The component re-renders whenever a new run is recorded.

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>
  );
}

The hook is cheap — the subscription is a single Set membership; the only React work happens on a real fire. The limit argument truncates the visible slice; the buffer itself remembers up to inspectorBufferSize entries.

Three patterns, increasing in commitment.

@triggery/devtools-panel ships a pre-styled panel that works out of the box. Mount it under your runtime provider in development and you have a working inspector:

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

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

It’s plain inline styles, no CSS imports, no external deps beyond @triggery/react. Useful for a quick “wire it up and forget”.

When you want a real product-style devtool — search, status filters, trigger-by-trigger drill-down — drive it off useInspectHistory and a piece of local state:

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>
  );
}

This is enough to debug 90 % of trigger problems. The remaining 10 % — diff-ing two runs, replaying a sequence — is what the standalone replay tool is for.

Replay and time-travel — @triggery/devtools-replay

Section titled “Replay and time-travel — @triggery/devtools-replay”

By default the inspector is on in development, off in production. The check looks at process.env.NODE_ENV (which Vite, Webpack, Rollup, esbuild and Next all replace at build time). In a production build the runtime swaps the real inspector for a no-op:

  • record() is a no-op — no allocation, no Map write, no listener fan-out.
  • subscribe() returns a token whose callback never fires.
  • getInspectorBuffer() returns a frozen shared empty array.
  • useInspect(trigger) returns undefined.
  • useInspectHistory() returns the empty array on every render.

The hot path saves ~30–40 % throughput on a trivial dispatch. The runtime exposes a boolean for tooling that needs to know:

runtime.inspectorEnabled; // true in dev, false in prod (with defaults)

Sometimes you want it on in production — a hidden support flow, a ?debug=1 flag for a beta cohort, a Sentry breadcrumb. Pass inspector: true to createRuntime:

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

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

Or pass an object to override per environment — handy when you want one branch of an A/B test to record:

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

Possible values:

ValueBehaviour
undefined (default)DEV on, PROD off — auto.
trueAlways on.
falseAlways off.
{ dev?: boolean; prod?: boolean }Override per environment. Unset fields fall back to the auto default.

When the inspector is off, devtools that subscribe to it (@triggery/devtools-redux, @triggery/devtools-bridge, useInspectHistory) emit a one-time DEV warning so you don’t waste time wondering why your panel is empty.

The default ring buffer is what you want 99 % of the time. For specialised tooling — a multi-runtime aggregator, a custom replay format, a session-recorder — you can build your own and feed it through middleware. The public factory mirrors the runtime’s internal 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() {},
  };
}

Wire it via a middleware whose onActionEnd / onSkip / onError hooks call your custom record. The built-in inspector remains active for useInspectHistory; your inspector adds an extra side-channel. See the Middleware page for the full lifecycle.

  • @triggery/devtools-panel<InspectorView>, <TriggerSnapshotView> for drop-in UIs.
  • @triggery/devtools-redux — render Triggery runs in Redux DevTools (they look like dispatched actions).
  • @triggery/devtools-bridge — postMessage bridge to a standalone window, useful for SSR / iframe debugging.

All three subscribe to the same inspector. Mount as many as you like — the buffer fans out to every listener.