PostHog Event Culling — Audit Findings

Change under review: commit d79dbcb“perf(analytics): cut PostHog billable event volume ~45%, sampled replay” (Yasharth, 2026-07-09)

App: BhuMe/newapp (unified RN 0.77 base)  ·  PostHog: project 228843 “BhuMe App”, queried 2026-07-11 (all saved insights + all experiments)

HEAD is 2 commits ahead of d79dbcb; the culling logic was audited as it currently lives in the tree.

Summary. The culling is largely safe. The two full drops (screen_back_pressed, pm_payment_*) and the once-per-session dedupes are confirmed harmless because the insights that use those events read unique users (dau) or funnel entry, which the “keep first occurrence” rule preserves. The genuine exposure is two analytics findings (one count metric, one funnel) and three code-correctness bugs (concurrency / state-reset).

Severity: high medium low

All findings

IDAreaIssueSeverityStatus
A1Analytics“Total Search Events (Estimated)” undercountsmediumConfirmed
A2Analytics“Fork C” ordered funnel breaks on event timinghighConfirmed
A3AnalyticsSuffix dedupe rule is a blind matchlowDesign risk
A4AnalyticsAggregation loses whole visit on hard-killmediumDesign risk
B3CodeDedupe state not reset on login/logouthighVerify→fix
B1CodeNon-atomic read-modify-write race ($set)highVerify→fix
B2CodeAttribution dropped on flush racehighVerify→fix
B8CodeSilent AsyncStorage failuresmediumVerify→fix
B5CodeReplay sampling per JS-load, not per sessionlowLikely by design
CIncidentalud_-prefixed event names not in live taxonomylowPre-existing

A. Analytics / data-loss issues (verified against live PostHog)

A1 Total Search Events (Estimated) undercounts medium

Insight mz2whEOB is a count-based HogQL metric (sum(CASE…)) that includes name_search_performed, ud_interaction (action=owner_carousel_tapped), and ud_live_search_started. All three are now deduped once per session, so the count drops artificially post-culling. This is the only insight where dedupe changes a real number — everything else is dau.

Options: exclude these events from the gate · rewrite the insight to reconstruct volume · accept and annotate the metric.

A2 “Fork C” ordered funnel breaks on event timing high

Funnel IMbvIzad “Fork C: owner_search → naksha → property action” is an ordered funnel. Steps 2→3→4 are os_mode_entered → os_card_tapped → os_view_naksha — all now aggregated and flushed together on screen blur/background with near-identical exit-time timestamps. Blur ≈ the moment the user navigates to step 5 (ps_action).

Collapsing three sequential steps into one instant, and moving them to screen-exit time, breaks ordered-funnel timestamp monotonicity and can invert “step 4 before step 5” → the funnel under-counts conversion. Same risk class: 1EMel6Ep “Bookmark Journey” (uses ud_card_tapped).

Options: keep a lightweight per-tap event for funnel-critical steps and emit the aggregate under a new *_summary name · or adjust the funnels to tolerate the batched timing.

A3 Suffix dedupe rule is a blind match low

The gate dedupes any event ending in _screen_viewed / _nudge_shown / _nudge_dismissed. Any future event with those suffixes is silently capped once-per-session with no code change and no review. Recommend converting the suffix set to an explicit allow-list of event names.

A4 Aggregation loses a whole visit on hard-kill medium

polygon_tapped_summary and the card-tap family only flush on nav-blur + AppState background/inactive. A swipe-kill or JS crash mid-visit loses the entire visit’s taps. The previous code flushed every 5 taps, so partial data survived. Quantify accepted loss or add a flush-on-terminate safeguard.

B. Code-correctness bugs (static review — verify before fixing)

B3 Dedupe state not reset on login/logout high

seenThisSession (src/services/analyticsGate.js) resets only on cold start / 30-min background. setAnalyticsAuthState does not reset it. An event fired both pre- and post-login within one session (e.g. a *_screen_viewed) is dropped the second time → breaks signup funnels that expect the post-login occurrence.

Fix: export resetAnalyticsSession(); call it from setAnalyticsAuthState on auth transition. Add a regression test.

B1 Non-atomic read-modify-write race high

diffAgainstLastSent (src/services/posthog.js:222) does getItem → compute → setItem non-atomically, and the setItem is fire-and-forget. Two concurrent $set calls (e.g. deep-link + notification UTM sync) read the same baseline; the second write clobbers the first → a changed property is recorded as “already sent” and suppressed on the next session.

Fix: serialize person-prop writes via an in-memory promise chain (mutex); await the write. Add a concurrency test.

B2 Attribution dropped on flush race high

flushPendingAttribution (src/services/posthog.js:277) reads the queue, then removeItems at line 298. Anything queued in that window (e.g. a deep link arriving during login flush) is deleted unsent.

Fix: claim-and-clear (read + clear atomically at start; re-queue anything that races in). Add a test.

B8 Silent AsyncStorage failures medium

Storage errors in the diff/queue paths are swallowed with .catch(() => {}) and no Sentry breadcrumb. Fail-open is acceptable for $set (re-send), but queued-attribution loss should be surfaced.

B5 Replay sampling per JS-load, not per session low (likely by design)

replaySampledIn = Math.random() < 0.12 is drawn once at module load (App.js). The 30-min background rotation starts a new PostHog session while this stays fixed, so sampling is per-JS-process, not per-SDK-session. The code comment says this is intentional; document it and only change if product wants true per-session sampling.

Confirmed non-bugs (do not “fix”): disabled: __DEV__ making sampling dead-code in dev (correct); the setAnalyticsAuthState dependency array (correct — the gap it exposes is B3).

C. Incidental finding (pre-existing, not this commit) low

The event constants use ud_ prefixes (ud_live_search_started, ud_live_search_results_shown, ud_pagination_load_more), but the live taxonomy only has the un-prefixed names (live_search_started, …). Insights built on the old names are already missing new data from a prior rename. Flag to the analytics owner separately.

What was confirmed SAFE

Recommended next steps

  1. Decide the fix approach for A2 (funnel) — new *_summary name vs tolerate batching.
  2. Ship the self-contained code fixes B1 / B2 / B3 (+ B8) with regression tests — independent of the analytics decisions.
  3. Decide on A1 — exclude from gate, rewrite insight, or accept + annotate.
  4. Convert the A3 suffix rule to an explicit allow-list.
  5. File C with the analytics owner separately.