PostHog event tracking
A user-facing feature ships with the PostHog events needed to analyse how it is used. Add them in the same change as the feature, not as a follow-up.
What to track
Track the moments someone will ask about later:
- a surface is shown (
view,open) - a user acts on it (
click,select,submit,dismiss,close) - the outcome of that action (
create,delete,connect), and outcomes only the server knows (a limit hit, a request refused, a model downgraded)
Do not track UI plumbing (hover, scroll, re-render). Check what the feature already sends before
adding: git grep -n "trackEvent\|withTracking" front/components/<feature>.
Client events
Use trackEvent or withTracking from @app/lib/tracking, never posthog directly. The event
name is built as ${area}:${object}:${action}:
area: fromTRACKING_AREAS, the product surface. Add a key only for a real new surface.object:snake_case, stable, names the thing acted on (model_picker,suggestion_card). Put variants inextra, not in the object.action: fromTRACKING_ACTIONS, defaults toclick.
tsx<Button onClick={withTracking(TRACKING_AREAS.BUILDER, "create_agent", handleCreate)} /> trackEvent({ area: TRACKING_AREAS.TOOLS, object: "tool_select", action: TRACKING_ACTIONS.SELECT, extra: { tool_id: serverView.sId }, });
A feature with several events gets one <feature>Tracking.ts module next to its components, with
one typed helper per event. Components call the helpers. Models:
front/components/model_picker/modelPickerTracking.ts,
front/components/markdown/suggestion/suggestionTracking.ts.
Views fire from an effect, once per mount. For a list whose items change state, remember the ids
already seen in a ref so each item counts once (model: useTrackSuggestionCardViews):
tsconst seenIds = useRef(new Set<string>()); useEffect(() => { for (const item of items) { if (!seenIds.current.has(item.id)) { seenIds.current.add(item.id); trackItemView(item.id); } } }, [items]);
Server events
Use PostHogServerSideTracking.trackEvent from @app/lib/api/posthog for outcomes the browser
cannot see. Name the outcome in flat snake_case (premium_model_downgraded), always pass
distinctId: user.sId and workspaceId, and never await it: tracking must not slow down or fail
the request.
tsPostHogServerSideTracking.trackEvent({ distinctId: user.sId, event: "trigger_blocked", workspaceId: workspace.sId, extra: { trigger_id: trigger.sId }, });
Properties
extra is a flat Record<string, string | number | boolean>, sent both spread for PostHog and as
one object for Snowflake.
- Carry the id that joins the event to the data it will be analysed with (
batch_id,agent_id), or every id for an action on several items (batch_ids). If that id is not available where the event fires, skip the event. - Ids are
sIds, neverModelIds. Keys aresnake_case, units are in the name (duration_ms). - No PII and no user content: no emails, names, messages, prompts, file names, or typed text.
- Do not repeat what is added for every event (URL, referrer, UTM, user and workspace).
- Prefer a small string enum over a boolean when a third value is plausible
(
surface: "pile" | "single"rather thanin_pile).
Contracts and tests
When analysis relies on a guarantee about events (every event carries a join key, a view is
counted once), state it as a @cc contract on the tracking helper (see the code-contracts
skill).
Test the helpers by mocking only the emit and asserting the exact event:
tsvi.mock("@app/lib/tracking", async (importOriginal) => { const actual = await importOriginal<typeof import("@app/lib/tracking")>(); return { ...actual, trackEvent: vi.fn() }; });
For a view hook, renderHook it, rerender with the same and with changed items, and assert the
call count. Model: front/components/markdown/suggestion/suggestionTracking.test.ts. For server
events, mock PostHogServerSideTracking.trackEvent the same way.


