Use when adding or changing something a dashboard might ask about — a new flow, screen, primary button, form submit, funnel step, outcome, async job, payment, or status change — in gp-webapp (frontend) or gp-api (backend). This skill decides whether the moment earns an event, which side should fire it, names it, registers it, and fires it. For frontend events it then hands off to the event-metadata skill to record the event's governance metadata in Amplitude (step 6); and when a change removes a client event, it routes that event to retirement (see "When a change removes an event").
Background: events flow app/api → Segment → Amplitude (and HubSpot). There are two registries:
Naming and governance are adopted from the Analytics Event Tracking Guide (product-os processes/analytics-standards.md, owner: Bryan Levine), which remains the source of truth.
-
Decide whether to instrument.
The rule everything serves: every event must answer a question you would put on a dashboard. If you cannot name the question, do not fire the event. More events is not more insight; un-asked events are noise.
Instrument these moments:
- Every distinct stage of a multi-step flow, each fired as a
Viewed (on entry) and a Completed (on advance) — per-stage drop-off is the dashboard question, so instrument the whole staircase, not just the first screen and the final outcome. Any flow with more than one step counts, not just classic onboarding funnels. This holds even when a stage does not change the URL (a wizard, stepper, or modal flow): the root RouteTracker fires a page view only on a route change, so URL-stable stages are invisible to it and must be instrumented explicitly. The unit is the stage, not the click: the Viewed event must fire every time the user enters a stage, including when they press Back and re-enter a stage they already passed. Sub-interactions inside a stage (field edits, toggles) still follow the skip list below. The Back and Next button clicks themselves are also skipped — the stage transition is captured by the stage's Viewed / Completed events, not by click events on the navigation controls.
- Primary calls to action: the main action a screen exists to drive.
- Outcomes: the user produced the thing the feature makes (a poll sent, a website published). When a flow's final stage both completes the funnel and produces the outcome, fire both — the stage
Completed (funnel completion) and the outcome event (the thing produced) answer different questions; do not collapse them into one.
- Blockers and errors that stop a user (they explain drop-off).
Concrete pattern — a multi-step flow done right (from the live registry): Onboarding V2 fires … - {Stage} Viewed when a screen renders and … - {Stage} Completed when the user advances, all within a single route (e.g. Onboarding V2 - Office Viewed then Onboarding V2 - Office Completed). A purely informational stage with no user decision (a success page, a read-only value-prop slide) is legitimately Viewed-only — it presents no action to complete, so omitting Completed is correct and does not count as a missing instrumentation. Counter-example to fix, not copy: the Schedule Text Campaign wizard fired only Next / Back / Submit clicks, so per-stage entry and completion — and therefore drop-off — are invisible. The fix there is a Viewed + Completed per stage, not more click events.
Capture the core action; let the higher-level metric be derived. Do not mint separate Activated, Converted, or First-Time events. Activation and conversion are metrics defined on top of the action events above. Which specific action counts as activation or conversion for a product (for example, a poll sent in Win) is a product designation that can change without re-instrumenting, and Amplitude derives first-occurrence-per-user from any event automatically. Your job is to make sure the underlying core action is tracked.
Skip these:
- Opening or closing a single UI element (a menu, modal, accordion, drawer, tooltip — expand/collapse, show/hide). This covers one element's own state, not progress through a flow: closing a modal is a skip, but advancing to the next stage of a wizard is instrumented (see the multi-step rule above). Exception: opening an AI chat and sending a message are real engagement, not chrome. Track both (chat opened and message sent) so we can see the open-to-send drop-off.
- In-page navigation with no decision (tab switches, scrolling, carousel arrows). Main navigation destinations are already captured as page views (the root
RouteTracker fires a Segment page() call on every route change), so do not add click events for them unless you need the entry point or source and can name the question it answers. A multi-step flow that advances without changing the URL is not in-page navigation of this kind — its stages are captured by no page view and must be instrumented per the multi-step rule above.
- Hover, focus, mouseover.
- Anything a page view already captures (route changes auto-fire a
page() call via the root RouteTracker; do not double-track). RouteTracker fires only on a route change, so this exemption does not reach the stages of a flow that advance without a URL change — those still need explicit events.
- A variation that a property could capture instead (one event with a
channel prop beats one event per channel).
-
Decide where it fires — frontend or backend.
Fire from the webapp (frontend) when the browser directly observes the moment: a screen view, a button click, a form submit, a funnel step the user navigates.
Fire from gp-api (backend) when the moment is server truth the client cannot honestly observe:
- an async or AI job completes (the browser only knows it started),
- a payment or subscription confirms,
- a webhook lands, or a status changes (compliance, domain, publish),
- the event needs server-only data, or must be tamper-proof / guaranteed to fire.
Do not double-fire. If the backend owns the outcome, the webapp must not also emit it. Live example from the Serve product (poll results): the webapp fires Polls - Poll Results Overview Viewed when the official opens the results screen, while gp-api fires Poll - Results Synthesis Complete when the synthesis job finishes in the queue worker. "Did the user look" and "did the job finish" are two different questions — two sides, no overlap.
-
Name the event.
Format: {Product Area} - {Noun} {Past-Tense Verb}, in Title Case. Prefer the verbs Viewed and Completed; reach for Created, Updated, Dismissed, Blocked, Errored only when those do not fit.
Briefing Assistant - Briefing Viewed
Briefing Assistant - Agenda Submitted
Polls - Poll Results Overview Viewed
- Product area is the navigation area the user is in (frontend) or the domain the work belongs to (backend). Follow the app's navigation as the source of truth for the area name rather than inventing one or leaning on a fixed list; the canonical set is still evolving, so match how the product is organized in the nav today.
- If it is something the user is or has (officeType, isPro, onboardingCompleted), it is a user property, not an event — set it with
identifyUser (frontend, from @shared/utils/analytics) or AnalyticsService.identify (backend), not a track call.
-
Register it in the EVENTS map.
The casing rule, both sides: group keys are PascalCase, the event-name string values are Title Case, and property keys (the object you pass when firing) are camelCase.
Frontend — edit helpers/analyticsHelper.ts. Add the event under its product-area group (create the group if new):
BriefingAssistant: {
BriefingViewed: 'Briefing Assistant - Briefing Viewed',
AgendaSubmitted: 'Briefing Assistant - Agenda Submitted',
},
Backend — edit src/vendors/segment/segment.types.ts:
Polls: {
ResultsSynthesisCompleted: 'Poll - Results Synthesis Complete',
},
⚠️ HubSpot caveat (backend only). That file is load-bearing for HubSpot. Names marked ⚠️ DO NOT MODIFY trigger HubSpot workflows (email sequences, 10DLC compliance) on the exact string — never rename one. Adding a new event is safe; renaming an existing one breaks the integration. Test the full App → Segment → HubSpot path before touching any existing name.
Always reference the map — never pass a string literal to the tracker. The literal lives in exactly one place.
-
Fire it.
Frontend — import { EVENTS, trackEvent } from 'helpers/analyticsHelper':
-
"Viewed" / screen-entry events fire in a useEffect so they run once per view, not on every render:
useEffect(() => {
trackEvent(EVENTS.BriefingAssistant.BriefingViewed, { briefingId })
}, [briefingId])
In a URL-stable wizard where the stage component stays mounted across steps, a useEffect keyed on a value that does not change on Back (or []) will not re-fire on re-entry. Key the useEffect on the active-stage identifier (e.g. [currentStep]) so the Viewed re-fires every time the user enters the stage, including via Back. Avoid key={currentStep} on stage components that hold form state — remounting tears down all component-local state (form values, validation, in-flight requests) on every navigation. Reserve key={currentStep} only for purely display stages that carry no local state.
-
Action / completion / outcome events fire in the handler, with camelCase properties:
trackEvent(EVENTS.BriefingAssistant.AgendaSubmitted, {
meetingDate,
source: 'upload',
})
trackEvent already merges in persisted UTMs and the impersonation flag, and never throws — you do not need a try/catch around it.
Backend — inject AnalyticsService (it is a @Global() provider, so no module import is needed):
import { AnalyticsService } from '@/analytics/analytics.service'
import { EVENTS } from 'src/vendors/segment/segment.types'
constructor(private readonly analytics: AnalyticsService) {}
Fire telemetry fire-and-forget so a Segment hiccup never blocks or breaks the request — pass the user id, the event, and camelCase properties:
void this.analytics
.track(userId, EVENTS.Polls.ResultsSynthesisCompleted, {
pollId,
durationMs,
})
.catch(() => undefined)
await the call only when the outcome must propagate — e.g. a payment must not be considered tracked if Segment failed. track already merges in the user's email/hubspotId context and the impersonation flag.
Emailable events — flatten collections into indexed scalar props. If an event will drive a user email (a HubSpot workflow reads the event's properties to render the template), it must carry flat scalar properties, not arrays or nested objects — HubSpot custom-event properties are predefined flat fields and cannot index into an array. So encode a list as numbered scalar keys, not an array of objects:
{ topIssues: [{ title, summary, priority }, ...] }
{
topIssueCount: 5,
topIssue1Title: '…', topIssue1Summary: '…', topIssue1Priority: 'high',
topIssue2Title: '…', topIssue2Summary: '…', topIssue2Priority: 'medium',
}
Key format stays camelCase: <collection><N><Field>, 1-based. Cap N at a fixed, documented max (e.g. top 5 by rank) so the property set stays bounded and predefinable in HubSpot, and keep the true total in a <collection>Count prop. This applies to any event a user email is built from, not just this one.
-
Record the event's metadata (frontend/client events).
If this change only removes a frontend trackEvent call (no new event is being added), skip directly to "When a change removes an event" below, complete the RETIRE handoff (including its CSV step), then go to step 8.
A new event is illegible later without its governance metadata. For a new frontend event, hand off to the event-metadata skill, which writes the purpose, status, product tag, and supersession lineage into Amplitude. Pass an ADD payload — you supply hints, it owns the write and the human confirmations:
mode=add,
event = the Title Case event-name string you just registered (e.g. Briefing Assistant - Agenda Submitted),
purposeDraft = the one-line question this event answers (you already reasoned about this when naming it),
productHint = win | serve | shared (from the nav area you used to name it),
supersedes = the event it replaces — only if this event explicitly replaces a named one. Never infer a supersession from an unrelated event being removed in the same change.
Backend (segment.types.ts) events are out of scope for metadata for now (ClickUp 86aj7bdkp) — skip the handoff for them.
If this change also removes a frontend trackEvent call, go to "When a change removes an event" below and complete the RETIRE handoff (including its CSV step), then complete steps 7–8.
-
Record code provenance (frontend AND backend).
Unlike the metadata handoff above (client-only), this step runs for every event you
instrument — frontend or backend. It writes a provisional row into the omni provenance
CSV (packages/runbooks/scripts/python/instrumentation_data/amplitude_event_provenance.csv),
the curated summary of instrumentation-related git events in this repo, read by the
DATA-1952 monitor. Resolve the PR number once (gh pr view --json number -q .number on the
current branch; omit --pr if none yet), then from the repo root:
uv run --project packages/runbooks/scripts/python \
python packages/runbooks/scripts/python/amplitude_event_provenance_backfill.py \
upsert --event "Briefing Assistant - Agenda Submitted" --direction add --pr 1234
The engine stores the PR as a full link (https://github.com/thegoodparty/omni/pull/1234),
writes today's date and status present, and leaves the exact merge SHA blank for the
periodic git-walk (packages/runbooks/books/refresh-event-provenance.md) to fill. The skill is the freshness
writer; the walk is the truth writer. Do not hand-edit the CSV — always go through upsert
so the file's sort and quoting stay identical to a full walk.
-
Verify.
Frontend (from the repo root):
npm exec -w packages/gp-webapp -- next typegen
npm exec -w packages/gp-webapp -- tsc --noEmit
npm exec -w packages/gp-webapp -- tsc --noEmit --project e2e-tests/tsconfig.json
npm run lint -w packages/gp-webapp
Backend (from the repo root):
npm run verify -w packages/gp-api
To confirm the event actually reaches Segment, trigger the interaction and watch the Segment debugger — frontend: also the browser network tab for the Segment t call; backend: the [ANALYTICS] debug logs.
To confirm the provenance write, git status should show the modified
packages/runbooks/scripts/python/instrumentation_data/amplitude_event_provenance.csv
with exactly the row(s) for the event(s) you touched changed.