From a9a5ae0cb2537ad2b3f04f112e5087604fb83449 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 11 Sep 2026 13:14:19 -0400 Subject: [PATCH 1/6] feat(push): capture each PostHog push open once across automatic and manual paths capturePushNotificationOpened now skips a repeat of a PostHog-sent notification (same posthog.invocation_id and action_id) captured in the last 5 minutes. Every automatic path and the manual API end in this method, so an app that still calls the manual API from Firebase's onNotificationOpenedApp / onMessageOpenedApp next to the plugins' automatic capture counts one open instead of two, whichever reports first. Payloads without a posthog.invocation_id are captured as before. Claude-Session: https://claude.ai/code/session_01UJgnRvz58rzgFVCiUfjxkL --- .changeset/push-open-dedupe.md | 6 + .../posthog/android/PostHogAndroidConfig.kt | 2 +- posthog/src/main/java/com/posthog/PostHog.kt | 52 +++++- .../main/java/com/posthog/PostHogInterface.kt | 5 + .../src/test/java/com/posthog/PostHogTest.kt | 164 ++++++++++++++++++ 5 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 .changeset/push-open-dedupe.md diff --git a/.changeset/push-open-dedupe.md b/.changeset/push-open-dedupe.md new file mode 100644 index 000000000..ccb4b61e4 --- /dev/null +++ b/.changeset/push-open-dedupe.md @@ -0,0 +1,6 @@ +--- +'posthog': minor +'posthog-android': minor +--- + +Change `capturePushNotificationOpened` to skip a repeat open of a PostHog-sent notification (same `invocation_id` and `action_id`) captured in the last 5 minutes, so an automatic capture plus a manual call for one tap counts once. diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt index 3910401b6..56c71a94f 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroidConfig.kt @@ -31,7 +31,7 @@ import com.posthog.internal.PostHogQueue * extra). A warm-start tap arrives at `Activity.onNewIntent`, which is not observable here — * forward that intent to [PostHogAndroid.capturePushNotificationOpened], which is deduped against * this path. Foreground data messages and push delivered outside FCM need - * [PostHog.capturePushNotificationOpened], which is not. Also gates + * [PostHog.capturePushNotificationOpened], which dedupes only notifications sent by PostHog. Also gates * [PostHogAndroid.capturePushNotificationOpened]. Default: `true`. */ public open class PostHogAndroidConfig diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index ea7cf3753..b08d3fbd8 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -48,6 +48,15 @@ import java.util.concurrent.Executors private const val PUSH_NOTIFICATION_OPENED_EVENT = "\$push_notification_opened" +// A duplicate report of one tap arrives within the same launch: milliseconds after a warm tap, seconds +// after a cold start while the host's JS/Dart handlers register. The window stays finite because a +// workflow that loops back to a push step re-sends the same `invocation_id`/`action_id` pair as a new +// notification, and that later open must still count. +private const val PUSH_OPEN_DEDUPE_WINDOW_NANOS = 5 * 60 * 1_000_000_000L + +// Only needs the opens of the last few minutes; the cap bounds memory for hosts that call this in bulk. +private const val MAX_RECENT_PUSH_OPENS = 20 + public class PostHog private constructor( private val queueExecutor: ExecutorService = Executors.newSingleThreadScheduledExecutor( @@ -117,6 +126,9 @@ public class PostHog private constructor( private var logsRateCapWindowStartMillis: Long = 0 private var logsRateCapWindowCount: Int = 0 + // Captured PostHog push opens, by `invocation_id/action_id`, to the dateProvider nanoTime of capture. + private val recentPushOpens = LinkedHashMap() + private val remoteConfig: PostHogRemoteConfig? get() = config?.remoteConfigHolder @@ -2112,20 +2124,52 @@ public class PostHog private constructor( return } + val posthogPayload = payload?.get("posthog")?.let { posthogPayloadMap(it) } + if (!recordPushOpen(posthogPayload)) { + return + } + val props = mutableMapOf() title?.takeIf { it.isNotEmpty() }?.let { props["\$notification_title"] = it } body?.takeIf { it.isNotEmpty() }?.let { props["\$notification_body"] = it } action?.takeIf { it.isNotEmpty() }?.let { props["\$notification_action"] = it } - payload?.get("posthog")?.let { raw -> - posthogPayloadMap(raw)?.forEach { (key, value) -> - value?.let { props["\$notification_$key"] = it } - } + posthogPayload?.forEach { (key, value) -> + value?.let { props["\$notification_$key"] = it } } capture(PUSH_NOTIFICATION_OPENED_EVENT, properties = props) } + /** + * Records the open of a PostHog-sent push and returns false if the same notification was already + * captured within [PUSH_OPEN_DEDUPE_WINDOW_NANOS]. The key is `invocation_id` plus `action_id`, + * since every step of one workflow run shares the run's `invocation_id`. A payload without an + * `invocation_id` has no key and is always captured. + */ + private fun recordPushOpen(posthogPayload: Map?): Boolean { + val invocationId = posthogPayload?.get("invocation_id") as? String + if (posthogPayload == null || invocationId.isNullOrEmpty()) { + return true + } + val key = "$invocationId/${posthogPayload["action_id"] as? String ?: ""}" + val now = config?.dateProvider?.nanoTime() ?: return true + + synchronized(recentPushOpens) { + val capturedAt = recentPushOpens[key] + if (capturedAt != null && now - capturedAt < PUSH_OPEN_DEDUPE_WINDOW_NANOS) { + config?.logger?.log("Skipped \$push_notification_opened: notification $key was already captured.") + return false + } + recentPushOpens.remove(key) + recentPushOpens[key] = now + if (recentPushOpens.size > MAX_RECENT_PUSH_OPENS) { + recentPushOpens.remove(recentPushOpens.keys.first()) + } + return true + } + } + /** * Coerces the `posthog` entry of a push payload into a map. FCM data maps are string→string, * so the value is accepted either as a nested [Map] or as a JSON string. Parse failures are diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index c19e68c47..acf7e8fdd 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -453,6 +453,11 @@ public interface PostHogInterface : PostHogCoreInterface { * Each key of `payload["posthog"]` (accepted as a `Map` or a JSON string) is attached as a * `$notification_` property. * + * A notification sent by PostHog is captured once: when `payload["posthog"]` carries an + * `invocation_id`, a repeat with the same `invocation_id` and `action_id` within 5 minutes of the + * first capture is skipped, whether that first capture came from this method or from the SDK's + * automatic capture. Payloads without a `posthog.invocation_id` are always captured. + * * @param title the notification title, attached as `$notification_title` when non-empty * @param body the notification body, attached as `$notification_body` when non-empty * @param payload the notification data payload; its `posthog` entry is spread into `$notification_*` props diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 334203330..19d28f1b0 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -2,6 +2,8 @@ package com.posthog import com.posthog.internal.PostHogBatchEvent import com.posthog.internal.PostHogContext +import com.posthog.internal.PostHogDateProvider +import com.posthog.internal.PostHogDeviceDateProvider import com.posthog.internal.PostHogMemoryPreferences import com.posthog.internal.PostHogNetworkStatus import com.posthog.internal.PostHogPreferences @@ -30,7 +32,9 @@ import okhttp3.mockwebserver.MockResponse import org.junit.Rule import org.junit.rules.TemporaryFolder import java.io.File +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.collections.get import kotlin.test.AfterTest import kotlin.test.Test @@ -4727,6 +4731,166 @@ internal class PostHogTest { sut.close() } + private val stepOne = """{"workflow_id":"wf-1","invocation_id":"inv-1","action_id":"step-1"}""" + private val stepTwo = """{"workflow_id":"wf-1","invocation_id":"inv-1","action_id":"step-2"}""" + private val pushOpens = CopyOnWriteArrayList() + + private fun getPushOpenSut(optOut: Boolean = false): PostHogInterface = + getSut( + mockHttp().url("/").toString(), + optOut = optOut, + preloadFeatureFlags = false, + reloadFeatureFlags = false, + beforeSend = { event -> + if (event.event == "\$push_notification_opened") pushOpens.add(event) + null + }, + ) + + private fun PostHogInterface.captureAutomaticPushOpen(posthog: Any?) = + capturePushNotificationOpened(payload = mapOf("google.message_id" to "m-1", "posthog" to posthog)) + + private fun PostHogInterface.captureManualPushOpen(posthog: Any?) = + capturePushNotificationOpened(title = "Hello", body = "World", payload = mapOf("posthog" to posthog)) + + @Test + fun `capturePushNotificationOpened skips a manual repeat of an automatically captured PostHog push`() { + val sut = getPushOpenSut() + + sut.captureAutomaticPushOpen(stepOne) + sut.captureManualPushOpen(stepOne) + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(1, pushOpens.size) + assertNull(pushOpens.single().properties!!["\$notification_title"]) + assertEquals("inv-1", pushOpens.single().properties!!["\$notification_invocation_id"]) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened skips an automatic repeat of a manually captured PostHog push`() { + val sut = getPushOpenSut() + + sut.captureManualPushOpen(mapOf("workflow_id" to "wf-1", "invocation_id" to "inv-1", "action_id" to "step-1")) + sut.captureAutomaticPushOpen(stepOne) + sut.captureManualPushOpen(stepOne) + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(1, pushOpens.size) + assertEquals("Hello", pushOpens.single().properties!!["\$notification_title"]) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened keys PostHog pushes by invocation and action`() { + val sut = getPushOpenSut() + val noAction = """{"workflow_id":"wf-1","invocation_id":"inv-1"}""" + val otherRun = """{"workflow_id":"wf-1","invocation_id":"inv-2","action_id":"step-1"}""" + + listOf(stepOne, stepTwo, noAction, otherRun).forEach { sut.captureAutomaticPushOpen(it) } + listOf(stepOne, stepTwo, noAction, otherRun).forEach { sut.captureManualPushOpen(it) } + queueExecutor.shutdownAndAwaitTermination() + + assertEquals( + listOf("inv-1" to "step-1", "inv-1" to "step-2", "inv-1" to null, "inv-2" to "step-1"), + pushOpens.map { + it.properties!!["\$notification_invocation_id"] to it.properties!!["\$notification_action_id"] + }, + ) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened never dedupes a push without a posthog entry`() { + val sut = getPushOpenSut() + + sut.capturePushNotificationOpened(payload = mapOf("google.message_id" to "m-1")) + sut.capturePushNotificationOpened(title = "Hello", payload = mapOf("google.message_id" to "m-1")) + sut.capturePushNotificationOpened() + sut.capturePushNotificationOpened() + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(4, pushOpens.size) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened never dedupes a push with a malformed posthog entry`() { + val sut = getPushOpenSut() + val malformed = + listOf( + "{not json", + """{"action_id":"step-1"}""", + """{"invocation_id":""}""", + """{"invocation_id":42}""", + """["inv-1"]""", + 42, + ) + + malformed.forEach { + sut.captureAutomaticPushOpen(it) + sut.captureManualPushOpen(it) + } + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(malformed.size * 2, pushOpens.size) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened captures a repeat once the dedupe window has passed`() { + val sut = getPushOpenSut() + var nanos = 0L + config.dateProvider = + object : PostHogDateProvider by PostHogDeviceDateProvider() { + override fun nanoTime(): Long = nanos + } + + sut.captureAutomaticPushOpen(stepOne) + nanos = TimeUnit.MINUTES.toNanos(5) - 1 + sut.captureManualPushOpen(stepOne) + nanos = TimeUnit.MINUTES.toNanos(5) + sut.captureManualPushOpen(stepOne) + sut.captureAutomaticPushOpen(stepOne) + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(listOf(null, "Hello"), pushOpens.map { it.properties!!["\$notification_title"] }) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened does not record a push skipped while opted out`() { + val sut = getPushOpenSut(optOut = true) + + sut.captureAutomaticPushOpen(stepOne) + sut.optIn() + sut.captureManualPushOpen(stepOne) + sut.captureAutomaticPushOpen(stepOne) + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(listOf("Hello"), pushOpens.map { it.properties!!["\$notification_title"] }) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened is a no-op after close`() { + val sut = getPushOpenSut() + sut.close() + + sut.captureAutomaticPushOpen(stepOne) + sut.captureManualPushOpen(stepOne) + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(0, pushOpens.size) + } + @Test fun `flush retries a push subscription registration deferred while offline`() { val http = mockHttp() From 5c647e8913e48fc4d99ed361547efb0877a0be1f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 11 Sep 2026 13:38:45 -0400 Subject: [PATCH 2/6] fix(push): time the push-open dedupe window with wall-clock time nanoTime is uptime-based on Android and stops in deep sleep, so a looped workflow's next open on an idle phone could fall inside the 5-minute window and be dropped. The window now uses the date provider's currentTimeMillis, and a negative gap (wall clock moved back) captures the open instead of skipping it. Claude-Session: https://claude.ai/code/session_01UJgnRvz58rzgFVCiUfjxkL --- posthog/src/main/java/com/posthog/PostHog.kt | 16 ++++++---------- posthog/src/test/java/com/posthog/PostHogTest.kt | 8 ++++---- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index b08d3fbd8..86e4322b2 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -52,7 +52,7 @@ private const val PUSH_NOTIFICATION_OPENED_EVENT = "\$push_notification_opened" // after a cold start while the host's JS/Dart handlers register. The window stays finite because a // workflow that loops back to a push step re-sends the same `invocation_id`/`action_id` pair as a new // notification, and that later open must still count. -private const val PUSH_OPEN_DEDUPE_WINDOW_NANOS = 5 * 60 * 1_000_000_000L +private const val PUSH_OPEN_DEDUPE_WINDOW_MILLIS = 5 * 60 * 1000L // Only needs the opens of the last few minutes; the cap bounds memory for hosts that call this in bulk. private const val MAX_RECENT_PUSH_OPENS = 20 @@ -126,7 +126,7 @@ public class PostHog private constructor( private var logsRateCapWindowStartMillis: Long = 0 private var logsRateCapWindowCount: Int = 0 - // Captured PostHog push opens, by `invocation_id/action_id`, to the dateProvider nanoTime of capture. + // Captured PostHog push opens, by `invocation_id/action_id`, to the dateProvider millis of capture. private val recentPushOpens = LinkedHashMap() private val remoteConfig: PostHogRemoteConfig? @@ -2141,23 +2141,19 @@ public class PostHog private constructor( capture(PUSH_NOTIFICATION_OPENED_EVENT, properties = props) } - /** - * Records the open of a PostHog-sent push and returns false if the same notification was already - * captured within [PUSH_OPEN_DEDUPE_WINDOW_NANOS]. The key is `invocation_id` plus `action_id`, - * since every step of one workflow run shares the run's `invocation_id`. A payload without an - * `invocation_id` has no key and is always captured. - */ private fun recordPushOpen(posthogPayload: Map?): Boolean { val invocationId = posthogPayload?.get("invocation_id") as? String if (posthogPayload == null || invocationId.isNullOrEmpty()) { return true } + // Every step of one workflow run shares the run's invocation_id, so action_id tells the steps apart. val key = "$invocationId/${posthogPayload["action_id"] as? String ?: ""}" - val now = config?.dateProvider?.nanoTime() ?: return true + val now = config?.dateProvider?.currentTimeMillis() ?: return true synchronized(recentPushOpens) { val capturedAt = recentPushOpens[key] - if (capturedAt != null && now - capturedAt < PUSH_OPEN_DEDUPE_WINDOW_NANOS) { + // A negative gap means the wall clock moved back; capture rather than risk dropping an open. + if (capturedAt != null && now - capturedAt in 0 until PUSH_OPEN_DEDUPE_WINDOW_MILLIS) { config?.logger?.log("Skipped \$push_notification_opened: notification $key was already captured.") return false } diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 19d28f1b0..4571bd37f 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -4845,16 +4845,16 @@ internal class PostHogTest { @Test fun `capturePushNotificationOpened captures a repeat once the dedupe window has passed`() { val sut = getPushOpenSut() - var nanos = 0L + var millis = 0L config.dateProvider = object : PostHogDateProvider by PostHogDeviceDateProvider() { - override fun nanoTime(): Long = nanos + override fun currentTimeMillis(): Long = millis } sut.captureAutomaticPushOpen(stepOne) - nanos = TimeUnit.MINUTES.toNanos(5) - 1 + millis = TimeUnit.MINUTES.toMillis(5) - 1 sut.captureManualPushOpen(stepOne) - nanos = TimeUnit.MINUTES.toNanos(5) + millis = TimeUnit.MINUTES.toMillis(5) sut.captureManualPushOpen(stepOne) sut.captureAutomaticPushOpen(stepOne) queueExecutor.shutdownAndAwaitTermination() From c77e70357ebca5e3686e7c17904e85f3da6f9481 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 11 Sep 2026 13:58:51 -0400 Subject: [PATCH 3/6] test(push): shut down the push-open tests' mock server --- posthog/src/test/java/com/posthog/PostHogTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 4571bd37f..d85af33a4 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -29,6 +29,7 @@ import com.posthog.internal.PostHogThreadFactory import com.posthog.internal.errortracking.PostHogThrowable import com.posthog.vendor.uuid.TimeBasedEpochGenerator import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer import org.junit.Rule import org.junit.rules.TemporaryFolder import java.io.File @@ -124,6 +125,7 @@ internal class PostHogTest { @AfterTest fun `set down`() { + pushOpenHttp?.shutdown() tmpDir.root.deleteRecursively() } @@ -4734,10 +4736,11 @@ internal class PostHogTest { private val stepOne = """{"workflow_id":"wf-1","invocation_id":"inv-1","action_id":"step-1"}""" private val stepTwo = """{"workflow_id":"wf-1","invocation_id":"inv-1","action_id":"step-2"}""" private val pushOpens = CopyOnWriteArrayList() + private var pushOpenHttp: MockWebServer? = null private fun getPushOpenSut(optOut: Boolean = false): PostHogInterface = getSut( - mockHttp().url("/").toString(), + mockHttp().also { pushOpenHttp = it }.url("/").toString(), optOut = optOut, preloadFeatureFlags = false, reloadFeatureFlags = false, From f1c3c536c952413b88761eadc3e78198188530e2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 12 Sep 2026 11:50:47 -0400 Subject: [PATCH 4/6] test(push): cover the open-dedupe cap eviction and a backward wall clock The 20-entry eviction and the lower bound of the dedupe window had no test entering them: evicting the newest key instead of the oldest, or dropping the 0 lower bound, left the suite green. --- .../src/test/java/com/posthog/PostHogTest.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index d85af33a4..8c403d530 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -4867,6 +4867,42 @@ internal class PostHogTest { sut.close() } + @Test + fun `capturePushNotificationOpened captures a repeat once the wall clock has moved backwards`() { + val sut = getPushOpenSut() + var millis = TimeUnit.MINUTES.toMillis(5) + config.dateProvider = + object : PostHogDateProvider by PostHogDeviceDateProvider() { + override fun currentTimeMillis(): Long = millis + } + + sut.captureAutomaticPushOpen(stepOne) + millis -= TimeUnit.SECONDS.toMillis(60) + sut.captureManualPushOpen(stepOne) + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(listOf(null, "Hello"), pushOpens.map { it.properties!!["\$notification_title"] }) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened evicts the oldest open at the cap`() { + val sut = getPushOpenSut() + + repeat(21) { + sut.captureAutomaticPushOpen("""{"invocation_id":"inv-$it","action_id":"step-1"}""") + } + sut.captureManualPushOpen("""{"invocation_id":"inv-0","action_id":"step-1"}""") + sut.captureManualPushOpen("""{"invocation_id":"inv-20","action_id":"step-1"}""") + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(22, pushOpens.size) + assertEquals("inv-0", pushOpens.last().properties!!["\$notification_invocation_id"]) + + sut.close() + } + @Test fun `capturePushNotificationOpened does not record a push skipped while opted out`() { val sut = getPushOpenSut(optOut = true) From b57cef86bf67400acd2c8131c7a9100fe8c3b995 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 12 Sep 2026 13:45:01 -0400 Subject: [PATCH 5/6] fix(push): count an open of a resent notification separately A workflow that reruns or loops back to its push step sends a second notification with the same invocation_id/action_id pair. The dedupe key could not tell that from a second report of one tap, so the second open was dropped inside the 5-minute window. Remember the delivery id (google.message_id, present on the intent extras) next to each entry and use it only to recognise a new delivery: a report whose id disagrees with the stored one is captured, everything else stays deduped. --- .changeset/push-open-dedupe.md | 2 +- posthog/src/main/java/com/posthog/PostHog.kt | 41 ++++++++++++++--- .../main/java/com/posthog/PostHogInterface.kt | 6 +++ .../src/test/java/com/posthog/PostHogTest.kt | 46 ++++++++++++++++++- 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/.changeset/push-open-dedupe.md b/.changeset/push-open-dedupe.md index ccb4b61e4..396a6f7b3 100644 --- a/.changeset/push-open-dedupe.md +++ b/.changeset/push-open-dedupe.md @@ -3,4 +3,4 @@ 'posthog-android': minor --- -Change `capturePushNotificationOpened` to skip a repeat open of a PostHog-sent notification (same `invocation_id` and `action_id`) captured in the last 5 minutes, so an automatic capture plus a manual call for one tap counts once. +Change `capturePushNotificationOpened` to skip a repeat open of a PostHog-sent notification (same `invocation_id` and `action_id`) captured in the last 5 minutes, so an automatic capture plus a manual call for one tap counts once, while a resend of that notification (a new `google.message_id`) still counts. diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 86e4322b2..60a9728d2 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -57,6 +57,12 @@ private const val PUSH_OPEN_DEDUPE_WINDOW_MILLIS = 5 * 60 * 1000L // Only needs the opens of the last few minutes; the cap bounds memory for hosts that call this in bulk. private const val MAX_RECENT_PUSH_OPENS = 20 +// FCM stamps every message it delivers with its own id and puts it on the launch intent, so a payload +// built from that intent's extras identifies the delivery, not just the workflow step. `RemoteMessage.getData()` +// strips the `google.` prefix keys, so a foreground data message relayed by the host carries none — which is +// exactly the repeat this dedupe exists for. +private const val PUSH_DELIVERY_ID_KEY = "google.message_id" + public class PostHog private constructor( private val queueExecutor: ExecutorService = Executors.newSingleThreadScheduledExecutor( @@ -126,8 +132,13 @@ public class PostHog private constructor( private var logsRateCapWindowStartMillis: Long = 0 private var logsRateCapWindowCount: Int = 0 - // Captured PostHog push opens, by `invocation_id/action_id`, to the dateProvider millis of capture. - private val recentPushOpens = LinkedHashMap() + // Captured PostHog push opens, by `invocation_id/action_id`, to when they were captured. + private val recentPushOpens = LinkedHashMap() + + private class RecentPushOpen( + val capturedAt: Long, + val deliveryId: String?, + ) private val remoteConfig: PostHogRemoteConfig? get() = config?.remoteConfigHolder @@ -2125,7 +2136,7 @@ public class PostHog private constructor( } val posthogPayload = payload?.get("posthog")?.let { posthogPayloadMap(it) } - if (!recordPushOpen(posthogPayload)) { + if (!recordPushOpen(posthogPayload, payload?.get(PUSH_DELIVERY_ID_KEY) as? String)) { return } @@ -2141,7 +2152,10 @@ public class PostHog private constructor( capture(PUSH_NOTIFICATION_OPENED_EVENT, properties = props) } - private fun recordPushOpen(posthogPayload: Map?): Boolean { + private fun recordPushOpen( + posthogPayload: Map?, + deliveryId: String?, + ): Boolean { val invocationId = posthogPayload?.get("invocation_id") as? String if (posthogPayload == null || invocationId.isNullOrEmpty()) { return true @@ -2151,14 +2165,15 @@ public class PostHog private constructor( val now = config?.dateProvider?.currentTimeMillis() ?: return true synchronized(recentPushOpens) { - val capturedAt = recentPushOpens[key] + val previous = recentPushOpens[key] // A negative gap means the wall clock moved back; capture rather than risk dropping an open. - if (capturedAt != null && now - capturedAt in 0 until PUSH_OPEN_DEDUPE_WINDOW_MILLIS) { + val insideWindow = previous != null && now - previous.capturedAt in 0 until PUSH_OPEN_DEDUPE_WINDOW_MILLIS + if (insideWindow && !isNewDelivery(previous?.deliveryId, deliveryId)) { config?.logger?.log("Skipped \$push_notification_opened: notification $key was already captured.") return false } recentPushOpens.remove(key) - recentPushOpens[key] = now + recentPushOpens[key] = RecentPushOpen(now, deliveryId) if (recentPushOpens.size > MAX_RECENT_PUSH_OPENS) { recentPushOpens.remove(recentPushOpens.keys.first()) } @@ -2166,6 +2181,18 @@ public class PostHog private constructor( } } + /** + * Whether this report is a second notification rather than a second report of one tap. A rerun of a + * workflow, or a loop back to its push step, re-sends the same `invocation_id`/`action_id` pair, and + * only the delivery id tells that apart from the manual repeat the dedupe exists for — so the two + * ids have to disagree, not merely be absent. A report without one, or a first capture that had + * none, stays deduped. + */ + private fun isNewDelivery( + previous: String?, + reported: String?, + ): Boolean = previous != null && reported != null && previous != reported + /** * Coerces the `posthog` entry of a push payload into a map. FCM data maps are string→string, * so the value is accepted either as a nested [Map] or as a JSON string. Parse failures are diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index acf7e8fdd..275eda098 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -458,6 +458,12 @@ public interface PostHogInterface : PostHogCoreInterface { * first capture is skipped, whether that first capture came from this method or from the SDK's * automatic capture. Payloads without a `posthog.invocation_id` are always captured. * + * A rerun of that workflow, or a loop back to its push step, sends the pair again as a new + * notification, and its open counts separately: a payload whose `google.message_id` differs from the + * one captured first is captured. Forward the tapped intent's extras (which carry that id) to keep + * those apart — a payload without one, such as an FCM foreground `message.data`, is treated as a + * repeat report of the tap already captured. + * * @param title the notification title, attached as `$notification_title` when non-empty * @param body the notification body, attached as `$notification_body` when non-empty * @param payload the notification data payload; its `posthog` entry is spread into `$notification_*` props diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 8c403d530..4c157c5b4 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -4750,8 +4750,10 @@ internal class PostHogTest { }, ) - private fun PostHogInterface.captureAutomaticPushOpen(posthog: Any?) = - capturePushNotificationOpened(payload = mapOf("google.message_id" to "m-1", "posthog" to posthog)) + private fun PostHogInterface.captureAutomaticPushOpen( + posthog: Any?, + messageId: String = "m-1", + ) = capturePushNotificationOpened(payload = mapOf("google.message_id" to messageId, "posthog" to posthog)) private fun PostHogInterface.captureManualPushOpen(posthog: Any?) = capturePushNotificationOpened(title = "Hello", body = "World", payload = mapOf("posthog" to posthog)) @@ -4786,6 +4788,46 @@ internal class PostHogTest { sut.close() } + @Test + fun `capturePushNotificationOpened captures a resend of the same workflow step`() { + val sut = getPushOpenSut() + + sut.captureAutomaticPushOpen(stepOne, messageId = "m-1") + sut.captureAutomaticPushOpen(stepOne, messageId = "m-2") + sut.captureManualPushOpen(stepOne) + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(2, pushOpens.size) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened skips a repeat report of the same delivery`() { + val sut = getPushOpenSut() + + sut.captureAutomaticPushOpen(stepOne, messageId = "m-1") + sut.captureAutomaticPushOpen(stepOne, messageId = "m-1") + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(1, pushOpens.size) + + sut.close() + } + + @Test + fun `capturePushNotificationOpened skips a resend when the first capture carried no delivery id`() { + val sut = getPushOpenSut() + + sut.captureManualPushOpen(stepOne) + sut.captureAutomaticPushOpen(stepOne, messageId = "m-2") + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(1, pushOpens.size) + + sut.close() + } + @Test fun `capturePushNotificationOpened keys PostHog pushes by invocation and action`() { val sut = getPushOpenSut() From cf1cb1778931667cb682c58dfde711f7e2b111b5 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 12 Sep 2026 15:54:12 -0400 Subject: [PATCH 6/6] refactor(push): fold the delivery-id check into recordPushOpen isNewDelivery had one caller and a doc comment restating what the public KDoc and the dedupe-window constant already said, so the resend rule was written out three times. Inline it as a named `resent` condition and keep the explanation once, where the two ids are compared. Also drop the `posthogPayload == null` disjunct, which could never decide the guard that `invocationId.isNullOrEmpty()` already settles, and correct the comment on recentPushOpens, which still described a map of keys to timestamps from before the delivery id moved in beside them. Trim the changeset to one line of observable behaviour per the changelog style; the rationale belongs in the PR body. No behaviour change. --- .changeset/push-open-dedupe.md | 2 +- posthog/src/main/java/com/posthog/PostHog.kt | 42 +++++++++----------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/.changeset/push-open-dedupe.md b/.changeset/push-open-dedupe.md index 396a6f7b3..beb0f02dc 100644 --- a/.changeset/push-open-dedupe.md +++ b/.changeset/push-open-dedupe.md @@ -3,4 +3,4 @@ 'posthog-android': minor --- -Change `capturePushNotificationOpened` to skip a repeat open of a PostHog-sent notification (same `invocation_id` and `action_id`) captured in the last 5 minutes, so an automatic capture plus a manual call for one tap counts once, while a resend of that notification (a new `google.message_id`) still counts. +Change `capturePushNotificationOpened` to skip a PostHog-sent notification open already captured in the last 5 minutes (same `invocation_id` and `action_id`), unless the payload's `google.message_id` differs. diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 60a9728d2..7e2ec926d 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -49,9 +49,8 @@ import java.util.concurrent.Executors private const val PUSH_NOTIFICATION_OPENED_EVENT = "\$push_notification_opened" // A duplicate report of one tap arrives within the same launch: milliseconds after a warm tap, seconds -// after a cold start while the host's JS/Dart handlers register. The window stays finite because a -// workflow that loops back to a push step re-sends the same `invocation_id`/`action_id` pair as a new -// notification, and that later open must still count. +// after a cold start while the host's JS/Dart handlers register. Finite so that a re-send carrying no +// delivery id to tell it apart still counts once the window has passed. private const val PUSH_OPEN_DEDUPE_WINDOW_MILLIS = 5 * 60 * 1000L // Only needs the opens of the last few minutes; the cap bounds memory for hosts that call this in bulk. @@ -132,7 +131,8 @@ public class PostHog private constructor( private var logsRateCapWindowStartMillis: Long = 0 private var logsRateCapWindowCount: Int = 0 - // Captured PostHog push opens, by `invocation_id/action_id`, to when they were captured. + // Recently captured PostHog push opens, keyed by `invocation_id/action_id`, oldest first. In memory + // only: both reports of one tap happen in the same launch. private val recentPushOpens = LinkedHashMap() private class RecentPushOpen( @@ -2157,22 +2157,28 @@ public class PostHog private constructor( deliveryId: String?, ): Boolean { val invocationId = posthogPayload?.get("invocation_id") as? String - if (posthogPayload == null || invocationId.isNullOrEmpty()) { + if (invocationId.isNullOrEmpty()) { return true } // Every step of one workflow run shares the run's invocation_id, so action_id tells the steps apart. - val key = "$invocationId/${posthogPayload["action_id"] as? String ?: ""}" + val key = "$invocationId/${posthogPayload?.get("action_id") as? String ?: ""}" val now = config?.dateProvider?.currentTimeMillis() ?: return true synchronized(recentPushOpens) { val previous = recentPushOpens[key] - // A negative gap means the wall clock moved back; capture rather than risk dropping an open. - val insideWindow = previous != null && now - previous.capturedAt in 0 until PUSH_OPEN_DEDUPE_WINDOW_MILLIS - if (insideWindow && !isNewDelivery(previous?.deliveryId, deliveryId)) { - config?.logger?.log("Skipped \$push_notification_opened: notification $key was already captured.") - return false + if (previous != null) { + // A negative gap means the wall clock moved back; capture rather than risk dropping an open. + val insideWindow = now - previous.capturedAt in 0 until PUSH_OPEN_DEDUPE_WINDOW_MILLIS + // A re-send of the same workflow step reuses the key, so only delivery ids that are present + // on both reports and disagree prove a second notification rather than a second report of + // one tap. + val resent = previous.deliveryId != null && deliveryId != null && previous.deliveryId != deliveryId + if (insideWindow && !resent) { + config?.logger?.log("Skipped \$push_notification_opened: notification $key was already captured.") + return false + } + recentPushOpens.remove(key) } - recentPushOpens.remove(key) recentPushOpens[key] = RecentPushOpen(now, deliveryId) if (recentPushOpens.size > MAX_RECENT_PUSH_OPENS) { recentPushOpens.remove(recentPushOpens.keys.first()) @@ -2181,18 +2187,6 @@ public class PostHog private constructor( } } - /** - * Whether this report is a second notification rather than a second report of one tap. A rerun of a - * workflow, or a loop back to its push step, re-sends the same `invocation_id`/`action_id` pair, and - * only the delivery id tells that apart from the manual repeat the dedupe exists for — so the two - * ids have to disagree, not merely be absent. A report without one, or a first capture that had - * none, stays deduped. - */ - private fun isNewDelivery( - previous: String?, - reported: String?, - ): Boolean = previous != null && reported != null && previous != reported - /** * Coerces the `posthog` entry of a push payload into a map. FCM data maps are string→string, * so the value is accepted either as a nested [Map] or as a JSON string. Parse failures are