Skip to content

Commit 88fb266

Browse files
cevrclaude
andcommitted
feat: add machine introspection and tracing
Two-layer observability for effect-machine: Layer 1: Effect Spans (OTel integration) - effect-machine.actor.spawn - actor lifecycle span - effect-machine.event.process - per-event processing span - effect-machine.effect.{entry,exit,transition} - effect spans - All spans include semantic attributes for state, event, actor ID Layer 2: Inspector API (callback-based) - InspectorService - optional context service - InspectionEvent union: @machine.spawn, @machine.event, @machine.guard, @machine.transition, @machine.effect, @machine.stop - Helpers: makeInspector(), collectingInspector(), consoleInspector() Guard Naming for introspection: - Guard.make(predicate, name?) - optional name param - Guard.named(name, guard) - add name to existing guard - Guard.and/or/not compose names: "and(guardA, guardB)" Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3dc5e18 commit 88fb266

7 files changed

Lines changed: 846 additions & 85 deletions

File tree

‎src/combinators/on.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ function onImpl<
5656
guard: normalizedOptions?.guard as unknown as
5757
| ((ctx: TransitionContext<State, Event>) => boolean)
5858
| undefined,
59+
guardName: normalizedOptions?.guardName,
5960
effect: normalizedOptions?.effect as unknown as
6061
| ((ctx: TransitionContext<State, Event>) => Effect.Effect<void, never, R2>)
6162
| undefined,

‎src/index.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,21 @@ export type {
6969
} from "./internal/types.js";
7070
export { Guard, normalizeGuard } from "./internal/types.js";
7171
export type { Guard as GuardType } from "./internal/types.js";
72+
73+
// Inspection / introspection
74+
export type {
75+
EffectEvent,
76+
EventReceivedEvent,
77+
GuardEvaluatedEvent,
78+
InspectionEvent,
79+
Inspector,
80+
SpawnEvent,
81+
StopEvent,
82+
TransitionEvent,
83+
} from "./inspection.js";
84+
export {
85+
collectingInspector,
86+
consoleInspector,
87+
Inspector as InspectorService,
88+
makeInspector,
89+
} from "./inspection.js";

‎src/inspection.ts‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { Context } from "effect";
2+
3+
// ============================================================================
4+
// Inspection Events
5+
// ============================================================================
6+
7+
/**
8+
* Event emitted when an actor is spawned
9+
*/
10+
export interface SpawnEvent<S> {
11+
readonly type: "@machine.spawn";
12+
readonly actorId: string;
13+
readonly initialState: S;
14+
readonly timestamp: number;
15+
}
16+
17+
/**
18+
* Event emitted when an actor receives an event
19+
*/
20+
export interface EventReceivedEvent<S, E> {
21+
readonly type: "@machine.event";
22+
readonly actorId: string;
23+
readonly state: S;
24+
readonly event: E;
25+
readonly timestamp: number;
26+
}
27+
28+
/**
29+
* Event emitted when a guard is evaluated
30+
*/
31+
export interface GuardEvaluatedEvent<S, E> {
32+
readonly type: "@machine.guard";
33+
readonly actorId: string;
34+
readonly state: S;
35+
readonly event: E;
36+
readonly guardName: string | undefined;
37+
readonly guardIndex: number;
38+
readonly result: boolean;
39+
readonly timestamp: number;
40+
}
41+
42+
/**
43+
* Event emitted when a transition occurs
44+
*/
45+
export interface TransitionEvent<S, E> {
46+
readonly type: "@machine.transition";
47+
readonly actorId: string;
48+
readonly fromState: S;
49+
readonly toState: S;
50+
readonly event: E;
51+
readonly timestamp: number;
52+
}
53+
54+
/**
55+
* Event emitted when an entry/exit/transition effect runs
56+
*/
57+
export interface EffectEvent<S> {
58+
readonly type: "@machine.effect";
59+
readonly actorId: string;
60+
readonly effectType: "entry" | "exit" | "transition";
61+
readonly state: S;
62+
readonly timestamp: number;
63+
}
64+
65+
/**
66+
* Event emitted when an actor stops
67+
*/
68+
export interface StopEvent<S> {
69+
readonly type: "@machine.stop";
70+
readonly actorId: string;
71+
readonly finalState: S;
72+
readonly timestamp: number;
73+
}
74+
75+
/**
76+
* Union of all inspection events
77+
*/
78+
export type InspectionEvent<S, E> =
79+
| SpawnEvent<S>
80+
| EventReceivedEvent<S, E>
81+
| GuardEvaluatedEvent<S, E>
82+
| TransitionEvent<S, E>
83+
| EffectEvent<S>
84+
| StopEvent<S>;
85+
86+
// ============================================================================
87+
// Inspector Service
88+
// ============================================================================
89+
90+
/**
91+
* Inspector interface for observing machine behavior
92+
*/
93+
export interface Inspector<S, E> {
94+
readonly onInspect: (event: InspectionEvent<S, E>) => void;
95+
}
96+
97+
/**
98+
* Inspector service tag - optional service for machine introspection
99+
* Uses `any` types to allow variance flexibility when providing the service
100+
*/
101+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
102+
export const Inspector = Context.GenericTag<Inspector<any, any>>("@effect/machine/Inspector");
103+
104+
/**
105+
* Create an inspector from a callback function
106+
*/
107+
export const makeInspector = <S, E>(
108+
onInspect: (event: InspectionEvent<S, E>) => void,
109+
): Inspector<S, E> => ({ onInspect });
110+
111+
// ============================================================================
112+
// Built-in Inspectors
113+
// ============================================================================
114+
115+
/**
116+
* Console inspector that logs events in a readable format
117+
*/
118+
export const consoleInspector = <
119+
S extends { readonly _tag: string },
120+
E extends { readonly _tag: string },
121+
>(): Inspector<S, E> =>
122+
makeInspector((event) => {
123+
const prefix = `[${event.actorId}]`;
124+
switch (event.type) {
125+
case "@machine.spawn":
126+
console.log(prefix, "spawned →", event.initialState._tag);
127+
break;
128+
case "@machine.event":
129+
console.log(prefix, "received", event.event._tag, "in", event.state._tag);
130+
break;
131+
case "@machine.guard":
132+
console.log(prefix, "guard", event.guardName ?? `#${event.guardIndex}`, "→", event.result);
133+
break;
134+
case "@machine.transition":
135+
console.log(prefix, event.fromState._tag, "→", event.toState._tag);
136+
break;
137+
case "@machine.effect":
138+
console.log(prefix, event.effectType, "effect in", event.state._tag);
139+
break;
140+
case "@machine.stop":
141+
console.log(prefix, "stopped in", event.finalState._tag);
142+
break;
143+
}
144+
});
145+
146+
/**
147+
* Collecting inspector that stores events in an array for testing
148+
*/
149+
export const collectingInspector = <S, E>(events: InspectionEvent<S, E>[]): Inspector<S, E> =>
150+
makeInspector((event) => events.push(event));

0 commit comments

Comments
 (0)