Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesEvent TrackingEvent Tracking — Behavior

Event Tracking — Behavior

State machine

Stateless. Every track is independent.

track(distinctId, event, props) │ ├── hoist props.$groups → capture's top-level `groups` │ ├── logger.log(event, props) (always, all envs) │ └── if (NODE_ENV === 'production' && POSTHOG_API_KEY): client.capture({ distinctId, event, properties, groups }) ├── batched in SDK (flushAt=20 OR flushInterval=10s) └── caught try/catch → logger.error on failure

Organization group

Call sites name the org the way ProgrammingEvents documents: track(id, event, { ..., $groups: { organization: orgId } }). track() lifts that key out of properties and passes it as capture’s own top-level groups field. Callers do not need to change, and should keep using $groups.

The hoist is load-bearing, not cosmetic. [email protected] builds the outgoing properties as

{ ...flagProps, ...message.properties, $groups: message.groups || groups }

— $groups written last and unconditionally, so a caller’s properties.$groups is replaced by undefined whenever the top-level field is absent. It always was, so every server event reached PostHog with no group: on 2026-09-22, 0 of 11,332 events in the previous 30 days ($feature_flag_called excepted — the SDK emits those itself through the top-level field). Every per-org dashboard read as zero. See TKN-349.

Upstream stopped clobbering in 5.38.6 (posthog-js#3896), but the top-level groups field is the documented input on both sides of that bump, so the hoist stays after any SDK upgrade.

A $groups value that is not an object of string keys to string values is dropped rather than sent — a malformed group reference is worse than none.

Note that the group is the ONLY reliable org attribution for a server event. Some events also carry org_id / orgId as a plain property, but not consistently: member_workout_viewed and workout_assigned do, workout_created and workout_done do not.

Invariants

InvariantEnforcement
No-op in non-prodConstructor checks NODE_ENV === 'production'
Missing API key disables sends but keeps loggerConstructor branch
Capture errors never throwtry/catch around client.capture
Distinct ID is the canonical user UUIDCaller responsibility; verify usage at call sites
An event keyed on a non-user id (subscription, org, join session, 'system') sets $process_person_profile: false, so it creates no PostHog personCaller responsibility; PaymentObservabilityService.emit (any event without a userId) and JoinObservabilityService do it, asserted in payment-observability.service.unit.spec.ts. Payment funnels therefore link steps on properties.subscriptionId, never on person
$groups in properties becomes the SDK’s groups fieldtrack() hoist; asserted on the wire shape in event-tracking.service.unit.spec.ts
Pending events flushed on shutdownonModuleDestroy → client.shutdown()

Golden paths

Coach publishes an announcement

  1. AnnouncementsService.createAnnouncement finishes.
  2. Calls this.tracking.track(userId, 'announcement_published', { announcement_id, org_id, priority }).
  3. Logger emits info log.
  4. In prod, PostHog SDK queues the event; flushes within 10s.

Payment funnel

  1. Payment domain emits multiple track calls (payment_initiated, payment_succeeded, payment_failed).
  2. PaymentObservabilityService delegates the PostHog capture to this service so the Sentry + log fan-out stays local.

Edge cases

ScenarioBehavior
PostHog API unreachableCaught; logged; events lost (no retry queue in this layer)
App SIGKILL’d before flushPending events lost
Distinct ID is missing / nullPostHog still accepts (with $anonymous fallback); call sites should always pass user.id
$groups present but malformedDropped; event is sent with no group
Properties include large objectsPostHog rejects payloads > some limit (~512KB); no client-side enforcement
Same event captured twiceBoth sent; PostHog has no native dedup

Side effects

ActionLogsSends to PostHog
track (any env)yesonly in prod with key
identify (any env)no (no log line in current code)only in prod with key
onModuleDestroy—flushes pending events

No DB writes. No queue. No events emitted internally.

Permissions

System-internal — no HTTP surface.