Projection Stepper
The Projection Stepper is the operator-facing replay tool for projections running inside a monitored service. Pick a registered projection, point it at a source slice of events, and step row-by-row through the resulting per-event before / after state — with the same projection code the monitored service runs in production. It is the answer to "what is this projection doing on this stream?" and "why is the state shaped like that at version N?".
Route: /explorer → Projection Stepper tab
The stepper lives on the Event Store Explorer page alongside the read-only Recent Streams / Stream Events / Projection Statuses / Tag Query / Rehydrate Aggregate tabs (#145). The other tabs describe what's on the store; the stepper runs a projection over a slice of it and shows you each step.
What you see
The stepper renders a single page with five regions, top to bottom:
| Region | What it does |
|---|---|
| Projection picker | An <el-select> of projections registered on the selected service. Sourced from the same RequestProjectionStatuses cache the Projection Statuses tab populates — opening the stepper for a never-viewed service lazy-fetches the list. |
| Source-mode picker | An <el-radio-group> that swaps the source input row between Stream, Stream Slice, and Tag Query modes (see Walking through a replay). |
| Source input row | Conditional on the source mode: a stream-id field for Stream and StreamSlice, plus from / to version <el-input-number> pair for StreamSlice, or a key / value tag-row editor for TagQuery. |
| Run button + toolbar | Run Projection dispatches the wire request. Once a response lands, the toolbar exposes Restart · Play / Pause · Step · Jump to N · Scrubber · cursor (Step N / Total). |
| Timeline + diff | Below the toolbar: a tab strip with one tab per aggregate instance the source slice touches, the per-tab timeline list (Event Type · Timestamp · After State preview · View Apply Source · expand-for-diff), and the Monaco JSON diff between the active row's before and after. |
Walking through a replay
Pick a projection
The picker enumerates whatever the monitored service's IEventStore knows about — Marten, Polecat, anything else. The control is <el-select filterable> so typing narrows the list, and registering a projection only via source-generated [GeneratedEvolver] dispatchers is sufficient (the picker doesn't require an EventModelDefinition).
Pick a source mode
The same projection can be replayed over three different shapes of source slice. The picker is mode-distinct: switching modes wipes the in-flight inputs so a previous mode's stale values can't accidentally ride along.
| Mode | What flows into the projection | Wire shape |
|---|---|---|
| Stream (default) | Every event in streamId, in stream-version order. | RequestProjectionRun { SourceMode = Stream, StreamId } |
| Stream Slice | Events in streamId bounded by inclusive [FromVersion, ToVersion]. | RequestProjectionRun { SourceMode = StreamSlice, StreamId, FromVersion, ToVersion } |
| Tag Query | The (possibly cross-stream) event list matched by a DCB tag map. | RequestProjectionRun { SourceMode = TagQuery, Tags = { ... } } |
Stream is the common case — most operators reach for the stepper to chase down "what does this projection look like at version N of this stream?" Stream Slice is the precision tool for cordoning off a single chunk of the stream (e.g. step through events 42..50 to inspect a known incident window). Tag Query is the DCB-projection escape hatch: when the projection isn't stream-anchored, the operator supplies the tag map and the server pulls the matching events via IEventStore.QueryByTagsAsync.
Read the tabbed timeline
A single source slice can touch multiple aggregate instances — a multi-stream projection's Apply chain might land state on Trip and Day in the same replay. The response carries one AggregateInstanceBucket per aggregate identity, and the stepper renders one tab per bucket with a row count beside the label.
Each bucket has its own cursor. Switching tabs preserves the position the operator was parked on in the other tab, so a back-and-forth comparison flow doesn't lose context. Cross-bucket diffs are explicitly out of scope — a row diff is always within a single bucket.
When the response produces exactly one bucket (the common stream-id case), the tab strip is hidden so the page looks identical to a flat single-aggregate timeline. The strip lights up the moment a second bucket appears.
Diff the state at each step
Expanding a row in the timeline reveals a Monaco JSON diff between that row's before state and its after state. The before / after for step i is the projection's full state object after applying step i-1 (before) and step i (after) — so the diff highlights exactly what the event changed.
Monaco is heavy. To keep large state objects from costing a hundred milliseconds per row mount, the diff is lazy-mounted above 100KB combined size (PROJECTION_DIFF_LAZY_THRESHOLD_BYTES in components/monacoJsonDiff.ts, gate from chip 23c). Lazy diffs render a placeholder with an explicit "Load diff" affordance; the operator clicks once to opt in for that specific row.
Toolbar
Once a response lands, the toolbar is operable:
| Control | Behaviour |
|---|---|
| Restart | Rewinds the active bucket's cursor to step 0. Other buckets' cursors untouched. |
| Play / Pause | Auto-advances the cursor at PROJECTION_STEPPER_PLAYBACK_STEP_MS (600ms) per tick. The button swaps Play ↔ Pause on click; auto-pauses when the cursor reaches the last row so a second Play click restarts from 0. |
| Step | Advances the cursor one row. Disabled at end-of-rows. |
| Jump to N | An <el-input-number> plus Enter / blur commit. Type the target step, commit, and the cursor lands there. Clamped to [0, totalSteps]. |
| Scrubber | <el-slider> bound to the same cursor. Drag commits a jump; scrubbing auto-pauses any in-flight playback so the timer doesn't overwrite the dragged position. |
| Cursor | Step N / Total label. The denominator is the active bucket's row count. |
Two behaviours worth calling out:
- Pause-on-bucket-switch. When the operator clicks a different aggregate tab, playback auto-pauses. Silently continuing into a different bucket's row list would be confusing, so an explicit Play click is required after a switch.
- Pause-on-fresh-response. A new response wholesale replaces the cache slot; the entry lands paused even if the prior shape had been mid-playback.
Drill into the projection source
Every row in the timeline carries a View Apply button. Click it and the source-viewer drawer opens with the source code for <ProjectionType>.Apply(<EventType>) — the exact overload that ran on that row. The drawer is the same one the swim-lane projection-lane click uses (Event Modeling), but with method + event qualifiers so the response zooms into the specific overload instead of dumping the whole projection class.
The wire pair is RequestProjectionSourceCode and the resolution is server-side via JasperFx.Events.SourceGenerator (see Architecture). The drawer degrades gracefully: services without a source-generated dispatcher render an empty drawer with the reason in a tag at the top.
Architecture (for developers)
The wire pair
Wolverine.CritterWatch.Messages.Inbound.RequestProjectionRun:
public record RequestProjectionRun(string ProjectionName, string StreamId, string? QueryId) : IServiceQuery
{
public ProjectionRunSourceMode SourceMode { get; init; } = ProjectionRunSourceMode.Stream;
public long? FromVersion { get; init; }
public long? ToVersion { get; init; }
public Dictionary<string, string>? Tags { get; init; }
// ServiceName, ServiceMessage base, etc.
}
public enum ProjectionRunSourceMode { Stream, StreamSlice, TagQuery }The positional ctor is single-mode by design so pre-23d callers compile without touching the new discriminator. The init-only fields carry the per-mode payload — the handler validates them per mode and short-circuits a well-formed error response when something is missing.
Wolverine.CritterWatch.Messages.Outbound.ProjectionRunResponse:
public record ProjectionRunResponse(
string ServiceName, string ProjectionName, string StreamId,
IReadOnlyList<AggregateInstanceBucket>? AggregateInstances,
string? QueryId, string? Error)
{
public string? SourceKey { get; init; }
}SourceKey is the per-source-mode cache routing key the operator-side store keys cache slots by. The handler computes it once and echoes it on every response (success and validation-failure both), so the frontend never has to re-derive it from the request payload.
Server-side handler
Wolverine.CritterWatch.Handlers.RequestProjectionRunHandler runs on the monitored service. It branches on SourceMode for source-event selection:
| Mode | Source read |
|---|---|
Stream | IEventStore.ReadStreamAsync(streamId) end-to-end. |
StreamSlice | IEventStore.ReadStreamAsync(streamId) filtered in-process by [FromVersion, ToVersion]. (TODO #88: push the slice filter into JasperFx.Events once a sliced read is exposed directly.) |
TagQuery | IEventStore.QueryByTagsAsync(tags). Projection identity is the projection name since the source isn't stream-anchored. |
The events flow into IEventStore.RunProjectionByNameAsync(projectionName, identity, events, startingState: null, cancellation). The handler wraps the resulting ProjectionTimelineRaw into an AggregateInstanceBucket[] (one bucket per aggregate identity; today's RunProjectionByNameAsync always returns a single-identity timeline, so the array is always length 1 until a multi-aggregate surface reaches IEventStore upstream).
The handler is gated by CritterWatchOptions.EnableEventStoreExplorer — when disabled, it short-circuits a response with Error = "Event store explorer is disabled on this service.".
Source-generator integration
Projection method resolution (for the View Apply drill-down) is owned by the JasperFx.Events.SourceGenerator analyzer, which emits per-projection [GeneratedEvolver] dispatchers at compile time. Marten / Polecat call DiscoverGeneratedEvolvers(...) at startup to register them. The RequestProjectionSourceCode handler resolves <ProjectionType>.Apply(<EventType>) against the dispatcher's method table — services without the source-generator integration produce an empty drill-down drawer.
Pinia store cache
stores/projection-stepper-store.ts holds the per-replay state:
runs.value[entryKey(serviceName, projectionName, sourceKey)] = ProjectionRunEntryThe sourceKey is mode-distinct:
Stream→streamIdStreamSlice→${streamId}@${fromVersion}..${toVersion}TagQuery→tags::sorted-key=value&...(alphabetical, deterministic)
Both the C# handler and the TS store use the same encoding (RequestProjectionRunHandler.BuildSourceKey ↔ buildStreamSourceKey / buildSliceSourceKey / buildTagSourceKey), so the cache slot the store opens on send is the slot the response lands in on receive.
Each entry carries aggregateBuckets, activeBucketIndex, currentStepByBucket[] (parallel to aggregateBuckets by index, so tab switches preserve cursors), and playbackPlaying. The auto-advance timer lives in the component, not the store — the store stays timer-free so SSR / test cleanup don't have to chase dangling intervals.
Wire pairs used
| Wire pair | Purpose |
|---|---|
RequestProjectionRun / ProjectionRunResponse | The replay itself. |
RequestProjectionStatuses / ProjectionStatusesResponse | Populates the projection picker. Shared with the Projection Statuses tab. |
RequestProjectionSourceCode / ProjectionSourceCodeResponse | The View Apply drill-down. Shared with the Event Modeling swim-lane's projection-lane click. |
Performance notes
The stepper has been tuned for 3 aggregate buckets × 1000 events per bucket (chip 23g). The cold-render path (diffInputsForSteps over a freshly-loaded bucket) lands at ~6.5ms / bucket on a dev machine — 2.6× faster than the pre-23g shape.
Two pieces of machinery matter:
- Batched diff inputs.
components/monacoJsonDiff.tsexposesdiffInputsForSteps(steps)— a single O(n) pass that stringifies each step'safterexactly once and reuses the size for both this row's lazy-gate check and the next row's prior-size comparison. The timeline-listrowscomputed uses this; the per-stepdiffInputsForStepremains for callers without the full array. markRawon landed buckets. Wire-payload buckets are read-only from the UI's point of view (a fresh response replaces the slot wholesale), so Vue's deep-reactive Proxy wrapping is overhead with no upside. The store callsmarkRawon each bucket when landing the response; entry-level fields that actually mutate (loading,activeBucketIndex,currentStepByBucket,playbackPlaying) stay reactive.
Both wins are locked behind regression guards in stores/__tests__/projection-stepper-store-perf.test.ts against a deterministic fixture at stores/__tests__/projection-stepper-perf-fixture.ts. Budgets are conservative — they fire if work doubles, not on a 10% tuning win.
Troubleshooting
| What you see | Likely cause | Where to look |
|---|---|---|
| Empty timeline after Run | Stream had no events; or the projection's Apply chain produced no state transitions. | Cross-check the source-mode inputs against the Stream Events tab; verify the projection has handlers for the events on the stream. |
| "Event store explorer is disabled on this service" | The monitored service hasn't opted into the explorer. | Set CritterWatchOptions.EnableEventStoreExplorer = true on the host. Default is true in Development, false everywhere else. |
| "No IEventStore is registered on this service" | The service doesn't expose a JasperFx.Events IEventStore in DI. | Verify the service registered Marten / Polecat via AddCritterWatchMonitoring. |
| No source code in the View Apply drawer | The service doesn't ship the JasperFx.Events.SourceGenerator analyzer, so no [GeneratedEvolver] dispatcher was emitted. | Reference JasperFx.Events.SourceGenerator from the service's csproj. The drawer surfaces the reason in a tag at the top. |
| Tags-mode run returns nothing | The event store's QueryByTagsAsync is NotImplementedException, or no events carry the supplied tags. | The error message rides on ProjectionRunResponse.Error — check the inline alert under the toolbar. |
| Lazy "Load diff" placeholder on every row | Combined before / after size for the row exceeds the 100KB lazy gate. | Expected; click "Load diff" for the specific rows you care about, or open a perf issue if the threshold needs revisiting. |
| Monaco diff slow at scale | A regression in the cold-render path. | Run npm run test:unit -- src/stores/__tests__/projection-stepper-store-perf.test.ts — the regression guards should pinpoint which budget tripped. |
| Playback continues into the wrong bucket | A bug — setActiveBucket should auto-pause. | File against #88; the contract is locked by the 'pauses playback when the operator switches to a different bucket' test in projection-stepper-store.test.ts. |
