Skip to content

Analytics and error tracking

PostHog Cloud (US region) receives all analytics, error, and session-replay data from Kwilo AI. This page covers the architecture, identity model, funnels and error capture. How to add an event: the analytics README in MySetu-AI/kwilo-ui.


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 Learn 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

Kwilo Campus has dimensions and screen views but no product events yet. See Known limitations.


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 Learn<br/>kwiloai-b2c apps/mobile<br/><i>app: mobile-learner</i>"]
        campus["Kwilo Campus<br/>apps/mobile-b2b<br/><i>app: mobile-campus</i>"]
    end

    pkgWeb["@kwilo/ui/analytics<br/>posthog-js wrapper"]
    pkgNative["@kwilo/ui-native/analytics<br/>posthog-react-native wrapper"]
    contract["@kwilo/ui/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/ui/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/ui-native/analytics and reset() in identify.ts in @kwilo/ui/analytics 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/ui/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. Kwilo Learn 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:

  • @kwilo/ui/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/ui/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.
  • @kwilo/ui/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.
  • @kwilo/ui-native/analytics — the React Native twin of @kwilo/ui/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.
  • lib/analytics.ts in Kwilo Learn and in apps/mobile-b2b — 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/ui/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 identify.ts in @kwilo/ui/analytics) 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 Learn, 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.

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.


Funnels

Events from apps/site and the web apps only. No mobile step exists yet, so a cross-surface funnel reads a mobile install as a drop-off.

Funnel Steps
Marketing to signup landing_viewed → cta_clicked → signup_started → signup_submitted → email_verified → first_login
Onboarding first_login → onboarding_step_viewed (per step) → onboarding_completed
Engagement first_login or onboarding_completed → ai_tutor_session_started → ai_tutor_message_sent → course_published → lesson_created
Trial to paid engagement_event → plan_viewed → plan_selected

Error tracking

captureError is the single entry point. Every capture passes through the deduplication gate in dedupe.ts in @kwilo/ui/analytics:

  • 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/ui-native/analytics 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: dedupe.ts in @kwilo/ui/analytics 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 Learn 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.


Cloudflare Worker 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.

Both apps read the host from VITE_PUBLIC_POSTHOG_HOST: https://t.kwilo.ai in production, https://us.i.posthog.com when unset.


Sourcemaps

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

It runs @posthog/cli sourcemap inject and sourcemap upload, then deletes the .map files.

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


Environment variables

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.


Known limitations

  • Consent gates analytics. ConsentProvider from @kwilo/ui/consent wraps both apps/site and apps/web; PostHog starts only after the visitor accepts analytics cookies.
  • Backend events not integrated. FastAPI-side events (API calls, background jobs, email sends) are not tracked.
  • 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. Don't build Campus funnels until it has an event catalog.
  • 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.