Skip to content
Open
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/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
69 changes: 65 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,58 @@ 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 now = config?.dateProvider?.currentTimeMillis() ?: return true

synchronized(recentPushOpens) {
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
11 changes: 11 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,17 @@ 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.
*
* 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