Analytics and notifications
PostHog events, push notifications, in-app nudges
Analytics provider
PostHog is the product analytics. Hosted EU cloud (configurable via POSTHOG_API_HOST). GDPR erasure docs.
| Env var | Purpose |
|---|---|
POSTHOG_API_HOST | PostHog region (e.g. https://eu.posthog.com) |
POSTHOG_PROJECT_ID | Project ID |
POSTHOG_PERSONAL_API_KEY | Personal API key — needed for the GDPR erasure path |
The personal API key is required because the standard ingest API can't process erasure requests; only the personal-API-key path can.
Where events are defined
AnalyticsEvent.* types live in @app/core/analytics. Events are defined alongside the feature that fires them, not in a centralized event catalog. The reasoning: a feature's events change with the feature; centralized event catalogs drift.
The rule (enforced by code review): every user action ships with its PostHog event in the same PR.
Event categories
| Category | Example events | Fires from |
|---|---|---|
| Lifecycle | app_opened, app_backgrounded, signed_in, signed_out | mobile app shell |
| Onboarding | welcome_seen, signup_started, signup_completed, quiz_*_started, quiz_*_completed | onboarding flow |
| Discover | discover_opened, rail_scrolled, chip_selected, card_opened, card_dismissed, refresh_pulled | discover screen |
| College detail | detail_opened, peek_*_tapped, outcome_section_viewed | college detail screen |
| Saves | school_saved, school_unsaved, save_list_opened, save_list_sorted | saved-schools UI |
| Profile | profile_section_opened, profile_field_filled, completion_percentage_changed | profile UI |
| Notifications | notification_received, notification_opened, notification_dismissed | system-level handlers |
| Errors | auth_error, network_error, unexpected_error | error boundaries |
A complete inventory lives at packages/core/analytics/events.ts.
Event payload conventions
distinct_idis the WorkOS user ID (consistent across mobile + web).- Numeric properties are typed numbers, not stringified. PostHog charts treat them correctly.
- No PII in event names. No PII in event property values. Email is anonymized to a hash if it must travel.
- Property keys are snake_case. PostHog convention.
What was missing in V1 that V2 fires
V1 analytics were sparse. V2 adds these events because they map directly to the questions the product needs answered:
| Question | Event(s) |
|---|---|
| Are users saving schools? | school_saved |
| Are users completing their profile? | completion_percentage_changed + profile_field_filled |
| Are users finishing quizzes? | quiz_*_completed |
| Are first-session vs returning-session behaviors different? | app_opened with session_count property |
| Which notification drove return? | notification_opened with cron_id property |
Push notifications
What's wired
- Deadline reminders at 14 / 7 / 3 / 1 days out, in the user's own timezone (
lib/tzHelper.ts). - Weekly new-matches alert when the user's personalized list changes enough to be worth mentioning. Fires Sunday 11am ET (
crons.ts→weekly new matches). - In-app profile nudges ("Add your test scores to unlock 12 more matches") rendered as banner / sheet UI, not push.
Delivery path
iOS: Apple Push Notification service (APNS) via Firebase Cloud Messaging (FCM) gateway. Android: FCM directly. Mobile uses expo-notifications to subscribe.
Configuration files (gitignored):
apps/mobile/google-services.json— Firebase config- Apple credentials in Expo dashboard
Daily cron health check
The scorecard cron health check cron (12:00 UTC daily) verifies the monthly Scorecard refresh fired on schedule. If silent, flags via ops/cronHealth.ts. The same pattern can be extended to monitor notification crons if a silent-failure surface becomes important.
Throttling
There is no per-user throttle today. The current cadence (deadline reminders only fire on actual deadlines, weekly matches only fires once per week) self-limits to ~5–6 pushes per user per month maximum.
In-app nudges
In-app surfaces:
- Profile completion banner on the Discover home tab.
- Saved-list portfolio balance ("you have 12 reaches and 1 safety — try adding 2 fits").
- First-time visitor prompts during pre-signup browse.
All are static rules + computed counts. No AI-generated nudges (the only AI in the app is the per-school blurb; see AI and matching).
How to add a new analytics event
- Add the event type to
@app/core/analyticsnext to the feature. - Fire the event from the feature code (
posthog.capture(...)). - Verify in PostHog (Live Events tab) during dev.
- Ship in the same PR as the feature.
How to add a new notification
- Add the cron job entry to
packages/backend/convex/crons.ts. - Implement the handler as an
internalActioninconvex/features/notifications/cron.ts. - Use the paginated fan-out pattern (Entry → recursive Batch) if iterating users — see the existing
weeklyNewMatchesEntryfor reference. - Fire
notification_receivedanalytics from the device-side handler. - Test in dev with a manual run:
npx convex run features/notifications/cron:weeklyNewMatchesEntry.
Vercel Analytics + Speed Insights
Wired on the marketing site (@vercel/analytics, @vercel/speed-insights). Free tier. No env vars required.
Useful for:
- Marketing site traffic (sessions, top pages, referrers).
- Core Web Vitals (LCP, INP, CLS).
Not used for product analytics — that's PostHog's job. Vercel Analytics has no insight into the mobile app.
Technical detail
Why AnalyticsEvent.* colocates with features
The alternative — a centralized events.ts catalog — drifts. A feature gets removed; its events stay. A feature gets renamed; the events lag. Colocation means the events live and die with the code that fires them.
Why no auto-instrumentation
PostHog's auto-capture would fire on every tap. The signal-to-noise ratio is poor for understanding the product. Manual instrumentation forces a question: "what does this event tell us?" If there's no answer, don't fire the event.
Why no Mixpanel / Amplitude
Considered. PostHog won on (a) self-hostable if needed, (b) session replays included, (c) GDPR erasure API. The trade-off: PostHog's funnel UX is rougher than Amplitude's.