Skip to content
Merged
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
76 changes: 39 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,93 +367,97 @@ machine := gstate.New[MyState, MyEvent, any]("invalid_target").

## 12. Observing Lifecycle Events

When you want to record transitions, guard outcomes, state entries/exits, transition actions, and invoked services — for telemetry, tracing, audit logs, or just to debug what your machine is doing — you do **not** need to wrap every `Entry`, `Exit`, `Guard`, and `Invoke` by hand. Pass an `Observer` to `Start`:
When you want to record transitions, guard outcomes, state entries/exits, transition actions, and invoked services — for telemetry, tracing, audit logs, or just to debug what your machine is doing — you do **not** need to wrap every `Entry`, `Exit`, `Guard`, and `Invoke` by hand. Pass one or more observers to `Start`:

```go
obs := &gstate.RecordingObserver[MyState, MyEvent, MyData]{}
actor := gstate.Start(machine, MyData{}, machine.WithObserver(obs))
rec := &gstate.RecordingObserver[MyState, MyEvent, MyData]{}
actor := gstate.Start(machine, MyData{}, machine.WithObservers(rec))
```

> The `machine.WithObserver(obs)` form lets Go infer the `[MyState, MyEvent, MyData]` type parameters from `machine`, so you don't have to repeat them on every option. The package-level `gstate.WithObserver[S, E, D](obs)` form still works if you prefer.
> The `machine.WithObservers(...)` form lets Go infer the `[MyState, MyEvent, MyData]` type parameters from `machine`, so you don't have to repeat them on every option. `WithObservers` is variadic — pass any number of observers and they all receive callbacks for the kinds they implement.

The nine hooks on `Observer[S, E, D]`:
`Observer[S, E, D]` is a sealed marker interface; you opt into specific callback kinds by implementing any of the nine narrow observer interfaces. The engine builds and dispatches each payload only when at least one installed observer subscribes to that kind, so unused hooks cost nothing.

| Hook | Fires when |
|---|---|
| `OnEventReceived` | An event lands in the mailbox and is about to be processed |
| `OnGuardEvaluated` | A non-nil `Guard` was evaluated (carries the boolean result) |
| `OnEventDropped` | An event was processed but no transition fired (`Reason: "no_transition"`) |
| `OnStateExited` | A state's `Exit` actions completed and the state was removed from `active` |
| `OnActionExecuted` | A transition's `Action` (Assign) completed |
| `OnStateEntered` | A state's `Entry` actions completed and the state is now `active` |
| `OnTransition` | A transition fully resolved (after all exits + entries) |
| `OnInvokeStarted` | An invoked service goroutine was launched |
| `OnInvokeCompleted` | An invoked service returned (success, error, or cancellation) |
| Narrow interface | Method | Fires when |
|---|---|---|
| `EventReceivedObserver` | `OnEventReceived` | An event lands in the mailbox and is about to be processed |
| `GuardObserver` | `OnGuardEvaluated` | A non-nil `Guard` was evaluated (carries the boolean result) |
| `EventDroppedObserver` | `OnEventDropped` | An event was processed but no transition fired (`Reason: "no_transition"`) |
| `StateExitedObserver` | `OnStateExited` | A state's `Exit` actions completed and the state was removed from `active` |
| `ActionObserver` | `OnActionExecuted` | A transition's `Action` (Assign) completed |
| `StateEnteredObserver` | `OnStateEntered` | A state's `Entry` actions completed and the state is now `active` |
| `TransitionObserver` | `OnTransition` | A transition fully resolved (after all exits + entries) |
| `InvokeStartedObserver` | `OnInvokeStarted` | An invoked service goroutine was launched |
| `InvokeCompletedObserver` | `OnInvokeCompleted` | An invoked service returned (success, error, or cancellation) |

### Threading and locking contract

