Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/push-open-dedupe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog': minor
'posthog-android': minor
---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 68 additions & 4 deletions posthog/src/main/java/com/posthog/PostHog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ 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. 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.
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(
Expand Down Expand Up @@ -117,6 +131,15 @@ public class PostHog private constructor(
private var logsRateCapWindowStartMillis: Long = 0
private var logsRateCapWindowCount: Int = 0

// 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<String, RecentPushOpen>()

private class RecentPushOpen(
val capturedAt: Long,
val deliveryId: String?,
)

private val remoteConfig: PostHogRemoteConfig?
get() = config?.remoteConfigHolder

Expand Down Expand Up @@ -2112,20 +2135,61 @@ public class PostHog private constructor(
return
}

val posthogPayload = payload?.get("posthog")?.let { posthogPayloadMap(it) }
if (!recordPushOpen(posthogPayload, payload?.get(PUSH_DELIVERY_ID_KEY) as? String)) {
return
}

val props = mutableMapOf<String, Any>()
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)
Comment thread
turnipdabeets marked this conversation as resolved.
}

private fun recordPushOpen(
posthogPayload: Map<String, Any?>?,
deliveryId: String?,
): Boolean {
val invocationId = posthogPayload?.get("invocation_id") as? String
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?.get("action_id") as? String ?: ""}"
val dateProvider = config?.dateProvider ?: return true

synchronized(recentPushOpens) {
// Sampled under the lock so two concurrent reports can't be admitted out of order and read
// the inversion as a backwards clock.
val now = dateProvider.currentTimeMillis()
val previous = recentPushOpens[key]
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[key] = RecentPushOpen(now, deliveryId)
if (recentPushOpens.size > MAX_RECENT_PUSH_OPENS) {
recentPushOpens.remove(recentPushOpens.keys.first())
Comment thread
turnipdabeets marked this conversation as resolved.
}
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
Expand Down
13 changes: 13 additions & 0 deletions posthog/src/main/java/com/posthog/PostHogInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,19 @@ public interface PostHogInterface : PostHogCoreInterface {
* Each key of `payload["posthog"]` (accepted as a `Map` or a JSON string) is attached as a
* `$notification_<key>` 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. Only the 20
* most recently captured notifications are remembered, so a host that reports more than that
* inside the window can capture a repeat of the oldest.
*
* 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
Expand Down
Loading
Loading