Skip to content

Analytics and Error Tracking

PostHog Cloud (US region) receives all analytics, error, and session-replay data from Kwilo AI. This document covers the end-to-end architecture, identity model, funnels, and operational runbook for anyone debugging missing events or maintaining the system.


Overview

We track:

  • Funnel events — visitor → signup → onboarding → engagement → paid conversion
  • CTA clicks — every significant button, with location and name metadata
  • Errors — unhandled exceptions, React boundary catches, query/mutation failures, axios errors
  • Pageviews — manual captures on every route change in apps/site and apps/web
  • Session replays — 10 % baseline sample of authenticated apps/web sessions, 100 % of sessions that produce an error; mobile records with all text inputs and images masked

Everything lands in one PostHog Cloud US project (us.i.posthog.com, project 400097). No self-hosting, and no per-app projects — see Why one project.

Production only. No surface reports from staging or local. The PostHog token is injected exclusively into production builds, so a non-production build initialises no client at all rather than sending data that every insight then has to filter out.

Coverage today

Read this table before trusting any mobile number in PostHog.

Capability site web Kwilo Learner Kwilo Campus
Client init + super properties
Error capture ✅ JS layer only ✅ JS layer only
Session replay off by choice ✅ 10 % ✅ masked ✅ masked
Identify n/a ✅ login + hydration ✅ login + hydration
product_line narrowed n/a ✅ static b2c ✅ static b2b
role dimension n/a ✅ super + person ✅ resolved from backend role ✅ resolved from backend role
Institution group n/a n/a
Screen / pageviews ✅ from expo-router segments ✅ from expo-router segments
Product events 3 call sites ❌ none

Product events on Kwilo Campus are the one gap left by design: this pass built the dimensions and screen views a funnel needs, not the event catalog. See Non-goals.


Architecture

Four surfaces, two client stacks, one ingest path, one project.

flowchart TB
    subgraph web["Browser surfaces — posthog-js"]
        site["kwilo.ai<br/>apps/site<br/><i>app: site</i>"]
        webapp["app.kwilo.ai<br/>apps/web<br/><i>app: web</i>"]
    end

    subgraph native["Native surfaces — posthog-react-native"]
        learner["Kwilo Learner<br/>apps/mobile-b2c<br/><i>app: mobile-learner</i>"]
        campus["Kwilo Campus<br/>apps/mobile-b2b<br/><i>app: mobile-campus</i>"]
    end

    pkgWeb["@kwilo/analytics<br/>posthog-js wrapper"]
    pkgNative["@kwilo/analytics-native<br/>posthog-react-native wrapper"]
    contract["@kwilo/analytics-contract<br/>super properties · group types<br/>event naming · zero deps"]

    site --> pkgWeb
    webapp --> pkgWeb
    learner --> pkgNative
    campus --> pkgNative

    contract -.shared types.-> pkgWeb
    contract -.shared types.-> pkgNative

    pkgWeb --> proxy
    pkgNative --> proxy

    proxy["t.kwilo.ai<br/>PostHog managed proxy<br/>defeats ad blockers"]

    proxy --> ph["PostHog Cloud US · project 400097<br/>Events · Persons · Groups · Issues · Replays"]

The contract package carries no runtime dependency in either direction. That is deliberate: React Native must not pull posthog-js or a react-dom peer into the Metro graph, so the shared vocabulary lives in a package with zero dependencies rather than in @kwilo/analytics.

Why one project

All four surfaces report into project 400097. PostHog cannot query across projects — no cross-project funnels, cohorts, or person merge — so splitting by app would make "signed up on web, activated on mobile" unanswerable. PostHog's own guidance reserves projects for environments and recommends super properties for separating surfaces, which is what we do.

Since we do not track staging at all, the usual reason to hold multiple projects does not apply either.

Surface segmentation

With one project, five super properties registered at client init are the only thing separating four surfaces. A dashboard that forgets to filter on app is reading all of them at once.

Property Values Set
app site, web, mobile-learner, mobile-campus at init, static per build
platform web, ios, android at init
product_line b2c, b2b, unknown at init on mobile; on web via registerProductLine() once the role resolves
environment production, staging, development at init
release git SHA (web) or app version (mobile) at init