- All callbacks except `OnInvokeCompleted` run **synchronously on the actor's event-processing goroutine** while it holds the actor's internal write lock. This includes `OnInvokeStarted`, which fires when an invoked service is launched during state entry. Implementations must be non-blocking.
- Observers **must not** call methods on the same `Actor` that would re-enter the actor lock (e.g. `Snapshot()`, `State()`).
- Observers **must not** call `Send` / `SendCtx` synchronously: the channel send can block on a full mailbox, and the loop goroutine that would drain it cannot acquire the actor lock the observer is holding — a hard deadlock. If you need to dispatch an event from an observer, do it from a fresh goroutine:
```go
func (o *myObs) OnTransition(_ context.Context, e gstate.TransitionEvent[...]) {
func (o *myObs) OnTransition(_ context.Context, e *gstate.TransitionEvent[...]) {
go func() { actor.Send(EventX) }()
}
```
- The `Data *D` field on each payload points to a **defensive copy** of the actor's data taken at the moment the hook fires. Reading is safe and accurately reflects state at that point; mutating the pointee has no effect on the actor. If `D` implements `Cloner`, that deep copy is used.
- Payload structs expose data via a lazy `Data()` method (rather than a field). The first call to `e.Data()` clones the actor's data via `Cloner.Clone()` and caches the result with `sync.Once`; subsequent calls (including from other observers receiving the same payload pointer) return the cached pointer without re-cloning. Observers that don't need the data pay zero clones. Reading is safe; mutating the pointee has no effect on the actor.
- When multiple observers subscribe to the same callback kind, the engine builds the payload **once** and passes the same pointer to each. The shared `sync.Once` on the payload guarantees at most one clone per callback firing across the whole fan-out.
- `OnInvokeCompleted` fires from the invoke goroutine and does not hold the actor lock.

### Implementing only the methods you care about (`NopObserver`)
### Implementing only the methods you care about (`BaseObserver`)

Embed `NopObserver[S, E, D]` to satisfy the interface with no-ops, then override what you want:
Embed `BaseObserver[S, E, D]` to satisfy the marker interface, then implement only the narrow observer interfaces you need. Any callback you don't implement simply isn't dispatched to you — there are no required stubs.

```go
type loggingObs struct {
gstate.NopObserver[MyState, MyEvent, MyData]
gstate.BaseObserver[MyState, MyEvent, MyData]
}

func (l *loggingObs) OnTransition(ctx context.Context, e gstate.TransitionEvent[MyState, MyEvent, MyData]) {
// implements TransitionObserver — every other callback kind is skipped for this observer
func (l *loggingObs) OnTransition(ctx context.Context, e *gstate.TransitionEvent[MyState, MyEvent, MyData]) {
log.Printf("[%s] %s --%s--> %s", e.ActorID, e.From, e.Event, e.To)
}
```

A single observer type may implement any subset (or all nine) of the narrow interfaces — the engine independently type-asserts your value against each one at install time and only dispatches the kinds you implement.

### Wake on any lifecycle event with `SignalObserver`

```go
ready := make(chan struct{}, 1)
obs := gstate.SignalObserver[MyState, MyEvent, MyData](func() {
select { case ready <- struct{}{}: default: }
})
actor := gstate.Start(machine, ctx, machine.WithObserver(obs))
actor := gstate.Start(machine, ctx, machine.WithObservers(obs))
actor.Send(EventGo)
<-ready // deterministically woken by the first lifecycle callback
```

Every callback on `SignalObserver` calls the supplied function. The callback's context and typed payload are discarded — `SignalObserver` is intentionally minimal. If you need them, use `ObserverFuncs` (below). The signal function must be non-blocking — observer callbacks run synchronously under the actor's write lock.

### Avoid `NopObserver` boilerplate with `ObserverFuncs`
### Avoid boilerplate with `ObserverFuncs`

```go
obs := gstate.ObserverFuncs[MyState, MyEvent, MyData]{
AnyFunc: func(ctx context.Context) {
// fires for every callback
},
TransitionFunc: func(ctx context.Context, e gstate.TransitionEvent[MyState, MyEvent, MyData]) {
TransitionFunc: func(ctx context.Context, e *gstate.TransitionEvent[MyState, MyEvent, MyData]) {
log.Printf("[%s] %s --%s--> %s", e.ActorID, e.From, e.Event, e.To)
},
}
actor := gstate.Start(machine, ctx, machine.WithObserver(obs))
actor := gstate.Start(machine, ctx, machine.WithObservers(obs))
```

`ObserverFuncs` is a struct of optional function fields plus a generic `AnyFunc`. Each callback dispatches to `AnyFunc` first (if set), then to the kind-specific field (if set). Nil fields are no-ops. Useful when you want a partial observer without embedding `NopObserver`, or when one hook should fire for every event in addition to specific typed handlers.
`ObserverFuncs` is a struct of optional function fields plus a generic `AnyFunc`. Each callback dispatches to `AnyFunc` first (if set), then to the kind-specific field (if set). Nil fields are no-ops. Useful when you want a partial observer without defining a named type, or when one hook should fire for every event in addition to specific typed handlers.

