Repository navigation
fix: run beforeSend once per captured event - #885
Conversation
The stateful capture built the event and ran beforeSend, then handed the result to captureStateless, which rebuilt the properties and ran beforeSend again. Enqueue the event the stateful path already built, and minimize $feature_flag_called before its single beforeSend, as the stateless path does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed ec33bb7: 1 Must fix.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
posthog-android Compliance ReportDate: 2026-10-26 17:37:21 UTC ✅ All Tests Passed!46/46 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The removed second pass checked opt-out after beforeSend had run. Check it again before queueing, so an opt-out from inside the hook still drops the event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| } | ||
|
|
||
| @Test | ||
| fun `beforeSend runs once per capture`() { |
There was a problem hiding this comment.
🤖 An AI agent wrote this on behalf of @ioannisj.
[nit] Worth adding a stateful test that a hook edit to distinctId or timestamp actually lands in the sent body? That's the user-visible bug from the description, but the new tests only check call count, opt-out and the minimal flag-called shape, and the only modified-event-reaches-queue test is on the stateless path (posthog/src/test/java/com/posthog/PostHogStatelessTest.kt:1027).
Something like the hook returning it.copy(distinctId = "hooked") and asserting distinct_id in the batch should fail on main and pass here. Could be a follow-up if you'd rather keep this one tight.
There was a problem hiding this comment.
Added in 20ea392: an event edited by beforeSend is sent as edited. One wrinkle: a fixed edit like it.copy(distinctId = "hooked") passes on main too, because the second pass reran the hook and it made the same edit again. Only a hook that returned something different on its second run lost its first edit. So the test uses "hooked-${++calls}". On main it fails with expected:<hooked-1> but was:<hooked-2>, and it passes here.
| ) | ||
|
|
||
| val postHogEvent = buildEvent(event, newDistinctId, mergedProperties, timestamp) | ||
| val eventProperties = |
There was a problem hiding this comment.
🤖 An AI agent wrote this on behalf of @ioannisj.
[nit] This is now the same six lines as posthog/src/main/java/com/posthog/PostHogStateless.kt:268-273. Worth folding the event check into the helper (minimizeIfFeatureFlagCalled(event, properties)) so the two paths can't drift, or overkill here?
There was a problem hiding this comment.
Done in 20ea392. It is now minimizeIfFeatureFlagCalled(event, properties) and both paths call it. It takes and returns a MutableMap, so the stateful path still passes mergedProperties through without copying it for every non-flag event.
Both capture paths now call minimizeIfFeatureFlagCalled, so they can't drift. Adds a test that a beforeSend edit reaches the sent batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| } else if (queue != null) { | ||
| queue.add(postHogEvent) |
There was a problem hiding this comment.
P2 · Recheck SDK state after running beforeSend
If beforeSend calls close() and returns the event, capture now adds it to the closed SDK's queue. close() leaves queue non-null and PostHogQueue.stop() only stops its timer/network listener, so the event is persisted and can still be sent. The removed captureStatelessInternal rejected it via isEnabled(); restore that post-hook check before enqueueing.
💡 Motivation and Context
beforeSendran twice for every event captured through the stateful SDK (Android and the stateful JVM client).PostHog.capturebuilt the event and ran the hooks, then passed the result tocaptureStateless, which rebuilt the properties and ran the hooks again. This came in with #284, which madePostHogextendPostHogStatelessand replacedqueue?.add(postHogEvent)withsuper.captureStateless(...)while keeping the firstbuildEventcall. That PR set out to make no functional changes.What users saw:
uuid, kept about 25% of events at a 50% rate, because each pass made its own draw (the second pass also built a new event with a newuuid). Sampling keyed ondistinctIdor a property was unaffected.distinctId,timestamporuuidcould be lost, because the second pass rebuilt the event from the original values. A hook that made the same edit on both passes was unaffected. One whose second run returned something different kept only the second result. The before-send-hook spec says downstream queueing observes the hook's changes.getDynamicContext()and the rest of the property build ran twice on the calling thread.The fix:
$feature_flag_calledis minimized before the singlebeforeSend, ascaptureStatelessand iOS already do. So the hook sees the same shape that's sent. Before, the hook's first run saw the full event.captureStatelessInternal, added in fix(replay): pare down debug properties on captured events #833 only to report back to the stateful path whether the event was queued, is folded back intocaptureStateless.minimizeFeatureFlagCalledPropertiesbecomesinternal minimizeIfFeatureFlagCalled(event, properties), which both capture paths call. There's no public API change (apiCheckpasses).posthog-servercallscaptureStatelessdirectly and already ran the hooks once, so it doesn't change.Behavior changes for apps with hooks: a hook now runs once per event, its edits to
distinctId/timestamp/uuidare kept, and for$feature_flag_calledwith minimal flag-called events on, it only sees the minimal properties. What gets sent for unchanged hooks is the same, apart from random oruuid-based sampling hooks now keeping their configured rate.💚 How did you test it?
beforeSend runs once per captureandbeforeSend sees the minimal feature flag called event. Both fail onmain(expected 1 call, was 2) and pass with the fix.an event edited by beforeSend is sent as edited. It fails on the base commit (expected:<hooked-1> but was:<hooked-2>) and passes with the fix.an event is dropped when beforeSend opts out.an SDK event captured from inside beforeSend does not also carry the bundle, whose comment documented the double run and whoseflushAtrelied on the nested capture firing twice../gradlew spotlessCheck :posthog:apiCheck :posthog-server:apiCheck :posthog:test :posthog-server:test :posthog-android:testDebugUnitTestall pass.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
$groupidentify, snapshots, the survey and replay handlers, and the replay debug window. None of it is still needed, because the stateful path already does each of these. The only follow-up was this changeset.beforeSendand the stateful property build, and anything in the 4.0 capture v1 stack. feat!: add per-event capture options and one property precedence #882 will need a rebase over this.🤖 Generated with Claude Code