Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesEvent TrackingEvent Tracking (PostHog Facade)

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 — onModuleDestroy shuts 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) — a const object of cancellation.* / plan_change.presale_swap server names plus staff_cancel_* / staff_case_* client names, and a CancellationEventName union type.
    • Server side, every CancellationEvents name is emitted through PaymentObservabilityService.emit from RetentionCaseService, RecurringChargeService (PRESALE_CHARGE_FAILED) and PlanChangeService, except OFFER_MADE, FORM_ISSUED and FORM_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 calls emit with them yet.
    • Client side, posthog.capture fires all four: STAFF_CANCEL_DIALOG_OPENED / _INITIATOR_SELECTED from staff-cancel-dialog.tsx, and STAFF_CASE_OPENED / STAFF_SAVE_TOOL_APPLIED from retention-case-panel.tsx. There is no member-facing cancel dialog.
  • 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 from use-session-copy.ts once 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, through trackTrial (apps/api/src/trials/trial-telemetry.ts): keyed on the acting user (or org:<orgId>) with $groups: { organization }, ids/enums/counts only. Client-side: trial_roster_viewed (web + mobile), trials_today_opened and trial_brief_opened (mobile), trials_report_viewed (web). trial_converted carries a uuid derived from the subscription id (track(…, { uuid })), so a redelivered subscription.started counts 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 the organization group (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 of booking_quota_refused). member_cancel_blocked { reason: 'cancellation_closed', sessionId }: a cancel refused past its deadline (the member’s side of booking_cancel_refused). The older member_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, keyed org:<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 a check-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 on orgId with the organization PostHog group, via $groups: { organization: orgId } on the track() call (the convention payment-observability.service.ts and activation-events.listener.ts already use — track() itself has no group parameter). Client-side captures rely on the organization group already set globally (user-provider.tsx’s posthog.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_completed adds elapsedMs. Client-side, fired from the session sheet flow: add-workout-popover.tsx’s three option handlers fire STARTED; session-detail-sheet.tsx independently arms a local ref at the same click and fires COMPLETED when the pick/quick-create write resolves (never on a remove). For entry: 'builder', STARTED fires before the coach navigates to the builder route, carrying classTypeId/postStartedAt as 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; source from the caller-supplied CreateWorkoutDto.source, default 'builder'), WorkoutsService.setSections (only when the caller passes SetSectionsDto.source — a plain builder edit of an already-structured workout never fires this), ClassSessionsService.createInlineWorkout (source: 'quick'), ParseCommitService.commit → WorkoutsService.create/setSections with source: 'paste' on both the fresh-paste and structure-existing branches, and use-builder-save.ts’s save() (source: 'template_apply' when context.kind === 'template-cell', else 'builder').
    • wod_published — { classTypeId, date, sessionCount }. Server-side from DailyProgrammingService.set (when status: '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’s openVideo (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). fields is the sorted allow-listed keys touched by that one patch, not the accumulated override. movement_video_replaced fires alongside movement_customized only when the patch sets a truthy videoUrl (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.

TileComputed 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 WODcount 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 shareworkout_created count where mode = 'structured' ÷ total workout_created, per org, trended weekly
Share of attendees per session who logged doneworkout_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 scoreworkout_done count where hasScore = true ÷ total workout_done
Video reports per weekcount 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 onModuleDestroy doesn’t flush in time.
  • No structured event taxonomy enforcement (any string event name accepted) — CancellationEvents is a per-family opt-in registry, not a platform-wide one; every other call site (announcement_published, most of PaymentObservabilityService) still passes a bare string.
  • No PII redaction policy at this layer — callers are responsible for what they pass into properties.

Source code

  • API: apps/api/src/event-tracking/event-tracking.{service,module}.ts

Config

EnvPurpose
NODE_ENVMust be production for any send to occur
POSTHOG_API_KEYRequired for prod sending; absent → log-only
POSTHOG_HOSTOptional; defaults to https://eu.i.posthog.com