Skip to content

fix: emit presentation_id, close_reason, cache_key, build_id on paywall events - #448

Open
chroxify wants to merge 2 commits into
developfrom
fix/android-page-tracking-presentation-id
Open

fix: emit presentation_id, close_reason, cache_key, build_id on paywall events#448
chroxify wants to merge 2 commits into
developfrom
fix/android-page-tracking-presentation-id

Conversation

@chroxify

@chroxify chroxify commented Aug 6, 2026

Copy link
Copy Markdown

Changes in this pull request

  • Adds presentation_id to every paywall analytics event (paywall_open, paywall_page_view, paywall_close, etc.) — a unique ID minted once per paywall presentation, used by the dashboard to correlate a set of page views into one paywall session.
  • Adds the previously-missing close_reason, cache_key, and build_id fields to the same event payload. These already exist on Paywall/PaywallInfo but were never written into PaywallInfo.eventParams().

Root cause

PaywallInfo.eventParams() on Android never included presentation_id at all — the field doesn't exist on the model, let alone get serialized. Confirmed on live ClickHouse data: presentation_id is 100% empty on Android across every SDK version, vs 0% empty on iOS. Full comparison, including exact iOS source references, in outputs/android-ios-page-tracking-comparison-2026-08-06.md from the investigation that led to this PR.

Without presentation_id, the dashboard can't group an Android paywall's page views into a single session — any Android-heavy campaign's page-view funnel silently breaks. This is what surfaced the bug: campaign 98723's page-view data looked broken.

close_reason, cache_key, build_id have the same shape of bug: the data exists on the Android models (Paywall.closeReason, .cacheKey, .buildId) but was never serialized into the outgoing event params, unlike iOS's PaywallInfo.swift.

iOS parity

  • presentationId: minted as UUID().uuidString once per paywall fetch (RawPaywallResponse.swift:19), stored on Paywall, copied into PaywallInfo, emitted as "presentation_id". Android now mints it in PaywallRequestManager.updatePaywall, which — like iOS re-minting on Paywall.update(from:) — runs on every getPaywall() call that results in an actual presentation (fresh fetch, in-flight-task reuse, and Android's own paywallsByHash content-cache hit), so repeat presentations of the same cached paywall get distinct, correlatable IDs.
  • close_reason: emitted as closeReason.description on both platforms now, using the same camelCase strings (systemLogic, forNextPaywall, webViewFailedToLoad, manualClose, none).
  • cache_key / build_id: emitted directly, matching iOS.

Documented trade-off

PaywallLoad.Complete / PaywallProductsLoad.* events track before updatePaywall runs in the call sequence, so those response/product-loading-lifecycle events won't carry presentation_id. This is not a new inconsistency — experiment_id, variant_id, and presentation_source_type have the exact same timing gap today. The events that matter for the reported dashboard bug (paywall_open, paywall_page_view, paywall_close, and everything else tracked once a paywall is actually presented) all fire after updatePaywall and reliably get a stable, correct ID. Preload-only calls skip updatePaywall entirely via existing early-return branches, so preloading correctly doesn't mint a wasted ID.

Explicitly out of scope (flagged only, not bundled here)

  • Removing the dead trigger_session_id field (// TODO remove in next major update).
  • Porting state/customer_info to iOS, or intro_offer_eligibility/app_transaction_id/shimmer-load timestamps to Android.

These need a product/eng call, not a bundled change in a bug-fix PR.

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run ktlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

Note on unchecked boxes: this sandbox has no JDK/Android SDK available (apt-get install openjdk-17-jdk-headless timed out), so I could not run ./gradlew test, connectedCheck, or ktlintFormat/ktlintCheck locally. All changes were reviewed manually against existing, proven patterns in the codebase (named-argument construction everywhere Paywall/PaywallInfo are built, so the new trailing fields with defaults are non-breaking; kotlinx.serialization.Transient fields are unaffected by JSON deserialization). New/updated tests are included and should be validated by CI:

  • PaywallInfoTest: eventParams() includes presentation_id/close_reason/cache_key/build_id, and omits presentation_id when null.
  • PaywallRequestManagerTest: getPaywall() sets a non-blank presentationId, and mints a new one on a second call that hits the request-hash cache (the direct regression test for the reported bug).
  • InternalSuperwallEventTest: PaywallPageView.getSuperwallParameters() carries presentation_id sourced from paywallInfo.presentationId.

Please run CI (./gradlew test, ktlint) to confirm before merging.

…ll events

Superwall-Android never wrote presentation_id into outgoing paywall
event payloads (paywall_page_view, paywall_open, paywall_close, etc.),
which breaks any dashboard funnel that correlates a set of page views
into one paywall session. Confirmed on live ClickHouse data: the field
is 100% empty on Android across every SDK version, vs 0% empty on iOS.

- PaywallCloseReason: add a `description` extension mirroring iOS's
  camelCase close-reason strings (systemLogic, forNextPaywall,
  webViewFailedToLoad, manualClose, none).
- PaywallInfo: add `presentationId`, and serialize it alongside the
  already-modeled-but-never-emitted close_reason/cache_key/build_id in
  eventParams().
- Paywall: add a transient `presentationId` field, threaded through
  getInfo().
- PaywallRequestManager.updatePaywall: mint a fresh UUID presentationId
  on every getPaywall() call that results in a presentation (fresh
  fetch, in-flight-task reuse, and content-cache hit), so repeat
  presentations of a cached paywall get distinct, correlatable IDs.

Trade-off: PaywallLoad.Complete/PaywallProductsLoad.* events track
before updatePaywall runs, so they won't carry presentation_id — same
existing timing gap as experiment_id/variant_id/presentation_source_type.
paywall_open/paywall_page_view/paywall_close all fire after
updatePaywall and reliably get a stable ID.
@chroxify
chroxify requested a review from ianrumac August 6, 2026 14:17
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.

1 participant