Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-actors-inspect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect-machine": minor
---

Replace the Inspector Hub with typed `inspect` spawn options and late system-wide registration through `actor.system.inspect`.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ Read [Atom and UI integration](./docs/atom-and-ui.md) and browse [all examples](
- Durability saves committed transitions.
- Supervision restarts defects within an Effect `Schedule` budget.
- Inspection reports events, named transition operations, transitions, named guards, tasks, Effects, errors, stops, and actor generations.
- Dynamic Inspector hubs let lazy tools observe existing actors without actor restart or state duplication.
- Typed `inspect` spawn options observe one actor.
- `actor.system.inspect` lets a late tool observe all actors in one system.

Read [Persistence and supervision](./docs/persistence-and-supervision.md) and [Inspection](./docs/inspection.md).

Expand Down
35 changes: 26 additions & 9 deletions docs/inspection.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
# Inspection

Provide `InspectorService` when you allocate an actor. The actor captures it with the rest of its Effect context.
Pass `inspect` when you spawn one actor. The inspector uses the machine state and event types.

```ts
const actor =
yield * Machine.spawn(machine).pipe(Effect.provideService(InspectorService, consoleInspector()));
yield *
Machine.spawn(machine, {
inspect: consoleInspector(),
});
```

You can also provide `InspectorService` as an ambient Effect service. The spawn option replaces the ambient inspector for that actor.

Inspection events cover:

- actor spawn
Expand Down Expand Up @@ -42,22 +47,34 @@ machine.on(State.Accepted, Event.Submit, function submitOrder({ state }) {

Each inspection event includes the actor generation. Generation zero is the first run. The value increases after each supervised restart.

The console inspector logs readable machine events with Effect logging. The tracing inspector emits spans and events. The collecting inspector stores typed events for tests. `combineInspectors` isolates an inspector failure from the other inspectors.
The console inspector logs readable machine events with Effect logging. The tracing inspector emits spans and events. The collecting inspector stores typed events for tests. `combineInspectors` runs inspectors in order. It isolates each inspector failure.

Use `makeInspectorHub` when inspection consumers load after actors start. Provide the hub Inspector before actor startup. Register and unregister sinks later without restarting actors or duplicating actor state.
Use `actor.system.inspect` when an inspection consumer loads after an actor starts. The system inspector receives events from all actors in that system.

```ts
const hub = makeInspectorHub<typeof State, typeof Event>();
const actor =
yield * Machine.spawn(machine).pipe(Effect.provideService(InspectorService, hub.inspector));
const actor = yield * Machine.spawn(machine);
yield * actor.start;

const unregister = hub.register(collectingInspector(events));
const unregister = actor.system.inspect(
makeInspector((event) => {
events.push(event);
}),
);
yield * actor.send(Event.Refresh);
unregister();
```

The late sink receives future inspection events. It does not receive events emitted before registration. The hub isolates each sink failure from the actor and the other sinks.
The late inspector receives future events. It does not receive prior events. A system inspector is heterogeneous. Use `makeInspector()` without machine-specific type arguments. The runtime runs the actor inspector first. It then runs system inspectors in registration order. It waits for each inspector. It isolates each failure.

Effect code can scope a late registration with `Effect.acquireRelease`.

```ts
yield *
Effect.acquireRelease(
Effect.sync(() => actor.system.inspect(consoleInspector())),
(unregister) => Effect.sync(unregister),
);
```

Do not log secrets in state, events, or tracing attributes.

Expand Down
16 changes: 5 additions & 11 deletions examples/core/src/inspection.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import { Effect, Schema } from "effect";
import {
collectingInspector,
Event,
InspectorService,
Machine,
State,
type InspectionEvent,
} from "effect-machine";
import { collectingInspector, Event, Machine, State, type InspectionEvent } from "effect-machine";

const AccessState = State({
Locked: { attempts: Schema.Finite },
Expand Down Expand Up @@ -41,9 +34,10 @@ const accessMachine = Machine.make({

export const inspectionProgram = Effect.gen(function* () {
const events: Array<InspectionEvent<AccessState, AccessEvent>> = [];
const actor = yield* Machine.spawn(accessMachine, { id: "access" }).pipe(
Effect.provideService(InspectorService, collectingInspector(events)),
);
const actor = yield* Machine.spawn(accessMachine, {
id: "access",
inspect: collectingInspector(events),
});
yield* actor.start;
yield* actor.send(AccessEvent.EnterCode({ code: "0000" }));
yield* actor.send(AccessEvent.EnterCode({ code: "1234" }));
Expand Down
113 changes: 69 additions & 44 deletions src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import type { InspectorService } from "./inspection.js";
import { Inspector as InspectorTag } from "./inspection.js";
import { resolveTransition, resolveTransitionEffect } from "./internal/transition.js";
import type { ProcessEventHooks, ProcessEventResult } from "./internal/transition.js";
import { emitWithTimestamp, makeInspectionHooks } from "./internal/inspection.js";
import {
emitWithTimestamp,
makeInspectionDispatcher,
makeInspectionHooks,
} from "./internal/inspection.js";
import type { NoReplyError } from "./errors.js";
import { DuplicateActorError, ActorStoppedError } from "./errors.js";
import {
Expand Down Expand Up @@ -354,14 +358,28 @@ export interface ActorSystemService {
* Returns an unsubscribe function.
*/
readonly subscribe: (fn: SystemEventListener) => () => void;

/**
* Inspect all actors in this system.
*
* Registration affects future events only. The returned function removes the inspector.
*/
readonly inspect: (
inspector: InspectorService<{ readonly _tag: string }, { readonly _tag: string }>,
) => () => void;
}

export type SystemSpawnOptions<S, E, Input> = {
readonly supervision?: Supervision.Policy;
readonly lifecycle?: Lifecycle<S, E>;
readonly hydrate?: S;
readonly inspect?: InspectorService<S, E>;
} & ([Input] extends [void] ? { readonly input?: never } : { readonly input: Input });

type SystemInspector = InspectorService<AnyState, AnyState>;

const systemInspectorsBySystem = new WeakMap<ActorSystemService, Set<SystemInspector>>();

/**
* ActorSystem service tag
*/
Expand Down Expand Up @@ -792,12 +810,13 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
hydrated?: boolean;
supervision?: Supervision.Policy;
lifecycle?: Lifecycle<S, E>;
inspect?: InspectorService<S, E>;
/** @internal Called by system after each restart — emits ActorRestarted system event */
onRestart?: (generation: number, exit: ActorExit<unknown>) => Effect.Effect<void>;
},
) {
const lifecycle: Lifecycle<S, E> | undefined = options.lifecycle;
const serviceContext = yield* Effect.context<R>();
const capturedContext = yield* Effect.context<R>();

// Spawn is cold. The caller has already resolved machine input and hydration.
// Recovery runs during start, not allocate.
Expand All @@ -807,10 +826,13 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <

const { system, implicitSystemScope } = yield* resolveActorSystem();

// Get optional inspector from context
const inspectorValue = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as
const ambientInspector = Option.getOrUndefined(yield* Effect.serviceOption(InspectorTag)) as
| InspectorService<S, E>
| undefined;
const localInspector = options.inspect ?? ambientInspector;
const systemInspectors = systemInspectorsBySystem.get(system) ?? new Set<SystemInspector>();
const inspectorValue = makeInspectionDispatcher(localInspector, systemInspectors);
const serviceContext = Context.add(capturedContext, InspectorTag, inspectorValue);

// Actor-specific state

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Inspection ownership now follows this runtime path:
Machine.spawn / system.spawn
  -> createActor
     -> select spawn inspector or ambient inspector
     -> makeInspectionDispatcher
        -> actor inspector
        -> registered system inspectors

The dispatcher is placed in the captured Effect context. Task inspection and transition inspection use the same ordered path.

const childrenMap = new Map<string, ActorRef<AnyState, unknown>>();
Expand All @@ -820,10 +842,8 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
// Generation counter. Recovery and inspection use the same value.
const generation = { current: 0 };

const inspectionHooks = (runtimeGeneration: number): ProcessEventHooks<S, E> | undefined => {
if (inspectorValue === undefined) return undefined;
return makeInspectionHooks(id, inspectorValue, () => runtimeGeneration);
};
const inspectionHooks = (runtimeGeneration: number): ProcessEventHooks<S, E> =>
makeInspectionHooks(id, inspectorValue, () => runtimeGeneration);

// Cell-owned resources: stable across generations (supervision)
const stateRef = yield* SubscriptionRef.make<S>(initial);
Expand Down Expand Up @@ -868,44 +888,35 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
/** Build lifecycle hooks for a generation */
const buildRuntimeLifecycle = (runtimeGeneration: number): RuntimeLifecycleHooks<S, E> => {
let stopEmitted = false;
let onEvent: RuntimeLifecycleHooks<S, E>["onEvent"] = undefined;
if (inspectorValue !== undefined) {
onEvent = (state: S, event: E) =>
emitWithTimestamp(inspectorValue, (timestamp) => ({
type: "@machine.event",
actorId: id,
generation: runtimeGeneration,
state,
event,
timestamp,
}));
}
let onFinal: RuntimeLifecycleHooks<S, E>["onFinal"] = undefined;
if (inspectorValue !== undefined) {
onFinal = (state: S) =>
Effect.gen(function* () {
stopEmitted = true;
yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
type: "@machine.stop",
actorId: id,
generation: runtimeGeneration,
finalState: state,
timestamp,
}));
});
}
let onInitialSpawnEffects: RuntimeLifecycleHooks<S, E>["onInitialSpawnEffects"] = undefined;
if (inspectorValue !== undefined) {
onInitialSpawnEffects = (state: S) =>
emitWithTimestamp(inspectorValue, (timestamp) => ({
type: "@machine.effect",
const onEvent: RuntimeLifecycleHooks<S, E>["onEvent"] = (state, event) =>
emitWithTimestamp(inspectorValue, (timestamp) => ({
type: "@machine.event",
actorId: id,
generation: runtimeGeneration,
state,
event,
timestamp,
}));
const onFinal: RuntimeLifecycleHooks<S, E>["onFinal"] = (state) =>
Effect.gen(function* () {
stopEmitted = true;
yield* emitWithTimestamp(inspectorValue, (timestamp) => ({
type: "@machine.stop",
actorId: id,
generation: runtimeGeneration,
effectType: "spawn",
state,
finalState: state,
timestamp,
}));
}
});
const onInitialSpawnEffects: RuntimeLifecycleHooks<S, E>["onInitialSpawnEffects"] = (state) =>
emitWithTimestamp(inspectorValue, (timestamp) => ({
type: "@machine.effect",
actorId: id,
generation: runtimeGeneration,
effectType: "spawn",
state,
timestamp,
}));
return {
onEvent,
onStateChange: (result, event) =>
Expand Down Expand Up @@ -1129,6 +1140,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
// Observable infrastructure
const eventPubSub = yield* PubSub.unbounded<SystemEvent>();
const eventListeners = new Set<SystemEventListener>();
const systemInspectors = new Set<SystemInspector>();

const emitSystemEvent = (event: SystemEvent): Effect.Effect<void> =>
Effect.sync(() => notifySystemListeners(eventListeners, event)).pipe(
Expand All @@ -1143,7 +1155,11 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
MutableHashMap.forEach(actorsMap, (actor) => {
stops.push(actor.stop);
});
return Effect.all(stops).pipe(Effect.andThen(PubSub.shutdown(eventPubSub)), Effect.asVoid);
return Effect.all(stops).pipe(
Effect.andThen(Effect.sync(() => systemInspectors.clear())),
Effect.andThen(PubSub.shutdown(eventPubSub)),
Effect.asVoid,
);
});

/** Check for duplicate ID, register actor, attach scope cleanup if available */
Expand Down Expand Up @@ -1244,6 +1260,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
hydrated: spawnOptions?.hydrate !== undefined,
supervision: spawnOptions?.supervision,
lifecycle: spawnOptions?.lifecycle,
inspect: spawnOptions?.inspect,
onRestart,
});
actorRef = actor as unknown as ActorRef<AnyState, unknown>;
Expand Down Expand Up @@ -1339,7 +1356,7 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
return true;
});

return ActorSystem.of({
const system = ActorSystem.of({
spawn,
get,
watch,
Expand All @@ -1358,7 +1375,15 @@ const make = Effect.fn("effect-machine.actorSystem.make")(function* () {
eventListeners.delete(fn);
};
},
inspect: (inspector) => {
systemInspectors.add(inspector);
return () => {
systemInspectors.delete(inspector);
};
},
});
systemInspectorsBySystem.set(system, systemInspectors);
return system;
});

/**
Expand Down
2 changes: 0 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ export type {
EventReceivedEvent,
InspectionEvent,
InspectorService as Inspector,
InspectorHub,
InspectorHandler,
OperationEvent,
SpawnEvent,
Expand All @@ -106,6 +105,5 @@ export {
Inspector as InspectorService,
makeInspector,
makeInspectorEffect,
makeInspectorHub,
tracingInspector,
} from "./inspection.js";
Loading
Loading