← All posts

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_complete
  • item_purchase
  • tutorial_skip
  • session_start
  • ad_watch
  • friend_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 mix purchased and purchase.
  • No event-name versioning in the string. item_purchase_v2 is a smell. Version with a property (schema_version) or migrate properly.
  • No high-cardinality values baked into names. level_complete_42 will 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. Send 42, not "42".
  • Booleans for flags: is_first_purchase: true, not "true" or 1.
  • 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:

eventwhen it firesproperties (type)
level_startplayer enters a levellevel_number (int), mode (string)
level_completelevel clearedlevel_number (int), duration_sec (number), stars (int)
item_purchaseIAP confirmeditem_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. score that 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.