Skip to content

feat(push): capture each PostHog push open once across automatic and manual paths - #783

Open
turnipdabeets wants to merge 6 commits into
mainfrom
feat/push-open-dedupe
Open

feat(push): capture each PostHog push open once across automatic and manual paths#783
turnipdabeets wants to merge 6 commits into
mainfrom
feat/push-open-dedupe

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

The React Native and Flutter plugins now capture Android notification taps automatically (@posthog/react-native-plugin 2.6.0 via PostHog/posthog-js#4858, posthog_flutter 5.40.0). The posthog.com docs used to tell those apps to also call the manual API from Firebase's onNotificationOpenedApp / onMessageOpenedApp. Apps that still have that call now send two $push_notification_opened events per tap. The server's push-open-tracking.ts turns every one of those events into a push_opened app metric with no dedupe, so Workflows open rates are inflated too. PostHog/posthog.com#20102 fixes the docs going forward. This PR covers the apps that already shipped the old snippet.

PostHog/posthog-js#4919 and PostHog/posthog-flutter#578 fixed this inside each plugin. Both work, but each one copies the SDK's skip rules, and the two implementations already differ (consume-once vs. not). This PR moves the logic into posthog-android, where every path already ends up. Those plugin PRs will be repurposed afterwards.

Changes

  • One place. Every open path ends in PostHog.capturePushNotificationOpened(title, body, payload, action) in posthog core:

    • the automatic cold-start path (onActivityCreated);
    • PostHogAndroid.capturePushNotificationOpened(intent), which the Flutter plugin and the RN plugin's onNewIntent call, and which native apps call from onNewIntent;
    • the RN plugin's own cold-start path, which calls the core API directly with the intent extras;
    • the manual API from native code, and from both plugins' method channel / native module.

    The check lives there, so it doesn't depend on which path reports first. A manual call that arrives before the automatic one is handled the same way.

  • Match key: invocation_id + action_id from the payload's posthog entry, read as a JSON string or a map, the same way the event properties are read. The server stamps that entry on every push it sends (pushCorrelationData in push-notification.service.ts). Every step of one workflow run shares the run's invocation_id, so the key includes action_id. A missing action_id is allowed. With no usable invocation_id there's no key, and the call behaves exactly as today. That covers pushes from other senders and any malformed entry.

  • Skip and record. A repeat of a key captured in the last 5 minutes is skipped and logged through config.logger. Otherwise the key is recorded and the event is captured. The opted-out and disabled checks run first, so a call they drop records nothing. The existing google.message_id dedupe layer above this one is unchanged.

  • A resend is a different notification. Each entry also remembers the delivery id of the report that captured it: payload["google.message_id"], which FCM leaves on the tapped intent. A report whose delivery id disagrees with the stored one is a second notification, not a second report of one tap, so it is captured and the entry is updated. The delivery id is never part of the key, because the manual API's caller has no delivery id to match with (RemoteMessage.getData() strips every google. key), so keying on it would break the manual-repeat dedupe this PR exists for. A report with no delivery id, or one against an entry that stored none, stays deduped.

  • Memory. Keys are kept in memory, per PostHog instance, in a synchronized insertion-ordered map capped at 20 entries.

    • In memory only: both reports of one tap happen in the same process, during the same launch. I found no case where the duplicate crosses a process, so nothing is persisted.
    • Why a 5-minute window: notifications are dismissed on tap, but one workflow run can still send two notifications with the same key. The workflow graph allows cycles (graph_validation.py tolerates them, and the executor tracks loop revisits through actionStepCount), and a rerun reuses the run's invocation_id (rerun-paginator.service.ts). With no window, a "remind every day until they convert" loop would lose every open after the first for as long as the process stayed alive. The duplicate itself arrives much sooner: 14–33 ms after the automatic capture on RN (measured in fix(react-native): pick up posthog-android 3.64.0 push-open dedupe posthog-js#4919), and 0.37 s warm / 0.66 s cold on Flutter debug builds (measured here). Five minutes leaves room for apps that call getInitialMessage() late, for example after a splash screen.
    • Why 20: an entry only matters within that window, and a person opens a handful of notifications at most in 5 minutes. The cap only bounds memory for a host that calls the API in bulk.
  • Scope. posthog-server has its own PostHog class built on PostHogStateless, with no push-open API, so server behavior doesn't change.

  • Public API. No new API; apiCheck is clean. The capturePushNotificationOpened KDoc in PostHogInterface now describes the skip. The PostHogAndroidConfig.capturePushNotificationOpened KDoc said the manual API is never deduped; it now says it dedupes only notifications sent by PostHog.

Behavior change (minor)

The manual capturePushNotificationOpened changes behavior for native apps too. A call is now skipped if its payload carries the same posthog.invocation_id and action_id as an open captured in the last 5 minutes, whether that open was captured automatically or by an earlier manual call. This affects:

  • Apps that capture one tap twice: automatic plus manual, or manual from both onMessageOpenedApp and getInitialMessage(). These now count once, which is the intent of this PR.
  • Two different notifications that share a key, when both are opened within 5 minutes. This needs a workflow loop or rerun. Both opens are counted when each tap's intent carries its own google.message_id, which is every FCM tray tap. Only a report that carries no delivery id, such as a foreground message.data relay, is dropped in that case.

Nothing changes for payloads without a posthog.invocation_id. The changeset bumps posthog and posthog-android as minor. posthog-android is listed so it republishes with the new core (it re-exports it via api(project(":posthog"))). The next release should be posthog 6.36.0 / posthog-android 3.64.0, unless another changeset lands first. The plugins can then raise their floor to 3.64.0.

Cross-SDK

sdk-specs covers this in the open proposal openspec/changes/add-push-notification-opens. PostHog/posthog-ios#828 lands the identical rule, including the resend case, where the delivery id is UNNotificationResponse.notification.request.identifier. The two SDKs also agree on the no-delivery-id case: a report without one never counts as a new delivery.

The proposal's "Exactly one open per tap" requirement does not yet mention resends inside the window; it needs amending to match what both SDKs now do.

Rebase

Rebased onto origin/main (was 5 commits behind). The branch predated PostHogSessionReplayConfig.captureTouches (#780), which posthog-flutter's main calls, so an artifact published from this branch alone could not compile the Flutter example. No CI job catches that.

💚 How did you test it?

Unit tests. ./gradlew spotlessCheck :posthog:apiCheck :posthog-android:apiCheck :posthog:test :posthog-android:testReleaseUnitTest passed: core 974, Android 534 (3 skipped), 0 failures. There are 11 new core tests:

  • manual after automatic;
  • automatic after manual, with the map form vs. the JSON string form;
  • steps of one run, a missing action_id, and another run;
  • no posthog entry;
  • six malformed entries;
  • the window boundary (4:59 skipped, 5:00 captured);
  • opted out;
  • disabled;
  • a resend of one step (two deliveries, one key) is captured twice;
  • a repeat report of the same delivery is captured once;
  • a resend after a first capture that carried no delivery id is captured once.

With the whole check removed, 5 of the 11 fail. Reverting only the delivery-id comparison (isNewDelivery forced to false) fails exactly 1: the resend test. Every other test, including all the manual-repeat ones, still passes both ways, which is the regression risk this change had to clear.

Devices. A fresh Pixel 6 AVD (Android 17, API 37) on its own port. Every count below is the number of $push_notification_opened events in the SDK's /batch bodies, sent to a local mock server. App data was cleared before every row. Both reports carry the same posthog entry, {"workflow_id":"wf-1","invocation_id":"inv-…","action_id":"step-1"}. The plugin rows use each plugin's origin/main, unchanged. "Before" is the posthog-android version that origin/main resolves: 3.63.1 for Flutter's range, 3.62.0 for RN's pin. "After" is this branch, published to mavenLocal as 3.63.99-dedupe and forced in with a temporary Gradle override.

Native (posthog-android sample). Warm taps were delivered with am start and tray-tap extras (google.message_id, posthog) into the singleTop NormalActivity, which forwards onNewIntent to PostHogAndroid.capturePushNotificationOpened(intent). A temporary, uncommitted hook in onNewIntent then calls PostHog.capturePushNotificationOpened(title = "manual", payload = mapOf("posthog" to …)).

Scenario main This PR
Warm tap, then manual call, same posthog entry 2 1 (automatic kept, skip logged)
Manual call first, then warm tap, same entry 2 1 (manual kept)
Warm tap step-1, manual call step-2 (same run) 2 2
Warm tap + manual call, no posthog entry 2 2
Warm tap only 1 1
Cold tap 1 1

Real FCM tray taps (posthog-android sample + firebase-messaging 25.1.2). A fresh API34_test AVD on its own port, rooted so am broadcast com.google.android.c2dm.intent.RECEIVE reaches Firebase's receiver. The Firebase SDK builds and posts the tray notification itself; taps are real taps in the shade. This covers the resend case end to end.

Scenario Delivery ids Without the delivery-id logic This PR
Two notifications, one invocation_id/action_id, both tapped m-r1, m-r2 1 (second skipped) 2
One tap, automatic capture plus a manual call m-s1 1 1 (skip logged)

Flutter (posthog_flutter example, origin/main 5.40.1). The harness is the one from PostHog/posthog-flutter#578: firebase_core + firebase_messaging 16.6.0 with placeholder Firebase options, and the old docs snippet pasted in verbatim. FCM messages were injected with a root am broadcast com.google.android.c2dm.intent.RECEIVE. Taps were real taps in the notification shade. For cold rows the process was killed with am kill after the notification was posted.

Scenario Before (3.63.1) This PR
Old snippet, warm tap, PostHog push 2 1
Old snippet, cold tap, PostHog push 2 1
Old snippet + getInitialMessage() handler, cold tap 3 1
Old snippet, warm tap, push with no posthog entry 2 2

In every deduped row, the event that survives is the automatic one. The third row shows why a match doesn't consume the key: one automatic capture faces two manual calls.

React Native (examples/example-rn-native-plugin, origin/main, plugin 2.7.0). This uses real @react-native-firebase/app + messaging 23.8.8 (26.x needs RN ≥ 0.80; the example is on 0.79.6), with placeholder Firebase options. The old snippet is messaging().onNotificationOpenedApp(m => capturePushNotificationOpened({ title, body, payload: m.data })). Injection and taps are the same as for Flutter.

Scenario Before (3.62.0) This PR
Old snippet, warm tap, PostHog push 2 1 (skip logged)
Old snippet, cold tap after the task was removed from recents 1 1
Old snippet, warm tap, push with no posthog entry 2 2
Old snippet, cold tap with the task still in recents (am kill) 0 0

The last row is a separate, existing RN plugin gap, and this PR doesn't change it. With the task kept, Android recreates MainActivity from its original intent and delivers the tray intent through onNewIntent. RN drops that intent (ReactHost.raiseSoftException(onNewIntent(...)) in logcat) because React isn't ready yet, so neither the plugin nor RNFirebase sees the tap. It needs its own issue.

Not verified:

  • A real FCM delivery from a PostHog Workflow. Messages were injected locally, through Firebase's own receiver, with the same posthog JSON shape the server sends.
  • The 5-minute window on a device. It is covered by a unit test with a fake clock.
  • Physical devices, release builds, and the RN old architecture.
  • The native sample has no Firebase, so its warm taps were am start intents rather than shade taps.

📝 Checklist

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

This is a behavior change for the manual API (see above), released as a minor with a changeset entry.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

Related: PostHog/posthog-js#4919 and PostHog/posthog-flutter#578 (the per-plugin fixes this replaces), #753 (PostHogAndroid.capturePushNotificationOpened(intent)), PostHog/posthog-js#4858 (RN automatic warm-tap capture), PostHog/posthog.com#20102 (docs).

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code, driven by @turnipdabeets, to replace the two per-plugin fixes with one check in the SDK core.

  • Where the check lives. I traced every open path (native automatic, PostHogAndroid.capturePushNotificationOpened(intent), the RN plugin's direct cold-start call, and the manual API from native code and both plugins) before putting the check in PostHog.capturePushNotificationOpened. That removes the plugins' need to mirror the SDK's gates or know which path captured first.
  • The time window. The starting assumption was that no window was needed, since notifications are dismissed on tap. Reading the workflow executor changed that: a loop revisit, or a rerun, sends a new notification under the same invocation_id/action_id, so an unbounded set would drop real opens.
  • No persistence. No cross-process duplicate turned up, so nothing is persisted.

Related PRs

One push-open capture effort across the mobile SDKs: count every notification tap exactly once, and stop losing taps the SDK starts too late to see.

PR What it does Blocked by
#783 (this PR) Core: capture each PostHog push open once, whichever path reports it (ships as 3.65.0)
PostHog/posthog-ios#828 Same rule on iOS, so both platforms behave identically (ships as 3.75.0)
PostHog/posthog-js#4921 React Native on iOS: capture a tap that launches the app
PostHog/posthog-js#4929 React Native on Android: capture a tap lost when the process was killed but the task stayed in recents
PostHog/posthog-flutter#579 Flutter: replay a tap that arrives before the SDK is set up, instead of relying on firebase_messaging
PostHog/posthog-js#4919 React Native plugin: take the core dedupe, drop the plugin-level copy posthog-android 3.65.0
PostHog/posthog-flutter#578 Flutter plugin: take the core dedupe, drop the plugin-level copy posthog-android 3.65.0
PostHog/posthog.com#20114 Docs corrections that are wrong today, independent of any release
PostHog/posthog.com#20102 Docs for the release-dependent behavior changes the SDK releases above

Merge order: posthog-android#783 and posthog-ios#828 first, then their releases. #4921, #4929 and #579 are independent and can go any time. #4919 and #578 go green once posthog-android 3.65.0 is published. Docs: #20114 can go now; #20102 last, after the releases.

Earlier work this builds on: #753, PostHog/posthog-ios#792, PostHog/posthog-js#4858, PostHog/posthog-flutter#556, PostHog/posthog-flutter#557, PostHog/posthog.com#19905.

@turnipdabeets turnipdabeets self-assigned this Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-09-12 20:00:26 UTC
Duration: 118531ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 379ms
Format Validation.Event Has Uuid 44ms
Format Validation.Event Has Lib Properties 33ms
Format Validation.Distinct Id Is String 29ms
Format Validation.Token Is Present 28ms
Format Validation.Custom Properties Preserved 27ms
Format Validation.Event Has Timestamp 29ms
Retry Behavior.Retries On 503 7030ms
Retry Behavior.Does Not Retry On 400 4026ms
Retry Behavior.Does Not Retry On 401 4024ms
Retry Behavior.Respects Retry After Header 7027ms
Retry Behavior.Implements Backoff 17037ms
Retry Behavior.Retries On 500 7018ms
Retry Behavior.Retries On 502 7021ms
Retry Behavior.Retries On 504 7019ms
Retry Behavior.Max Retries Respected 17022ms
Deduplication.Generates Unique Uuids 43ms
Deduplication.Preserves Uuid On Retry 7018ms
Deduplication.Preserves Uuid And Timestamp On Retry 12033ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7020ms
Deduplication.No Duplicate Events In Batch 40ms
Deduplication.Different Events Have Different Uuids 26ms
Compression.Sends Gzip When Enabled 20ms
Batch Format.Uses Proper Batch Structure 20ms
Batch Format.Flush With No Events Sends Nothing 13ms
Batch Format.Multiple Events Batched Together 40ms
Error Handling.Does Not Retry On 403 4019ms
Error Handling.Does Not Retry On 413 4023ms
Error Handling.Retries On 408 5028ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 39ms
Request Payload.Flags Request Uses V2 Query Param 29ms
Request Payload.Flags Request Hits Flags Path Not Decide 36ms
Request Payload.Flags Request Omits Authorization Header 27ms
Request Payload.Token In Flags Body Matches Init 25ms
Request Payload.Groups Round Trip 29ms
Request Payload.Groups Default To Empty Object 25ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 25ms
Request Payload.Disable Geoip Omitted Defaults To False 23ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 20ms
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 48ms
Request Lifecycle.Mock Response Value Is Returned To Caller 30ms
Retry Behavior.Retries Flags On 502 329ms
Retry Behavior.Retries Flags On 504 327ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 24ms

@turnipdabeets
turnipdabeets marked this pull request as ready for review September 11, 2026 17:52
@turnipdabeets
turnipdabeets requested a review from a team as a code owner September 11, 2026 17:52
@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
posthog/src/main/java/com/posthog/PostHog.kt:2127-2141
**Dropped events consume dedupe keys**

The push key is recorded before `capture()` runs its `beforeSend` hooks. If a hook drops the first automatic report—for example, because it lacks the title present on the manual report—the later manual report is still skipped as a duplicate, so no push-open event is delivered. Record the key only after the event passes the capture filters.

### Issue 2
posthog/src/main/java/com/posthog/PostHog.kt:2162-2163
**Live keys are evicted**

The 20-entry limit breaks the documented five-minute guarantee. After 21 distinct opens within the window, the first key is evicted and another report for it is captured again, producing a duplicate push-open event. Expire entries by timestamp or otherwise retain every key for the promised window.

### Issue 3
posthog/src/test/java/com/posthog/PostHogTest.kt:4738-4748
**Test resources remain open**

`getPushOpenSut` creates a `MockWebServer` but discards its handle, and the new tests call neither `sut.clear()` nor `http.shutdown()`. This violates the repository directive requiring both cleanup operations and leaks test resources. Expose the server through the fixture and perform both cleanup calls after each test; this requirement must be satisfied before merging.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(push): time the push-open dedupe win..." | Re-trigger Greptile

Comment thread posthog/src/main/java/com/posthog/PostHog.kt
Comment thread posthog/src/main/java/com/posthog/PostHog.kt
Comment thread posthog/src/test/java/com/posthog/PostHogTest.kt
turnipdabeets added a commit to PostHog/posthog-js that referenced this pull request Sep 11, 2026
posthog-android 3.64.0 (PostHog/posthog-android#783) skips a repeat of a
PostHog-sent notification open (same posthog invocation_id + action_id)
captured within 5 minutes, whichever path reported it first. Every path
this plugin uses ends there: onNewIntent, the cold-start capture and the
manual capturePushNotificationOpened method. A manual call from an old
onNotificationOpenedApp handler is therefore counted once without any
plugin-level dedupe.

Also adds the migration sentence to the published 2.6.0 changelog entry.

Claude-Session: https://claude.ai/code/session_01UJgnRvz58rzgFVCiUfjxkL
turnipdabeets added a commit to PostHog/posthog-js that referenced this pull request Sep 12, 2026
posthog-android 3.64.0 (PostHog/posthog-android#783) skips a repeat of a
PostHog-sent notification open (same posthog invocation_id + action_id)
captured within 5 minutes, whichever path reported it first. Every path
this plugin uses ends there: onNewIntent, the cold-start capture and the
manual capturePushNotificationOpened method. A manual call from an old
onNotificationOpenedApp handler is therefore counted once without any
plugin-level dedupe.

Also adds the migration sentence to the published 2.6.0 changelog entry.

Claude-Session: https://claude.ai/code/session_01UJgnRvz58rzgFVCiUfjxkL
…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
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
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.
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.
Comment thread posthog/src/main/java/com/posthog/PostHog.kt Fixed
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.
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