Server-side landmark events (login, welcome_cta, timeline_view) into a unified events table with device fields. CLI-only readout via bin/events.js. bifrost_joins left in place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
9.1 KiB
Markdown
168 lines
9.1 KiB
Markdown
# Engagement Tracking — Design
|
||
|
||
**Date:** 2026-04-27
|
||
**Status:** Approved (awaiting implementation plan)
|
||
|
||
## 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.
|
||
|
||
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.
|
||
|
||
## Events to track
|
||
|
||
| `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 |
|
||
|
||
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.
|
||
|
||
## Schema
|
||
|
||
New table in `src/db.js`:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS events (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
event_type TEXT NOT NULL,
|
||
email TEXT NOT NULL,
|
||
occurred_at INTEGER NOT NULL,
|
||
session_id TEXT,
|
||
device_type TEXT,
|
||
os TEXT,
|
||
browser TEXT,
|
||
user_agent TEXT,
|
||
meta TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_events_email ON events(email);
|
||
CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, occurred_at);
|
||
```
|
||
|
||
Field semantics:
|
||
|
||
- `event_type` — one of `login`, `welcome_cta`, `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.
|
||
- `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.
|
||
- `user_agent` — raw UA string, stored as fallback so a bad regex can be re-parsed later. Same data is already kept on `sessions.user_agent`, so no new privacy posture.
|
||
- `meta` — JSON-encoded object with event-specific fields. `NULL` if none. Always written via `JSON.stringify(...)` and read via `JSON.parse(...)`.
|
||
|
||
No migration needed — `CREATE TABLE IF NOT EXISTS` is enough on first deploy.
|
||
|
||
## Code layout
|
||
|
||
### New files
|
||
|
||
**`src/ua.js`** — UA parser. ~30 lines, hand-rolled regex, no new dependency.
|
||
|
||
```js
|
||
export function parseUA(ua) {
|
||
// returns { device_type, os, browser } — nullable on missing UA
|
||
}
|
||
```
|
||
|
||
Regex set:
|
||
- `device_type`: tablet (`iPad`, `Android(?!.*Mobile)`) → `tablet`; existing `MOBILE_UA_RE` from `server.js` → `mobile`; else `desktop`. Move `MOBILE_UA_RE` from `server.js` into `src/ua.js` and re-export so the existing dispatcher uses the same source of truth.
|
||
- `os`: `iPhone|iPad|iPod` → `iOS`; `Android` → `Android`; `Windows` → `Windows`; `Mac OS X|Macintosh` → `macOS`; `Linux` → `Linux`; else `other`.
|
||
- `browser`: order matters — `Edg/` → `Edge`; `Firefox/` → `Firefox`; `Chrome/` → `Chrome`; `Safari/` → `Safari`; else `other`. (Edge before Chrome before Safari; all Chromium UAs include `Safari/`, all Edge UAs include `Chrome/`.)
|
||
|
||
**`src/events.js`** — thin recorder.
|
||
|
||
```js
|
||
import { q } from './db.js';
|
||
import { parseUA } from './ua.js';
|
||
|
||
export function recordEvent(req, type, email, 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,
|
||
device_type,
|
||
os,
|
||
browser,
|
||
ua || null,
|
||
meta ? JSON.stringify(meta) : null
|
||
);
|
||
}
|
||
```
|
||
|
||
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 <event>] [--limit <n>]` — 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 for <email>` — full event history for one user
|
||
- `node bin/events.js stats` — totals per event type + unique users per event type + device-type breakdown
|
||
|
||
### Modified files
|
||
|
||
**`src/db.js`**
|
||
- Add the `events` table + indexes to the `db.exec` block.
|
||
- Add prepared statements: `recordEvent`, `listEvents`, `listEventsByType`, `listEventsForEmail`, `summariseEvents`, `countEventsByType`, `deviceBreakdown`.
|
||
|
||
**`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.
|
||
|
||
**`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.
|
||
|
||
**`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.
|
||
|
||
### Docs
|
||
|
||
**`CLAUDE.md`** — under "Common commands":
|
||
```
|
||
node bin/events.js list # read engagement event log
|
||
# (also: summary, for <email>, stats)
|
||
```
|
||
|
||
**`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."
|
||
|
||
## Operational notes
|
||
|
||
- No new env vars.
|
||
- No CSP changes — the welcome-CTA fetch is same-origin.
|
||
- 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`.
|
||
- 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.
|
||
|
||
## Out of scope (explicitly)
|
||
|
||
- Failed login tracking
|
||
- Logout tracking
|
||
- Scroll-depth / dwell-time on the timeline
|
||
- Per-section timeline views
|
||
- Folding `bifrost_joins` into the `events` table
|
||
- Web UI for viewing events (CLI only, matching `joins.js`)
|
||
- Bot/crawler filtering — the site is invite-list-only and fully gated, so no bots reach gated routes
|
||
|
||
## 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.
|
||
- **`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.
|