EN

Event Naming Conventions

Analytics & Tracking Standard

Analytics Standard

Name events for the thing that happened, not the code that sent them.

This is the naming standard for every new Segment event across SolidProfessor — platform frontend and backend, SolidCareer, LCMS, Admin, VILT and Live Training. It follows Segment's own Object-Action convention, which is what our destinations expect and what keeps the catalogue navigable as it grows.

Object  Action
A noun, then a past-tense verb. Title Case, spaces between words, no subject. Lesson Viewed · Order Completed · Trial Started

Naming rules

Four rules. They compose into a single name, and together they make events sort, group and read predictably.

01

Object first, action in past tense

The object is the noun the event is about. The action is what happened to it, already completed. Object-first means every event about the same noun sorts together — which is the difference between a browsable catalogue and an alphabetical soup.

Do
  • Lesson Viewed
  • Lesson Bookmarked
  • Assignment Updated
  • Test Attempt Started
Don't
  • View Lesson imperative — reads like a command
  • Viewing Lesson present participle — not a completed fact
  • Bookmark Lesson ambiguous: intent or outcome?
02

Title Case with spaces — and never mix casings

Write events as Title Case With Spaces. Segment normalises to UPPER_SNAKE when it lands in Snowflake, so the warehouse name is derived, not authored. The consistency matters more than the choice: Lesson Viewed, lesson_viewed and Lesson viewed are three separate events with split volume, and nothing downstream will reconcile them for you.

Do
  • Lesson Viewed → LESSON_VIEWED in Snowflake
Don't
  • lesson_viewed
  • lessonViewed
  • Lesson viewed
03

Leave the subject out

Every track call already carries userId (or anonymousId). Putting User in the name restates what the envelope already says, costs characters in every name, and breaks object-first sorting — you get one enormous User * bucket instead of grouping by what the event is about. Drop articles too; A and An carry no information.

Do
  • Lesson Viewed
  • Search Performed
  • School Left
Don't
  • User Viewed A Lesson
  • User Searched For Content
  • User Confirmed Leave School
Exception. Name the subject when it genuinely isn't the acting user — an admin acting on someone else, or a system process. Then the actor is part of the fact: Admin Impersonation Started, Subscription Renewal Charged.
04

No dynamic values, no implementation detail

An event name is a fixed vocabulary term. Anything that varies per call is a property. Anything describing how the UI is currently built will be wrong after the next redesign — the name should survive a refactor.

Do
  • Lesson Viewed { lesson_id: 12345 }
  • Navigation Item Clicked { item: "library" }
  • Assignment Filter Selected { filter_type: "software" }
Don't
  • Viewed Lesson 12345 unbounded event cardinality
  • Sidebar V2 Button Clicked encodes the current build
  • Test Event From Handle Submit names the function, not the fact

Use Segment's reserved spec names where one exists

Segment publishes canonical specs, and destinations key off them. Amplitude, GA4, Meta CAPI and the ad platforms auto-map reserved names — so a reserved name gets you revenue attribution and funnel reporting for free, and a bespoke synonym means writing mappings by hand in every destination, forever. Check the spec before inventing a name.

SpecReserved names we should be usingApplies to
B2B SaaSAccount Created · Account Deleted · Signed In · Signed Out · Invite Sent · Trial Started · Trial Ended · Account Added User · Account Removed UserCompany / school accounts, auth, seat management
EcommerceProduct Viewed · Product Added · Cart Viewed · Checkout Started · Payment Info Entered · Order Completed · Order RefundedSubscription purchase, Student Store, renewals
EmailEmail Delivered · Email Opened · Email Clicked · Email Bounced · UnsubscribedTransactional and lifecycle mail
VideoVideo Playback Started · Video Content Started · Video Content CompletedLesson playback, if we ever instrument it properly
Worth knowing. Order Completed is the single highest-value reserved name we have — it's the hook every revenue and attribution integration looks for. Anything we call a purchase should land on it rather than a synonym.

Push variance into properties, not into new event names

This is the highest-leverage rule in the document. Every new event name is a permanent cost: something to document, map in each destination, and teach every analyst. A new property value is free. A healthy tracking plan runs dozens of events, not hundreds.

The test: if two candidate names differ only by which kind of thing the action applied to, they are one event with a type property.

// One concept, seven event names — our current bookmarking
USER_BOOKMARKED_A_LESSON
USER_UNBOOKMARKED_A_LESSON
USER_BOOKMARKED_COURSE_VERSION
USER_UNBOOKMARKED_COURSE_VERSION
LESSON_BOOKMARKED_CLICKED
COURSE_VERSION_BOOKMARKED_CLICKED
USER_SELECTED_BOOKMARKED_FILTER

