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 failureOrganization 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
| Invariant | Enforcement |
|---|---|
| No-op in non-prod | Constructor checks NODE_ENV === 'production' |
| Missing API key disables sends but keeps logger | Constructor branch |
| Capture errors never throw | try/catch around client.capture |
| Distinct ID is the canonical user UUID | Caller 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 person | Caller 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 field | track() hoist; asserted on the wire shape in event-tracking.service.unit.spec.ts |
| Pending events flushed on shutdown | onModuleDestroy → client.shutdown() |
Golden paths
Coach publishes an announcement
AnnouncementsService.createAnnouncementfinishes.- Calls
this.tracking.track(userId, 'announcement_published', { announcement_id, org_id, priority }). - Logger emits info log.
- In prod, PostHog SDK queues the event; flushes within 10s.
Payment funnel
- Payment domain emits multiple
trackcalls (payment_initiated,payment_succeeded,payment_failed). - PaymentObservabilityService delegates the PostHog capture to this service so the Sentry + log fan-out stays local.
Edge cases
| Scenario | Behavior |
|---|---|
| PostHog API unreachable | Caught; logged; events lost (no retry queue in this layer) |
| App SIGKILL’d before flush | Pending events lost |
| Distinct ID is missing / null | PostHog still accepts (with $anonymous fallback); call sites should always pass user.id |
$groups present but malformed | Dropped; event is sent with no group |
| Properties include large objects | PostHog rejects payloads > some limit (~512KB); no client-side enforcement |
| Same event captured twice | Both sent; PostHog has no native dedup |
Side effects
| Action | Logs | Sends to PostHog |
|---|---|---|
track (any env) | yes | only 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.