Skip to content

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

Merged
turnipdabeets merged 8 commits into
mainfrom
feat/push-open-dedupe
Sep 15, 2026
Merged

turnipdabeets merged 8 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): take the core push-open dedupe from posthog-android 3.65.0 and posthog-ios 3.75.0 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-14 20:03:46 UTC
Duration: 118396ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 386ms
Format Validation.Event Has Uuid 34ms
Format Validation.Event Has Lib Properties 29ms
Format Validation.Distinct Id Is String 26ms
Format Validation.Token Is Present 24ms
Format Validation.Custom Properties Preserved 23ms
Format Validation.Event Has Timestamp 23ms
Retry Behavior.Retries On 503 7030ms
Retry Behavior.Does Not Retry On 400 4025ms
Retry Behavior.Does Not Retry On 401 4023ms
Retry Behavior.Respects Retry After Header 7026ms
Retry Behavior.Implements Backoff 17030ms
Retry Behavior.Retries On 500 7019ms
Retry Behavior.Retries On 502 7021ms
Retry Behavior.Retries On 504 7014ms
Retry Behavior.Max Retries Respected 17035ms
Deduplication.Generates Unique Uuids 39ms
Deduplication.Preserves Uuid On Retry 7011ms
Deduplication.Preserves Uuid And Timestamp On Retry 12030ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7019ms
Deduplication.No Duplicate Events In Batch 35ms
Deduplication.Different Events Have Different Uuids 21ms
Compression.Sends Gzip When Enabled 19ms
Batch Format.Uses Proper Batch Structure 19ms
Batch Format.Flush With No Events Sends Nothing 11ms
Batch Format.Multiple Events Batched Together 30ms
Error Handling.Does Not Retry On 403 4021ms
Error Handling.Does Not Retry On 413 4019ms
Error Handling.Retries On 408 5026ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 31ms
Request Payload.Flags Request Uses V2 Query Param 24ms
Request Payload.Flags Request Hits Flags Path Not Decide 18ms
Request Payload.Flags Request Omits Authorization Header 37ms
Request Payload.Token In Flags Body Matches Init 24ms
Request Payload.Groups Round Trip 21ms
Request Payload.Groups Default To Empty Object 21ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 23ms
Request Payload.Disable Geoip Omitted Defaults To False 26ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 22ms
Request Lifecycle.No Flags Request On Init Alone 10ms
Request Lifecycle.No Flags Request On Normal Capture 22ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 36ms
Request Lifecycle.Mock Response Value Is Returned To Caller 23ms
Retry Behavior.Retries Flags On 502 324ms
Retry Behavior.Retries Flags On 504 322ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 21ms

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

@dustinbyrne dustinbyrne left a comment

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.

Clock ordering

Consider sampling the clock inside the cache lock. An earlier caller can be overtaken by a later caller, then interpret that scheduling inversion as a backwards clock, clear the cache, and admit the same notification again. A concurrency test with reversed sampling/admission order would cover this.

Bounded guarantee

Consider qualifying the once-only guarantee with the five-minute window and twenty-distinct-key retention limit.

Two concurrent reports of one tap could be admitted in the opposite order
to their clock samples; the later admission then read the inversion as a
backwards wall clock, cleared the entry, and captured the open twice.

Also document the 20-key retention bound on the public API.
Parks the first report between sampling the clock and admitting it, so the
second samples later but is admitted first. Sampling outside the lock reads
that inversion as a backwards clock and captures the open twice; the test
fails on the pre-fix code and passes once the sample moves under the lock.
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

Thanks @dustinbyrne — both addressed.

Clock ordering — real bug, fixed in bd305b8. The sample now happens inside synchronized(recentPushOpens), so admission order and clock order can't disagree and there's no inversion left to misread as a backwards clock.

A concurrency test — added in 5699d71. A fake PostHogDateProvider parks the first report between sampling the clock and admitting it, so the second report samples later but is admitted first. It captures the open twice on the pre-fix code and once after, and the park is a bounded 200ms wait so it can't hang if the sample ever moves back outside the lock. Verified both directions locally, and ran the suite five times for flakiness.

Bounded guarantee — qualified in bd305b8. The capturePushNotificationOpened KDoc now names both limits: the 5-minute window it already described, and "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."

posthog-ios#828 gets the same three changes, so the platforms stay identical.

@turnipdabeets
turnipdabeets requested a review from a team September 14, 2026 22:05

@dustinbyrne dustinbyrne left a comment

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.

Source re-review at 5699d71385cb3f1cf9c50977a23b3da881f1224b: clock sampling is now inside the admission lock, and the retention bound is documented. No remaining findings in this change.

@turnipdabeets
turnipdabeets merged commit 61c1a0e into main Sep 15, 2026
17 checks passed
@turnipdabeets
turnipdabeets deleted the feat/push-open-dedupe branch September 15, 2026 01:03
turnipdabeets added a commit to PostHog/posthog-ios that referenced this pull request Sep 15, 2026
…manual paths (#828)

* feat(push): capture each PostHog push open once across automatic and manual paths

Every push-open path on iOS ends in the field-based
`capturePushNotificationOpened`, so the skip lives there: a repeat of a
PostHog-sent notification (same `posthog.invocation_id` and `action_id`)
captured in the last 5 minutes is skipped and logged, whichever path
reported it first. Payloads without a usable `invocation_id` behave
exactly as before.

This matches PostHog/posthog-android#783, so apps that call the manual
API next to the SDK's automatic capture stop double counting on both
platforms.

Claude-Session: https://claude.ai/code/session_01UJgnRvz58rzgFVCiUfjxkL

* test(push): cover the open-dedupe cap eviction and a backward wall clock

The cap eviction and the negative-elapsed half of the dedupe guard had no test
entering them: swapping removeFirst() for removeLast(), or deleting the
elapsed >= 0 check, left the suite green. Also states that recentPushOpens is
in insertion order and that eviction drops the front.

* 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 (UNNotificationResponse's request identifier)
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. The field-based overload carries no identifier, so a
manual repeat of one tap still matches.

Also point the config's local-notification guidance at the field-based
overload: the response: overload reads content only from PostHog-sent
notifications, so it captured a local one with no content at all.

* refactor(push): fold the delivery-id check into recordPushOpen

isNewDelivery had one caller and a doc comment restating what the public
doc comments and the dedupe-window constant already said, so the resend
rule was written out three times. Inline it as a named `resent` condition,
bind the matched entry once instead of subscripting it twice, and keep the
explanation where the two ids are compared.

Trim the changeset to one line of observable behaviour per the changelog
style; the rationale belongs in the PR body.

No behaviour change. Keeps the implementation shape in step with
posthog-android#783.

* fix(push): sample the dedupe clock under the lock

Two concurrent reports of one tap could be admitted in the opposite order
to their clock samples; the later admission then read the inversion as a
backwards wall clock, cleared the entry, and captured the open twice.

The test parks the first report between sampling and admission so the
second samples later but is admitted first: it captures twice before the
fix and once after.

Also document the 20-key retention bound on the public API.
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.

3 participants