NXT

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 varPurpose
POSTHOG_API_HOSTPostHog region (e.g. https://eu.posthog.com)
POSTHOG_PROJECT_IDProject ID
POSTHOG_PERSONAL_API_KEYPersonal 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

CategoryExample eventsFires from
Lifecycleapp_opened, app_backgrounded, signed_in, signed_outmobile app shell
Onboardingwelcome_seen, signup_started, signup_completed, quiz_*_started, quiz_*_completedonboarding flow
Discoverdiscover_opened, rail_scrolled, chip_selected, card_opened, card_dismissed, refresh_pulleddiscover screen
College detaildetail_opened, peek_*_tapped, outcome_section_viewedcollege detail screen
Savesschool_saved, school_unsaved, save_list_opened, save_list_sortedsaved-schools UI
Profileprofile_section_opened, profile_field_filled, completion_percentage_changedprofile UI
Notificationsnotification_received, notification_opened, notification_dismissedsystem-level handlers
Errorsauth_error, network_error, unexpected_errorerror boundaries

A complete inventory lives at packages/core/analytics/events.ts.

Event payload conventions

  • distinct_id is 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:

QuestionEvent(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.tsweekly 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

  1. Add the event type to @app/core/analytics next to the feature.
  2. Fire the event from the feature code (posthog.capture(...)).
  3. Verify in PostHog (Live Events tab) during dev.
  4. Ship in the same PR as the feature.

How to add a new notification

  1. Add the cron job entry to packages/backend/convex/crons.ts.
  2. Implement the handler as an internalAction in convex/features/notifications/cron.ts.
  3. Use the paginated fan-out pattern (Entry → recursive Batch) if iterating users — see the existing weeklyNewMatchesEntry for reference.
  4. Fire notification_received analytics from the device-side handler.
  5. 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.

On this page