From 4d2ff042e365e74995cf0cf880854550c95c3a35 Mon Sep 17 00:00:00 2001 From: Arlind Ukshini Date: Mon, 27 Apr 2026 10:03:53 +0200 Subject: [PATCH] docs: drop welcome_cta event from engagement tracking spec There's only one welcome-step button (#welcome-continue, label "Start the introduction"); the recent renames were sequential edits to the same button, not two separate CTAs. The click immediately navigates to /timeline so the subsequent timeline_view event already captures it. Also clarified GET / no longer has a timeline branch, and pinned down how session_id flows into the login event (refactor issueSession to return its new ID). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-27-engagement-tracking-design.md | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/superpowers/specs/2026-04-27-engagement-tracking-design.md b/docs/superpowers/specs/2026-04-27-engagement-tracking-design.md index cabae80..64cda20 100644 --- a/docs/superpowers/specs/2026-04-27-engagement-tracking-design.md +++ b/docs/superpowers/specs/2026-04-27-engagement-tracking-design.md @@ -5,7 +5,7 @@ ## Goal -Capture landmark engagement events on the site so we can answer "who's logging in, what device they're on, do they get past the welcome screen, do they reach the timeline." Server-side wherever possible. One unified `events` table, one CLI for reading it. +Capture landmark engagement events on the site so we can answer "who's logging in, what device they're on, do they reach the timeline." Server-side, no client instrumentation. One unified `events` table, one CLI for reading it. The existing `bifrost_joins` table (final-CTA clicks) stays as-is — already has a working CLI and documented schema. Folding it into `events` would require a migration and breaking `bin/joins.js`; not worth the churn now. Possible follow-up later if one-stop reporting is wanted. @@ -14,10 +14,11 @@ The existing `bifrost_joins` table (final-CTA clicks) stays as-is — already ha | `event_type` | Trigger | `meta` (JSON) | |---|---|---| | `login` | `POST /auth/login` returns 200 | — | -| `welcome_cta` | New `POST /api/track/welcome-cta` from `public/entrance.js` on click | `{cta: "start_intro" \| "learn_more"}` | -| `timeline_view` | Server-side, on the `GET /timeline` route AND on the `GET /` → timeline branch | `{view: "mobile" \| "desktop", forced: true \| false}` — `forced` is true when `?view=` query param overrode the UA guess | +| `timeline_view` | Server-side, on the `GET /timeline` route | `{view: "mobile" \| "desktop", forced: true \| false}` — `forced` is true when `?view=` query param overrode the UA guess | -Failed login attempts (`403 not_invited`) are out of scope (engagement focus, not security audit). Logout is also out of scope — session lifetime can be inferred from `sessions.issued_at` if needed. +Failed login attempts (`403 not_invited`) are out of scope (engagement focus, not security audit). Logout is also out of scope — session lifetime can be inferred from `sessions.issued_at` if needed. The welcome-step "Start the introduction" button is **not** tracked separately — it's a single one-button transition that immediately navigates to `/timeline`, so the click is fully captured by the subsequent `timeline_view` event. Adding a `welcome_cta` event would duplicate that signal and require client-side instrumentation for negligible gain. + +`GET /` always serves the entrance shell (`public/entrance.html`); `entrance.js` then routes the user client-side to either the email step or the welcome step. There is no server-side dispatch from `/` to the timeline, so `/` is not a tracking trigger. ## Schema @@ -42,10 +43,10 @@ CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, occurred_a Field semantics: -- `event_type` — one of `login`, `welcome_cta`, `timeline_view`. New types may be added later without schema change. +- `event_type` — one of `login`, `timeline_view`. New types may be added later without schema change. - `email` — lowercased, matches `invites.email`. Always populated; events without an authenticated user are not recorded. - `occurred_at` — `Date.now()` at insertion time (ms since epoch, matching the rest of the schema). -- `session_id` — the cookie's session ID if available. Nullable for the `login` event because the session is issued in the same request — populate from `req.cookies.fenja_session` *after* `issueSession` has run. +- `session_id` — the session ID. For `timeline_view`, sourced from `req.session.id` (the row attached by `requireAuth`). For `login`, sourced from the new ID returned by a refactored `issueSession()` — see the `src/sessions.js` modification below. - `device_type` — one of `mobile`, `tablet`, `desktop`. Nullable if the UA is missing. - `os` — one of `iOS`, `Android`, `Windows`, `macOS`, `Linux`, `other`. Nullable. - `browser` — one of `Safari`, `Chrome`, `Firefox`, `Edge`, `other`. Nullable. Note: Chrome on iOS reports as Safari to the UA detector — acceptable noise; raw UA is preserved for re-parsing. @@ -77,14 +78,14 @@ Regex set: import { q } from './db.js'; import { parseUA } from './ua.js'; -export function recordEvent(req, type, email, meta = null) { +export function recordEvent(req, { type, email, sessionId, meta = null }) { const ua = req.headers['user-agent'] || ''; const { device_type, os, browser } = parseUA(ua); q.recordEvent.run( type, email, Date.now(), - req.cookies?.fenja_session || null, + sessionId || null, device_type, os, browser, @@ -94,12 +95,14 @@ export function recordEvent(req, type, email, meta = null) { } ``` +Caller passes `sessionId` explicitly (rather than the recorder reading `req.cookies`) because the `login` event happens before `req.cookies` reflects the freshly-issued session. + Fire-and-forget — synchronous (better-sqlite3 is sync) and fast enough that no try/catch wrapper is needed. If a future event becomes hot-path, revisit. **`bin/events.js`** — CLI mirroring `bin/joins.js`. Subcommands: - `node bin/events.js list [--type ] [--limit ]` — every event, newest first -- `node bin/events.js summary` — per-user counts by event type (one row per user, columns: email, logins, welcome_cta clicks, timeline_views, last_seen) +- `node bin/events.js summary` — per-user counts by event type (one row per user, columns: email, logins, timeline_views, last_seen) - `node bin/events.js for ` — full event history for one user - `node bin/events.js stats` — totals per event type + unique users per event type + device-type breakdown @@ -109,23 +112,19 @@ Fire-and-forget — synchronous (better-sqlite3 is sync) and fast enough that no - Add the `events` table + indexes to the `db.exec` block. - Add prepared statements: `recordEvent`, `listEvents`, `listEventsByType`, `listEventsForEmail`, `summariseEvents`, `countEventsByType`, `deviceBreakdown`. +**`src/sessions.js`** +- Refactor `issueSession(req, res, email)` to **return the new session ID** (currently returns nothing). The function already generates the ID internally — just `return id;` at the bottom. No callers break: existing call sites can ignore the return value. + **`src/auth.js`** -- After `issueSession(req, res, email)` succeeds, call `recordEvent(req, 'login', email)`. Order matters: `issueSession` sets the cookie, and `recordEvent` reads it for `session_id`. If `issueSession` doesn't mutate `req.cookies` (Express does not back-populate after `res.cookie()`), grab the new session ID from `issueSession`'s return value instead — confirm during implementation and adjust signatures if needed. +- Capture the returned ID: `const sessionId = issueSession(req, res, email);` +- After it succeeds, call `recordEvent(req, { type: 'login', email, sessionId })`. **`server.js`** -- Move `MOBILE_UA_RE` import from inline to `src/ua.js` (keep `isMobile()` working). -- On `app.get('/timeline', requireAuth, ...)`: after the dispatch decision, call `recordEvent(req, 'timeline_view', s.email, {view, forced})`. Need to look up the session row to get the email (already done elsewhere — share or re-query). -- On `app.get('/', ...)` for the authenticated → timeline branch: same call. -- Add `app.post('/api/track/welcome-cta', requireAuth, ...)`: - - Validate body `cta` is one of `"start_intro" | "learn_more"`. 400 on anything else. - - Pull email from the session. - - `recordEvent(req, 'welcome_cta', email, {cta})`. - - Respond `204 No Content`. No body needed; the client fires-and-forgets. +- Move `MOBILE_UA_RE` from `server.js` into `src/ua.js` and import it back. The `wantsMobileView()` helper continues to use the same regex. +- On `app.get('/timeline', requireAuth, ...)`: after the dispatch decision, call `recordEvent(req, { type: 'timeline_view', email: req.session.email, sessionId: req.session.id, meta: { view, forced } })`. The session row is already attached to `req.session` by `requireAuth` — no extra DB lookup. Capture both `view` (`'mobile'` or `'desktop'`) and `forced` (`true` if `?view=` overrode the UA, otherwise `false`) before calling `wantsMobileView()` collapses them. +- No new endpoints. No `/api/track/...` route. -**`public/entrance.js`** -- On click of each welcome-step CTA, fire `fetch('/api/track/welcome-cta', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({cta: 'start_intro'}) }).catch(() => {})` *before* navigating. -- Use `keepalive: true` on the fetch so the request survives the navigation. -- `.catch(() => {})` so a tracking failure never blocks navigation. +**No frontend changes.** `public/entrance.js`, `protected/`, and the timeline assets are untouched. ### Docs @@ -137,15 +136,15 @@ node bin/events.js list # read engagement event log **`OPERATIONS.md`** — short section "Engagement events" mirroring the existing "Bifrost joins" section: what's tracked, how to read, where the data lives. -**`CHECKLIST.md`** — add a row to the relevant section: "log in fresh; click each welcome CTA; view timeline on desktop; force `?view=mobile`; run `node bin/events.js list` and confirm 4 rows with correct device + meta fields." +**`CHECKLIST.md`** — add a row to the relevant section: "log in fresh; view timeline on desktop; force `?view=mobile`; run `node bin/events.js list` and confirm rows with correct device + meta fields (login, timeline_view × 2 with `forced: true` on the second)." ## Operational notes - No new env vars. -- No CSP changes — the welcome-CTA fetch is same-origin. +- No CSP changes — no new client-side fetches. - No security-invariant changes: - `events` table is write-only from app code, read-only from CLI. - - No new public endpoints; `POST /api/track/welcome-cta` is behind `requireAuth`. + - No new public endpoints. - Email enumeration posture unchanged. - Storage: ~150 bytes/row × low event volume (invite-list-only site) → trivial. No retention/pruning policy needed for the foreseeable future. @@ -161,8 +160,9 @@ node bin/events.js list # read engagement event log ## Decisions / non-obvious choices -- **Single `events` table** over per-event-type tables — device fields are identical across all event types; splitting would duplicate the schema and need 3 CLI subcommands. +- **Single `events` table** over per-event-type tables — device fields are identical across all event types; splitting would duplicate the schema. - **`bifrost_joins` left alone** — has a working CLI and existing data; migration cost not justified now. - **Hand-rolled UA parser** over `ua-parser-js` — project keeps a small dependency footprint; the parsed fields we need are coarse-grained. - **Raw `user_agent` stored** — same field already lives on `sessions`, so no new privacy footprint, and lets us re-parse later if a regex misclassifies. -- **`session_id` nullable** — the `login` event happens at session creation; depending on whether `issueSession` returns the new ID, this may be populated or null. Confirm during implementation. +- **`issueSession` refactored to return the new ID** — lets the `login` event populate `session_id` properly without reading-back-the-cookie hacks. Tiny change, no callers break. +- **No `welcome_cta` event** — the welcome step has a single one-button transition that immediately navigates to `/timeline`. The click is fully captured by the subsequent `timeline_view` event; a separate `welcome_cta` event would duplicate that signal.