product_line starts unknown on apps/web because one bundle serves both learners and campus staff. It is narrowed after sign-in rather than guessed from the role union, since the B2C role sits outside TUserRole and a wrong guess mislabels an entire product line silently.

Never call the client's reset() directly — on either platform

posthog.reset() clears registered super properties along with the identity, and both platforms register them exactly once at init. Every surface therefore wraps it: resetAnalyticsIdentity() in @kwilo/analytics-native and reset() in packages/analytics/src/identify.ts both re-register app / platform / product_line / environment / release immediately afterwards.

This matters most on web, which is a SPA: signing out does not reload the page, so a bare posthog.reset() would leave every event for the rest of that tab with no app and no environment — unattributable to any surface in a single-project setup, and unfilterable by environment. role is deliberately not restored: it belongs to the person who just left, and the next sign-in registers it again.

Identity and groups

flowchart LR
    anon["Anonymous<br/>random distinct_id"] -->|identify(userId)| person["Person<br/>distinct_id = backend user id"]
    person -->|group('institution', orgUnitId)| inst["Institution group<br/>name · tier · seat_count"]
    person -->|"resetAnalyticsIdentity()"| anon

    inst -.enables.-> q1["Institution activation<br/>and churn"]
    inst -.enables.-> q2["Per-institution<br/>feature flags"]

The distinct_id must be byte-identical on every surface — PostHog does no normalisation, so user-456 and USER-456 are two unmergeable people. Always pass the backend user id verbatim.

institution groups map onto org_unit. They are what let B2B questions be counted per college instead of per user.

setInstitutionGroup() is called from apps/mobile-b2b/services/auth.ts (login()) and apps/web/src/hooks/useAnalyticsIdentity.ts, both gated on resolveInstitutionTier() from @kwilo/analytics-contract resolving a k12 / higher_ed / corporate tier for the user's org_unit_type. The group is skipped, not sent with a placeholder, when the org unit or its tier is unresolved — a college counted under the wrong tier is worse than one not counted yet. apps/mobile-b2c never calls it. Every B2C user does carry a non-null org_unit_id on the backend, but B2C isn't an institution — there's no group to attach it to, so the field is ignored here by design, not by oversight.

Environment gating

flowchart LR
    prod["Production build"] -->|token injected| client["PostHog client initialises"] --> ph["Project 400097"]
    stg["Staging / preview / local"] -->|token blank| noop["Client stays null<br/>every helper no-ops"]

Web and site read the token from a CI expression that resolves to an empty string unless environment == 'production'. Mobile sets EXPO_PUBLIC_POSTHOG_KEY only in the production EAS profile. Both stacks treat a blank key as "analytics compiled out".

Key components:

  • packages/analytics — shared TypeScript package. Wraps posthog-js, exports typed event constants, identity helpers, error capture, PII scrubbing. All helpers (track, identify, captureError, etc.) import the posthog-js singleton directly — no getClient() indirection.
  • apps/web/src/main.tsx — uses <KwiloAnalyticsProvider apiKey={...} options={buildPostHogOptions(...)}> wrapping <App>. Docs-canonical React pattern from PostHog docs. Provider and direct posthog imports reference the same singleton.
  • apps/site/src/lib/analytics.ts — SSR-safe facade. AnalyticsBootstrap calls initAnalytics() inside useEffect (never at module level) so posthog-js is never bundled into the Node prerender. Re-exports compile-time constants from the @kwilo/analytics/events subpath.
  • apps/workers/ph-relay/ — Cloudflare Worker at t.kwilo.ai. Proxies PostHog API traffic to defeat ad blockers, adds CORS headers, rewrites Set-Cookie domain to .kwilo.ai.
  • KwiloAnalyticsProvider — re-export of @posthog/react's PostHogProvider. Use useAnalyticsClient() (= usePostHog) anywhere inside the Provider tree to access the singleton from React components.
  • packages/analytics-contract — zero-dependency vocabulary shared by browser and native: super-property types, the app / platform / product_line / environment taxonomies, institution group types, the closed verb list, and the TAnalyticsEventName template type. Zero deps so importing it from React Native cannot drag posthog-js or a react-dom peer into Metro.
  • packages/analytics-native — the React Native twin of packages/analytics. Wraps posthog-react-native, owns configureAnalytics, track, identifyUser, resetAnalyticsIdentity, captureException, setInstitutionGroup, and the ErrorUtils global handler. Shared by both mobile apps; neither keeps its own copy.
  • apps/mobile-b2c/lib/analytics.ts and apps/mobile-b2b/lib/analytics.ts — thin per-app entry points. Each calls configureAnalytics() at module scope with its own app and product_line, then re-exports the package. Module scope rather than inside the Provider so non-component callers (the axios interceptor, for one) get a ready client before the tree mounts.

