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.
Turn it on
Section titled “Turn it on”With Mixpanel, create a project and copy its project token from the project’s settings:
MIXPANEL_TOKEN=0123456789abcdef0123456789abcdefMIXPANEL_REGION=eu # where the project keeps its data; left empty, usWith PostHog, create a project and copy its project API key (phc_…) from the project’s
settings:
ANALYTICS_PROVIDER=posthogPOSTHOG_KEY=phc_...POSTHOG_HOST=https://eu.i.posthog.com # left empty, the US cloud; or your own PostHog's addressThe 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.
What is sent
Section titled “What is sent”| 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.
Add an event
Section titled “Add an event”-
Name it, with what it carries, in
ANALYTICS_EVENTSinpackages/contracts/src/analytics.ts. Usesnake_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']) }), -
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.
Use another provider
Section titled “Use another provider”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.
Test it
Section titled “Test it”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.