customer-presentation/docs/superpowers/specs/2026-04-27-engagement-tracking-design.md
Arlind Ukshini 9044573d01 docs: spec for engagement event tracking
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>
2026-04-27 09:53:48 +02:00

9.1 KiB
Raw Blame History

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 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_atDate.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.

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.jsmobile; 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|iPodiOS; AndroidAndroid; WindowsWindows; Mac OS X|MacintoshmacOS; LinuxLinux; 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.

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.