// The same coverage in two events
Content Bookmarked   { content_type: "lesson" | "course_version", content_id: … }
Content Unbookmarked { content_type: "lesson" | "course_version", content_id: … }
// (the filter is a filter event, not a bookmark event)

Same pattern in the assignment builder, where the filter dimension was baked into four separate names:

// Four names that differ only by which dropdown moved
USER_SELECTED_A_SOFTWARE_FILTER_WHEN_UPDATING_AN_ASSIGNMENT
USER_SELECTED_A_LANGUAGE_FILTER_WHEN_UPDATING_AN_ASSIGNMENT
USER_SELECTED_A_VERSION_FILTER_WHEN_UPDATING_AN_ASSIGNMENT
USER_SELECTED_A_COURSE_TYPE_FILTER_WHEN_UPDATING_AN_ASSIGNMENT

// One event, one property
Assignment Filter Selected { filter_type: "software" | "language" | "version" | "course_type", value: … }

Note what this buys beyond tidiness: "which filters do people actually use?" becomes a GROUP BY filter_type instead of a four-way UNION across tables — and adding a fifth filter needs no new instrumentation, no new dbt model, and no dictionary entry.

One event, one emitter

An event name belongs to exactly one emitter. Two surfaces firing the same name is the most expensive mistake in this document, because it corrupts the numbers rather than merely making them awkward to query.

Why duplication is worse than it looks

When two sources emit the same name, an analyst who unions them double-counts, and an analyst who picks one silently drops a population. Neither error announces itself. Worse, the split is rarely clean — client-side emission is lossy in ways server-side isn't, so the ratio between the two copies drifts with ad-blocker rates and consent settings and can't be corrected after the fact.

We have this live. USER_VIEWED_A_LESSON fires from both PLATFORM_BACKEND (5.34M rows) and PLT_PROD_FRONTEND (4.84M rows) — 10.2M rows for what reads as one fact, on our highest-volume event. The two are not interchangeable: they share only 16 properties, while the frontend copy carries 23 and the backend copy 72. The frontend has session and device UUIDs and company/school context; the backend has account/partner fields and the full lesson hierarchy. So you cannot union them, and you cannot drop either one without losing fields somebody depends on.

Unresolved. The overlap between those two populations isn't documented, and the row counts don't divide cleanly, so we don't currently know how much of the 10.2M is genuine duplication versus two different populations sharing a name. Establishing that is real work — which is precisely the cost this rule exists to avoid incurring again.

Which side should own an event

The dividing line is intent versus outcome. The client sees interaction the server cannot; the server is the system of record for anything that actually happened.

Server-side — facts & outcomes
  • Order Completed revenue, entitlements, state changes
  • Lesson Viewed progress that must reconcile with the DB
  • Account Added User anything downstream bills on
  • Signed In auth outcomes
Client-side — interaction only
  • Assignment Filter Selected server never sees a dropdown
  • Search Result Clicked which result, in what position
  • Navigation Item Clicked UI intent
  • Checkout Started intent, paired with a server-side outcome

Server-side is the default for anything that matters, because client-side emission loses somewhere between a tenth and a third of traffic to ad blockers and privacy tooling, has an untrustworthy clock, and can be spoofed. At SolidProfessor the case is stronger still: Cookiebot opt-out blocks client-side stats events outright, so a client-emitted fact is structurally lossy here in a way it wouldn't be everywhere.

Send the originating surface, not the emitter

Every event carries origin — the product surface the action came from. This is not "client or server": Segment already tells you that via context.library.name and via which source the event arrived on. Origin answers the question the envelope can't — which of our surfaces did this come from — so one well-owned event can serve every surface instead of being cloned per app.

We already have this as meta_origin, on 149 of 222 events. It carries real surface values, and the backend lesson-view event correctly attributes 1.2M rows to platform-frontend, so the semantics are right. Two things to tighten:

  • Make it required. 43 frontend and backend events don't send it at all, including USER_LOGGED_IN (966k rows) and PASSWORD_RESET (70k).
  • Enumerate the values. Free text drifts — there are already null meta_origin rows on events that otherwise populate it. Current legitimate values: platform-frontend, platform-backend, library-frontend, solidprofessor-portfolio. Adding a surface means adding a value here, deliberately.
Don't call it source. Segment already uses "source" for the thing an event arrives on — a write key and, in the warehouse, a schema. A property called source reads as that and will be misread by anyone who knows Segment. origin or surface is unambiguous.

If two emitters are genuinely unavoidable

