Skip to content

feat: skip push registration when the project has no integration for the app_id - #703

Merged
dmarchuk merged 6 commits into
mainfrom
claude/push-app-ids-gate
Aug 20, 2026
Merged

feat: skip push registration when the project has no integration for the app_id#703
dmarchuk merged 6 commits into
mainfrom
claude/push-app-ids-gate

Conversation

@dmarchuk

@dmarchuk dmarchuk commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

A device on a project without push configured makes a registration request on every app open that the server can only throw away, and a project that turns push on afterwards never reaches the devices it already has.

  • The SDK registers its token at startup whenever push capture is on, against every project it reports to. It has no way to know whether that project opted in.
  • /api/push_subscriptions/ answers those with 200 {"stored": false, "push_enabled": false} and discards the token.
  • Because any 2xx writes deliveredForDistinctId, the device then records a success for a token the server never kept and stops asking. No response reaches a client that has stopped asking, so a project that configures push later reaches nothing installed before it — until a token rotation, an identify() to a different distinct id, or a reinstall.

Server side is merged: PostHog/posthog#83148 publishes the list, and the contract this implements is docs/internal/push-subscription-registration.md there (plus PostHog/posthog#83941, which adds the caching requirement below).

Changes

Remote config now carries push.appIds — the Firebase project ids and APNs bundle ids a project accepts registrations for.

  • The send is gated in attempt(), the choke point that already covers register, flush-driven retryPending, and the identify resend. The record is still persisted when the send is gated, so onPushAppIdsChanged has a token to register later rather than waiting for the app to hand us one again.
  • The list is cached and preloaded on launch, mirroring errorTracking. Registration runs at startup and remote config resolves asynchronously, so without the cache the gate would only take effect from the second launch onward.
  • Absent stays permissive. A server older than the key sends nothing, which an SDK cannot tell apart from a project with push disabled, so null means attempt. Only a published empty list means skip.
  • onPushAppIdsChanged clears the delivered marker for an app_id that just became registerable, and re-registers. It fires only on that transition — firing on every config load would put the request back on every launch, which is what the marker exists to prevent.

On the first launch that ever sees the key, the previous list is treated as empty rather than unknown, so every configured app_id counts as new. That costs one request per device, once, and it is the only way to reach a device whose project was configured before it updated to an SDK that reads this key.

💚 How did you test it?

New unit tests in PostHogPushSubscriptionManagerTest:

  • An app_id outside the list sends nothing but still persists the record. Catches the gate being dropped, and catches the record not being persisted, which would leave onPushAppIdsChanged with no token.
  • A null list still registers. Catches the gate failing closed against a server older than the key, which would silently disable push on every such deployment.
  • An app_id inside the list registers. Catches the gate being inverted.
  • An app_id becoming registerable clears the delivered marker and re-registers. This is the un-stranding path.
  • An unrelated app_id becoming registerable does not re-register. Catches over-firing.

Manual E2E on the emulator against a local server

Ran the six-scenario verification brief on an Android emulator (posthog-android-sample as com.posthog.pushtest, real FCM token, Firebase project posthog-dev) against a local PostHog serving real push.appIds remote config for a team with a togglable Firebase integration. :posthog:test (62 push-manager cases) and the full suite pass.

  • Configured launch: exactly one POST, server store, person property $device_push_subscription_posthog-dev set (verified in ClickHouse and the persons UI).
  • Unconfigured launch: no POST, "skipped: posthog-dev not configured", push_subscription.pending written without a delivered marker.
  • Cold start unconfigured: skip logged ~7s before a deliberately delayed /config responded, so the disk-cached slice gated it, not the fresh fetch.
  • Un-stranding: after the integration was added, the next launch logged "became registerable; re-registering" and sent exactly one POST.
  • No repeat: three further relaunches sent nothing.
  • Old server: with the push key removed from the response, the SDK registered.

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

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Authored with Claude Code, as the reference implementation of the three client rules; iOS mirrors it next, matching how #656 and PostHog/posthog-ios#735 were kept in step.

Two design points worth a reviewer's attention. The gate lives in attempt() rather than performRegister so flush and identify resends are covered by the same check. And the transition set is computed in processPushConfig from the previously cached list, then drained once by consumeNewlyRegisterablePushAppIds, so a device re-registers on the transition and not on every subsequent config load.

I read the server behavior from the merged endpoint rather than assuming it, and the delivered-marker mechanics from this repo's own performRegister and currentRecord.


Generated by Claude Code

…the app_id

A mobile SDK with push capture on registers its device token on every app
open, against every project it reports to, whether or not that project has a
Firebase or APNs integration. The server accepts the request and discards
the token, so the request was never worth making.

Remote config now publishes push.appIds, the app_ids a project accepts
registrations for. The send is gated on it in attempt(), the single choke
point that already covers register, flush-driven retry and identify resend.
The record is still persisted when the send is gated, so a project that
configures push later has a token to register.

The list is cached to disk and preloaded on launch, mirroring errorTracking.
Registration runs at startup and remote config resolves asynchronously, so
without the cache the gate would only take effect from the second launch.

