Analytics Events (PHON-168)¶
Cloudflare-native, privacy-first usage analytics for PhonoLex. Two layers:
- Custom events — the frontend client (
packages/web/frontend/src/lib/analytics.ts) batches allowlisted events toPOST /api/events(packages/web/workers/src/routes/events.ts), which writes to a Workers Analytics Engine dataset. - 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.dev → env: '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 token4b57c67e1c754099870db9ae78c7bfdc) 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. Thepages.devhostname is not a configured Web Analytics hostname. Staging usage is covered by the/api/eventspipeline (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.