Event Tracking — Behavior
State machine
Stateless. Every track is independent.
track(distinctId, event, props)
│
├── logger.log(event, props) (always, all envs)
│
└── if (NODE_ENV === 'production' && POSTHOG_API_KEY):
client.capture({ distinctId, event, properties })
├── batched in SDK (flushAt=20 OR flushInterval=10s)
└── caught try/catch → logger.error on failureInvariants
| 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 |
| 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 |
| 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.