Event naming

category:object_action, lowercase snake_case, present tense, drawn from a closed verb list in the contract package. TAnalyticsEventName enforces the shape at compile time, and event constants are declared as const satisfies Record<string, TAnalyticsEventName> so a bad name fails at its definition rather than at the call site.

practice_hub:priority_tile_click
practice_hub:exam_target_update
quests:greeting_view

Renaming a shipped event orphans its history rather than migrating it, so the type is the guard that keeps names right the first time.


Identity Model

Anonymous session

On the first visit to any *.kwilo.ai page, PostHog generates a random distinct_id and writes it to the ph_kwilo cookie with Domain=.kwilo.ai. The cookie travels automatically between kwilo.ai and app.kwilo.ai — both apps share the same anonymous session.

Login: anonymous → known

apps/web/src/hooks/useAnalyticsIdentity.ts watches the current user via useCurrentUser(). When isFetched becomes true and a user is present, buildIdentity() resolves the backend role through resolveAnalyticsRole() from @kwilo/analytics-contract — the same resolver registerAnalyticsRole() uses on mobile — before calling:

identify({
  id: user.id,
  email: user.email,
  role,           // canonical TAnalyticsRole, resolved via resolveAnalyticsRole()
  orgId: user.organization_id,
  plan,           // mapped via PLAN_MAP
})

identify() (in packages/analytics/src/identify.ts) does two things with that role: posthog.register({ role }) puts it on every later event as a super property, and posthog.identify(user.id, { email, role, org_id, plan }) sets it as a person property. Both mirror registerAnalyticsRole() on native, so role is the one canonical dimension queryable the same way on apps/web, Kwilo Learner, and Kwilo Campus.

An unrecognised backend role (resolveAnalyticsRole() returns null) drops the role, never the person. identify() still runs, so the user keeps a person record, retention and their institution group; only the role dimension is absent. Native behaves identically — identifyUser() is unconditional and only registerAnalyticsRole() bails.

user_role was removed, not renamed — rebuild any insight that uses it

apps/web previously wrote a user_role person property holding a bucketed vocabulary: admin covered unit_manager, org_admin and platform_admin; learner covered both learner and b2c_user; instructor covered instructor and external_educator.

That property is gone. Dual-writing it alongside role was considered and rejected: cohorts filter on value, not key, and the values changed regardless — user_role = 'admin' returns zero from the first release whether or not the key survives. A property that disappears breaks an insight loudly; one whose vocabulary shifts underneath keeps returning plausible, wrong numbers.

Any saved insight or cohort built on user_role must be rebuilt on role, and the three collapsed buckets must be expanded into their canonical members.

Logout

When useCurrentUser() returns no user and a user was previously identified in this session, reset() is called. This clears the PostHog person from the client and generates a new anonymous distinct_id for the next session.

Cross-subdomain

cross_subdomain_cookie: true and cookie_name: 'ph_kwilo' are set in initAnalytics. PostHog writes and reads the cookie on .kwilo.ai (root domain), so it is shared across kwilo.ai, app.kwilo.ai, and staging.kwilo.ai with no extra configuration.


The Four Funnels We Measure

All four are apps/site and apps/web only. No mobile step exists in any of them yet, so a funnel built across surfaces today will read a mobile install as a drop-off rather than a continuation. Anything cross-surface — "signed up on web, activated on Kwilo Learner" — needs the mobile call sites first.

