Most product analytics is a pile of interface events. button_clicked, page_viewed, modal_opened, with a label property carrying whatever text was on the control. It accumulates fast, it looks like data, and it answers exactly one class of question: what did people touch.
The questions anyone actually asks are different. Did people finish the thing they started. Which step loses them. Are the accounts that stay doing something the accounts that leave are not. Those are questions about the domain, and an event stream that only knows about the DOM cannot answer them without a layer of guesswork on top.
There is a second cost, and it is the one that eventually forces the rewrite: interface events are coupled to the interface. Split one screen into two steps and every funnel built on page views is quietly wrong. Rename a button and a chart goes flat. Nothing errors. The numbers just stop meaning what the dashboard says they mean.
Two kinds of event, and only one of them is durable
| Interface event | Domain event | |
|---|---|---|
| Example | button_clicked, label “Send invoice” | invoice_sent |
| Describes | A gesture against a control | A state change in the business |
| Survives a redesign | No | Yes |
| Emitted from | The click handler | The place the state actually changed, ideally the server |
| Answers | Is this control discoverable | Did the job get done |
Both are legitimate. Interface events are the right tool for a specific, bounded question: is this control findable, is this empty state working, did anyone notice the new entry point. Treat them as temporary instrumentation with an owner and an expiry, the way you would treat a debug log.
Domain events are the permanent record, and the location matters as much as the name. An invoice_sent emitted from a click handler fires when someone clicked, not when an invoice was sent. Those diverge exactly where you care most: on failures, on retries, on the double submit, on the request that timed out client-side but succeeded server-side. Emit domain events where the state changed.
If an event can fire when the thing did not happen, it is not measuring the thing.
The schema is a contract, so write it like one
Event streams rot in a predictable way. Someone needs a number, adds an event, names it in their own style, and ships. Six months later there are four events for the same action, two of them misspelled, and a query that has to UNION them with a comment explaining the history. The cure is a contract enforced at the boundary rather than a naming document nobody reads.
// One registry, checked at emit time. An unregistered name throws in
// development and is dropped with a counter in production, so a typo
// shows up as a metric rather than as a silently forked event stream.
export const EVENTS = {
"invoice_sent": {
version: 2,
required: ["invoice_id", "account_id", "amount_minor", "currency"],
optional: ["template_id"],
},
"project_created": {
version: 1,
required: ["project_id", "account_id", "source"],
optional: [],
},
};
export function emit(name, props, { key }) {
const spec = EVENTS[name];
if (!spec) return reject("unregistered_event", name);
const missing = spec.required.filter((k) => props[k] === undefined);
if (missing.length) return reject("missing_properties", name, missing);
const extra = Object.keys(props).filter(
(k) => !spec.required.includes(k) && !spec.optional.includes(k)
);
if (extra.length) return reject("unregistered_properties", name, extra);
return sink.write({
name,
version: spec.version,
// idempotency: the same logical event retried must not double count
event_id: hash(name, key),
occurred_at: props.occurred_at ?? now(),
...props,
});
}
Four decisions in there earn their place:
- Names are
object_verbin the past tense.invoice_sent, notsend_invoiceorInvoice Sent. Past tense because an event is a record of something that already happened, and object first because it groups every event about a noun together when sorted. - A version on every event. When the meaning of an event changes, bump it rather than mutating history. Queries that need the old definition can still find it.
- Unregistered properties are rejected. This feels harsh for about a week, then it is the reason your table does not have 340 columns of which 300 are null.
- An idempotency key derived from the logical event. A retried request, a replayed queue message, and a client that fired twice all produce the same
event_id, and the warehouse deduplicates instead of inflating your counts.
Note also occurred_at being passed in rather than always stamped at write time. When a batch job or a replay emits events for things that happened yesterday, the difference between when it happened and when you recorded it is the difference between a correct chart and a spike on the day of the deploy. Keep both timestamps.
Funnels belong on milestones, not on screens
A funnel defined as “viewed step 1, viewed step 2, viewed step 3” is a description of your current routing. Redesign the flow and the funnel is silently measuring something else, usually while still rendering a plausible-looking chart, which is worse than breaking.
Define the funnel as the sequence of domain milestones the user has to reach: account_created, workspace_configured, first_project_created, first_invite_sent. Those are true whether the flow takes three screens or one, whether it is a wizard or a checklist, and whether someone completes it in the app or through an import. When the design changes, the funnel keeps meaning the same thing, which is the entire point of instrumenting the domain.
Identity, and the temptation to rewrite history
Events before sign-up belong to an anonymous identifier; events after belong to a user. Joining the two is the part everyone gets wrong, usually by going back and rewriting the earlier rows to carry the new user id.
Do not do that. Mutating recorded history means your warehouse can no longer be rebuilt from the event log, which was the one property making it trustworthy. Emit an explicit alias event instead, stating that anonymous id A and user id B are the same person as of this moment, and resolve identity at query time. It costs a join and it keeps the log append-only.
The same discipline answers a question that will arrive eventually: when someone asks to be deleted, an append-only log with identity resolved at query time gives you one place to break the link, rather than a search through every derived table for copies of the identifier.
What not to put in an event
Event streams get copied into more systems than any other data you own: a warehouse, a product analytics tool, probably a couple of dashboards, occasionally a spreadsheet. Everything in them should be safe in all of those places, which rules out more than people expect.
- Free text a user typed. Note bodies, search queries, file names, message contents. It will contain something sensitive eventually, and by then it is in five systems.
- Anything you would have to redact later. If the answer to “could this be in a support screenshot” is no, it is not an event property.
- Identifiers as names.
customer_acme_corp_clickedis an event name with data smuggled into it, and it will produce one column per customer somewhere downstream. - Money as a float. Integer minor units and a currency code. This is not an analytics rule, it is arithmetic, and it still shows up in event schemas constantly.
Two numbers that tell you whether anyone owns this
Monitoring an event pipeline for uptime is easy and nearly useless, because the failure mode is not the pipeline stopping. It is the pipeline running perfectly while the data drifts away from meaning anything. Two metrics catch that:
- Rejected events per day, by reason. Unregistered names and missing properties are a code smell arriving as a number. A spike after a deploy means someone shipped instrumentation that never worked, and you find out that day instead of when an analyst tries to use it.
- Count of distinct event names, tracked over time. If it only grows, nobody is retiring anything, and the registry is a landfill with a schema. A healthy stream loses events as features are removed.
Both of these are cheap, and both work because they measure the thing that actually decays. A pipeline that is up, validating, and shrinking as often as it grows is one you can still answer questions with in two years, which is the only reason to build it.

