Skip to content

fix: run beforeSend once per captured event - #885

Merged
turnipdabeets merged 4 commits into
mainfrom
fix/before-send-once
Oct 26, 2026
Merged

turnipdabeets merged 4 commits into
mainfrom
fix/before-send-once

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

beforeSend ran twice for every event captured through the stateful SDK (Android and the stateful JVM client). PostHog.capture built the event and ran the hooks, then passed the result to captureStateless, which rebuilt the properties and ran the hooks again. This came in with #284, which made PostHog extend PostHogStateless and replaced queue?.add(postHogEvent) with super.captureStateless(...) while keeping the first buildEvent call. That PR set out to make no functional changes.

What users saw:

  • Hooks with side effects ran twice. A hook that samples at random, or by 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 new uuid). Sampling keyed on distinctId or a property was unaffected.
  • Changes a hook made to distinctId, timestamp or uuid could 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:

  • The stateful path adds the event it built straight to the queue. It commits the replay debug window only when the event was queued, as before.
  • $feature_flag_called is minimized before the single beforeSend, as captureStateless and 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 into captureStateless.
  • minimizeFeatureFlagCalledProperties becomes internal minimizeIfFeatureFlagCalled(event, properties), which both capture paths call. There's no public API change (apiCheck passes).

posthog-server calls captureStateless directly 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/uuid are kept, and for $feature_flag_called with minimal flag-called events on, it only sees the minimal properties. What gets sent for unchanged hooks is the same, apart from random or uuid-based sampling hooks now keeping their configured rate.

💚 How did you test it?

  • Added beforeSend runs once per capture and beforeSend sees the minimal feature flag called event. Both fail on main (expected 1 call, was 2) and pass with the fix.
  • Added 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.
  • Added an event is dropped when beforeSend opts out.
  • Updated an SDK event captured from inside beforeSend does not also carry the bundle, whose comment documented the double run and whose flushAt relied on the nested capture firing twice.
  • ./gradlew spotlessCheck :posthog:apiCheck :posthog-server:apiCheck :posthog:test :posthog-server:test :posthog-android:testDebugUnitTest all pass.
  • Not tested on a device.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Claude Code (Claude Opus 5.5) found the double run while comparing the Android and iOS capture v1 PRs, traced it to feat(java-sdk): Add a server-side stateless interface #284 through git history and the PR description, wrote the failing tests, then made the fix.
  • A two-agent review loop (review, then triage) checked everything the removed second pass did: the stateless property build, the sanitizer, the opt-out and enabled checks, $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.
  • Kept out of scope: changing the order of beforeSend and 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

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>
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 25, 2026 15:51
@turnipdabeets turnipdabeets self-assigned this Oct 25, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread posthog/src/main/java/com/posthog/PostHog.kt
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-10-26 17:37:21 UTC
Duration: 118419ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 387ms
Format Validation.Event Has Uuid ✅ 29ms
Format Validation.Event Has Lib Properties ✅ 30ms
Format Validation.Distinct Id Is String ✅ 27ms
Format Validation.Token Is Present ✅ 22ms
Format Validation.Custom Properties Preserved ✅ 25ms
Format Validation.Event Has Timestamp ✅ 26ms
Retry Behavior.Retries On 503 ✅ 7026ms
Retry Behavior.Does Not Retry On 400 ✅ 4024ms
Retry Behavior.Does Not Retry On 401 ✅ 4025ms
Retry Behavior.Respects Retry After Header ✅ 7027ms
Retry Behavior.Implements Backoff ✅ 17026ms
Retry Behavior.Retries On 500 ✅ 7017ms
Retry Behavior.Retries On 502 ✅ 7018ms
Retry Behavior.Retries On 504 ✅ 7018ms
Retry Behavior.Max Retries Respected ✅ 17036ms
Deduplication.Generates Unique Uuids ✅ 40ms
Deduplication.Preserves Uuid On Retry ✅ 7017ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 12020ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7016ms
Deduplication.No Duplicate Events In Batch ✅ 35ms
Deduplication.Different Events Have Different Uuids ✅ 24ms
Compression.Sends Gzip When Enabled ✅ 29ms
Batch Format.Uses Proper Batch Structure ✅ 17ms
Batch Format.Flush With No Events Sends Nothing ✅ 12ms
Batch Format.Multiple Events Batched Together ✅ 32ms
Error Handling.Does Not Retry On 403 ✅ 4019ms
Error Handling.Does Not Retry On 413 ✅ 4022ms
Error Handling.Retries On 408 ✅ 5029ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 35ms
Request Payload.Flags Request Uses V2 Query Param ✅ 26ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 24ms
Request Payload.Flags Request Omits Authorization Header ✅ 29ms
Request Payload.Token In Flags Body Matches Init ✅ 23ms
Request Payload.Groups Round Trip ✅ 25ms
Request Payload.Groups Default To Empty Object ✅ 28ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 22ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 25ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 24ms
Request Lifecycle.No Flags Request On Init Alone ✅ 10ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 24ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 41ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 22ms
Retry Behavior.Retries Flags On 502 ✅ 326ms
Retry Behavior.Retries Flags On 504 ✅ 324ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 27ms

turnipdabeets and others added 2 commits October 25, 2026 11:59
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`() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@turnipdabeets
turnipdabeets enabled auto-merge (squash) October 26, 2026 17:32

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 20ea392: 1 Should fix.

Comment on lines +1069 to +1070
} else if (queue != null) {
queue.add(postHogEvent)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@turnipdabeets
turnipdabeets merged commit cb6fe68 into main Oct 26, 2026
18 checks passed
@turnipdabeets
turnipdabeets deleted the fix/before-send-once branch October 26, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants