Analytics Spec
Description
Analytics tracks user behavior across the marketing site and product to support product–market fit discovery. We use two complementary tools:
- GA4 — acquisition funnel, marketing attribution, and key conversion events. Runs client-side on public pages and sends server-side conversion events via the Measurement Protocol.
- PostHog — product analytics, session replay, feature flags, experiments, and surveys. Runs client-side (JS SDK) inside the authenticated product and server-side (Node SDK) for reliable lifecycle tracking.
A thin internal tracking wrapper (track / identify) abstracts both tools so call sites never import GA4 or PostHog directly. This makes a future CDP migration (e.g., Segment) a single-file swap.
Guiding Principles
- Event-based measurement everywhere. Pageviews are table stakes. Every meaningful user action is a named event with typed properties, enabling funnels, retention, cohorts, and segmentation.
- Identity resolution. Anonymous pre-signup behavior is linked to a real user after signup so the full journey is visible.
- Server-side collection for reliability. Client-side events get blocked, duplicated, or lost. Lifecycle events fire from Prisma extensions on the server.
- Data quality and governance. A strict naming schema and a canonical event catalog prevent the event namespace from becoming a junk drawer.
- No PII in analytics (production). Email, phone, name, date of birth, and IP address are never sent to GA4 or PostHog in production. An alpha/beta override exists for early testing (see PII Policy).
Architecture
There are three layers:
Tracking Wrapper
A new packages/@erikdakoda/analytics package exports:
track(event, properties)— sends a named event to the configured destinations.identify(userId, traits)— links an anonymous session to a known user and sets user-level properties.- Destination routing — each event specifies whether it goes to GA4, PostHog, or both. The wrapper handles dispatch.
The function signatures are intentionally Segment-compatible (track, identify, alias) so migrating to a CDP later requires changing only the wrapper internals.
Client-Side Layer
- GA4 gtag loads on public/marketing pages (landing, signup, login). It handles automatic pageviews, UTM capture, and CTA click events.
- PostHog JS SDK loads on all authenticated pages. It provides session replay, autocapture, feature flags, and client-side
track/identifycalls.
Server-Side Layer
- PostHog Node SDK and GA4 Measurement Protocol are called from server code via the tracking wrapper.
- Prisma extensions are the primary event source for user lifecycle and quiz lifecycle events. They intercept
createandupdatequeries, compare old vs. new field values, and fire the appropriatetrack()call. - Direct calls in non-Prisma code paths (e.g.,
sendNotification) also go through the wrapper.
Data Flow
flowchart TD
subgraph client [Client]
MarketingPage["Marketing Pages"]
ProductUI["Product UI"]
end
subgraph server [Server]
BetterAuth["Better Auth Hooks"]
PrismaExt["Prisma Extensions"]
AppCode["App Code<br/>(sendNotification, etc.)"]
end
Wrapper["analytics.track() / identify()"]
MarketingPage -->|"gtag (pageview, cta_clicked)"| GA4
MarketingPage -->|"UTM params on signup"| server
ProductUI -->|"PostHog JS SDK"| PostHog
ProductUI -->|"identify on login"| Wrapper
BetterAuth --> Wrapper
PrismaExt --> Wrapper
AppCode --> Wrapper
Wrapper -->|"Node SDK"| PostHog
Wrapper -->|"Measurement Protocol"| GA4
Identity Resolution
Anonymous Phase
- PostHog JS SDK generates a
distinct_idstored in a cookie. - GA4 generates its own
client_idautomatically.
On Signup / Login
- Call
identify(user.id)from the client. This:- Calls PostHog
identify(userId)which merges the anonymousdistinct_idinto the known user. - Sets GA4
user_idviagtag('set', { user_id }).
- Calls PostHog
Server-Side Events
- Always use the database
user.id(an opaque CUID) as the identity key. - PostHog Node SDK calls pass
distinct_id: user.id. - GA4 Measurement Protocol calls pass
user_id: user.idand a server-generatedclient_id.
Cross-Device
- PostHog merges sessions by
user_idautomatically. - GA4 uses User-ID reporting view for cross-device analysis.
Event Naming Conventions
Format
All event names use object_action in snake_case.
Objects
| Object | Description |
|---|---|
signup | Account creation flow |
profile | User profile completion |
coach | AI coach selection |
domains | Life domain selection |
quiz | Quiz lifecycle |
journey_step | Step within the coaching journey |
notification | Push / email / in-app messages |
subscription | Billing (future) |
cta | Marketing call-to-action elements |
Actions
| Action | Meaning |
|---|---|
started | User began a flow |
completed | User finished a flow |
scored | System computed a result |
assigned | Value set for the first time |
changed | Value updated from a previous value |
sent | System dispatched a message |
received | User received a message (future) |
clicked | User clicked an element |
Base Properties
Every event carries:
| Property | Type | Description |
|---|---|---|
user_id | string | Database user.id (opaque CUID), omitted for anonymous events |
timestamp | string | ISO 8601 UTC |
source | string | client or server |
Event Catalog
User Lifecycle Events
Triggered by Prisma User extension (server-side).
| Event | Trigger | Key Properties | Destination | GA4 Conversion |
|---|---|---|---|---|
signup_started | User row created (Better Auth user.create.after hook) | onboarding_track, acquisition_channel, utm_source, utm_medium, utm_campaign | Both | Yes |
signup_completed | emailVerified set to true | time_to_verify_seconds | Both | Yes |
phone_added | phoneNumber set (was null) | PostHog | No | |
phone_verified | phoneNumberVerified set to true | PostHog | No | |
phone_changed | phoneNumber changed (was non-null) | PostHog | No | |
profile_completed | All required profile fields have values: nickName, dateOfBirth, gender, relationship, language, citizenship, country, state, city | onboarding_track | Both | Yes |
coach_assigned | coachId set (was null) | coach_id | PostHog | No |
coach_changed | coachId changed (was non-null) | previous_coach_id, new_coach_id | PostHog | No |
journey_step_started | journeyStepId changed — fires for the new step | journey_step_id | PostHog | No |
journey_step_completed | journeyStepId changed — fires for the previous step | journey_step_id | PostHog | No |
domains_assigned | lifeDomainIds set (was []) | domain_ids, domain_count | PostHog | No |
domains_changed | lifeDomainIds changed (was non-empty) | previous_domain_ids, new_domain_ids | PostHog | No |
How “profile completed” is detected: On every User update, the extension reads the result and checks whether all nine fields are non-null/non-empty. It fires the event only once — the first update where the check passes. To prevent duplicate firing, it compares against the previous state (fetched before the update query runs).
Quiz Events
Triggered by Prisma QuizResult extension (server-side).
| Event | Trigger | Key Properties | Destination | GA4 Conversion |
|---|---|---|---|---|
quiz_started | QuizResult state set to STARTED | quiz_handle, quiz_version | PostHog | No |
quiz_completed | QuizResult state set to COMPLETED | quiz_handle, quiz_version | Both | Yes |
quiz_scored | QuizResult state set to SCORED | quiz_handle, quiz_version, grade_percent, bracket | PostHog | No |
Notification Events
Triggered by direct track() call in sendNotification().
| Event | Trigger | Key Properties | Destination | GA4 Conversion |
|---|---|---|---|---|
notification_sent | sendNotification() executes successfully | workflow | PostHog | No |
Marketing / Acquisition Events
Triggered client-side on public pages.
| Event | Trigger | Key Properties | Destination | GA4 Conversion |
|---|---|---|---|---|
page_view | GA4 automatic (marketing pages only) | Standard UTM params | GA4 | No |
cta_clicked | User clicks a CTA on the landing page | cta_location, cta_text | GA4 | No |
Acquisition and UTM Tracking
UTM Capture
On first visit to any marketing page, a client-side utility reads URL query parameters:
utm_sourceutm_mediumutm_campaignutm_contentutm_term
These are stored in a first-party cookie (or localStorage fallback) so they persist across the session. If the user already has stored UTM values, they are not overwritten (first-touch attribution).
Persistence to User Model
On signup, the stored UTM values are sent to the server and written to the User record. New fields on the User model:
| Field | Type | Description |
|---|---|---|
acqChannel | String? | High-level channel rollup (see below) |
utmSource | String? | Raw utm_source value |
utmMedium | String? | Raw utm_medium value |
utmCampaign | String? | Raw utm_campaign value |
Standard Channel Values
acqChannel is derived from UTM parameters using these rules:
| Channel | Rule |
|---|---|
organic_search | utm_medium = organic or referrer is a search engine and no UTM present |
paid_search | utm_medium = cpc or ppc |
paid_social | utm_medium = paid_social or paidsocial |
organic_social | utm_medium = social or referrer is a social platform and no UTM present |
email | utm_medium = email |
referral | utm_medium = referral or non-search/non-social referrer present |
direct | No referrer and no UTM parameters |
PII Policy
Never Send to Analytics (Production)
The following fields are never included in event properties or user traits sent to GA4 or PostHog in production:
emailphoneNumbergivenName/familyName/nickNamedateOfBirth- IP address (PostHog server calls set
$ip: null; GA4 Measurement Protocol does not forward IP)
Alpha/Beta Override
During alpha/beta testing, set NEXT_PUBLIC_ANALYTICS_ALLOW_PII=true in the app’s .env file (and in Vercel environment settings) to bypass PII stripping. When enabled:
emailis included in the client-sideidentify()call (inAuthGuard.tsx), making users identifiable by email in PostHog and GA4.- Server-side
sanitizeProperties()/sanitizeTraits()skip the PII_KEYS check, so any PII passed totrackServeroridentifyServeris forwarded. - PostHog server calls omit the
$ip: nulloverride, allowing IP-based geolocation capture.
Remove or set to false before moving to production to restore the default no-PII policy.
Allowed in Analytics
These fields are safe to include because they are opaque identifiers or coarse categorical values:
user.id(opaque CUID)coachId(enum value)onboardingTrack(enum value)gender(enum value)country,state(coarse geographic, not street-level)acqChannel,utmSource,utmMedium,utmCampaign
Implementation Touchpoints
These are the primary code locations where analytics integration lives. This is not a step-by-step plan but a reference for where tracking logic belongs.
New Package
packages/@erikdakoda/analytics — contains the tracking wrapper, destination adapters (PostHog Node SDK, GA4 Measurement Protocol), client-side provider components, and the UTM capture utility.
Prisma Extensions
userAnalyticsExtension.ts— hooks into Usercreateandupdatequeries. Detects lifecycle transitions (signup_started, signup_completed, phone_added/verified/changed, profile_completed, coach_assigned/changed, journey_step_started/completed, domains_assigned/changed) by comparing previous and new field values.quizAnalyticsExtension.ts— hooks into QuizResultupdatequeries. Detects state transitions (STARTED, COMPLETED, SCORED) and fires the corresponding events.
Both extensions are registered via side-effect imports in apps/uvilo-ai/src/prismaExtensions.ts.
Better Auth Hook
The existing user.create.after hook in packages/@erikdakoda/auth/server/betterAuth.ts is the trigger point for signup_started. UTM/acquisition data is passed through from the signup form.
Notification Tracking
A track('notification_sent', ...) call is added directly in packages/@erikdakoda/notification/server/sendNotification.ts after the SuprSend workflow is triggered.
Client-Side Integration
- GA4 gtag script added to
apps/uvilo-ai/src/pages/_app.tsx(marketing pages). - PostHog JS SDK provider wraps the app in
_app.tsxfor authenticated pages. identify()is called on login/signup completion.
UTM Capture Implementation
A client-side utility reads UTM query parameters on first page load and stores them in a first-party cookie. On signup, the values are included in the signup request payload and persisted to the User model.
Environment Variables
| Variable | Context | Description |
|---|---|---|
NEXT_PUBLIC_GA4_MEASUREMENT_ID | Client | GA4 stream measurement ID (G-XXXXXXX) |
GA4_API_SECRET | Server | GA4 Measurement Protocol API secret |
NEXT_PUBLIC_POSTHOG_KEY | Both | PostHog project API key (same key for client and server SDKs) |
NEXT_PUBLIC_POSTHOG_HOST | Both | PostHog instance host (defaults to https://us.i.posthog.com) |
NEXT_PUBLIC_ANALYTICS_ALLOW_PII | Both | When true, disables PII stripping and IP nullification (alpha/beta only) |
Split Testing and Experiments
onboardingTrack(existing User field, enum:specific,expedited,comprehensive) is the primary split variable for onboarding experiments. It is included as a property on all lifecycle events to enable cohort analysis.- PostHog feature flags are used for runtime A/B tests and gradual rollouts.
- When running an experiment, define a PostHog feature flag, check it at the relevant decision point, and include the flag value as an event property so results can be analyzed.
Future Considerations
- Subscription events —
subscription_started,subscription_renewed,subscription_cancelled,subscription_churnedwill be added when billing is implemented. - Notification received — requires a client-side callback from the notification SDK (SuprSend) to confirm delivery. Not yet trackable.
- CDP migration — the wrapper’s
track()andidentify()signatures are Segment-compatible by design. Migrating to Segment means replacing the wrapper internals without changing any call sites.