1. Marketing → Signup

landing_viewed
  → cta_clicked (location: landing_hero | pricing_card | navbar | …)
  → signup_started
  → signup_submitted
  → email_verified
  → first_login

Drop-off questions: where do visitors click most? What percentage complete email verification? How many start signup but don't submit?

Fires in: apps/site/src/pages/Landing, apps/site/src/pages/Pricing, apps/site/src/pages/Individuals, apps/web/src/pages/auth/.

2. Onboarding Activation

first_login
  → onboarding_step_viewed (step: education_stage)
  → onboarding_step_viewed (step: subjects)
  → onboarding_step_viewed (step: goal)
  → onboarding_completed

Drop-off questions: which step loses the most users? What is the completion rate by role?

Fires in: apps/web/src/pages/ onboarding flow.

3. Engagement Loops

first_login | onboarding_completed
  → ai_tutor_session_started
  → ai_tutor_message_sent (repeating)
  → course_published
  → lesson_created

Drop-off questions: what fraction of activated users start an AI Tutor session? Do teachers who publish a course return?

Fires in: apps/web/src/pages/shared/AITutorPage, apps/web/src/pages/teacher/.

4. Trial → Paid

engagement_event (any)
  → plan_viewed
  → plan_selected (plan: free | student | pro | enterprise)

Drop-off questions: what triggers the plan view? Which plan is selected most? How long between signup and first paid conversion?

Fires in: apps/site/src/pages/Pricing, apps/web/src/pages/ plan selection.


Error Tracking

captureError is the single entry point. Every capture passes through the deduplication gate in packages/analytics/src/dedupe.ts:

  • Identical errors (same Error.name + Error.message + first stack frame) are suppressed if they repeat within 5 seconds.
  • Hard cap of 50 errors per session — additional errors are silently dropped.

On capture, posthog.captureException is called with the error and a context object (source, url, method, statusCode, queryKey, componentStack as applicable). posthog.startSessionRecording() is called immediately to force full replay capture for that session.

Automatic sources:

Source Capture point
errorBoundary ErrorBoundary.componentDidCatch in apps/web and apps/site
window installGlobalErrorHandlers()window.addEventListener('error', ...)
promise installGlobalErrorHandlers()window.addEventListener('unhandledrejection', ...)
query QueryCache.onError in apps/web/src/lib/query-client.ts
mutation MutationCache.onError in apps/web/src/lib/query-client.ts
axios Response interceptor in apps/web/src/services/api.ts — all non-401 errors

Redacted before capture:

  • Event properties matching /^(password|token|email|phone|otp|secret|authorization|api_key|access_token|refresh_token)$/i are replaced with '[REDACTED]' by the scrubPII before_send hook.
  • Axios URLs matching /\/(auth|payments)\// are replaced with '[PII_REDACTED]'.

Where to find errors in PostHog: navigate to Issues in the left sidebar. Filter by app = web or app = site (super property). Stack traces are symbolicated when the CI sourcemap upload step ran (see CI Sourcemap Upload below).

On mobile

The native apps do not share the web path. captureException({ error, context, tags }) in @kwilo/analytics-native is the single entry point, and it is best-effort by design: it swallows its own failures so a broken telemetry call can never take down the caller, including the global handler that forwards into it.

registerGlobalErrorHandler() wraps React Native's ErrorUtils global handler and chains the previous one rather than replacing it. That is what keeps the dev red box and expo-updates crash reporting working — a handler that returns without calling its predecessor silently disables both.

JS layer only

The ErrorUtils handler sees JavaScript errors and nothing else. A native crash, or anything thrown before the JS bundle finishes evaluating — a renderer/React version mismatch, a missing native module, a bad config plugin — never reaches it. Those are exactly the failures that make an app fail to open at all, and they are invisible here. PostHog's native crash autocapture would raise the floor but not remove it.

There is no dedupe gate and no session cap on mobile: packages/analytics/src/dedupe.ts is browser-side only. A native error inside a render loop will report every occurrence.

Neither mobile app uploads sourcemaps, so mobile stack traces are unsymbolicated against the release bundle.


Session Replay

