Skip to content

Analytics Events (PHON-168)

Cloudflare-native, privacy-first usage analytics for PhonoLex. Two layers:

  1. Custom events — the frontend client (packages/web/frontend/src/lib/analytics.ts) batches allowlisted events to POST /api/events (packages/web/workers/src/routes/events.ts), which writes to a Workers Analytics Engine dataset.
  2. Pageviews — Cloudflare Web Analytics (cookieless beacon), enabled in the dashboard, not in code. See Cloudflare Web Analytics status below.

Single source of truth for the allowlist and the storage layout: packages/web/workers/src/config/analyticsEvents.ts. The founder weekly report (packages/web/workers/scripts/analytics-report.sh) mirrors that layout.

Prohibited content (explicit rule)

The allowlist IS the privacy mechanism. /api/events drops unknown event names (counted in the response) and strips unknown prop keys before anything is written. There are no free-text fields: analytics must never carry target words, clinician notes, audio, transcripts, pasted passages, or any patient-identifying content. word_viewed deliberately has no props — we count that lookups happen, never what was looked up. If a proposed event seems to need free text, it does not belong in analytics.

Value constraints: strings max 64 chars; numbers must be finite. An invalid value on a known prop drops the whole event.

Event taxonomy

Event Props Purpose Owner
app_loaded Shell mounts; DAU/WAU proxy alongside pageviews founder
tool_opened tool (string) Which of the five tools get used founder
search_executed tool (string), constraint_count (number), result_count (number), elapsed_ms (number) Core-action volume, query complexity, perceived latency founder
results_exported tool (string), format (string), item_count (number) Value capture: results leaving the app founder
word_viewed — (NO word text) Lookup engagement, count only founder
error_shown tool (string), category (string) User-visible failure rate by surface founder
audio_request outcome (string), duration_ms (number) Server-emitted (PHON-167): one per production-surface audio request (/api/audio/analyze, /api/audio/attribute). Volume, abuse (429s), cold starts, host health founder

Server-emitted events

audio_request is written in-Worker (src/lib/audioMetrics.ts), not by the frontend client. It is flagged serverOnly in the allowlist, and /api/events drops it from client batches so it cannot be spoofed. Its envelope slots are fixed: session (blob3/index1) is the literal server; env (blob2) comes from the Worker's ANALYTICS_ENV var (prod/staging; falls back to dev). outcome is one of ok | rate_limited | quota_exceeded | host_error | timeout | invalid | warming. The client IP is used transiently as the rate-limit key and must never enter analytics — the point carries outcome + duration only.

Planned (reserved for PHON-177 — therapy-pack funnel)

Do not reuse these names. They are defined when PHON-177 lands: pack_started, pack_item_completed, pack_completed, pack_abandoned, pack_exported.

Analytics Engine data-point layout (load-bearing)

Analytics Engine has no column names — data points are positional. Every writer and every SQL query must agree on this layout (BLOB_LAYOUT / DOUBLE_LAYOUT in analyticsEvents.ts):

Slot Content
blob1 event name
blob2 env: prod | staging | dev
blob3 session (anonymous device id)
blob4 props.tool ('' if absent)
blob5 props.format ('' if absent)
blob6 props.category ('' if absent)
blob7 props.outcome ('' if absent; audio_request only)
double1 ts — ms epoch (client-supplied, else server receive time)
double2 props.constraint_count (-1 if absent)
double3 props.result_count (-1 if absent)
double4 props.elapsed_ms (-1 if absent)
double5 props.item_count (-1 if absent)
double6 props.duration_ms (-1 if absent; audio_request only)
index1 session (AE sampling key: one device samples together)

Doubles cannot be NULL and 0 is a legitimate count, so absent = -1; queries over count/elapsed slots must filter doubleN >= 0.

Session id (device id, not an account)

crypto.randomUUID() persisted in localStorage under phonolex_session, regenerated whenever the key is absent (new device, cleared storage, private browsing). It identifies a browser profile on a device — there are no accounts, no cross-device linkage, and no way to resolve it to a person. If localStorage is unavailable, the client falls back to the literal no-storage.

Environments

Env Dataset How it's tagged
production phonolex_events frontend PROD build on phonolex.com → env: 'prod'
staging phonolex_events_staging PROD build on develop.phonolex.pages.dev / *.pages.devenv: 'staging'
local dev phonolex_events_dev (local no-op sink) non-PROD Vite build → env: 'dev'

Separation is double-walled: staging Workers write to their own dataset AND every event carries its client-reported env in blob2. Founder queries filter blob2 = 'prod' regardless. If the Worker has no ANALYTICS binding (bare local dev), /api/events still answers 202 with note: 'no-binding'.

Ingest contract

POST /api/events, JSON body { events: [{ name, props?, ts? }], session, env }:

  • 1–20 events per request; payload cap ~16 KB (413 above).
  • Malformed JSON or a bad envelope → 400. Per-event problems never fail the request: response is 202 { accepted, dropped }.
  • The endpoint never 500s on user traffic; write errors are swallowed.

Retention

Workers Analytics Engine retains data points for ~90 days (Cloudflare default; not configurable per-dataset as of 2026). Longer-horizon trends require periodically exporting the weekly report output — there is no raw backfill after expiry.

Founder weekly report

CF_ACCOUNT_ID=... CF_API_TOKEN=... packages/web/workers/scripts/analytics-report.sh

Queries the AE SQL API for: events by name (7d), tool_opened by tool, search counts + median elapsed_ms by tool, error categories, distinct sessions per day, and audio request outcomes (audio_request by blob7 outcome with median double6 duration — the PHON-167 abuse/health view). The token needs the Account Analytics: Read permission.

Cloudflare Web Analytics status (verified 2026-07-12)

Pageview RUM is managed in the Cloudflare dashboard (Analytics & Logs → Web Analytics), not in this repo. Verified state:

  • phonolex.com (production): ACTIVE since ~2026-02. RUM = "Enable" (automatic edge injection); the beacon (static.cloudflareinsights.com/beacon.min.js, site token 4b57c67e1c754099870db9ae78c7bfdc) is confirmed present in browser-rendered pages. NOTE: the beacon is NOT visible to curl or other non-browser clients — Cloudflare skips injection based on TLS/bot fingerprinting — so "curl and grep for the beacon" is a false-negative check. Verify with a real browser.
  • develop.phonolex.pages.dev (staging): no RUM, deliberately. The pages.dev hostname is not a configured Web Analytics hostname. Staging usage is covered by the /api/events pipeline (env=staging); adding staging RUM would be redundant. Revisit only if staging pageview data is ever specifically needed.
  • Advanced Web Analytics options (sampling controls, etc.) sit behind a Cloudflare plan upgrade; not needed for the current funnel.