Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/before-send-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog': patch
'posthog-android': patch
---

Fix `beforeSend` running twice for each captured event, which discarded the hook's changes to `distinctId`, `timestamp` and `uuid` and compounded random sampling
25 changes: 12 additions & 13 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1037,7 +1037,9 @@ public class PostHog private constructor(
},
)

val postHogEvent = buildEvent(event, newDistinctId, mergedProperties, timestamp)
val eventProperties = minimizeIfFeatureFlagCalled(event, mergedProperties)

val postHogEvent = buildEvent(event, newDistinctId, eventProperties, timestamp)
if (postHogEvent == null) {
val originalMessage = "PostHog event $event was dropped"
val message =
Expand All @@ -1060,18 +1062,15 @@ public class PostHog private constructor(
if (isSnapshotEvent) {
replayQueue?.add(postHogEvent)
} else {
val queued =
captureStatelessInternal(
postHogEvent.event,
newDistinctId,
postHogEvent.properties ?: emptyMap(),
userProperties,
userPropertiesSetOnce,
groups,
timestamp,
)
if (queued && claimedReplayDebugBundle) {
commitReplayDebugPropertiesWindow()
val queue = queue
// beforeSend may have opted out
if (isOptedOut()) {
config?.logger?.log("PostHog is in OptOut state.")
} else if (queue != null) {
queue.add(postHogEvent)
Comment thread
turnipdabeets marked this conversation as resolved.
Comment on lines +1069 to +1070

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.

if (claimedReplayDebugBundle) {
commitReplayDebugPropertiesWindow()
}
}
// Notify surveys integration about the event
surveysHandler?.onEvent(event, mergedProperties)
Expand Down
44 changes: 15 additions & 29 deletions posthog/src/main/java/com/posthog/PostHogStateless.kt
Original file line number Diff line number Diff line change
Expand Up @@ -241,26 +241,14 @@ public open class PostHogStateless protected constructor(
groups: Map<String, String>?,
timestamp: Date?,
) {
captureStatelessInternal(event, distinctId, properties, userProperties, userPropertiesSetOnce, groups, timestamp)
}

internal fun captureStatelessInternal(
event: String,
distinctId: String,
properties: Map<String, Any>?,
userProperties: Map<String, Any>?,
userPropertiesSetOnce: Map<String, Any>?,
groups: Map<String, String>?,
timestamp: Date?,
): Boolean {
try {
if (!isEnabled()) {
return false
return
}

if (config?.optOut == true) {
config?.logger?.log("PostHog is in OptOut state.")
return false
return
}

var groupIdentify = false
Expand All @@ -277,18 +265,13 @@ public open class PostHogStateless protected constructor(
appendGroups = !groupIdentify,
)

val eventProperties =
if (event == PostHogEventName.FEATURE_FLAG_CALLED.event) {
minimizeFeatureFlagCalledProperties(mergedProperties)
} else {
mergedProperties
}
val eventProperties = minimizeIfFeatureFlagCalled(event, mergedProperties.toMutableMap())

val postHogEvent =
buildEvent(
event,
distinctId,
eventProperties.toMutableMap(),
eventProperties,
timestamp,
)
if (postHogEvent == null) {
Expand All @@ -300,15 +283,12 @@ public open class PostHogStateless protected constructor(
originalMessage
}
config?.logger?.log(message)
return false
return
}

val queue = queue ?: return false
queue.add(postHogEvent)
return true
queue?.add(postHogEvent)
} catch (e: Throwable) {
config?.logger?.log("Capture failed: $e.")
return false
}
}

Expand Down Expand Up @@ -551,17 +531,23 @@ public open class PostHogStateless protected constructor(
* enabled minimal flag-called events for this team AND the evaluated flag is not linked to an
* experiment. Any missing signal (gate absent from the response or cache, `has_experiment`
* unknown) keeps the full legacy shape, and experiment-linked flags always send the full
* envelope because experiment exposure analysis reads it.
* envelope because experiment exposure analysis reads it. Other events pass through unchanged.
*/
private fun minimizeFeatureFlagCalledProperties(properties: Map<String, Any>): Map<String, Any> {
internal fun minimizeIfFeatureFlagCalled(
event: String,
properties: MutableMap<String, Any>,
): MutableMap<String, Any> {
if (event != PostHogEventName.FEATURE_FLAG_CALLED.event) {
return properties
}
if (!isMinimalFlagCalledEventsEnabled()) {
return properties
}
if (properties["\$feature_flag_has_experiment"] != false) {
return properties
}

return properties.filterKeys { it in MINIMAL_FEATURE_FLAG_CALLED_PROPERTIES }
return properties.filterKeys { it in MINIMAL_FEATURE_FLAG_CALLED_PROPERTIES }.toMutableMap()
}

/**
Expand Down
98 changes: 95 additions & 3 deletions posthog/src/test/java/com/posthog/PostHogTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2517,6 +2517,100 @@ internal class PostHogTest {
http.shutdown()
}

@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 http = mockHttp()
val url = http.url("/")
var calls = 0

val sut =
getSut(url.toString(), preloadFeatureFlags = false, beforeSend = {
calls++
it
})

sut.capture("event")

queueExecutor.shutdownAndAwaitTermination()

assertEquals(1, calls)

sut.close()
http.shutdown()
}

@Test
fun `an event edited by beforeSend is sent as edited`() {
val http = mockHttp()
val url = http.url("/")
var calls = 0

val sut =
getSut(url.toString(), preloadFeatureFlags = false, beforeSend = {
it.copy(distinctId = "hooked-${++calls}")
})

sut.capture("event")

queueExecutor.shutdownAndAwaitTermination()

val batch = serializer.deserialize<PostHogBatchEvent>(http.takeRequest().body.unGzip().reader())
assertEquals("hooked-1", batch.batch.first().distinctId)

sut.close()
http.shutdown()
}

@Test
fun `an event is dropped when beforeSend opts out`() {
val http = mockHttp()
val url = http.url("/")
lateinit var sut: PostHogInterface

sut =
getSut(url.toString(), preloadFeatureFlags = false, beforeSend = {
sut.optOut()
it
})

sut.capture("event")

queueExecutor.shutdownAndAwaitTermination()

assertEquals(0, http.requestCount)

sut.close()
http.shutdown()
}

@Test
fun `beforeSend sees the minimal feature flag called event`() {
val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json")
val http = mockHttp(response = MockResponse().setBody(file.readText()))
http.enqueue(MockResponse().setBody(""))
val url = http.url("/")
val seenProperties = mutableListOf<Set<String>>()

val sut =
getSut(url.toString(), preloadFeatureFlags = false, beforeSend = {
if (it.event == "\$feature_flag_called") seenProperties.add(it.properties.orEmpty().keys.toSet())
it
})
sut.register("myCustomProp", "value")

sut.reloadFeatureFlags()
remoteConfigExecutor.shutdownAndAwaitTermination()

sut.getFeatureFlag("IAmInactive")
queueExecutor.shutdownAndAwaitTermination()

assertEquals(1, seenProperties.size)
assertFalse(seenProperties.first().contains("myCustomProp"))

sut.close()
http.shutdown()
}

@Test
fun `creates a group`() {
val http = mockHttp()
Expand Down Expand Up @@ -3683,10 +3777,8 @@ internal class PostHogTest {
fun `an SDK event captured from inside beforeSend does not also carry the bundle`() {
val http = mockHttp()
lateinit var sut: PostHogInterface
// beforeSend runs twice per stateful capture (PostHog.capture, then captureStateless), so the
// nested capture fires twice.
sut =
replayDebugSut(http, flushAt = 3, beforeSend = {
replayDebugSut(http, flushAt = 2, beforeSend = {
if (it.event == "\$outer") sut.capture("\$inner")
it
})
Expand Down
Loading