App Recording enabled Sample rate Masks
apps/web Yes 10 % (replaySampleRate: 0.1) All inputs + [data-ph-mask]
apps/site No n/a n/a
Kwilo Learner Yes 100 % of production sessions All text inputs + all images
Kwilo Campus Yes 100 % of production sessions All text inputs + all images

Mobile replay is configured in configureAnalytics() with maskAllTextInputs and maskAllImages both on, plus captureLog and captureNetworkTelemetry. Masking everything is the deliberate default: these apps show learner names, scores and institution data on ordinary screens, so an opt-out model would leak by default the first time someone adds a screen and forgets.

There is no sample rate on mobile — every production session records, because the token only exists in production builds and the volume is small at current install counts. Revisit that before a large rollout.

apps/web records 10 % of sessions at random. When any error is captured, posthog.startSessionRecording() is called for that session immediately — it upgrades to full recording regardless of the sample roll.

Default masking: maskAllInputs: true masks all <input> and <textarea> content. Text matching [data-ph-mask] is also masked.

To mask additional elements:

<p data-ph-mask>{student.reportCardText}</p>

This is the mechanism for student PII that appears in non-input elements (names, scores, personal details).

Where to find replays in PostHog: navigate to Session Replay in the left sidebar. Filter by app = web. To find the replay for a specific error, open the Issue in the Issues view — related replays are linked from the issue detail panel.


CF Worker Reverse Proxy

