Skip to content
Visibility internal Owner _ Approver _ Created _ Updated _

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 / identify calls.

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 create and update queries, compare old vs. new field values, and fire the appropriate track() 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_id stored in a cookie.
  • GA4 generates its own client_id automatically.

On Signup / Login

  • Call identify(user.id) from the client. This:
    • Calls PostHog identify(userId) which merges the anonymous distinct_id into the known user.
    • Sets GA4 user_id via gtag('set', { user_id }).

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.id and a server-generated client_id.

Cross-Device

  • PostHog merges sessions by user_id automatically.
  • GA4 uses User-ID reporting view for cross-device analysis.

Event Naming Conventions

Format

All event names use object_action in snake_case.

Objects

ObjectDescription
signupAccount creation flow
profileUser profile completion
coachAI coach selection
domainsLife domain selection
quizQuiz lifecycle
journey_stepStep within the coaching journey
notificationPush / email / in-app messages
subscriptionBilling (future)
ctaMarketing call-to-action elements

Actions

ActionMeaning
startedUser began a flow
completedUser finished a flow
scoredSystem computed a result
assignedValue set for the first time
changedValue updated from a previous value
sentSystem dispatched a message
receivedUser received a message (future)
clickedUser clicked an element

Base Properties

Every event carries:

PropertyTypeDescription
user_idstringDatabase user.id (opaque CUID), omitted for anonymous events
timestampstringISO 8601 UTC
sourcestringclient or server

Event Catalog

User Lifecycle Events

Triggered by Prisma User extension (server-side).

EventTriggerKey PropertiesDestinationGA4 Conversion
signup_startedUser row created (Better Auth user.create.after hook)onboarding_track, acquisition_channel, utm_source, utm_medium, utm_campaignBothYes
signup_completedemailVerified set to truetime_to_verify_secondsBothYes
phone_addedphoneNumber set (was null)PostHogNo
phone_verifiedphoneNumberVerified set to truePostHogNo
phone_changedphoneNumber changed (was non-null)PostHogNo
profile_completedAll required profile fields have values: nickName, dateOfBirth, gender, relationship, language, citizenship, country, state, cityonboarding_trackBothYes
coach_assignedcoachId set (was null)coach_idPostHogNo
coach_changedcoachId changed (was non-null)previous_coach_id, new_coach_idPostHogNo
journey_step_startedjourneyStepId changed — fires for the new stepjourney_step_idPostHogNo
journey_step_completedjourneyStepId changed — fires for the previous stepjourney_step_idPostHogNo
domains_assignedlifeDomainIds set (was [])domain_ids, domain_countPostHogNo
domains_changedlifeDomainIds changed (was non-empty)previous_domain_ids, new_domain_idsPostHogNo

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).

EventTriggerKey PropertiesDestinationGA4 Conversion
quiz_startedQuizResult state set to STARTEDquiz_handle, quiz_versionPostHogNo
quiz_completedQuizResult state set to COMPLETEDquiz_handle, quiz_versionBothYes
quiz_scoredQuizResult state set to SCOREDquiz_handle, quiz_version, grade_percent, bracketPostHogNo

Notification Events

Triggered by direct track() call in sendNotification().

EventTriggerKey PropertiesDestinationGA4 Conversion
notification_sentsendNotification() executes successfullyworkflowPostHogNo

Marketing / Acquisition Events

Triggered client-side on public pages.

EventTriggerKey PropertiesDestinationGA4 Conversion
page_viewGA4 automatic (marketing pages only)Standard UTM paramsGA4No
cta_clickedUser clicks a CTA on the landing pagecta_location, cta_textGA4No

Acquisition and UTM Tracking

UTM Capture

On first visit to any marketing page, a client-side utility reads URL query parameters:

  • utm_source
  • utm_medium
  • utm_campaign
  • utm_content
  • utm_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:

FieldTypeDescription
acqChannelString?High-level channel rollup (see below)
utmSourceString?Raw utm_source value
utmMediumString?Raw utm_medium value
utmCampaignString?Raw utm_campaign value

Standard Channel Values

acqChannel is derived from UTM parameters using these rules:

ChannelRule
organic_searchutm_medium = organic or referrer is a search engine and no UTM present
paid_searchutm_medium = cpc or ppc
paid_socialutm_medium = paid_social or paidsocial
organic_socialutm_medium = social or referrer is a social platform and no UTM present
emailutm_medium = email
referralutm_medium = referral or non-search/non-social referrer present
directNo 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:

  • email
  • phoneNumber
  • givenName / familyName / nickName
  • dateOfBirth
  • 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:

  • email is included in the client-side identify() call (in AuthGuard.tsx), making users identifiable by email in PostHog and GA4.
  • Server-side sanitizeProperties() / sanitizeTraits() skip the PII_KEYS check, so any PII passed to trackServer or identifyServer is forwarded.
  • PostHog server calls omit the $ip: null override, 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 User create and update queries. 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 QuizResult update queries. 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.tsx for 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

VariableContextDescription
NEXT_PUBLIC_GA4_MEASUREMENT_IDClientGA4 stream measurement ID (G-XXXXXXX)
GA4_API_SECRETServerGA4 Measurement Protocol API secret
NEXT_PUBLIC_POSTHOG_KEYBothPostHog project API key (same key for client and server SDKs)
NEXT_PUBLIC_POSTHOG_HOSTBothPostHog instance host (defaults to https://us.i.posthog.com)
NEXT_PUBLIC_ANALYTICS_ALLOW_PIIBothWhen 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 eventssubscription_started, subscription_renewed, subscription_cancelled, subscription_churned will 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() and identify() signatures are Segment-compatible by design. Migrating to Segment means replacing the wrapper internals without changing any call sites.