Skip to content

Behavioral Event Stream

The interaction-grain event taxonomy for NanaSelect's guided selection flow (issue #358, part of capability #346). Additive and v2-scoped per the snapshot-first decision (#357): the anonymous session store snapshot stays the source of truth for funnel counts; this stream captures the discrete, inside-a-step signals the snapshot cannot yield — time-on-question, back-navigation / answer churn, /compare open, media view, CTA click, and the "engaged" signal.

This is a reference doc — the taxonomy and its invariants, not a walkthrough. For the aggregate funnel metrics see behavioral-metrics.md; for the anonymity model both stores share, see session-store.md; for consent gating see privacy-consent.md.

Anonymity is inherited, not re-invented

Every event references a selection_sessions.id (the opaque server-minted UUID) and its payload holds only knowledge-base ids and bounded numeric measures. The write layer's sanitizeEventEnvelope (src/lib/server/session-events.ts) rejects free text — non-id keys and non-id string values are dropped entry-by-entry — exactly as sanitizeSessionPayload does for the snapshot. So the stream is PII-free by construction, not by promise: an email, name, or free note can never reach session_events.

The client beacon is fail-open twice over (AC-01): the browser send (navigator.sendBeacon, keepalive-fetch fallback) is wrapped in .catch(), and the server write is wrapped so a logging failure — including the FK case where the session row does not exist yet — is swallowed. A dropped event is only ever a dropped event; the selection flow never breaks.

  • Consent (#347): events are Tier-1 measurement data. The /api/session/events endpoint writes only with measurement consent, re-read on every request (withdrawal is honored on the next beacon). No consent → no event row, same as the snapshot.
  • Retention (#347 / #116): session_events.session_id is a foreign key with ON DELETE CASCADE, so the daily session purge that deletes idle selection_sessions rows (past SESSION_TTL_DAYS, default 90) removes their events in the same statement — no separate sweep. Events live exactly as long as the session they belong to.
  • Rollup (#349 discipline): analytics/event-rollup.ts pre-aggregates events into session_event_rollup (day × type → count) with the same guarantees as the snapshot rollup — genuine-human only (the aggregate JOINs to selection_sessions and applies the shared genuineHumanTraffic() predicate, so a bot/internal/legacy/test session's events never count) and idempotent / recomputable (delete-then-insert a day range).

Event taxonomy

The set is a closed whitelist (EVENT_TYPES in session-events.ts): an event whose type is not listed here is dropped by the sanitizer, so the client can never coin an untracked type. Payload values are knowledge-base ids or bounded numbers (0 ≤ n ≤ 86 400 000 ms) only.

EventWhen emittedPayload
question_viewThe visitor leaves a guided-flow question (step change, or reaching the summary). Carries how long the question was on screen.factorId (the factor left), ms (dwell)
answer_changeAn already-answered factor is changed — a revision / back-nav churn, not a first answer.factorId
compare_openThe visitor opens the /compare surface from results.systems (shortlist size)
media_viewThe visitor views a media asset (gallery image / Wistia video) on a system. Reserved — see instrumentation note.systemId (+ optional mediaId)
cta_clickThe visitor clicks a results deep-link CTA on the recommended pick.systemId, kind (pdp | configure | resources)
engagedFired once, on the first meaningful results interaction (compare open or CTA click) — distinct from merely completing the flow, which the snapshot already records.(none)
content_viewA piece of nanawall.com content surfaced with the recommendation (#458): a matched project, a cited evidence passage, or a pre-loaded resource document. One event per item, emitted once per session results view (client-deduped). The "shown" leg of the content→outcome funnel; the hand-off leg is cta_click.kind (project | evidence | resource), ref (project uuid / corpus item id)

Instrumentation note (media_view). The type is defined and the beacon supports it, but the trigger is reserved: emitting it cleanly needs a view hook inside the shared media components (MediaGallery / MediaFrame), out of scope for #358 (which would rather not modify those shared components). Wiring is a follow-up; the sink, sanitizer, rollup, and retention already cover it.

Sink & volume estimate (AC-03)

Default sink: D1 (session_events), with the rollup keeping read cost bounded regardless of raw volume.

Rough estimate (order of magnitude, to size the decision — not a forecast):

InputAssumption
Events per completed session~10–20 (≈ one question_view per factor + a few churns + 0–3 CTAs + compare/engaged)
Sessions~3 000 / month at early scale
Row size (with indexes)~300 bytes
Retention90-day session TTL (CASCADE) → steady state ≈ 3 months of events

→ ~45 000 events/month → ~135 000 steady-state rows ≈ ~40 MB. Even at a 10× traffic surge (~30 000 sessions/month) that is ~1.35 M rows ≈ ~400 MB — comfortably within D1's per-database budget, and reads stay cheap because dashboards hit the daily rollup, never the raw stream.

Escalation trigger. Move the sink (not the rollup) to Workers Analytics Engine — or Queue-buffered writes — if either holds:

  1. Sustained volume pushes the steady-state raw table toward multi-GB (roughly

    100 000 sessions/month sustained, ~>5 M steady-state rows), or

  2. Write contention/latency on the append path becomes observable (D1 is single-writer; a very high event rate can serialize behind other writes).

At those thresholds the beacon endpoint swaps its sink while the taxonomy, consent gate, and rollup contract stay identical. Until then, D1 is the sink and the choice is recorded here per AC-03.