The worker at apps/workers/ph-relay/src/index.ts is deployed to t.kwilo.ai (Cloudflare Worker route t.kwilo.ai/*).

Why it exists: many browser extensions (uBlock, AdBlock Plus, Ghostery) block requests to *.posthog.com and *.i.posthog.com by domain pattern. Routing PostHog traffic through a first-party subdomain (t.kwilo.ai) defeats these blocks.

What the worker does:

  1. Validates the Origin header against an allowlist (kwilo.ai, app.kwilo.ai, staging.kwilo.ai, app-staging.kwilo.ai). Other origins receive Access-Control-Allow-Origin: https://kwilo.ai.
  2. Strips incoming headers to the ALLOWED_HEADERS set: content-type, content-length, accept, accept-encoding, accept-language, user-agent, referer, origin. Forwards CF-Connecting-IP as X-Forwarded-For.
  3. Forwards to us.i.posthog.com (API) or us-assets.i.posthog.com (static assets at /static/ and /array/ paths).
  4. Caches static asset responses in Cloudflare's default cache.
  5. Rewrites Set-Cookie domain values from whatever PostHog sets to Domain=.kwilo.ai so the session cookie is scoped to the Kwilo root domain.
  6. Attaches CORS headers to every response.

Deploy:

cd apps/workers/ph-relay
pnpm wrangler deploy

The CI workflow .github/workflows/ph-relay-worker.yml auto-deploys on push to main or staging when files under apps/workers/ph-relay/ change.

Current apiHost in both apps: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, which defaults to https://us.i.posthog.com in local dev. In production the env var should be set to https://t.kwilo.ai to route through the worker. Verify by checking whether VITE_PUBLIC_POSTHOG_HOST is set in the CF Pages environment variables for each project.


CI Sourcemap Upload

Both web-cf-pages.yml and site-cf-pages.yml include a sourcemap upload step that runs only on main and staging branches (not PR preview deploys):

- name: Upload sourcemaps to PostHog
  if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging'
  env:
    POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_CLI_TOKEN }}
  run: |
    npx --yes @posthog/cli@latest sourcemap inject --directory dist
    npx --yes @posthog/cli@latest sourcemap upload --directory dist
    find dist -name "*.map" -type f -delete

The step: 1. Injects PostHog sourcemap references into the compiled JS. 2. Uploads .map files to PostHog so stack traces in Issues are symbolicated. 3. Deletes .map files from the dist/ directory so they are not served publicly.

POSTHOG_CLI_TOKEN must be a PostHog project API key with sourcemaps:write scope. Set it in GitHub → repo Settings → Secrets → Actions.

apps/site uploads from dist/client/ (SSR output). apps/web uploads from dist/.


Env Vars

Variable Used in Purpose Where to set
VITE_PUBLIC_POSTHOG_PROJECT_TOKEN apps/web, apps/site PostHog project token (public ingest key, public-safe). Without this, initAnalytics no-ops silently. CF Pages environment variables for kwilo-web and kwilo-site projects
VITE_PUBLIC_POSTHOG_HOST apps/web, apps/site PostHog API endpoint. Set to https://t.kwilo.ai in production to route through the Worker proxy. Defaults to https://us.i.posthog.com if unset. CF Pages environment variables
VITE_GIT_SHA apps/web, apps/site Injected by CI as github.sha. Registered as the release super property on every PostHog event, used to correlate events with deployments and to match sourcemap uploads. CI workflow env block (VITE_GIT_SHA: ${{ github.sha }})
POSTHOG_CLI_TOKEN CI only PostHog project API key with sourcemaps:write scope. Used by @posthog/cli to upload .map files. GitHub Actions Secrets → POSTHOG_CLI_TOKEN
EXPO_PUBLIC_POSTHOG_KEY both mobile apps Same project token. Blank by default, so a local or preview build constructs no client at all. eas.json, production profile only
EXPO_PUBLIC_POSTHOG_HOST both mobile apps Ingest host. Defaults to https://t.kwilo.ai. eas.json, production profile
EXPO_PUBLIC_ENVIRONMENT both mobile apps Registered as the environment super property. Anything unrecognised falls back to development, so a build that forgets it is never miscounted as production. eas.json, production profile
EXPO_PUBLIC_RELEASE both mobile apps The release super property. Falls back to the app.json version when EAS injects no commit. EAS build env

Every EXPO_PUBLIC_* value ships inside the binary

Expo inlines these at build time and they are extractable from any distributed APK or IPA. That is acceptable for the PostHog ingest token, which is public by design. It is not acceptable for anything else — see the Cloudflare Access note in apps/mobile-b2c/CLAUDE.md.


PostHog Dashboards

Recommended funnel insights to build in PostHog:

  1. Top-of-funnel acquisition: landing_viewedsignup_startedsignup_submittedfirst_loginonboarding_completed. Break down by cta_name on the cta_clicked step to see which CTAs drive conversions.

  2. Onboarding completion by step: onboarding_step_viewed (step=education_stage)onboarding_step_viewed (step=subjects)onboarding_step_viewed (step=goal)onboarding_completed. Identifies where users drop off in onboarding.

  3. Engagement depth: first_loginai_tutor_session_startedai_tutor_message_sent (3+ times). Shows users who engage meaningfully with AI Tutor after login.

  4. Trial-to-paid conversion: plan_viewedplan_selected. Break down by plan property to see plan distribution.

  5. CTA effectiveness by location: Create an Insights table for cta_clicked, grouped by location and cta_name. Sort by count to identify top-performing CTAs.


Troubleshooting

Events not appearing in PostHog Live Events

  1. Check that VITE_PUBLIC_POSTHOG_PROJECT_TOKEN is set in the app's CF Pages environment variables. An empty or missing key causes initAnalytics to return early without initialising.
  2. Open the browser console and run window.posthog?.get_distinct_id(). If it returns a string, the client initialised. If it returns undefined, initAnalytics did not run.
  3. Check the console for errors during init — network failures, CORS rejections, or PostHog's own debug output (enable with debug: true).
  4. Disable ad blockers or test in an incognito profile without extensions. If events appear only without the blocker, the Worker proxy is not routing correctly — check that VITE_PUBLIC_POSTHOG_HOST points to https://t.kwilo.ai.

Session replay not recording

  1. Confirm enableReplay: true is passed in the initAnalytics call for the app. apps/site deliberately has enableReplay: false.
  2. In PostHog, go to Settings → Session Replay and verify that disable_session_recording is not forced off at the project level.
  3. The 10 % sample rate means most sessions won't record. To force recording for testing, call window.posthog?.startSessionRecording() in the browser console.

No mobile events in PostHog at all

  1. Confirm the build is a production EAS build. Preview, development and local builds carry a blank EXPO_PUBLIC_POSTHOG_KEY on purpose, and configureAnalytics() returns early without constructing a client — every helper then no-ops silently. This is the single most common cause.
  2. Confirm you are not looking for events that were never written. Kwilo Campus emits no product events and never identifies; Kwilo Learner has three call sites. Errors and replays are the only mobile data flowing today — see Coverage today.
  3. Filter on app = mobile-learner or app = mobile-campus, not on the app name. All four surfaces share one project.

Mobile events arrive but have no person attached

Expected on Kwilo Campus: it never calls identifyUser(). On Kwilo Learner, identify runs inside completeAuthSession(), so it only fires on a token-issuing flow — a cold start that restores an existing session relies on the distinct_id PostHog persisted to MMKV, which survives relaunches but not an app reinstall or a cleared data store.

Errors not symbolicated (raw minified stack traces)

  1. Check the CI log for the "Upload sourcemaps to PostHog" step. It only runs on main and staging — PR preview deploys do not upload sourcemaps.
  2. Verify POSTHOG_CLI_TOKEN is set in GitHub Actions Secrets and has sourcemaps:write scope.
  3. Confirm VITE_GIT_SHA was set during the build — the CLI matches sourcemaps to events by the release identifier.

Person never identified (events appear under anonymous distinct_id)

  1. Confirm useAnalyticsIdentity() is called in the app tree. In apps/web it lives in src/App.tsx or a layout component.
  2. Check that useCurrentUser() resolves — open React DevTools and confirm the query returns a user object after login.
  3. Check the role mapping: roles platform_admin, org_admin, unit_manager map to 'admin'. Any unrecognised role string causes buildIdentity to return null and identify is not called.

All web tracking dead (no events, no pageviews)

  1. In the browser console: window.posthog?.get_distinct_id() — should return a UUID string.
  2. If undefined, initAnalytics did not run. Check apps/web/src/main.tsxinitAnalytics is called at module level only when import.meta.env.VITE_PUBLIC_POSTHOG_PROJECT_TOKEN is truthy.
  3. Check Network tab for requests to us.i.posthog.com or t.kwilo.ai. A blocked request appears as a CORS error or net::ERR_BLOCKED.

Developer guide

Adding tracking to a new feature

Decision flow:

  • New CTA button? → trackCta({ cta_name: CTA_NAMES.x, location: CTA_LOCATIONS.y })
  • New conversion milestone? → trackConversion({ event: EVENT_NAMES.x, properties: {...} })
  • Page mount metric? → useEffect(() => trackConversion({ event: EVENT_NAMES.x }), [])
  • Ad-hoc or non-enumerated event? → track({ event: EVENT_NAMES.x, properties: {...} })

Steps:

  1. Decide the event or CTA name. Add it to packages/analytics/src/events.ts in EVENT_NAMES or CTA_NAMES (whichever applies).
  2. If the call site is a new location, add it to CTA_LOCATIONS in the same file.
  3. Import and call at the trigger site — button onClick, useEffect, mutation onSuccess, etc.
  4. Run pnpm --filter @kwilo/analytics test and pnpm --filter @kwilo/web exec tsc --noEmit to verify no type errors.
  5. Verify events fire in browser per "Verifying events fire" below.

Example — CTA on a new paywall modal:

import { trackCta, CTA_NAMES, CTA_LOCATIONS } from '@kwilo/analytics'

function PaywallModal({ onClose }: { onClose: () => void }) {
  return (
    <Button
      variant="primary"
      onClick={() => {
        trackCta({ cta_name: CTA_NAMES.pricing_select_pro, location: CTA_LOCATIONS.pricing_card })
        onClose()
      }}
    >
      {t('paywall.upgrade')}
    </Button>
  )
}

For a conversion milestone on page mount:

import { useEffect } from 'react'
import { trackConversion, EVENT_NAMES } from '@kwilo/analytics'

useEffect(() => {
  trackConversion({ event: EVENT_NAMES.plan_viewed })
}, [])

Verifying events fire (local + staging)

  • Open browser DevTools console on the dev or staging URL.
  • Run window.posthog.get_distinct_id() — should return a non-null string. If it returns undefined, initAnalytics did not run; check VITE_PUBLIC_POSTHOG_PROJECT_TOKEN is set and <KwiloAnalyticsProvider> wraps the tree.
  • Click the CTA or trigger the flow. In the Network tab, filter by posthog — look for a POST to /e/. Status should be 200.
  • Open https://us.posthog.com → ActivityLive events. Your event appears within ~2 s. Filter by Person → Email to find your session.
  • For session replay: trigger the flow, wait ~10 s, then refresh. Check PostHog → Replay tab. Replays appear within 1-2 min.

Reading the dashboard as a dev

  • Insights: build custom queries. Use Funnels for ordered conversion steps, Trends for volume over time, Retention for cohort survival.
  • Funnels: add events in order. Don't insert cta_clicked between conversion events unless you explicitly want that step in the funnel — funnels only count sessions that hit every declared step in sequence.
  • Replay → filter by $exception_message: jump straight to sessions that errored without searching manually.
  • Persons: search by email. See every event, every property, and the merge history (anonymous distinct_id → identified user id).

Debugging when events are missing

Symptom Likely cause Fix
No events at all window.posthog is undefined Check VITE_PUBLIC_POSTHOG_PROJECT_TOKEN is set in env. Check <KwiloAnalyticsProvider> wraps apps/web or initAnalytics() ran in apps/site.
Events fire locally but absent in dashboard Ad-blocker eating requests Open incognito with extensions off. If events appear, VITE_PUBLIC_POSTHOG_HOST is not pointing to https://t.kwilo.ai — the Worker proxy is not active.
Person not identified identify() never called window.posthog.get_distinct_id() returns an anonymous UUID. Check useAnalyticsIdentity is mounted and useCurrentUser() resolves to a user object. Check resolveAnalyticsRole() — an unrecognised backend role returns null, buildIdentity() returns null, and identify is never called.
Replay missing enableReplay: false or sample rate apps/site is replay-off by design. On apps/web, force recording with window.posthog?.startSessionRecording() in the console to test without waiting for the 10 % sample roll.
Error not captured PostHog dedupe gate Identical errors (same message + first stack frame) are suppressed if they repeat within 5 s. Test with a distinct error: posthog.captureException(new Error('test-' + Date.now())) from the console.

Known Limitations / Non-Goals

  • No consent banner. DPDP Act 2023 requires user consent before tracking. This is a known gap, owned by the founders, not this package. Do not implement consent logic here without founder sign-off.
  • Backend events not integrated. FastAPI-side events (API calls, background jobs, email sends) are not tracked. Planned as a separate package in phase 2.
  • Kwilo Campus has no product event catalog. apps/mobile-b2b is wired for identity, role, institution group, and screen views, but has zero track() call sites — building an event catalog for it was explicitly out of scope for this pass. Do not build Campus funnels until that lands.
  • user_role was removed. packages/analytics/src/identify.ts now sends only role. The old property held a bucketed vocabulary that collapsed platform_admin / org_admin / unit_manager into one 'admin' value. Any insight or cohort built on it must be rebuilt on role with those buckets expanded — see the callout in Identity and groups for why dual-writing was rejected.
  • Mobile stack traces are unsymbolicated. Neither mobile app uploads sourcemaps, so a native issue in PostHog shows minified frames. Web and site both upload via @posthog/cli in CI.
  • analyticsEvent() is unused. The contract ships a builder that mechanically enforces category:object_verb, but every call site writes its event name by hand and relies on the satisfies constraint, which accepts any conforming string regardless of whether the key describes it. Adopt it when the event catalog lands.
  • Feature flags / experiments. The PostHog JS client supports feature flags. No flag-gated UI exists today. Do not add isFeatureEnabled calls without coordination.
  • Self-hosting not pursued. PostHog Cloud US is the deployment target. No plans to self-host.

Where to Learn More

  • PostHog JS SDK: https://posthog.com/docs/libraries/js
  • PostHog Error Tracking: https://posthog.com/docs/error-tracking
  • Session Replay privacy controls: https://posthog.com/docs/session-replay/privacy
  • Sourcemap upload CLI: https://posthog.com/docs/error-tracking/sourcemaps