Event Modeling
The Event Modeling tab on Service Details renders the monitored service's command-event-aggregate flow as a seven-lane swim-lane diagram. The diagram is built from two sources that the rest of the page reconciles into one picture:
- the generated manifest —
CritterWatchAppManifestpartials emitted by CritterWatch.SourceGeneration, aggregated at runtime byWolverine.CritterWatchinto a singleMergedAppManifest, - and the declared model —
EventModelSliceDescriptors pulled off the host'sEventModelDefinitionand shipped on the wire as part of the sameEventModelResponse.
Route: /services/<id> → Event Model tab
The swim-lane is the closer view on the static structure of a service. For live message flow over time, use the Timeline tab; for the projection / read-model angle, Projections.
What you see
Seven lanes, left to right, in the order each node typically participates in a request:
| Lane | What lands here |
|---|---|
| Trigger | Externally-originated commands (HTTP endpoints, scheduled jobs, message-bus subscribers) when a declared model names them. |
| Command | HandlerRelationship.MessageType from the manifest, plus declared command types from EventModelSliceDescriptor. |
| Handler | Handler classes (HandlerRelationship.HandlerType) and saga types. Sagas land here because they are handlers. |
| Event | The union of HandlerRelationship.EmittedEvents and Aggregate.AppliedEvents. |
| Projection | Projection types referenced by declared slices (EventModelSliceDescriptor.ProjectionTypes). Manifest-only services render this lane empty. |
| Read Model | Aggregates whose marker attribute is ReadAggregate or BoundaryModel. |
| Query | Declared query types — empty when the service ships only a manifest. |
Nodes carry their short type name on the body. Hover any node for a tooltip with the fully-qualified name, subtitle (e.g. "saga", aggregate kind, storage provider), drift reason if the node carries a status pill, and — when a replay is active — the matched span's timestamp and payload size.
Edges connect command → handler → emitted event, and command → saga for saga-starting messages. Edges are not drawn for AppliedEvents; the aggregate's relationship to those events is conveyed by the event lane staying on the right of any handler that emits the same event.
Reading the swim-lane
Drift palette
When both a declared model and a generated manifest are present, the diagram tags every slice with a status. The status drives a small coloured pill anchored to the top-right of each node and is computed by mergeSlices in event-model-store.ts:
| Pill | Status | Meaning |
|---|---|---|
| Green | declared | The slice name appears in both the declared model and the generated manifest. |
| Yellow | drift | The slice exists in the manifest but the host hasn't declared it. |
| Orange | missing | The slice is declared but no handler chain implements it. Lane nodes are synthesized for orange slices so the gap is visible. |
| (no pill) | null | Single-source case: the service shipped only a manifest or only a declared model, so no comparison is possible. |
When the same node participates in multiple slices, worst-status-wins — orange beats yellow beats green beats none.
Live causation overlay
The header strip on the tab carries a Live causation overlay switch. Flipping it on reveals a trace picker:
- Recent traces — dropdown of trace ids the operator-configured OTel backend returned for this service in the last 15 minutes.
- Paste a trace id — drop a trace id from a log line, a Jaeger / Datadog UI, or any APM into the textbox and click Overlay.
When you pick a trace, the store pulls its full span tree from the bound provider via the ITraceProvider HTTP path (tracing-store.getTrace). Architectural note: CritterWatch does not push per-envelope trace data over its own SignalR wire. The overlay is pull-on-demand from whichever OTel backend the operator has bound to the service — see the Settings page for the binding UI.
Once a trace lands, the diagram greys non-traversed nodes and edges, thickens the borders on traversed ones, and surfaces a playback strip:
- Play / Pause — animates the prefix overlay forward at 600ms per step. Each step corresponds to one in-service span sorted by start time.
- Restart — rewinds the scrubber to step 1 without auto-playing.
- Scrubber — drag-to-step, with manual scrubs auto-pausing so the timer doesn't immediately overwrite the dragged position.
- Step counter —
Step N of M. Auto-pauses when the last step is reached so the operator sees the final path; pressing Play again restarts from the top. - Clear selection — drops the trace and reverts the diagram to its static drift-tagged state.
The overlay also infers bridge-node highlights: a handler that doesn't carry its own span (handlers aren't messages) is highlighted once both its incoming command and outgoing event are. That's how a command → handler → event flow surfaces all three lanes from spans that only cover the command and the event.
Handler drill-down
Clicking a Handler lane node opens the source-viewer drawer with the handler chain's generated source code. The fetch goes through the RequestHandlerSourceCode wire pair introduced in PR #137 and reuses the same cache the HTTP Chain Detail page hits.
Projection drill-down
Clicking a Projection lane node opens the same drawer with the projection class's source code. The fetch goes through RequestProjectionSourceCode and resolves the whole projection class — not a single Apply / Create method. The Projection Stepper tab carries the method-level drill-down for that finer granularity.
Both drill-downs degrade gracefully: an empty drawer shows when the source generator wasn't run (or didn't capture this type), and the drawer surfaces backend errors in a tag at the top.
Exports
Two buttons on the tab header export the current SVG:
- Export SVG — serialises the live
<svg>element to disk. The diagram is self-contained (D3 inlinesfill/strokeon every element), so the file opens directly in browsers, Illustrator, Figma without external CSS. - Export PNG — rasterises the same SVG through a 2D canvas and downloads the resulting blob. White background so the image drops cleanly into docs or slides.
Whatever's on the diagram at click time gets exported — including the active playback step's highlight. Scrub to a frame you want, click Export, you get that frame.
Filenames are stamped {service}-event-model-yyyymmdd-hhmm.{svg|png} using local time.
Architecture (for developers)
Data sources
The Event Model tab is fed by three independent input streams:
CritterWatchAppManifest— a per-project JSON manifest emitted at compile time by theCritterWatch.SourceGenerationanalyzer (#144). One per assembly. CapturesAggregates,HandlerRelationships,Sagasdiscovered statically.MergedAppManifest—Wolverine.CritterWatchaggregates every loaded assembly'sCritterWatchAppManifestinto a single per-service view. Sent on the wire as part ofEventModelResponse.aggregates / handlerRelationships / sagas.EventModelDefinition.Slices— when the host registers anEventModelDefinition, itsEventModelSliceDescriptorlist is shipped on the sameEventModelResponseasmodel.slices. Each slice carriescommandType,handlerType,emittedEvents,projectionTypes,readModelTypes, and an optionaltriggerType— these flow straight into lane assignment.
The store action fetchModel(serviceName, sendMessage) dispatches RequestEventModel and the response is unpacked by handleEventModelResponse. The same store also feeds the per-service overlay; see event-model-store.ts for the full surface.
Lane assembly
flattenManifest(entry) does the manifest-only pass — derives nodes for command / handler / event / read-model from HandlerRelationships, Aggregates, and Sagas, dedupes by <lane>::<fullName>, and emits the command → handler → event edges.
applyStatusOverlay(base, mergedSlices) then walks the declared slices, tags matching manifest nodes with their drift status (worst-status-wins), and synthesizes lane nodes for orange "declared but missing" slices that don't have a manifest backing — that's how the diagram surfaces gaps in the implementation.
Drift detection model
mergeSlices(declared, generated) is the pure comparator. It indexes by slice name:
- Both sides have the name →
green. - Only generated →
yellow(the source generator found something the host didn't declare). - Only declared →
orange(the host declared something the source generator didn't find a handler chain for). - Neither has any slices → empty result.
- Only one source has any slices at all → all slices come through with
nullstatus (the pill is suppressed) — there's no second side to compare against.
statusRank orders the statuses so a node touched by multiple slices ends up with the worst of them. The pure function is exported and covered by event-model-store.test.ts.
Live causation overlay
buildTraceOverlay(detail, slices, serviceName) filters the trace's spans to the requested service, sorts by start time, then matches each span's messaging.message_type tag against the lane nodes' fullNames first and short labels second. Matched span ids feed nodeIds; bridge-node inference adds any node whose both incoming and outgoing neighbours are directly highlighted.
The playback animation reuses the same matcher (buildPlaybackSteps) but indexes the matches by chronological position so the controller can scrub through the prefix overlay step-by-step.
Wire pairs used
| Wire pair | Direction | Purpose |
|---|---|---|
RequestEventModel → EventModelResponse | Page → service | Fetch the merged manifest + declared model for one service. |
RequestHandlerSourceCode → HandlerSourceCodeResponse | Page → service | Drill-down for handler-lane nodes (#137). |
RequestProjectionSourceCode → ProjectionSourceCodeResponse | Page → service | Drill-down for projection-lane nodes (chip 21d). |
The causation overlay does not use a wire pair — it goes straight to the operator-configured OTel provider through the ITraceProvider HTTP layer. See Service Trace Binding.
Performance notes
The render path is tuned to stay smooth at 20+ commands and 50+ events. Three caches do most of the heavy lifting:
slicesForServiceis cached by(serviceName, entry identity)— entries are replaced wholesale byhandleEventModelResponse, so identity is a sufficient invalidation signal. Without the cache, every replay tick re-ranflattenManifest+mergeSlices+deriveGeneratedSlices+applyStatusOverlay.- The node-lookup + adjacency Maps used by
buildPlaybackStepsandoverlayFromStepsare WeakMap-cached by slices identity — they don't get rebuilt until the slices reference does. SwimlaneViewsplits structuralbuild()(one pass on slices change) fromapplyOverlay()(one pass per overlay change) — replay scrubs flip attributes on cached D3 selections instead of wiping + rebuilding the SVG.
A perf-regression guard at src/FrontEnd/src/stores/__tests__/event-model-store-perf.test.ts pins the three hot paths against a deterministic 20cmd/50ev/8aggr fixture. The thresholds (warm read <2ms, cold build <12ms, per-replay-tick overlay <6ms) are smoke alarms, not benchmarks — they trip if work doubles. See chip 21g (the perf pass) for the design rationale.
Troubleshooting
The swim-lane is empty. The service either doesn't reference CritterWatch.SourceGeneration or its CritterWatchAppManifest came up empty. Confirm the analyzer is in the project's <PackageReference> list and rebuild. The empty-state card on the tab surfaces this case explicitly.
Every slice is orange. The host declared an EventModelDefinition but no manifest is present — the swim-lane has nothing to reconcile against, so every declared slice surfaces as "missing." Check that the source generator ran for the project the host loads handlers from.
Replay controls don't appear after selecting a trace. Either the trace had no messaging.message_type tags (look for "no matching spans" copy under the picker), or none of the tagged message types match any swim-lane node. The matcher checks fullName first, then short label — if your Wolverine envelope ships an alias the source generator didn't surface, the spans won't match.
Replay drift between traces. The overlay is purely a function of the currently-loaded slices. If the operator switches services mid-replay, the controller clears the selection so a stale trace id doesn't get reapplied to a lane set it doesn't belong to.
Drift pills missing on a service I expect drift on. Drift detection requires both sides — declared and generated. A service with only one source renders without pills (null status). If you expect drift but see plain nodes, confirm EventModelResponse.model.slices is non-empty in the dev tools network tab.
Export PNG produces a blank image. The canvas roundtrip needs the SVG to load through an Image element before drawing. If the diagram references external resources (it shouldn't — D3 inlines everything), the image load will silently fail. Open the export's source SVG to inspect.
