From 5298384569ff35770204c4950b13f8ed696eb657 Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Thu, 6 Aug 2026 14:00:52 +0000 Subject: [PATCH 1/2] fix: emit presentation_id, close_reason, cache_key, build_id on paywall events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 5 ++ .../superwall/sdk/models/paywall/Paywall.kt | 7 ++ .../presentation/PaywallCloseReason.kt | 10 +++ .../sdk/paywall/presentation/PaywallInfo.kt | 7 ++ .../paywall/request/PaywallRequestManager.kt | 1 + .../trackable/InternalSuperwallEventTest.kt | 2 + .../paywall/presentation/PaywallInfoTest.kt | 27 ++++++++ .../request/PaywallRequestManagerTest.kt | 66 +++++++++++++++++++ 8 files changed, 125 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7a5980c..0d315fbb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## Unreleased + +## Fixes +- Paywall analytics events (`paywall_open`, `paywall_page_view`, `paywall_close`, etc.) now include a `presentation_id`, a unique identifier minted for each paywall presentation. Previously this field was always empty on Android, which broke dashboard funnels that correlate a paywall's page views into a single session. Also adds the previously-missing `close_reason`, `cache_key`, and `build_id` fields to these events, matching the data already sent by the iOS SDK. + ## 2.8.0 ## Enhancements diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt index 16b9a188c..470ec246a 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt @@ -122,6 +122,12 @@ data class Paywall( */ @kotlinx.serialization.Transient() var state: Map = emptyMap(), + /** + * A unique identifier minted for each distinct presentation of this paywall, used to + * correlate the events tracked during that presentation (e.g. page views). + */ + @kotlinx.serialization.Transient() + var presentationId: String? = null, @SerialName("url_config") val urlConfig: PaywallWebviewUrl.Config? = null, @Serializable @@ -275,6 +281,7 @@ data class Paywall( buildId = buildId, isScrollEnabled = isScrollEnabled ?: true, state = state, + presentationId = presentationId, ) companion object { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallCloseReason.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallCloseReason.kt index e3eb99b63..ef34ec278 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallCloseReason.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallCloseReason.kt @@ -31,3 +31,13 @@ sealed class PaywallCloseReason { else -> true } } + +val PaywallCloseReason.description: String + get() = + when (this) { + is PaywallCloseReason.SystemLogic -> "systemLogic" + is PaywallCloseReason.ForNextPaywall -> "forNextPaywall" + is PaywallCloseReason.WebViewFailedToLoad -> "webViewFailedToLoad" + is PaywallCloseReason.ManualClose -> "manualClose" + is PaywallCloseReason.None -> "none" + } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt index 008657cf9..7f4907b8f 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/PaywallInfo.kt @@ -64,6 +64,7 @@ data class PaywallInfo( @Serializable(with = AnyMapSerializer::class) val state: Map = emptyMap(), val customerInfo: CustomerInfo = CustomerInfo.empty(), + val presentationId: String? = null, ) { constructor( databaseId: String, @@ -99,6 +100,7 @@ data class PaywallInfo( isScrollEnabled: Boolean, state: Map = emptyMap(), customerInfo: CustomerInfo = CustomerInfo.empty(), + presentationId: String? = null, ) : this( databaseId = databaseId, identifier = identifier, @@ -187,6 +189,7 @@ data class PaywallInfo( isScrollEnabled = isScrollEnabled, state = state, customerInfo = customerInfo, + presentationId = presentationId, ) fun eventParams( @@ -220,6 +223,10 @@ data class PaywallInfo( "variant_id" to experiment?.variant?.id, "is_scroll_enabled" to isScrollEnabled, "state" to state, + "presentation_id" to presentationId, + "close_reason" to closeReason.description, + "cache_key" to cacheKey, + "build_id" to buildId, ) val customerParams = customerInfo.toParams() if (customerParams.isNotEmpty()) { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt index 0f099e806..86084070b 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt @@ -181,6 +181,7 @@ class PaywallRequestManager( return@withContext paywall.copy( experiment = request.responseIdentifiers.experiment, presentationSourceType = request.presentationSourceType, + presentationId = java.util.UUID.randomUUID().toString(), ) } diff --git a/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt index 48bbff7f5..2f58c5dcc 100644 --- a/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt @@ -958,6 +958,7 @@ class InternalSuperwallEventTest { presentation = PaywallPresentationInfo(PaywallPresentationStyle.Modal, 0), buildId = "build_1", cacheKey = "cache_1", + presentationId = "presentation_1", ) private fun stubStoreProduct( @@ -1033,6 +1034,7 @@ class InternalSuperwallEventTest { And("paywall info params are also included") { assertEquals(paywallInfo.identifier, params["paywall_identifier"]) + assertEquals(paywallInfo.presentationId, params["presentation_id"]) } And("the superwall placement is paywall_page_view") { diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/PaywallInfoTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/PaywallInfoTest.kt index 1cdaaf989..b127a5a80 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/presentation/PaywallInfoTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/presentation/PaywallInfoTest.kt @@ -123,6 +123,33 @@ class PaywallInfoTest { assertNotNull(params["paywall_response_load_start_time"]) } + @Test + fun eventParams_includesPresentationIdCloseReasonCacheKeyAndBuildId() { + val info = + PaywallInfo.empty().copy( + presentationId = "presentation-123", + closeReason = PaywallCloseReason.ManualClose, + cacheKey = "cache-456", + buildId = "build-789", + ) + + val params = info.eventParams() + + assertEquals("presentation-123", params["presentation_id"]) + assertEquals("manualClose", params["close_reason"]) + assertEquals("cache-456", params["cache_key"]) + assertEquals("build-789", params["build_id"]) + } + + @Test + fun eventParams_omitsPresentationId_whenNull() { + val info = PaywallInfo.empty().copy(presentationId = null) + + val params = info.eventParams() + + assertFalse(params.containsKey("presentation_id")) + } + private fun createProductItem( name: String, productIdentifier: String, diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt index 1d873b701..329503488 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallRequestManagerTest.kt @@ -320,6 +320,72 @@ class PaywallRequestManagerTest { assertEquals(sourceType, (result as Either.Success).value.presentationSourceType) } + @Test + fun test_getPaywall_setsPresentationId() = + runTest { + val paywall = Paywall.stub().copy(identifier = "test_paywall") + val request = + mockk { + every { responseIdentifiers } returns ResponseIdentifiers(paywallId = "test_paywall") + every { eventData } returns null + every { overrides } returns PaywallRequest.Overrides(products = null, isFreeTrial = null) + every { isDebuggerLaunched } returns false + every { presentationSourceType } returns null + } + + coEvery { network.getPaywall(any(), any()) } returns Either.Success(paywall) + coEvery { storeManager.getProducts(any(), any(), any()) } returns + mockk { + every { productItems } returns emptyList() + every { productsByFullId } returns emptyMap() + every { this@mockk.paywall } returns null + } + + val result = requestManager.getPaywall(request) + + assertTrue(result is Either.Success) + val presentationId = (result as Either.Success).value.presentationId + assertNotNull(presentationId) + assertTrue(presentationId!!.isNotBlank()) + } + + @Test + fun test_getPaywall_generatesNewPresentationId_onEachCall() = + runTest { + val paywall = Paywall.stub().copy(identifier = "test_paywall") + val request = + mockk { + every { responseIdentifiers } returns ResponseIdentifiers(paywallId = "test_paywall") + every { eventData } returns null + every { overrides } returns PaywallRequest.Overrides(products = null, isFreeTrial = null) + every { isDebuggerLaunched } returns false + every { presentationSourceType } returns null + } + + coEvery { network.getPaywall(any(), any()) } returns Either.Success(paywall) + coEvery { storeManager.getProducts(any(), any(), any()) } returns + mockk { + every { productItems } returns emptyList() + every { productsByFullId } returns emptyMap() + every { this@mockk.paywall } returns null + } + + // First call + val result1 = requestManager.getPaywall(request) + // Second call hits the request-hash cache, but should still mint a fresh presentation ID + val result2 = requestManager.getPaywall(request) + + assertTrue(result1 is Either.Success) + assertTrue(result2 is Either.Success) + val presentationId1 = (result1 as Either.Success).value.presentationId + val presentationId2 = (result2 as Either.Success).value.presentationId + assertNotNull(presentationId1) + assertNotNull(presentationId2) + assertTrue(presentationId1 != presentationId2) + // Network should only be called once due to caching + coVerify(exactly = 1) { network.getPaywall(any(), any()) } + } + @Test fun test_resetCache_clearsPaywallCache() = runTest { From e08f6f3348345a65a1f93461e3a8de89bdf1f09f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 6 Aug 2026 14:21:27 +0000 Subject: [PATCH 2/2] Update coverage badge [skip ci] --- .github/badges/jacoco.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index ec50def2a..fd9384d2c 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.4% \ No newline at end of file +coverage45.5% \ No newline at end of file