Occasionally you can't consolidate — an offline client that syncs later, or a third-party integration that fires independently. Two acceptable ways out, in order of preference:

  1. Deterministic messageId. Segment de-duplicates on messageId within a 24-hour window. Derive it in both emitters from the same business key — a UUIDv5 of the entity IDs plus the occurrence time — and the second copy is dropped at the edge, before it ever reaches a destination. Best-effort and window-bound, so it's a safeguard rather than a guarantee.
  2. Name one canonical and mark the other non-authoritative in the Event Dictionary, then enforce that choice in the dbt staging layer so no consumer has to know. Document it — an undocumented split is how the lesson-view situation arose.

What is never acceptable is two emitters, same name, no recorded decision about which one counts.

Identity: the two global IDs

Every event that needs to reach ChurnZero carries sp_global_user_id and sp_global_organization_id. These are the keys ChurnZero is built on, and without them an event cannot be attributed to a contact or an account.

What they are

Both keys identify a customer in a way that survives the migration from the old platform: where a legacy identity exists it is used, and the 2.0 UUID is the fallback. That's what makes them global — one id per user and per organization, so a customer's history doesn't split in two. ChurnZero is keyed on them: sp_global_user_id is the contact, sp_global_organization_id is the account.

Getting sp_global_user_id from the 2.0 database

Same shape, but keyed on the user:

select coalesce(
         (select m.value
            from meta m
           where m.metable_type = 'user'
             and m.key         = 'legacy_id'
             and m.metable_id   = u.id),
         u.uuid
       ) as sp_global_user_id
  from users u
 where u.id = :user_id;

This one is complete — it produces the correct value for every 2.0 user, with no exceptions and nothing outside plt required.

Getting sp_global_organization_id from the 2.0 database

Rule of thumb — it comes down to where the organization was created:

  • Created in 2.0 → its own uuid is the sp_global_organization_id. There's no legacy identity to prefer, and no meta row to find.
  • Created in the old platform and migrated to 2.0 → the legacy id, read from the account's meta row.

The query is just those two branches in one expression. Note the lookup hangs off the account — keyed on account_id for both commercial and academic — while the fallback uuid comes from the company or school itself:

-- Works for both commercial and academic.
-- Look the legacy id up on the ACCOUNT, then fall back to the org's own uuid.

select coalesce(
         (select m.value
            from meta m
           where m.metable_type = 'account'
             and m.key         = 'legacy_id'
             and m.metable_id   = c.account_id),
         c.uuid
       ) as sp_global_organization_id
  from companies c   -- or: from schools c
 where c.id = :id;

meta also holds legacy_id rows under metable_type = 'company' and 'school'. Don't use those — they're a different identifier, never the organization id.

Why Segment's userId isn't enough

userId on a 2.0 event is the 2.0 identifier. It says nothing about who that customer was before the migration, so it can't join to legacy history and it isn't what ChurnZero is keyed on. The global IDs are the deliberate unification layer; the envelope's identifiers are not a substitute for them.

Missing IDs don't degrade — they delete

The ChurnZero event models join on these keys with an inner join. An event arriving without them, or with a value that doesn't resolve, isn't merely unattributed: the row is dropped and never reaches ChurnZero at all, with nothing raised to say so. That's why these are required rather than recommended — the failure is silent, and it looks like the customer simply wasn't active.

Where we are today

Only 18 of 222 observed events carry both IDs, and all 18 are on PLATFORM_BACKENDUSER_VIEWED_A_LESSON is the reference implementation to copy. No frontend event carries either ID, and 31 of the 49 backend events are missing them, including USER_VIEWED_A_LESSON_FOR_THE_FIRST_TIME at 3.6M rows.

Scope this deliberately. Not every event needs to reach ChurnZero, so this isn't a mandate to add both IDs to all 222. It is a requirement for any event a CSM-facing metric depends on — and the decision belongs in the tracking plan when the event is designed, not discovered later when a ChurnZero number looks low.

Not sure whether it belongs in ChurnZero?

Have the PM ask Customer Success — Samantha Myatt. Don't lead with "does this Segment event need to go to ChurnZero"; that hands a technical question to someone who shouldn't have to answer it. Lead with the outcome you're trying to achieve and ask whether having this data in ChurnZero would help get there. CS knows what they act on; you know what the event can carry.

For context, these requests are normally about Commercial and Academic users.

Property naming

Properties are snake_case, even though events are Title Case. That's what Segment's own specs do, and it matches how the columns arrive in Snowflake.

  • sp_global_user_id and sp_global_organization_id are required on any event destined for ChurnZero. See Identity.
  • origin is required on every event — the originating product surface, from the enumerated list. See One event, one emitter.
  • Use the reserved property names where a spec defines one — order_id, revenue, currency, products, product_id. Same reasoning as reserved event names: destinations map them automatically.
  • Name the unit when a number has one: duration_seconds, not duration.
  • Booleans read as assertionsis_first_view, has_certificate — and are actual booleans, not "true" strings.
  • Send IDs, not just labels. lesson_id alongside lesson_name; names change and can't be joined on.
  • Don't repeat the envelope. userId, timestamp, page and campaign context are already on every call.
  • Keep a property's type stable forever. Changing one from string to number mid-flight splits the warehouse column and is genuinely painful to unwind.
