# Event names that still make sense in twelve months

> Why analytics projects rot into five spellings of "signup", and the object_verb convention plus a typed catalogue that stops it.

Open the event list of any analytics project that has been running for a year
without a convention. You will find something like this:

```
signup
Signup
signup_completed
user_signed_up
userSignedUp
Sign Up Completed
signup_success
signup_v2
```

Eight series. Each one is real data. None of them covers the whole year, the
funnel someone built in March silently stopped counting in June, and the person
who knew which was which left in September. Nobody can answer "how many people
signed up last quarter" without a forensic exercise, so the team stops asking
the question, which is the actual cost of bad naming, not the untidiness.

This does not happen through carelessness. It happens because every event is
added by a different person, in a different sprint, in a hurry, with no
mechanism that shows them what already exists.

## The convention: object_verb, past tense, snake_case

```
subscription_started
checkout_completed
project_created
invite_accepted
onboarding_step_completed
```

Four rules, each earning its place:

**Object first.** Sorting the event list alphabetically then groups everything
about the same object together: every `subscription_*` event sits in one block.
With verb-first names (`created_project`, `completed_checkout`) related events
scatter across the alphabet, and a list of 200 events becomes unusable.

**Past tense.** Events are records of things that already happened. Past tense
also reads correctly in every funnel step label and every insight title, and it
quietly stops people instrumenting intentions as if they were outcomes:
`subscription_start` is ambiguous, `subscription_started` is not.

**snake_case.** One casing, chosen so that nobody has to remember whether this
project uses camelCase or Title Case. The specific choice matters less than
having exactly one; snake_case matches PostHog's own `$pageview` style and
survives being pasted into SQL.

**At least two words.** `clicked`, `error` and `viewed` are not events. If the
name does not contain an object, the event cannot be interpreted without reading
the code that fires it.

## Never put a value in the name

The most expensive naming mistake is encoding data into names:

```
plan_pro_purchased
plan_team_purchased
plan_enterprise_purchased
onboarding_step_1_completed
onboarding_step_2_completed
```

Every new plan silently breaks every chart, because a chart built on the three
names you had in January cannot know about the fourth you added in April. The
same information as properties is one series with a breakdown:

```ts
subscription_started: { plan: string; interval: "month" | "year"; trial: boolean }
onboarding_step_completed: { step: number; step_name: string }
```

Now "revenue by plan" is a breakdown, adding a plan requires no chart changes,
and the funnel keeps its history.

The same applies to ids, emails, URLs and timestamps: they are properties, never
names. A name is a category; a category with a million members is not a
category.

## Names come from the domain, not the interface

Name the object the way your database and your team name it. If the schema says
`project`, the events say `project_created`, even if the current UI calls it a
"workspace" and marketing calls it a "board". Interfaces get renamed roughly
once a year; a rename that touches your event names costs you your history,
while a rename that touches only labels costs nothing.

## The mechanism: make the catalogue a type

Conventions written in a wiki decay. A convention the compiler enforces does
not. Declare every event in one file:

```ts
// src/lib/analytics/events.ts
export interface EventCatalogue {
  /** A visitor started the sign-up form. Browser event. */
  signup_started: { source: "pricing" | "home" | "docs" | "direct" };
  /** The account row exists. Server event, fired after the write. */
  signup_completed: { method: "email" | "oauth"; invited: boolean };
  /** Money moved. Server event, from the payment webhook. */
  subscription_started: { plan: string; interval: "month" | "year"; trial: boolean };
}

export type EventName = keyof EventCatalogue & string;
export type EventProperties<N extends EventName> = EventCatalogue[N];

export const EVENT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;
```

Then make the only capture function in the codebase take those types:

```ts
export function capture<N extends EventName>(name: N, properties: EventProperties<N>): void {
  posthog.capture(name, scrubProperties(properties));
}
```

Now `capture("signup")` does not compile. Neither does
`capture("signup_completed", { method: "magic-link" })`. The five-spellings
problem cannot recur, because the second spelling is a type error in the editor,
before the branch is even pushed.

Add one more field and the catalogue also documents itself:

```ts
export const EVENT_QUESTIONS: Record<EventName, string> = {
  signup_started: "How many visitors reach the form, and from which surface?",
  signup_completed: "What share of started sign-ups become accounts, by method?",
  subscription_started: "How many paid subscriptions started, on which plan?",
};
```

The `Record<EventName, string>` type forces one sentence per event. That single
sentence is what stops a catalogue rotting: an event nobody can justify in a
line is an event nobody will trust in a year, and the moment you cannot write
the sentence is the moment to not add the event.

## A test to keep the convention honest

```ts
import { expect, test } from "vitest";
import { EVENT_NAME_PATTERN, EVENT_QUESTIONS, eventNames } from "@/lib/analytics/events";

test("every event follows object_verb snake_case", () => {
  for (const name of eventNames()) {
    expect(name, `${name} must be object_verb, past tense, snake_case`).toMatch(EVENT_NAME_PATTERN);
  }
});

test("every event documents the question it answers", () => {
  for (const name of eventNames()) {
    expect(EVENT_QUESTIONS[name].length, `${name} needs a real question`).toBeGreaterThan(20);
  }
});
```

Twenty lines, and the convention is now enforced by CI-free local verification
rather than by whoever happens to review the PR.

## When you have to rename anyway

Sometimes the name really is wrong. Renaming splits history: charts on the old
name stop, charts on the new one start. Do it deliberately:

1. Fire both names for one full reporting period: a week, a month, whatever
   your longest routine report covers.
2. Migrate every insight, funnel and dashboard to the new name during that
   window. PostHog's "used in" list on the event tells you what depends on it.
3. Delete the old capture call, and write the change down in a solution doc.
   Somebody will see the seam in a graph two quarters from now and needs to find
   the explanation before they treat it as a product event.

## The pattern to steal

One file that declares every event with its exact property types. One capture
function that takes those types. One sentence per event saying what question it
answers. A regex test over the names. Everything else (the dashboards, the
funnels, the answers you actually wanted) follows from that.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