Absent stays permissive. A server older than the key sends nothing, which an
SDK cannot tell apart from a project with push disabled, so null means
attempt rather than skip.

onPushAppIdsChanged clears the delivered marker for an app_id that just
became registerable. A device that registered while its project had no
integration was answered 200 and recorded a delivery for a token the server
never kept; nothing else reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bbav6vE7Tm24KkdRhZmvNM
@dmarchuk dmarchuk self-assigned this Aug 18, 2026
The push config functions were inserted between reevaluateCapturePerformanceFromCachedConfig
and the comment describing it, which left the comment attached to the wrong
function and failed spotless. Move them below it instead.

Also drops a trailing blank line in the test file.
getPushAppIds and consumeNewlyRegisterablePushAppIds on PostHogRemoteConfig,
and the push property on PostHogRemoteConfigResponse, which also widens its
generated constructors.

Signatures verified against the compiled classes with javap rather than hand
written, since apiDump cannot run here.
@dmarchuk
dmarchuk marked this pull request as ready for review August 18, 2026 19:54
@dmarchuk
dmarchuk requested a review from a team as a code owner August 18, 2026 19:54
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt:439-446
**Stale eligibility permits registration**

If remote configuration excludes the app ID while asynchronous identity-token minting is in progress, `performSend` does not recheck eligibility before posting and persisting `deliveredForDistinctId`, causing a discarded token to be recorded as delivered and suppressing ordinary retries.

---

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

Reviews (1): Last reviewed commit: "chore: record the new public API in the ..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-08-19 12:22:08 UTC
Duration: 118442ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 397ms
Format Validation.Event Has Uuid 29ms
Format Validation.Event Has Lib Properties 33ms
Format Validation.Distinct Id Is String 24ms
Format Validation.Token Is Present 24ms
Format Validation.Custom Properties Preserved 30ms
Format Validation.Event Has Timestamp 26ms
Retry Behavior.Retries On 503 7027ms
Retry Behavior.Does Not Retry On 400 4025ms
Retry Behavior.Does Not Retry On 401 4024ms
Retry Behavior.Respects Retry After Header 7024ms
Retry Behavior.Implements Backoff 17036ms
Retry Behavior.Retries On 500 7015ms
Retry Behavior.Retries On 502 7019ms
Retry Behavior.Retries On 504 7020ms
Retry Behavior.Max Retries Respected 17028ms
Deduplication.Generates Unique Uuids 36ms
Deduplication.Preserves Uuid On Retry 7015ms
Deduplication.Preserves Uuid And Timestamp On Retry 12031ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7017ms
Deduplication.No Duplicate Events In Batch 36ms
Deduplication.Different Events Have Different Uuids 26ms
Compression.Sends Gzip When Enabled 22ms
Batch Format.Uses Proper Batch Structure 19ms
Batch Format.Flush With No Events Sends Nothing 12ms
Batch Format.Multiple Events Batched Together 33ms
Error Handling.Does Not Retry On 403 4021ms
Error Handling.Does Not Retry On 413 4021ms
Error Handling.Retries On 408 5030ms

Feature_Flags Tests

17/17 tests passed

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

Comment thread posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt Outdated
Comment thread .changeset/push-app-ids-gate.md
dmarchuk added a commit to PostHog/posthog-ios that referenced this pull request Aug 19, 2026
…the app_id (#767)

* feat: skip push registration when the project has no integration for the app_id

Mirrors PostHog/posthog-android#703, keeping the field names and the three
client rules identical between the two SDKs.

A device registers its token on every app open whether or not the project has
a Firebase or APNs integration. The server accepts the request and discards
the token, so the request was never worth making. Remote config now publishes
push.appIds, and the send is gated on it in attemptIfAllowed, the choke point
every caller already goes through.

The record is still persisted when the send is gated, so a project that
configures push later has a token to register.

Absent stays permissive. A server older than the key sends nothing, which an
SDK cannot tell apart from a project with push disabled, so nil means attempt
rather than skip.

onPushAppIdsChanged clears the delivered marker for an app_id that just became
registerable. A device that registered while its project had no integration
was answered 200 and recorded a delivery for a token the server never kept;
nothing else reaches it.

Unlike Android, no per-slice disk cache is needed: iOS already persists the
whole remote config blob and preloadPushConfig reads the push slice from it on
launch, before the first registration attempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bbav6vE7Tm24KkdRhZmvNM

* fix(push): add explicit self in onPushAppIdsChanged nested closures so it compiles

* fix(push): silence file_length lint on PostHogRemoteConfig after the appIds gate

* fix(push): recheck app_id eligibility after the identity token mint before sending

* chore(push): drop redundant self to satisfy swiftformat

* fix(push): atomic marker clear and durable one-time upgrade recovery for the appIds gate

* chore(push): multi-line guard to satisfy swiftformat

---------

Co-authored-by: Claude <noreply@anthropic.com>
@dmarchuk
dmarchuk merged commit 3e09338 into main Aug 20, 2026
17 checks passed
@dmarchuk
dmarchuk deleted the claude/push-app-ids-gate branch August 20, 2026 06:34
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