### Inspecting behavior with `RecordingObserver`

`RecordingObserver[S, E, D]` captures every callback into a thread-safe log. It is useful in tests and for ad-hoc debugging:

```go
rec := &gstate.RecordingObserver[MyState, MyEvent, MyData]{}
actor := gstate.Start(machine, MyData{}, machine.WithObserver(rec))
actor := gstate.Start(machine, MyData{}, machine.WithObservers(rec))
actor.Send(EventGo)

for _, t := range rec.Transitions() {
Expand Down Expand Up @@ -481,11 +485,11 @@ for _, ev := range rec.Events() {
}
```

Payload structs also carry `json` tags, so they can be marshaled directly for shipping to a telemetry pipeline. `InvokeEvent.Error` is rendered as its `Error()` string (or omitted when nil) via a custom `MarshalJSON`:
Payload structs also carry `json` tags and a custom `MarshalJSON` that materializes `Data()` into a top-level `data` field at marshal time, so they can be marshaled directly for shipping to a telemetry pipeline. `InvokeEvent.Error` is rendered as its `Error()` string (or omitted when nil) via the same mechanism:

```go
b, _ := json.Marshal(rec.Transitions()[0])
// {"machine_id":"...","actor_id":"V1StGXR8_Z5j","from":"idle","to":"active","event":"GO","context":{...},"timestamp":"..."}
// {"machine_id":"...","actor_id":"V1StGXR8_Z5j","from":"idle","to":"active","event":"GO","data":{...},"timestamp":"..."}
```

---
Expand All @@ -504,19 +508,17 @@ actor := gstate.Start(machine, MyData{Count: 0})
// machine to let Go infer the [S, E, D] type parameters.
actor := gstate.Start(machine, MyData{Count: 0},
machine.WithMailboxSize(500),
machine.WithObserver(myObs),
machine.WithObservers(logger, recorder),
machine.WithActorID("worker-42"),
)
```

Available options:

- `WithMailboxSize(n)` — buffered capacity for the event channel. Default `100`.
- `WithObserver(obs)` — install an [`Observer`](#12-observing-lifecycle-events). Default is a no-op.
- `WithObservers(obs...)` — install one or more [observers](#12-observing-lifecycle-events). Variadic; pass any number of observers (each implementing whichever narrow callback interfaces it cares about). When omitted, no observer is installed and the engine skips payload construction entirely.
- `WithActorID(id)` — override the auto-generated [`ActorID`](#actor-identity).

Each option is available both as a method on `*Machine[S, E, D]` (inference-friendly, recommended) and as a package-level generic helper (e.g. `gstate.WithObserver[S, E, D](obs)`).

### Actor Identity

Every actor is born with a stable `ActorID`. When you don't supply one via `WithActorID`, `Start` generates a short URL-safe nanoid:
Expand Down Expand Up @@ -650,12 +652,12 @@ A `Snapshot` contains:

`Hydrate` does **not** fire `OnStateEntered` or `OnTransition` for the states being restored — those events represent the original state changes that were already observed before the snapshot was captured. Hooks resume firing on the next event, Always evaluation, or invoke completion processed by the hydrated actor.

`Hydrate` accepts the same functional options as `Start`, so you can attach an observer or tune the mailbox on a restored actor:
`Hydrate` accepts the same functional options as `Start`, so you can attach observers or tune the mailbox on a restored actor:

```go
rec := &gstate.RecordingObserver[MyState, MyEvent, MyData]{}
actor := gstate.Hydrate(machine, loaded,
machine.WithObserver(rec),
machine.WithObservers(rec),
machine.WithMailboxSize(500),
)
```
Expand Down Expand Up @@ -790,7 +792,7 @@ Each example has its own README with a Mermaid state diagram, a walkthrough of w
| [invoke](./examples/invoke) | Async services, auto-cancellation | API calls, file uploads |
| [delayed](./examples/delayed) | Time-based transitions | Session timeouts, debouncing |
| [agent](./examples/agent) | Guards, always, invoke, retries | CI/CD pipelines, automated remediation |
| [observer](./examples/observer) | Observer, MultiObserver, RecordingObserver | Structured logging, metrics, test assertions |
| [observer](./examples/observer) | BaseObserver, narrow interfaces, RecordingObserver | Structured logging, metrics, test assertions |
| [persistence](./examples/persistence) | Snapshot & hydrate | Durable workflows, serverless |

---
Expand Down