Known trap. On server-side sources the timestamp field is unreliable — all LCMS rows and a large share of PLATFORM_BACKEND arrive as epoch zero. Use received_at for anything time-based in the warehouse until that's fixed.

What isn't an analytics event

The analytics stream is billed, queried and read by non-engineers. Things that aren't product behaviour belong somewhere else.

  • Exceptions and failed infrastructure calls go to error monitoring, not Segment. UNABLE_TO_GENERATE_DEVICE_ID_VIA_FINGERPRINT_JS is 3.8M rows — roughly one in four frontend track events — and no one has ever queried it as behaviour.
  • Test and debug events never ship to production. LIBRARY_TEST_EVENT and TEST_EVENT_FROM_HANDLE_SUBMIT are both live in prod today.
  • Internal control flow isn't behaviour. EVENT_TYPE_NOT_FOUND at 10.5k rows describes our own dispatch failing, not something a user did.

A user failing at something is different, and is a legitimate event — Skill Assessment Failed is a real product outcome. The line is whether a person did something, or whether our code broke.

Existing event names: alias, don't rename

This standard governs new events. Renaming live events is expensive and usually not worth it.

A rename breaks every downstream consumer at once — dbt models, dashboards, ChurnZero and Amplitude configs — and splits the historical series at the cutover, so every trend chart gets a cliff. Segment can't retroactively rename what's already in the warehouse.

The workable path when an old name is actively causing pain:

  1. Alias in dbt, not at the source. Map the legacy name to the conventional one in a staging model and let consumers migrate to the model, not to a new event.
  2. Adopt the convention for genuinely new events only. No dual-firing, no deprecation windows — those double volume and create two sources of truth.
  3. Consolidate only when you're touching the feature anyway. If the bookmarking UI gets rebuilt, that's the moment to collapse seven events into two — not before.
Don't dual-fire. Emitting both the old and new name during a transition doubles the volume, and any analyst who unions them silently double-counts. If a name must change, change it once and note the cutover date in the Event Dictionary.

Adding a new event

Run this before the instrumentation PR, not after.

  1. Does it already exist? Check the Event Dictionary — 222 events already fire, and 187 more are instrumented but never observed.
  2. Should it be a property instead? If it differs from an existing event only by which kind of thing was acted on, add a property to that event.
  3. Is there a reserved spec name? Use it verbatim if so.
  4. Decide which side owns it — server-side for facts and outcomes, client-side only for interaction the server can't observe. If the event already fires from another surface, extend that one with an origin value instead of adding a second emitter.
  5. Name it Object Action — noun, past-tense verb, Title Case, no subject, no articles, no dynamic values.
  6. Define the propertiessnake_case, required origin, IDs alongside labels, units named, types fixed for good.
  7. If it needs to reach ChurnZero, include sp_global_user_id and sp_global_organization_id. Copy USER_VIEWED_A_LESSON on the backend. Unsure whether it belongs in ChurnZero? Have the PM ask Customer Success.
  8. Confirm the destinations. Reaching Segment does not mean reaching Amplitude — 42 events currently fire in Segment and never arrive there. Say explicitly which destinations need it.
  9. Verify it landed. After release, confirm a row in SEGMENT_EVENTS.<SOURCE>.<EVENT_NAME> and add it to the dictionary by re-running the pipeline.

Where we stand

Measured against the 222 events observed firing in SEGMENT_EVENTS as of 2026-08-26. Context for why this document exists — not a backlog.

PatternEventsAgainst this standard
Names prefixed USER_129Rule 03 — subject restates the envelope
Names containing an article (_A_, _AN_)74Rule 03 — no information carried
Subject-Verb-Object word ordermostRule 01 — inverts Object-Action grouping
Bookmark toggle events for one concept6Properties — should be 2
Assignment-filter events differing only by dimension4Properties — should be 1
Event names fired from more than one Segment source4One emitter — incl. USER_VIEWED_A_LESSON at 10.2M combined rows
Frontend/backend events not sending meta_origin43Origin required
Events carrying both global IDs18Identity — all backend; no frontend event has either
Test / debug events live in production2Not events
Longest event name, in characters71USER_CLICKED_EXPLORE_OTHER_ASSESSMENTS_AFTER_FAILING_A_SKILL_ASSESSMENT

None of this is urgent, and per the section above none of it should be fixed by renaming. It's the argument for holding the line on new events so the catalogue stops drifting further.