api-events/src/observability/analytics.ts (trackProductEvent /
trackBillingEvent).
The goal is that an agent (or engineer) can rederive an event’s name from the
code that emits it — AcceptInvitation ⇒ invitation_accepted — and infer
what an event tracks from its name alone, with no tribal context.
The grammar
- Shape:
<entity>_<past-participle>, snake_case. The entity noun is the API/domain noun (invitation,ledger_item,crew_offer,subscription), never the UI label (invite,finance item,gig). UI vocabulary varies by surface and era; the domain noun doesn’t. - One emitter per action. Server-backed state transitions are emitted by
the backend only, at the service-layer transition
(
api-events/src/observability/analytics.ts). Clients must never re-capture an API call’s outcome — the backend event already fired on that exact path, and the duplicate double-counts. Clients own only interactions with no server-side state transition (views, navigation, flow progress, client-only telemetry). - Paired outcome events, not an outcome property. Symmetric outcomes are
two events (
invitation_accepted/invitation_declined), never one event with aresponseproperty. Funnels and conversion insights need distinct event names. A single crew-offer respond call that both accepts and declines days emits bothcrew_offer_acceptedandcrew_offer_declined(withis_mixed_response/ day-count properties) — still paired names, not onecrew_offer_respondedevent. - Verbs come from the registered vocabulary. Current set:
created,updated,deleted,viewed,sent,accepted,declined,published,cancelled,completed,settled,filled,reopened,reversed,initiated,started,abandoned,failed,voided,generated,revoked,copied,lost,added,synced,rejected,requested,applied,delivered,clicked,complained,saved,answered,selected,revised,reviewed,inspected,shown,withdrawn,confirmed,opened,cleared,dismissed,ended, plus the phrasalsigned_up/signed_in. Need a new verb? Add it toREGISTERED_VERBSinscripts/analytics/taxonomy-check.mjsand here in the same PR — don’t coin one silently. CI fails on unregistered verbs. - Observation events are a distinct class. Periodic or stateless metrics
(
network_density_snapshot,customer_service_unavailable,clock_contract_snapshot,ota_update_check) take no action verb. Name them<metric>_snapshotor<condition>, document them as non-action events, and never mix them into action funnels. - Lifecycle families read as state machines. A family shares one entity
prefix and its events map 1:1 to state transitions
(
event_created → event_published → event_completed → event_settled, withevent_cancelled/event_reopenedas branches). Parallel families stay isomorphic:invitation_*andcrew_offer_*intentionally mirror each other (sent/created→accepted|declined). - Renames are add-new → migrate dashboards → retire-old. Never rename in place; the old string keeps historical data.
Property conventions
- IDs are
<entity>_id, matching the domain noun:invitation_id,crew_offer_id,setlist_id,task_id,ledger_item_id.event_idalways means the gig/event entity. org_id, neverorganization_id. The backend base properties (trackProductEventinapi-events/src/observability/analytics.ts) set the standard:org_id,user_id,event_id,source,environment.sourceis reserved for provenance. Base value is"backend". The one sanctioned override:payment_settled/payment_reversedsetsourceto the payment origin (manual,stripe_payment_intent,stripe_invoice,stripe_refund,cleo_ach) — GMV insights filter on it, so it is load-bearing and documented in the runbook. Mobileupdate_gate_shown,update_gate_store_opened, andupdate_gate_clearedusesource: v1|v2for the policy controller that owns the gate. Do not add other meanings forsource; use a specific key instead (creation_source,surface, …).- IDs only — never email, phone, or free text in properties (PII stays out
of the analytics pipeline; see
has_decline_reasononinvitation_declinedfor the pattern: a boolean about the text, not the text).
Who emits what
Mobile update telemetry includesota_update_check with result,
ota_update_applied with from_update_id, to_update_id, and deferred_ms,
and ota_update_failed with stage and error. Application is reported after
the new bundle starts with the persisted target ID. The three update_gate_*
events carry kind, platform, installed, minimum, and controller source.
update_gate_cleared (v1 and v2) also fires on the first launch after a store update
that reaches the prompted minimum, with the properties of the gate that launch no
longer shows, so installed is the version that was prompted. A disabled or
lowered policy does not report it while the build is still below that minimum.
Every mobile event also carries update_id, update_channel, and
update_is_embedded, so usage and errors can be split by the running bundle.
event_template_saved carries mode, not two names. Saving a gig as a
template and saving it over an existing template are the same action on the
same entity with one different destination, so the backend emits one event with
mode: new | new_version (rule 3 pairs symmetric outcomes, which these are
not). It also carries excluded_keys — the review choices, as stable keys, never
labels or free text — and from_plan_id when the source gig was created from a
plan. saved is a registered verb for this event; setlist_saved remains the
grandfathered create/update conflation rule 3 wants split.
export_generated carries export_type, not one name per document. Every
server-built download (gig workbook, call sheet, advance packet, day sheet,
finance-items CSV, workspace zip) is the same action — “the server generated an
export” — so one event with export_type + export_format keeps the export
funnel on a single name. The client must not also capture a download click for
these.
sor_write is the one system-of-record write signal (PRO-402, PRO-651).
It is an observation event, registered by exact name: a measurement over every
write to gigs, crew demand, shifts, offers, messages and payouts, not a domain
transition, so it does not follow the <entity>_<verb> grammar and does not
replace any lifecycle event (event_created stays canonical for “a gig was
created”). A Postgres trigger on those tables queues one row in effect_outbox
per object per request (one per gig for messages; only PAYABLE ledger lines
count as payouts), in the writer’s own transaction, so a rolled-back write
never emits and a retried delivery never duplicates (the outbox row id is the
PostHog uuid). Writes with no request context (jobs, webhooks, syncs) are
creator_kind: system on the row and are not emitted. Properties: org_id,
event_id (null for payments), object_type (gig, crew_demand, shift,
offer, message, payout, payment), object_id, operation
(create | update; a create and edits in one request are one create),
creator_kind (person, checkai, external_agent, import),
agent_created (true for the last three), rail (app, chat, checkai,
mcp, import), confirm_gated, agent_id and user_id (the principal).
Definitions: docs/engineering/backend/creator-attribution.mdx.
import_completed / import_failed are one emission per import run. The
entity is the ImportJob (import_id), not the rows it wrote; row outcomes
travel as counts (records_created, failed_rows, …). A partial commit is
import_completed with import_status: partial; import_failed means nothing
was written (failure_stage: rows_rejected) or the commit threw after it began
(failure_stage: commit_error).
Public-sourced events use the org as the distinct id.
quote_requested (public storefront) has no acting Clerk user, so it passes
org_id as the distinct id and sets $process_person_profile: false, so
PostHog does not create a person for the org. Money on quote_requested is in minor units (estimate_low_cents) with currency.
org_invitation_sent / org_invitation_failed are web-owned on purpose.
Org invites go from the browser straight to Clerk (organization.inviteMember()),
so no api-events endpoint is hit and rule 2 has no backend emitter to defer to.
They are distinct from the backend’s invitation_sent (the crew/gig Invitation
entity) and org_member_added (a user added to an org through our API). One
emission per submit, with invite_count carrying the batch size; a partial batch
emits both, like the mixed-response crew_offer_* pair. reason is a category,
never Clerk’s raw message — those embed invitee emails.
Cross-platform events (onboarding_*, calendar_feed_*) intentionally share
one name across mobile and web — same action, one emission per occurrence,
different surface. That is the correct pattern; two names for one action is
the anti-pattern.
Event-chat interactions are also cross-platform client events. Mobile and
web emit event_chat_viewed, event_chat_message_sent, and
event_chat_message_failed. Sending remains client-owned because clients call
the Twilio Conversations SDK directly; api-events only observes the later
provider webhook and must not duplicate the user’s send attempt. Each event
includes chat_id and, when available, event_id and org_id. Message bodies,
phone numbers, member names, and other free text are never analytics properties.
Gigs layout events are mobile-owned. The Gigs tab layout is a device
preference in AsyncStorage, so no API call changes it. gigs_home_viewed
(view_mode: list | calendar, calendar_mode: day | week | month)
fires each time the Gigs list comes into view (launch, tab switch, or back from
a gig), after the saved preference and any ?view= link have been applied, so
it measures the layout people stay on; leaving Gigs before then sends nothing. gigs_view_mode_selected (from, to, trigger: toggle |
deep_link, calendar_mode) fires when the layout actually changes; picking
the mode already shown sends nothing. A ?view= link uses deep_link; when it
lands before the saved layout loads, from is null. trigger stands in for
source, which stays reserved for provenance.
push_preprompt_* is mobile-owned. The soft sheet before the OS notification
prompt emits push_preprompt_shown, then push_preprompt_dismissed (Not now) or
push_preprompt_accepted (Turn on), all with show_count (1 to 3). Accepting is
followed by push_preprompt_answered with the OS answer in status (granted,
denied, undetermined, unavailable).
push_opened is mobile-owned. Opening a push notification calls no API, so
the app emits it once per tap, from both the launch (cold start) path and the
running-app listener. It carries push_type (the registered data.type from
push-types/registry.json, or unknown), routed (whether the tap navigated
to a screen) and cold_start. A tap that cannot be routed still emits it with
routed: false, so routed rate per push_type is the delivery-to-screen
health signal.
Follow live is mobile-owned. The server audit log keeps
live_activity_registered and live_activity_stop_requested; these client events
cover what it cannot see, and api-events never re-emits them.
live_schedule_follow_clicked (event_id, role, platform, replacing:
already following another gig, surface: day | full | timeline |
setlist | schedule) fires on a tap of Follow live or Follow this gig
instead. live_schedule_follow_started (event_id, role, platform,
trigger: tap | auto, has_live_activity, background_updates:
connected | failed | pending | not_applicable, minutes_to_call:
minutes from start to the first schedule item, negative once it has begun) fires
once per session, after the first registration result or 30 seconds, whichever
comes first; not_applicable means no Live Activity (Android, or an iOS build
without the extension). live_schedule_follow_ended (event_id, role,
platform, duration_ms, reason: user_stopped | switched_gig |
signed_out | server_ended (gig cancelled or no longer visible) |
show_complete | expired (eight hours), background_updates_ever_connected)
fires once per session. A session lost to an app kill sends no end event.
live_schedule_registration_failed (event_id, platform, stage:
register | token_rotation) fires at most once per stage per session when
APNs token registration fails; registration keeps retrying while the app is open.
widget_opened is mobile-owned. Links from the Next gig widget and the
Live Activity carry ?src=widget, ?src=widget_directions or
?src=live_activity (lib/navigation/widgetLinks.ts). Opening one emits
widget_opened (event_id, platform, target: gig | directions |
live_activity, cold_start) from app/+native-intent.ts. Untagged links
to the same gig, from the Gigs list or a push, send nothing.
Run-of-show and setlist views are mobile-owned. Reading them calls no
state-changing API. run_of_show_viewed (event_id, item_count, role:
admin | member, the same role as gig_detail_viewed) fires once per gig
visit when the Run of Show panel is open and shows items in any view; a
collapsed section counts once it is opened, and an empty run of show sends
nothing. It sits beside schedule_timeline_viewed, which covers the
Schedule list, so divide it by gig_detail_viewed per role for the open rate.
setlist_viewed (event_id, setlist_id, song_count, attachment_count)
fires each time the full-screen setlist performance viewer opens;
attachment_count: 0 means the viewer showed its empty state.
Performance mode sessions are mobile-owned (usePerformanceModeCapture).
performance_mode_opened (event_id, role, item_count, start_index)
fires once per open, after the run of show has loaded at least one item; an
empty run of show or the flag turned off sends nothing.
performance_mode_ended is the one summary per session, sent on close, on
unmount while open and when the gig changes: event_id, role,
item_count, duration_ms (foreground time only), background_ms,
items_viewed (distinct items shown), max_index_reached, reached_end,
nav_next_count, nav_prev_count, jump_count, wrapped (next went from the
last item to the first), chart_zoom_count, chart_load_error_count (items
whose chart failed to load, each counted once) and backgrounded_count. Killing the app while the viewer is open loses ended.
performance_mode_item_viewed (event_id, role, item_index,
item_count, item_type, content_kind: image | svg | pdf | video |
audio | other | none, nav_method: initial | next | prev | jump
| restore) fires once per item per session after 2 s on screen in the
foreground (the clock pauses while charts load or the jump list covers the
slide), so a trend by event_id shows where a show is. An item is its cue plus
its attachment, so a refetch that keeps the same item on screen is not a new
view. A failed chart request counts toward chart_load_error_count when an
affected song slide is shown. The ended verb also closes
the Follow live family (live_schedule_follow_ended).
Mobile $screen names are route templates. ScreenTracker
(lib/providers/AppProviders.tsx) names each screen from the Expo Router
segments with groups such as (tabs) dropped (/events/[id], not
/events/<uuid>). The raw path travels in path, and previous_screen is the
previous template. Screens from builds before ENG-1422 carry the raw path as
the name.
Inbox events are mobile-owned surface markers. inbox_filter_selected
(filter, previous_filter) fires when crew pick a filter from the Inbox
header menu; it calls no API. mailbox_viewed (view: mail | messages) is
kept under its old name so existing insights keep working.
Crew responses carry paired inbox_* surface markers. Each successful
answer to an offer, invitation or availability request from the app fires
paired events, all from lib/analytics/crewResponseCapture.ts:
surface (inbox | crew_home | email_link) says where the answer was
given; email_link is an email reply button that the app submits without a
confirm step. It is a surface key, not source. Offer and availability events
also carry accepted_day_count, declined_day_count, per_day,
is_mixed_response and has_note; offer events add open_shift and inline
(answered from the card without opening a sheet). inbox_invitation_declined
carries has_note. With per_day: true the day counts come from the server’s
per-day results, so a day that filled or had already passed is not counted; a
whole answer counts every day of the reply. A per-day answer that both accepts
and declines fires both events of the pair. Nothing fires when the call fails
or the reply was already answered, and note text is never sent.
Before ENG-1738, inbox_offer_accepted fired only for inline card accepts and
had no inline property. Insights that measure the inline share filter on
inline is not false, so older events still count.
These are surface markers, not the outcome counts. The backend
crew_offer_accepted / crew_offer_declined and invitation_accepted /
invitation_declined stay canonical; divide the two for a surface’s share.
Availability requests have no backend event yet, so until the backend emits an
availability_request_* pair, the inbox_availability_request_* events are the
only record of availability answers given in the app.
payout_status_failed is mobile-owned client telemetry. The You payout
setup card emits it each time GET /v1/users/stripe/connect/status fails to give
a usable status. The read changes no state, so there is no backend event for it.
It carries reason (http_error | network | unrecognized_status),
http_status (or null), attempt (consecutive failures for this card) and
exhausted (the retry budget ran out and the card now offers setup again or
support instead of Retry).
Gig venue events are mobile-owned. Opening maps or copying an address calls
no API. venue_logistics_viewed fires once per gig visit when the gig venue
panel is shown (the Venue tab, the plan’s Venue & load-in section, or its own
screen), deduped like gig_detail_viewed. It carries event_id,
populated_field_count (venue access fields with a value: access code, dock,
load-in, load-out, elevator, parking, green room), has_load_in,
has_location and role (member | admin). venue_directions_opened
(event_id, has_coordinates, map_app: apple_maps | google_maps,
surface) fires when a map app is chosen in the maps chooser, never on cancel.
venue_address_copied (event_id, surface) fires from Copy address on the
venue card or in the maps chooser. surface is gig_venue for the venue
panel’s Directions and Copy address, and gig_detail for the gig header
address and the Key facts directions button. Field values, venue names and
addresses are never properties.
The litmus test for a new client event: does the user action call an API
endpoint that changes state? If yes, the event belongs in the backend service
(and probably already exists). If the backend can’t currently distinguish the
context you care about, add a request field and a property to the backend event
(see creation_source on event_created) — don’t add a client-side event.
Starter packs and usage data
Pack applications are the first-party usage signal for the industry-insights product. Capture is durable (a row in the caller’s transaction) and the PostHog event carries counts only.
There is no client “applied” event: the backend
starter_pack_applied is the outcome. Provisioned packs (system apply on org setup) are recorded in starter_pack_applications but emit no event.
Tables and views (migration 20261017140100_starter_pack_usage_foundation):
A qualifying gig is not a
TEMPLATE, DRAFT or CANCELLED event, has no cancelled_at, and has no deletion_scheduled_for. The views carry organization_id so aggregates can count distinct orgs; they are internal and have no public route.
Aggregates (api-events/src/systems/usage-data/) read the views through aggregateUsage(kind, options), with kind venue, hotel, song or gear and group-by entity, city, business_type or month. A row is returned only when at least USAGE_MIN_ORGS (5) distinct organizations contribute, and only organizations with usage_data_sharing = 'aggregate' are counted. Output carries entity, city_key, business_type, month, orgs and count: never an organization id or name, a person, money, or third-party data. starterPackAdoption(packKind, { businessType? }) returns adoption per pack id at STARTER_PACK_ADOPTION_MIN_ORGS (3) and above; the offerings starter-pack list shows it as adoption: { orgs, label }. The aggregate route is private (/internal/usage-data/aggregates, expose: false).
Provenance (first-party versus third-party). First-party identifiers are ours: venue, hotel, offering, setlist, inventory, organization and event ids; normalized names and categories; civil dates; counts. Third-party data is never output by an aggregate: Google Places ids and photos (venues.place_id, photo_url), Spotify ids and artwork (song catalog data), and context.dev research source URLs. Song and gear keys are derived from our own normalized text, not from a provider id. The catalog_key columns on inventory items and songs, and is_sample on events, are not referenced by these views until their PRs land.
Known legacy quirks (do not repeat; migrate opportunistically)
event_*collides with the analytics word “event”. The domain entity isEvent(a gig), soevent_createdmeans “a gig was created”. Renaming the family would break every dashboard, soevent_*stays canonical andgig_*is retired vocabulary in analytics — never introduce it.organization_createdvsorg_member_added/org_setup_*. The prefix is inconsistent. Standard going forward:org_(matches theorg_idproperty).organization_createdkeeps its name per rule 7.- Mobile
finance_item_voided/finance_item_deleteduse UI vocabulary; the domain noun isledger_item(anditem_idshould beledger_item_id). These are the sole emitters (no backend equivalent yet), so a rename is cheap — do it if you touch them, or move the emission to the backend. setlist_saved {mode: create|update}breaks rules 3/4 — it should besetlist_created+setlist_updated. Migrate per rule 7 when the setlist funnels are next touched.- Signup funnel is two events: clients emit
signup_startedwhen the user submits credentials / taps SSO (funnel start); the backend emitsuser_signed_upfrom the Clerkuser.createdwebhook (completion). Mobile still emitsuser_signed_infor login. Do not re-captureuser_signed_upon clients.user_signed_upcarriessignup_methodandemail_is_apple_relay(the primary email is a Sign in with Apple@privaterelay.appleid.comaddress). The sameemail_is_apple_relayis a person property, re-set on every Clerkuser.createdanduser.updated, so it turns false once the user makes a real email primary.user_signed_upandorganization_createdalso carryplatform(ios,androidorweb). Each client writesplatforminto the Clerk user’sunsafe_metadatawhen it creates a sign-up, and again right before it creates an organization.user_signed_upreads it from theuser.createdpayload;organization_createdreads the creator’s current value from Clerk. It is absent for app builds older than ENG-1808 and for accounts that never got a stamp. Mobile sendssignup_startedfrom every sign-up form (email, invite ticket, SMS gig invite) and from SSO on the sign-up screens when tapped. On the sign-in screen, Apple and Google send it only when the result created an account, so a returning user signing in is not counted. org_setup_*is one attempt per run on both clients.org_setup_started(flow:ownerororg_invite) fires once per real setup attempt, followed by at most oneorg_setup_completed(org_id,org_type,is_first_completion; owner flow only) ororg_setup_failed(reason,detail). Web skips it when the bootstrap only re-activates an owner org the user already has. Mobile emits it fromfinalizeFounderWorkspace(which wrapsensureOrg) andacceptStoredOrgInvite; it emits nothing when no invite is stored.onboarding_step_backlacks a verb; treat it as grandfathered, not as precedent.- Encore Stripe observations
subscription.trial_will_end/subscription.invoice_upcominguse dotted Stripe-adjacent names that predate the snake_case verb grammar. Grandfathered; still treated as backend-canonical so clients must not re-capture them.
Enforcement
scripts/analytics/taxonomy-check.mjs lints every event name across the emitter
tiers — Encore api-events trackProductEvent / trackBillingEvent literal
captures (backend-canonical set for the one-emitter rule), web
captureEvent/trackEvent call sites, and mobile capture call sites. It
fails CI (Code Quality / Static Checks) on: non-snake_case names,
unregistered verbs, retired ENG-530 names, dynamic (non-literal) event names
outside the sanctioned wrapper modules, and any client capture of a
backend-canonical name (the double-count bug). The grandfathered-legacy and
observation-event registries live in the script.
It also lints statically visible property keys on those capture sites:
- object-literal second args to web/mobile
captureEvent/capture/trackEvent - Encore inner
properties: { ... }blocks (not the envelope’sdistinctId/orgIdAPI params)
$… keys exempt), and banned aliases
are rejected (organization_id / organizationId → use org_id). Spreads and
variable-held property bags are opaque and skipped — keep literals at the
capture site when you need the lint to see them.
Run it locally with npm run lint:analytics (add --inventory to print every
event name with its emitter locations). Unit coverage for the property helpers
lives in scripts/analytics/taxonomy-check.test.mjs.
Retired duplicates (ENG-530)
These client events double-counted backend canonicals and were removed. Never reintroduce them; historical data ends at removal.
Dashboards or insights still keyed on a retired name (e.g. “Invite accept vs
decline rate”, “Invitation Response Rate”) must be re-pointed to the canonical
event in PostHog.
Checklist for a new event
- Is there a server-side state transition? → backend emits it, via
trackProductEventat the service layer; add the event name toapi-events/src/observability/analytics.tscall sites and a row to the runbook §5 table. - Name is
<domain-noun>_<registered-verb>; outcomes are paired events. - Properties:
<entity>_idkeys,org_id, no PII, no newsourcemeanings. - Grep all three tiers for an existing event covering the same action before adding one.
- Event names in code are string literals (Encore
trackProductEventfirst argument) or live in a typed helper (web/src/lib/analytics/productEvents.ts) — no inline ad-hoc strings in components when a helper exists. npm run lint:analyticspasses (CI enforces it — see Enforcement).