Skip to content

Product analytics

Product analytics says what people do with your product: who signs up, who invites their team, who pays, which screens they use. The kit sends it to Mixpanel or PostHog from the API, the web app and the mobile app. Each event is named once, in the contracts, and the three apps can only send the events named there. Which provider is one setting; the events, and the code that sends them, are the same with either. To pick one, see Mixpanel or PostHog.

With Mixpanel, create a project and copy its project token from the project’s settings:

Terminal window
MIXPANEL_TOKEN=0123456789abcdef0123456789abcdef
MIXPANEL_REGION=eu # where the project keeps its data; left empty, us

With PostHog, create a project and copy its project API key (phc_…) from the project’s settings:

Terminal window
ANALYTICS_PROVIDER=posthog
POSTHOG_KEY=phc_...
POSTHOG_HOST=https://eu.i.posthog.com # left empty, the US cloud; or your own PostHog's address

The web app and the mobile app read these from GET /api/config when they start, so nothing is built into them and no image has to be rebuilt. Without a key, nothing is sent, and the browser downloads neither provider’s script; with one, it downloads only that provider’s. On Railway, put them in .env.railway.

The mobile app runs mixpanel-react-native in JavaScript rather than Mixpanel’s native SDK, and posthog-react-native is JavaScript only, so either works in Expo Go and needs no new development build.

Event From When
signed_up API An account is created
workspace_created API Someone creates a workspace (teams only)
member_invited API An invitation is sent, with the role
member_joined API An invitation is accepted, with the role
subscription_started API A workspace starts paying, with the plan
subscription_changed API It moves to another plan, with the plan before
subscription_cancelled API It is cancelled, and runs to the end of the period
subscription_resumed API A cancellation is undone
subscription_ended API It is over, and the workspace is back on free
ai_message_sent API The assistant answers
page_viewed Web app The page changes, with its path
screen_viewed Mobile app The screen changes, with its path
upgrade_clicked Web app Someone picks a plan to pay for

People are identified when they sign in, by their user id, with their email and name. Pages seen before then, such as the sign-in page, are anonymous until the provider joins them to the person. While an admin is signed in as someone, nothing is sent.

With teams on, events are grouped by workspace, and each workspace has its name and plan. In Mixpanel the group key is the property workspace_id: add it as a group key in the project’s settings for Mixpanel to show workspaces. In PostHog the group type is workspace, created the first time a workspace is described. Group analytics is a paid add-on with both. With personal accounts nobody is grouped: a workspace is one person.

Payments are filed under the workspace’s first owner, who pays, so a funnel from signed_up to subscription_started follows one person.

Events from the API go out through the job queue, so a request never waits for the provider, and an event is not lost while it is down. Each has an id of its own, sent as Mixpanel’s $insert_id or PostHog’s uuid, which the provider uses to count a repeat once. The API sends no address, so the provider does not guess where people are from the server’s.

The web app never sends the page’s whole address, whose query string can hold a sign-in or reset token: page_viewed carries the path. Autocapture and session replay are off, and with PostHog so are heatmaps, surveys and feature flags. Turn them on in apps/web/src/lib/mixpanel.ts or apps/web/src/lib/posthog.ts if you want them, and say so in your privacy policy.

  1. Name it, with what it carries, in ANALYTICS_EVENTS in packages/contracts/src/analytics.ts. Use snake_case, say what kind of thing happened, and never who to: no emails, names or text people typed. Who did it goes with every event already.

    project_created: z.object({ template: z.enum(['blank', 'kanban']) }),
  2. Send it where it happens. In the API, where it is sure to have happened, inside database.transaction() when it belongs to a change in the database, so it is sent only if the change is saved:

    await this.analytics.track(
    'project_created',
    { template },
    {
    userId: user.id,
    workspaceId: workspace.id,
    },
    );

    In the web app or the mobile app, for what only they see:

    import { analytics } from '@/lib/analytics';
    analytics.track('project_created', { template });

An event or a property that is not in the contracts does not compile, and the API refuses one that does not match.

Each app talks to the provider in one file, behind an interface:

App Interface Mixpanel’s PostHog’s
API AnalyticsProvider in src/analytics src/analytics/mixpanel.provider.ts src/analytics/posthog.provider.ts
Web app AnalyticsClient in the contracts apps/web/src/lib/mixpanel.ts apps/web/src/lib/posthog.ts
Mobile app AnalyticsClient in the contracts apps/mobile/src/lib/mixpanel.ts apps/mobile/src/lib/posthog.ts

Write the other provider’s version of each, add it to ANALYTICS_PROVIDER and its variables in config/env.ts, choose it in src/analytics/analytics.config.ts (which also says what GET /api/config tells the apps), add its shape to analyticsConfigSchema in the contracts, and load it in each app’s lib/analytics.ts.

createTestApp({ teams, analytics: new FakeAnalytics() }) keeps every event the API would send. Run the queue with t.outbox.runJobs(), then read analytics.named('signed_up'). Add analyticsProvider: 'posthog' for GET /api/config to name PostHog. See apps/api/test/analytics.e2e.test.ts; what each provider is sent is tested in mixpanel.provider.test.ts and posthog.provider.test.ts.