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>
9.1 KiB
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:
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 oflogin,welcome_cta,timeline_view. New types may be added later without schema change.email— lowercased, matchesinvites.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 theloginevent because the session is issued in the same request — populate fromreq.cookies.fenja_sessionafterissueSessionhas run.device_type— one ofmobile,tablet,desktop. Nullable if the UA is missing.os— one ofiOS,Android,Windows,macOS,Linux,other. Nullable.browser— one ofSafari,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 onsessions.user_agent, so no new privacy posture.meta— JSON-encoded object with event-specific fields.NULLif none. Always written viaJSON.stringify(...)and read viaJSON.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.
export function parseUA(ua) {
// returns { device_type, os, browser } — nullable on missing UA
}
Regex set:
device_type: tablet (iPad,Android(?!.*Mobile)) →tablet; existingMOBILE_UA_REfromserver.js→mobile; elsedesktop. MoveMOBILE_UA_REfromserver.jsintosrc/ua.jsand 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; elseother.browser: order matters —Edg/→Edge;Firefox/→Firefox;Chrome/→Chrome;Safari/→Safari; elseother. (Edge before Chrome before Safari; all Chromium UAs includeSafari/, all Edge UAs includeChrome/.)
src/events.js — thin recorder.
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 firstnode 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 usernode bin/events.js stats— totals per event type + unique users per event type + device-type breakdown
Modified files
src/db.js
- Add the
eventstable + indexes to thedb.execblock. - Add prepared statements:
recordEvent,listEvents,listEventsByType,listEventsForEmail,summariseEvents,countEventsByType,deviceBreakdown.
src/auth.js
- After
issueSession(req, res, email)succeeds, callrecordEvent(req, 'login', email). Order matters:issueSessionsets the cookie, andrecordEventreads it forsession_id. IfissueSessiondoesn't mutatereq.cookies(Express does not back-populate afterres.cookie()), grab the new session ID fromissueSession's return value instead — confirm during implementation and adjust signatures if needed.
server.js
- Move
MOBILE_UA_REimport from inline tosrc/ua.js(keepisMobile()working). - On
app.get('/timeline', requireAuth, ...): after the dispatch decision, callrecordEvent(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
ctais 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.
- Validate body
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: trueon 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:
eventstable is write-only from app code, read-only from CLI.- No new public endpoints;
POST /api/track/welcome-ctais behindrequireAuth. - 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_joinsinto theeventstable - 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
eventstable over per-event-type tables — device fields are identical across all event types; splitting would duplicate the schema and need 3 CLI subcommands. bifrost_joinsleft 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_agentstored — same field already lives onsessions, so no new privacy footprint, and lets us re-parse later if a regex misclassifies. session_idnullable — theloginevent happens at session creation; depending on whetherissueSessionreturns the new ID, this may be populated or null. Confirm during implementation.