Event Tracking (PostHog Facade)
Status: Shipped — vendor-agnostic facade over posthog-node.
Last reviewed: 2026-05-28
What
A minimal, vendor-neutral analytics facade exposed as the global EventTrackingService. Today it wraps the PostHog Node SDK; the public methods (track, identify) keep callers from binding to PostHog directly.
Specifically NOT in this feature:
- No DB log of events (PostHog is the system of record).
- No webhook ingestion from PostHog back into Taikan.
- No client-side capture from web (PostHog browser SDK is configured separately, not in this module).
- No A/B testing / feature flag plumbing.
Why
- Funnel analytics: announcement publishes, signups, conversions, payments.
- Decouple call sites from the vendor — swapping to Segment / Amplitude / Snowflake would touch one file.
- Production-only by design — local dev events would pollute funnels. Non-prod runs as a log-only no-op.
Personas
System-internal. No user-facing surface. The data product team consumes PostHog dashboards.
Capabilities
track(distinctId, event, properties?)— fire-and-forget capture. Logs at info level always; sends to PostHog when configured + in production.identify(distinctId, properties?)— set / update PostHog person properties.- Auto-flush — 20-event batch or 10s interval (PostHog SDK defaults overridden).
- Lifecycle —
onModuleDestroyshuts down the PostHog client; flushes pending events on app stop. - Typed event registries — a per-family alternative to a free-string event name, so a client and the server can’t name the same moment two different ways. First one:
CancellationEvents(libs/shared/src/lib/analytics/cancellation-events.ts, exported from@taikan/shared) — aconstobject ofcancellation.*/plan_change.presale_swapserver names plusstaff_cancel_*/staff_case_*client names, and aCancellationEventNameunion type.- Server side, every
CancellationEventsname is emitted throughPaymentObservabilityService.emitfromRetentionCaseService,RecurringChargeService(PRESALE_CHARGE_FAILED) andPlanChangeService, exceptOFFER_MADE,FORM_ISSUEDandFORM_SIGNED— those three are declared in the registry (reserved for a later wave: staff explicitly offering a save tool short of acceptance, and the written cancellation form’s issue/sign moments) but nothing callsemitwith them yet. - Client side,
posthog.capturefires all four:STAFF_CANCEL_DIALOG_OPENED/_INITIATOR_SELECTEDfromstaff-cancel-dialog.tsx, andSTAFF_CASE_OPENED/STAFF_SAVE_TOOL_APPLIEDfromretention-case-panel.tsx. There is no member-facing cancel dialog.
- Server side, every
ScheduleEvents(libs/shared/src/lib/analytics/schedule-events.ts, exported from@taikan/shared) — the staff schedule.schedule_session_copied{ via: 'drag'|'menu', view: 'expanded'|'condensed', crossSpace }: client-side fromuse-session-copy.tsonce the server accepted a copy to another day (TKN-478). No names or people.TrialEvents(libs/shared/src/lib/analytics/trial-events.ts, exported from@taikan/shared) — trials. Server-side, after commit, throughtrackTrial(apps/api/src/trials/trial-telemetry.ts): keyed on the acting user (ororg:<orgId>) with$groups: { organization }, ids/enums/counts only. Client-side:trial_roster_viewed(web + mobile),trials_today_openedandtrial_brief_opened(mobile),trials_report_viewed(web).trial_convertedcarries a uuid derived from the subscription id (track(…, { uuid })), so a redeliveredsubscription.startedcounts once. Full list and wiring: Trials.MemberBookingEvents(libs/shared/src/lib/analytics/member-booking-events.ts, exported from@taikan/shared) — the member’s booking drawer (session-detail-drawer.tsx), client-side, camelCase properties, the org from theorganizationgroup (not a property).member_quota_blocked{ reason: 'not_covered_by_plan'|'bucket_exhausted', sessionId }: a booking the plan’s entry quotas refused (the member’s side ofbooking_quota_refused).member_cancel_blocked{ reason: 'cancellation_closed', sessionId }: a cancel refused past its deadline (the member’s side ofbooking_cancel_refused). The oldermember_booking_blocked{ org_id, session_id, reason }stays as it was, for the registration-window refusals only (registration_not_yet_open,registration_closed).BookingEvents(libs/shared/src/lib/analytics/booking-events.ts, exported from@taikan/shared) — server-side booking decisions, keyedorg:<orgId>with$groups: { organization }, ids and enums only. Booking policy:booking_quota_refused,booking_window_refused,booking_cancel_refused,booking_policy_conflict,booking_rule_saved,plan_quotas_saved,sweep_parity_revoked,booking_plan_selection_required. Waitlist (always on, all after commit):waitlist_promoted { sessionId, bookingId, trigger, graceApplied, removedCount },waitlist_removed_elsewhere { sessionId, promotedSessionId },seats_filled_from_waitlist { sessionId, count, trigger },waitlist_promotion_skipped { reason, sessionId },waitlist_hold_limit_refused { subscriptionId },booking_grace_cancel { bookingId },waitlist_expired { count }.trigger∈cancel | admin_cancel | sweep | capacity | arbox | book | scheduler. Each also has acheck-tagged log line; the table is in observability.ProgrammingEvents(libs/shared/src/lib/analytics/programming-events.ts, exported from@taikan/shared) — the coach-posts / member-logs programming loop baseline (TKN-315). All server-side names are keyed onorgIdwith theorganizationPostHog group, via$groups: { organization: orgId }on thetrack()call (the conventionpayment-observability.service.tsandactivation-events.listener.tsalready use —track()itself has no group parameter). Client-side captures rely on theorganizationgroup already set globally (user-provider.tsx’sposthog.group('organization', org.id)), so they don’t repeat it as a property.wod_post_started/wod_post_completed—{ entry: 'paste'|'pick'|'freeform'|'builder', scope: 'day'|'session', classTypeId },wod_post_completedaddselapsedMs. Client-side, fired from the session sheet flow:add-workout-popover.tsx’s three option handlers fire STARTED;session-detail-sheet.tsxindependently arms a local ref at the same click and fires COMPLETED when the pick/quick-create write resolves (never on a remove). Forentry: 'builder', STARTED fires before the coach navigates to the builder route, carryingclassTypeId/postStartedAtas URL params (BuilderContext['session-slot']);use-builder-save.ts’s session-slot attach mutation fires COMPLETED on success.entry: 'paste'is declared but not wired from this flow (paste happens elsewhere in the workout module, not the session sheet).workout_created—{ mode: 'structured'|'freeform', isLibrary, source: 'builder'|'paste'|'quick'|'template_apply' }. Server-side:WorkoutsService.create(always;sourcefrom the caller-suppliedCreateWorkoutDto.source, default'builder'),WorkoutsService.setSections(only when the caller passesSetSectionsDto.source— a plain builder edit of an already-structured workout never fires this),ClassSessionsService.createInlineWorkout(source: 'quick'),ParseCommitService.commit→WorkoutsService.create/setSectionswithsource: 'paste'on both the fresh-paste and structure-existing branches, anduse-builder-save.ts’ssave()(source: 'template_apply'whencontext.kind === 'template-cell', else'builder').wod_published—{ classTypeId, date, sessionCount }. Server-side fromDailyProgrammingService.set(whenstatus: 'published'and the day carries workouts) and.setStatus(sessionCount= the day’s current row count) — fired once per publish, not per workout.whiteboard_opened—{ workout_id }. Client-side,workout-detail-drawer.tsx(the member’s WOD-viewing drawer) on open.workout_done—{ hasScore, hasSets, scoring }. Server-side,WorkoutResultsService.create(result logging).movement_demo_opened—{ exercise_id }. Client-side,prescribed-section.tsx’sopenVideo(shared by the member whiteboard and the course player — both member surfaces).movement_video_reported— declared, no trigger. There is no “report this video” UI yet; reserved so a later surface fires it under the name analytics already expects.movement_customized({ exerciseId, fields }) /movement_video_replaced({ exerciseId }) — server-side,ExerciseOverridesService.upsertOverride(coach-side movement library overrides).fieldsis the sorted allow-listed keys touched by that one patch, not the accumulated override.movement_video_replacedfires alongsidemovement_customizedonly when the patch sets a truthyvideoUrl(not on a clear).
Programming loop dashboard
Baseline instrumentation for the coach-posts / member-logs programming loop (TKN-315), ahead of changing how workouts are posted and logged. Building the dashboard in PostHog is a human step — this table is the spec for it, not evidence it exists yet.
| Tile | Computed from |
|---|---|
| Time to post a WOD (median) | wod_post_completed.elapsedMs, median per org per day, sliced by entry |
| Share of class-type days with a published WOD | count of distinct (classTypeId, date) pairs carrying a wod_published ÷ count of distinct (classTypeId, date) pairs with at least one scheduled class session, per org per week |
| Structured vs freeform share | workout_created count where mode = 'structured' ÷ total workout_created, per org, trended weekly |
| Share of attendees per session who logged done | workout_done count (per org per day) ÷ booked-and-attended count for sessions carrying a published WOD that day (bookings/attendance from the existing booking events, not this module) |
| Share with a score | workout_done count where hasScore = true ÷ total workout_done |
| Video reports per week | count of movement_video_reported per org per week — reads zero until a report UI exists |
Capabilities (gaps)
- No backend-side queue between API call and PostHog send — a slow PostHog wouldn’t block (try/catch), but events could be lost on shutdown if
onModuleDestroydoesn’t flush in time. - No structured event taxonomy enforcement (any string event name accepted) —
CancellationEventsis a per-family opt-in registry, not a platform-wide one; every other call site (announcement_published, most ofPaymentObservabilityService) still passes a bare string. - No PII redaction policy at this layer — callers are responsible for what they pass into
properties.
Related
../announcements/— capturesannouncement_published.../payments/—PaymentObservabilityServicedelegates here for PostHog calls.../subscriptions-plans/behavior.md— what opens/closes a retention case and drives eachCancellationEventsemission.../analytics/— separate folder if it exists for client-side surfaces.
Source code
- API:
apps/api/src/event-tracking/event-tracking.{service,module}.ts
Config
| Env | Purpose |
|---|---|
NODE_ENV | Must be production for any send to occur |
POSTHOG_API_KEY | Required for prod sending; absent → log-only |
POSTHOG_HOST | Optional; defaults to https://eu.i.posthog.com |