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.
Naming rules
Four rules. They compose into a single name, and together they make events sort, group and read predictably.
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.
- Lesson Viewed
- Lesson Bookmarked
- Assignment Updated
- Test Attempt Started
- View Lesson imperative — reads like a command
- Viewing Lesson present participle — not a completed fact
- Bookmark Lesson ambiguous: intent or outcome?
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.
- Lesson Viewed → LESSON_VIEWED in Snowflake
- lesson_viewed
- lessonViewed
- Lesson viewed
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.
- Lesson Viewed
- Search Performed
- School Left
- User Viewed A Lesson
- User Searched For Content
- User Confirmed Leave School
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.
- Lesson Viewed { lesson_id: 12345 }
- Navigation Item Clicked { item: "library" }
- Assignment Filter Selected { filter_type: "software" }
- 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.
| Spec | Reserved names we should be using | Applies to |
|---|---|---|
| B2B SaaS | Account Created · Account Deleted · Signed In · Signed Out · Invite Sent · Trial Started · Trial Ended · Account Added User · Account Removed User | Company / school accounts, auth, seat management |
| Ecommerce | Product Viewed · Product Added · Cart Viewed · Checkout Started · Payment Info Entered · Order Completed · Order Refunded | Subscription purchase, Student Store, renewals |
| Email Delivered · Email Opened · Email Clicked · Email Bounced · Unsubscribed | Transactional and lifecycle mail | |
| Video | Video Playback Started · Video Content Started · Video Content Completed | Lesson playback, if we ever instrument it properly |
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.
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.
- 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
- 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_originrows 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.
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:
- Deterministic
messageId. Segment de-duplicates onmessageIdwithin 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. - 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
uuidis thesp_global_organization_id. There's no legacy identity to prefer, and nometarow to find. - Created in the old platform and migrated to 2.0 → the legacy id, read from the account's
metarow.
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_BACKEND — USER_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.
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_idandsp_global_organization_idare required on any event destined for ChurnZero. See Identity.originis 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, notduration. - Booleans read as assertions —
is_first_view,has_certificate— and are actual booleans, not"true"strings. - Send IDs, not just labels.
lesson_idalongsidelesson_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.
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:
- 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.
- Adopt the convention for genuinely new events only. No dual-firing, no deprecation windows — those double volume and create two sources of truth.
- 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.
Adding a new event
Run this before the instrumentation PR, not after.
- Does it already exist? Check the Event Dictionary — 222 events already fire, and 187 more are instrumented but never observed.
- 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.
- Is there a reserved spec name? Use it verbatim if so.
- 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
originvalue instead of adding a second emitter. - Name it Object Action — noun, past-tense verb, Title Case, no subject, no articles, no dynamic values.
- Define the properties —
snake_case, requiredorigin, IDs alongside labels, units named, types fixed for good. - If it needs to reach ChurnZero, include
sp_global_user_idandsp_global_organization_id. Copy USER_VIEWED_A_LESSON on the backend. Unsure whether it belongs in ChurnZero? Have the PM ask Customer Success. - 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.
- 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.
| Pattern | Events | Against this standard |
|---|---|---|
| Names prefixed USER_ | 129 | Rule 03 — subject restates the envelope |
| Names containing an article (_A_, _AN_) | 74 | Rule 03 — no information carried |
| Subject-Verb-Object word order | most | Rule 01 — inverts Object-Action grouping |
| Bookmark toggle events for one concept | 6 | Properties — should be 2 |
| Assignment-filter events differing only by dimension | 4 | Properties — should be 1 |
| Event names fired from more than one Segment source | 4 | One emitter — incl. USER_VIEWED_A_LESSON at 10.2M combined rows |
Frontend/backend events not sending meta_origin | 43 | Origin required |
| Events carrying both global IDs | 18 | Identity — all backend; no frontend event has either |
| Test / debug events live in production | 2 | Not events |
| Longest event name, in characters | 71 | USER_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.