Product analytics — the event model
The registry lives in code:libs/analytics/src/events.ts. This document says
what it is for, how it is used, and what was wrong with what it replaced.
Two planes, kept apart on purpose:
Neither replaces the other, and neither is a log. Operator detail — exception
text, failure reasons, prompts, tool arguments — belongs to the log and to the
trace, never to PostHog.
Identity
A person is their Clerk user id. The same id in the marketing site, in the app, and on every server event caused by their request. A workspace is a PostHog group (workspace), carried on every event as $groups.
An agent — an API key, an MCP bearer, a background run — has no human behind it
and is attributed to workspace:<uuid> with actor_kind: 'agent'.
What this replaced, and why it mattered
Every server event used to be attributed toworkspace:<uuid>, and a
posthog.alias() then merged that id into each Clerk user who touched the
workspace. In a multi-seat workspace that merged every teammate into one
PostHog person: per-user activation, per-user retention and seat expansion were
unanswerable, and because an alias cannot be undone the merges already recorded
are permanent. Workspace-level questions are now asked of the group, which is
what a group is for — people are never fused to answer them.
Historical data carries the old identities. Insights that span the cutover should
be built on the group, which is stable across it.
Where an event is emitted
Server-side by default. Anything a request reaches the API for — a topic written, a record moved, a chat answer, an invitation — is captured once, in the API, where the actor is known and no ad-blocker can drop it. A client-side copy of a server write is double counting, and the two copies drift. Client-side only for what the server cannot see:- navigation (
$pageview,$pageleave), - intent that precedes a request or replaces one (
cta_clicked,signup_started,checkout_started,billing_portal_opened), - content reading on the marketing site.
WriteTelemetryInterceptor, configured per
controller with a handler-name → event map (topics.telemetry.ts,
collections.telemetry.ts). Two rules it enforces:
- POST endpoints that are reads are excluded. A query body is not a mutation.
Counting
records/queryandtopics/retrieveas writes is what made a retrieve-heavy agent look like a prolific writer. - An unmapped mutating handler still emits —
workspace_writewith{ domain, action }. Nothing goes dark, and nothing pretends to be a funnel step it isn’t. When one of those starts mattering, promote it to a named event.
record_created,
record_stage_changed) are emitted from RecordsService, which has the
collection’s archetype and the record’s prior status; the route does not.
Naming rules
Enforced bylibs/analytics/src/events.spec.ts, not by convention:
object_verb_past, snake_case —record_stage_changed, neverupdateRecord.- The surface is a property, never part of the name. No
cli_*/mcp_*: one funnel has to be answerable across web, app, api, cli and mcp. - No
$prefix — that namespace belongs to PostHog. - A read is not an event.
- Every event answers a question someone would act on.
surface, origin, actor_kind, workspace_id.
The two funnels the model is shaped around
1. PLG
onboarding_completed is the step to watch: a workspace with a criterion is a
workspace that can be served. is_first_turn on chat_answer_received is the
activation edge — the first answer a workspace ever gets.
checkout_started (client, intent) and subscription_started (Stripe webhook,
money) are deliberately separate: the drop between them is the checkout
abandonment rate, and it needs both halves.
2. Pipeline
record_stage_changed carries from_stage, to_stage, moved_by
(human | agent | api) and age_days. That is enough for stage conversion,
stage velocity and stall detection in PostHog directly, and enough to answer the
question this product exists to answer: is the agent moving the pipeline, or
only the humans?
Migration
libs/analytics/src/legacy.ts maps every old name to its replacement, or to
null when it was retired. Retired means the event described a product Driftless
no longer is (repo linking, skill patterns, pattern validation), or it was
traffic dressed as a decision (per-command CLI reads).
Headlines:
Insights built on an old name keep their history but stop receiving data. Point
them at the replacement; for a retired name, the question it was answering is not
one this product asks.
Privacy
- No PII on events. Email and name are person properties, set once at
identify.signed_upused to carry the email on the event itself — a contact address copied onto every row for no analytical gain. - No exception text.
request_failedcarries the code, the endpoint, the method and the actor. The log has the message, joinable onrequest_id. - No customer content. No topic body, no record field value, no chat text, no invitee address. Stage names and archetypes are configuration, not content.
Adding an event
- Add it to
EVENT_REGISTRYwith its stage, surfaces, description and properties. If you cannot write the description as a question someone would act on, the event does not exist. - Emit it — server-side unless only the client can see it.
pnpm --filter @driftless/analytics test— the naming rules and the size cap are checks, not suggestions.
