Designing an Event Taxonomy That Scales
Most analytics problems aren’t query problems. They’re naming problems. You sit down six months in to answer “how many people bought something after finishing the tutorial?” and discover you have Tutorial Complete, tutorial_done, complete_tutorial, and a stray OnboardingFinished — all firing from different builds, none of them comparable. No SQL is going to save you from that.
An event taxonomy is the contract between the code that emits events and the people who later have to make sense of them. Get it right early and it pays compounding dividends. Get it wrong and you pay an exponential tax in cleanup, confused dashboards, and “wait, which event do I trust?” arguments that never fully resolve.
Here’s the convention we recommend and the reasoning behind each part.
Use snake_case verb_object for every event name
Pick one casing and one grammatical shape, and never deviate. Our convention is snake_case with a verb_object structure:
level_completeitem_purchasetutorial_skipsession_startad_watchfriend_invite
The verb_object order (object-after-verb, both present) does real work. It groups related events when you sort alphabetically — every level_* event sits together — and it forces you to name the action, not just the noun. level is not an event. level_complete, level_start, and level_fail are events. The thing that happened is in the name.
Avoid casing soup. levelComplete, Level Complete, and level-complete are three different strings to a database, and you will eventually have all three if you don’t standardize. snake_case sidesteps the space-vs-camel debate, survives copy-paste into SQL without quoting, and reads cleanly in a column header.
A few rules that keep the namespace sane:
- Past tense or imperative, but pick one. We use the bare verb (
purchase,complete), which reads as “the moment this happened.” Don’t mixpurchasedandpurchase. - No event-name versioning in the string.
item_purchase_v2is a smell. Version with a property (schema_version) or migrate properly. - No high-cardinality values baked into names.
level_complete_42will generate thousands of distinct event names and break every “events over time” chart. The level number is a property, not part of the name.
That last point is the most common mistake, so let’s talk properties.
Push detail into typed properties, not event names
The event name answers what happened. Properties answer the details. The instinct to encode detail into the name (purchase_gold_pack_large) feels convenient and is a trap — you lose the ability to aggregate, and your event list balloons.
Instead, fire one item_purchase event with structured properties:
event: item_purchase
properties:
item_id: "gold_pack_large"
item_category: "currency"
price_usd: 4.99
currency_qty: 1200
payment_method: "apple_iap"
is_first_purchase: true
Now you can answer “revenue by category,” “average price per purchase,” and “first-purchase conversion” without touching the event name. Properties use the same snake_case convention as event names — consistency means nobody has to guess whether it’s itemId or item_id.
Keep numbers numeric and booleans boolean
This is the rule people regret skipping. Store numbers as numbers, not strings. price_usd: 4.99 (a number) lets you SUM, AVG, and bucket it. price_usd: "4.99" (a string) forces a cast in every query, breaks silently when someone sends "$4.99" or "4,99", and quietly ruins your revenue rollups.
The same goes for the rest of your types:
- Numbers for anything you’ll do math on:
price_usd,level_number,session_length_sec,coins_balance. Send42, not"42". - Booleans for flags:
is_first_purchase: true, not"true"or1. - Strings for identifiers and categories:
item_id,country,ab_variant. - Timestamps in one format everywhere — Unix epoch seconds or ISO 8601, chosen once.
A quick gut check: if you’d ever want to SUM or AVG it, it must be a number. If you’d ever want to GROUP BY it, it’s probably a string. A property that’s a number you only group by (like level_number) can go either way — just be consistent.
Because Keentics stores raw events in your own ClickHouse instance and gives you restricted read-only SQL over them, types aren’t cosmetic. A column typed as a number lets the engine do real aggregation; a stringified number means casting in every query and a class of bugs that only show up in production dashboards.
Maintain a tracking plan as the source of truth
A taxonomy that lives only in code is a taxonomy that drifts. Keep a tracking plan — a single document or spreadsheet listing every event, its trigger, and its expected properties with types. One row per event:
| event | when it fires | properties (type) |
|---|---|---|
level_start | player enters a level | level_number (int), mode (string) |
level_complete | level cleared | level_number (int), duration_sec (number), stars (int) |
item_purchase | IAP confirmed | item_id (string), price_usd (number), is_first_purchase (bool) |
Review the plan before adding any new event. Ask: does an event for this already exist? Does the new property belong on an existing event instead of a new one? This five-minute check is what stops tutorial_done from being born next to tutorial_complete.
Plan for change without breaking history
Your taxonomy will evolve. The goal is to evolve it additively:
- Add new properties freely — old rows just have null for them.
- Never repurpose an event name or property to mean something new.
scorethat meant “points” in v1 and “rank” in v2 is a permanent landmine. - Deprecate, don’t delete. When you retire an event, mark it dead in the tracking plan and stop emitting it. The historical rows stay valid.
If you absolutely must change the meaning of something, ship a new name (checkout_complete replacing an overloaded purchase) and migrate, rather than mutating the old one in place.
Good taxonomy is boring on purpose. The payoff shows up later: when a new analyst can read an event name and know exactly what it means, when a funnel just works because the step events were named consistently, and when nobody has to write a 40-line CASE statement to undo six months of naming entropy.
FAQ
Should event names be past tense (level_completed) or present (level_complete)?
Either works — what matters is that you pick one and enforce it everywhere. We use the bare verb (level_complete) because it reads as the moment of the action and is slightly shorter. Mixing tenses is the actual problem.
What about properties that only sometimes apply, like a discount code?
Include them only on the events where they’re meaningful and let them be absent elsewhere. A discount_code property on item_purchase that’s null for full-price buys is fine. Don’t invent a separate item_purchase_discounted event.
Is it ever OK to put a value in the event name, like button_click_play?
No — use button_click with a button_id: "play" property. Otherwise every button becomes its own event name, your event list grows unbounded, and “total button clicks” becomes impossible to chart.