From 81ce5d7c40bea4941237abb520781276de244b47 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 22 Jul 2026 08:46:50 +0300 Subject: [PATCH 1/9] fix(error-tracking): install autocapture handler by default on first launch Claude-Session: https://claude.ai/code/session_01S97TdXJVAUNVmR29ezqwSM --- .../error-tracking-first-launch-crashes.md | 5 + posthog/api/posthog.api | 1 + ...tHogErrorTrackingAutoCaptureIntegration.kt | 19 ++- .../posthog/internal/PostHogRemoteConfig.kt | 16 ++ ...ErrorTrackingAutoCaptureIntegrationTest.kt | 144 ++++++++++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 .changeset/error-tracking-first-launch-crashes.md diff --git a/.changeset/error-tracking-first-launch-crashes.md b/.changeset/error-tracking-first-launch-crashes.md new file mode 100644 index 000000000..5d124326d --- /dev/null +++ b/.changeset/error-tracking-first-launch-crashes.md @@ -0,0 +1,5 @@ +--- +"posthog-android": patch +--- + +Error tracking autocapture now installs the uncaught-exception handler on the very first app launch, before the remote config (`/flags`) response arrives, so an uncaught exception in that startup window is no longer missed. Local `errorTrackingConfig.autoCapture` stays the primary gate; remote config acts only as a kill-switch, uninstalling the handler if the resolved config reports `autocaptureExceptions: false`. Ports the iOS fix (#731 / #551). diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 116af9bc6..b6f6d4aed 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -955,6 +955,7 @@ public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/intern public fun getRequestId (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; public final fun getSessionRecordingSampleRate ()Ljava/lang/Double; public final fun getSurveys ()Ljava/util/List; + public final fun hasCachedErrorTrackingConfig ()Z public final fun hasRemoteConfigFetched ()Z public final fun isAutocaptureExceptionsEnabled ()Z public final fun isCaptureNetworkTimingEnabled ()Z diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index 8e59687d4..b2e69cffc 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -34,8 +34,19 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th return } - val autocaptureExceptionsEnabled = config.remoteConfigHolder?.isAutocaptureExceptionsEnabled() ?: false - if (!autocaptureExceptionsEnabled) { + // Local config is the primary gate (the remote check below is only a kill-switch). + if (!config.errorTrackingConfig.autoCapture) { + return + } + + // Remote config is a kill-switch, not a gate: install by default and skip only when a + // config that already exists — fetched this session or cached from a prior launch — + // explicitly disables autocapture, keeping the first-launch window (before /flags) covered. + val remoteConfig = config.remoteConfigHolder + val hasRemoteConfig = + remoteConfig?.hasRemoteConfigFetched() == true || + remoteConfig?.hasCachedErrorTrackingConfig() == true + if (hasRemoteConfig && remoteConfig?.isAutocaptureExceptionsEnabled() == false) { return } @@ -69,7 +80,9 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th } override fun onRemoteConfig(loaded: Boolean) { - // Only react to a live config; a failed attempt applies no fresh values. + // Only react to a live config; a failed attempt applies no fresh values, so leave the + // default first-launch install in place rather than tearing it down until a real config + // says otherwise. if (!loaded) { return } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index b9b732d3b..412eca7fb 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -124,6 +124,11 @@ public class PostHogRemoteConfig( @Volatile private var autoCaptureExceptions = false + // Set by preloadErrorTrackingConfig when a disk-cached error-tracking config exists at startup. + // Survives clear()/reset(): it's project-level config, not user data, like the cached config. + @Volatile + private var errorTrackingConfigCached = false + @Volatile private var consoleLogRecordingEnabled = true @@ -555,6 +560,7 @@ public class PostHogRemoteConfig( @Suppress("UNCHECKED_CAST") val errorTracking = preferences.getValue(ERROR_TRACKING) as? Map if (errorTracking != null) { + errorTrackingConfigCached = true val autocaptureExceptions = errorTracking["autocaptureExceptions"] autoCaptureExceptions = autocaptureExceptions as? Boolean ?: false } @@ -634,6 +640,16 @@ public class PostHogRemoteConfig( */ public fun isAutocaptureExceptionsEnabled(): Boolean = autoCaptureExceptions && config.errorTrackingConfig.autoCapture + /** + * Whether a disk-cached error tracking config was present at SDK startup, before any live + * `/config` fetch. Together with [hasRemoteConfigFetched] this tells the error-tracking + * integration whether the server's autocapture stance is already known: when neither is true + * (first launch, no cache) the integration installs by default so a crash in that first-launch + * window — before `/flags` responds — isn't silently missed; a cached-or-fetched config that + * disables autocapture blocks (or removes) it. + */ + public fun hasCachedErrorTrackingConfig(): Boolean = errorTrackingConfigCached + /** * Returns whether console log recording is enabled remotely. * Both remote config (sessionRecording.consoleLogRecordingEnabled) AND local config must be enabled. diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index b82fa5526..bd343fd28 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -271,4 +271,148 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uninstall() } + + // First-launch default-on (issue #648, mirrors iOS #551/#731) + + @Test + fun `install installs by default before remote config arrives on first launch`() { + // First launch: no cached config and /config not yet fetched, so isAutocaptureExceptionsEnabled() + // is still false. The handler must install by default so a crash in this window is not missed. + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(false) + whenever(mockRemoteConfig.hasCachedErrorTrackingConfig()).thenReturn(false) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) + + integration.uninstall() + } + + @Test + fun `install does not install when local autoCapture is disabled even without remote config`() { + // Local off is the primary gate: default-on must never override a host that disabled it. + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(false) + whenever(mockRemoteConfig.hasCachedErrorTrackingConfig()).thenReturn(false) + + val integration = getSut(autoCapture = false) + integration.install(mockPostHog) + + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } + + @Test + fun `install does not install when cached remote config disables autocapture`() { + // A prior launch cached an error-tracking config that disables autocapture. That known + // stance gates installation even before this session's /config fetch completes. + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(false) + whenever(mockRemoteConfig.hasCachedErrorTrackingConfig()).thenReturn(true) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } + + @Test + fun `install does not install when fetched remote config disables autocapture`() { + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(true) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } + + @Test + fun `install installs when cached remote config enables autocapture`() { + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.hasCachedErrorTrackingConfig()).thenReturn(true) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) + + integration.uninstall() + } + + @Test + fun `onRemoteConfig uninstalls default install when freshly loaded config disables autocapture`() { + // Default-on install on first launch (no config yet), then /config lands disabling autocapture. + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(false) + whenever(mockRemoteConfig.hasCachedErrorTrackingConfig()).thenReturn(false) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + + val integration = getSut() + integration.install(mockPostHog) + + // Installed by default even though remote config hasn't confirmed autocapture + verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) + + // /config arrives with autocapture disabled → uninstall restores the original handler + integration.onRemoteConfig(loaded = true) + + verify(mockAdapter).setDefaultUncaughtExceptionHandler(mockExceptionHandler) + + integration.uninstall() + } + + @Test + fun `onRemoteConfig keeps default install when freshly loaded config enables autocapture`() { + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(false) + whenever(mockRemoteConfig.hasCachedErrorTrackingConfig()).thenReturn(false) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter, times(1)).setDefaultUncaughtExceptionHandler(integration) + + // /config arrives confirming autocapture enabled → already installed, stays installed + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) + integration.onRemoteConfig(loaded = true) + + verify(mockAdapter, times(1)).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } + + @Test + fun `onRemoteConfig keeps default install when remote config fetch fails`() { + // Offline / failed first-launch fetch (loaded = false) must not tear down the default install. + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(false) + whenever(mockRemoteConfig.hasCachedErrorTrackingConfig()).thenReturn(false) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter, times(1)).setDefaultUncaughtExceptionHandler(integration) + + integration.onRemoteConfig(loaded = false) + + // No uninstall: the handler is still ours, so setDefault isn't called again + verify(mockAdapter, times(1)).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } } From 9a7f67aeadb562805dadc662765d188b5f332928 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 27 Jul 2026 14:41:22 +0300 Subject: [PATCH 2/9] fix(error-tracking): cache disabled autocapture stance so returning launches skip default install --- .../com/posthog/internal/PostHogRemoteConfig.kt | 14 +++++++------- .../posthog/internal/PostHogRemoteConfigTest.kt | 8 +++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index 412eca7fb..c2e710cfa 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -524,19 +524,19 @@ public class PostHogRemoteConfig( } } - private fun clearErrorTracking() { - autoCaptureExceptions = false - config.cachePreferences?.remove(ERROR_TRACKING) - } - private fun processErrorTrackingConfig( errorTracking: Any?, persist: Boolean = true, ) { when (errorTracking) { is Boolean -> { - // if errorTracking is a Boolean, it's always false (disabled) - clearErrorTracking() + // errorTracking as a Boolean is always false (disabled). Persist the disabled + // stance instead of evicting the key, so the next launch skips the default + // first-launch install before its live /config lands (mirrors iOS). + autoCaptureExceptions = false + if (persist) { + config.cachePreferences?.setValue(ERROR_TRACKING, mapOf("autocaptureExceptions" to false)) + } } is Map<*, *> -> { @Suppress("UNCHECKED_CAST") diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index af6195e7b..d408412f8 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -676,7 +676,7 @@ internal class PostHogRemoteConfigTest { } @Test - fun `explicit errorTracking false from remote config evicts the cached config`() { + fun `explicit errorTracking false from remote config caches the disabled stance`() { // Stale cache from when the project had autocapture enabled. preferences.setValue(ERROR_TRACKING, mapOf("autocaptureExceptions" to true)) @@ -690,8 +690,10 @@ internal class PostHogRemoteConfigTest { executor.shutdownAndAwaitTermination() assertFalse(sut.isAutocaptureExceptionsEnabled()) - // Must evict the cache, else a later reset()+reload could re-arm a disabled project. - assertNull(preferences.getValue(ERROR_TRACKING)) + // Persist the disabled stance (not evict), so the next launch knows the project + // disabled autocapture before /config lands and skips the default first-launch install. + // Overwrites the stale enabled cache, so a later reset()+reload can't re-arm it. + assertEquals(mapOf("autocaptureExceptions" to false), preferences.getValue(ERROR_TRACKING)) sut.clear() http.shutdown() From 03ffbdf8c2ff915e3e9a5acb569cf96802837cfa Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 27 Jul 2026 17:42:09 +0300 Subject: [PATCH 3/9] fix(error-tracking): guard handler restore and cache stance on review feedback --- .../error-tracking-first-launch-crashes.md | 3 +- ...tHogErrorTrackingAutoCaptureIntegration.kt | 6 +++- .../posthog/internal/PostHogRemoteConfig.kt | 9 +++--- ...ErrorTrackingAutoCaptureIntegrationTest.kt | 28 +++++++++++++------ .../internal/PostHogRemoteConfigTest.kt | 19 +++++++++++++ 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/.changeset/error-tracking-first-launch-crashes.md b/.changeset/error-tracking-first-launch-crashes.md index 5d124326d..9de4beebc 100644 --- a/.changeset/error-tracking-first-launch-crashes.md +++ b/.changeset/error-tracking-first-launch-crashes.md @@ -1,5 +1,6 @@ --- +"posthog": patch "posthog-android": patch --- -Error tracking autocapture now installs the uncaught-exception handler on the very first app launch, before the remote config (`/flags`) response arrives, so an uncaught exception in that startup window is no longer missed. Local `errorTrackingConfig.autoCapture` stays the primary gate; remote config acts only as a kill-switch, uninstalling the handler if the resolved config reports `autocaptureExceptions: false`. Ports the iOS fix (#731 / #551). +Error tracking autocapture now installs the uncaught-exception handler on the very first app launch, before the remote config (`/flags`) response arrives, so an uncaught exception in that startup window is no longer missed. Local `errorTrackingConfig.autoCapture` stays the primary gate; remote config acts only as a kill-switch, uninstalling the handler if the resolved config reports `autocaptureExceptions: false`. diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index b2e69cffc..836e4d640 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -74,7 +74,11 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th if (!integrationInstalled) { return } - adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) + // Only restore if we are still the active handler. Something installed after us (or another + // PostHog instance that owns the process-wide flag) must not be replaced by our saved handler. + if (adapterExceptionHandler.getDefaultUncaughtExceptionHandler() === this) { + adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) + } integrationInstalled = false config.logger.log("Exception autocapture is disabled.") } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index c2e710cfa..e01a2d3d4 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -530,11 +530,12 @@ public class PostHogRemoteConfig( ) { when (errorTracking) { is Boolean -> { - // errorTracking as a Boolean is always false (disabled). Persist the disabled - // stance instead of evicting the key, so the next launch skips the default - // first-launch install before its live /config lands (mirrors iOS). + // errorTracking as a Boolean means disabled. Persist the disabled stance instead of + // evicting the key, so the next launch skips the default first-launch install before + // its live /config lands. Only persist when actually disabled, so a truthy value never + // caches the disabled stance permanently. autoCaptureExceptions = false - if (persist) { + if (persist && !errorTracking) { config.cachePreferences?.setValue(ERROR_TRACKING, mapOf("autocaptureExceptions" to false)) } } diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index bd343fd28..9c53a4d16 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -24,9 +24,19 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { private val mockExceptionHandler = mock() private val mockRemoteConfig = mock() + // Stateful stand-in for the process JVM handler slot so the adapter reflects installs/restores + // like the real one, letting the ownership guard in uninstall() be exercised for real. + private var currentHandler: Thread.UncaughtExceptionHandler? = null + @BeforeTest fun setUp() { whenever(mockConfig.logger).thenReturn(mockLogger) + currentHandler = null + whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenAnswer { currentHandler } + whenever(mockAdapter.setDefaultUncaughtExceptionHandler(anyOrNull())).thenAnswer { + currentHandler = it.getArgument(0) + null + } } private fun getSut(autoCapture: Boolean = true): PostHogErrorTrackingAutoCaptureIntegration { @@ -71,7 +81,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { fun `install sets up exception handler when current handler is null`() { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) - whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(null) + currentHandler = null val integration = getSut() integration.install(mockPostHog) @@ -85,7 +95,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { fun `install sets up exception handler when current handler is different`() { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) - whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + currentHandler = mockExceptionHandler val integration = getSut() integration.install(mockPostHog) @@ -98,7 +108,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { @Test fun `install does not replace current handler when it is already PostHogErrorTrackingAutoCaptureIntegration`() { val existingIntegration = getSut() - whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(existingIntegration) + currentHandler = existingIntegration val integration = getSut() integration.install(mockPostHog) @@ -123,7 +133,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { fun `uninstall restores original exception handler and resets state`() { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) - whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + currentHandler = mockExceptionHandler val integration = getSut() integration.install(mockPostHog) @@ -153,7 +163,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { fun `uncaughtException calls default handler after capturing exception`() { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) - whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + currentHandler = mockExceptionHandler val thread = Thread.currentThread() val throwable = RuntimeException("Test exception") @@ -194,7 +204,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { fun `onRemoteConfig uninstalls when autocapture exceptions is disabled`() { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) - whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + currentHandler = mockExceptionHandler val integration = getSut() integration.install(mockPostHog) @@ -247,7 +257,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { fun `onRemoteConfig can re-install after being disabled`() { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) - whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + currentHandler = mockExceptionHandler val integration = getSut() integration.install(mockPostHog) @@ -272,7 +282,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uninstall() } - // First-launch default-on (issue #648, mirrors iOS #551/#731) + // First-launch default-on (issue #648) @Test fun `install installs by default before remote config arrives on first launch`() { @@ -358,7 +368,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(false) whenever(mockRemoteConfig.hasCachedErrorTrackingConfig()).thenReturn(false) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) - whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + currentHandler = mockExceptionHandler val integration = getSut() integration.install(mockPostHog) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index d408412f8..7f952925f 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt @@ -699,6 +699,25 @@ internal class PostHogRemoteConfigTest { http.shutdown() } + @Test + fun `a returning launch sees the cached disabled stance before its own config lands`() { + val disabled = File("src/test/resources/json/basic-remote-config-features-disabled.json").readText() + val http = mockHttp(response = MockResponse().setBody(disabled)) + val sut = getSut(host = http.url("/").toString()) + config!!.errorTrackingConfig.autoCapture = true + sut.loadRemoteConfig("my_identify", anonymousId = "anonId", emptyMap()) + executor.shutdownAndAwaitTermination() + sut.clear() + + // Fresh process over the same on-disk preferences. + val next = getSut(host = http.url("/").toString()) + config!!.errorTrackingConfig.autoCapture = true + assertTrue(next.hasCachedErrorTrackingConfig()) + assertFalse(next.isAutocaptureExceptionsEnabled()) + + http.shutdown() + } + @Test fun `explicit capturePerformance false from remote config evicts the cached config`() { // Stale cache from when the project had network timing enabled. From 8348351e14b55c23aab28f73763450916bdfed7f Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 09:35:47 +0300 Subject: [PATCH 4/9] fix(error-tracking): keep dormant handler in chain instead of capturing after disable --- ...tHogErrorTrackingAutoCaptureIntegration.kt | 29 ++++++-- ...ErrorTrackingAutoCaptureIntegrationTest.kt | 73 +++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index 836e4d640..f8ff17955 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -12,6 +12,12 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null private var postHog: PostHogInterface? = null + // Tracks whether we should capture, separate from whether we're linked into the handler chain. + // We can't always unlink (a handler installed after us keeps us as its delegate), so a disabled + // instance stays in the chain but dormant. + @Volatile + private var captureEnabled = false + public constructor(config: PostHogConfig) { this.config = config this.adapterExceptionHandler = UncaughtExceptionHandlerAdapter.Adapter.getInstance() @@ -30,7 +36,11 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th override fun install(postHog: PostHogInterface) { this.postHog = postHog + // Already linked into the chain: just resume capturing. Re-running the link logic while + // we're a mid-chain delegate would point defaultExceptionHandler back at a handler that + // delegates to us, looping uncaughtException until it StackOverflows. if (integrationInstalled) { + captureEnabled = true return } @@ -67,6 +77,7 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th private fun installHandler() { adapterExceptionHandler.setDefaultUncaughtExceptionHandler(this) integrationInstalled = true + captureEnabled = true config.logger.log("Exception autocapture is enabled.") } @@ -74,12 +85,15 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th if (!integrationInstalled) { return } - // Only restore if we are still the active handler. Something installed after us (or another - // PostHog instance that owns the process-wide flag) must not be replaced by our saved handler. + // Stop capturing regardless of whether we can unlink. + captureEnabled = false + // Only unlink (and clear the installed flag) if we're still the active handler. If something + // installed after us, we stay linked as its delegate: captureEnabled=false keeps us dormant, + // and leaving integrationInstalled=true stops a later re-enable from re-linking into a loop. if (adapterExceptionHandler.getDefaultUncaughtExceptionHandler() === this) { adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) + integrationInstalled = false } - integrationInstalled = false config.logger.log("Exception autocapture is disabled.") } @@ -102,11 +116,14 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th thread: Thread, throwable: Throwable, ) { - postHog?.let { postHog -> - postHog.captureException(PostHogThrowable(throwable, thread)) - postHog.flush() + if (captureEnabled) { + postHog?.let { postHog -> + postHog.captureException(PostHogThrowable(throwable, thread)) + postHog.flush() + } } + // Always delegate: we may still be mid-chain even while dormant. defaultExceptionHandler?.uncaughtException(thread, throwable) } } diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index 9c53a4d16..10c21f4c4 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -405,6 +405,79 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uninstall() } + // Mid-chain disable/re-enable (discussion_r3667142339): when a handler installs after us we + // can't unlink, so a disabled instance must stay dormant, keep delegating, and never re-link. + + @Test + fun `uninstall stops capturing but keeps delegating when another handler installed after us`() { + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) + currentHandler = mockExceptionHandler + + val integration = getSut() + integration.install(mockPostHog) + verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) + + // A third party installs on top of us; we're now a mid-chain delegate. + val overlay = mock() + currentHandler = overlay + + // Remote disables autocapture → we can't unlink (we're not the active handler). + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + integration.onRemoteConfig(loaded = true) + + // We did not clobber the overlay by restoring our saved handler. + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(mockExceptionHandler) + + // A crash still routes through us: we must NOT capture (kill-switch), but must delegate down. + val thread = Thread.currentThread() + val throwable = RuntimeException("crash while dormant") + integration.uncaughtException(thread, throwable) + + verify(mockPostHog, never()).captureException(any(), anyOrNull()) + verify(mockExceptionHandler).uncaughtException(thread, throwable) + + // Clear the process-wide install flag so it doesn't leak into the next test. + currentHandler = integration + integration.uninstall() + } + + @Test + fun `onRemoteConfig re-enable resumes capturing without re-linking when mid-chain`() { + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) + currentHandler = mockExceptionHandler + + val integration = getSut() + integration.install(mockPostHog) + + // Overlay installs after us, then remote disables. + currentHandler = mock() + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + integration.onRemoteConfig(loaded = true) + + // Remote re-enables. We must resume capturing WITHOUT re-linking — re-linking here would + // point defaultExceptionHandler at the overlay (which delegates to us) and loop to a + // StackOverflow on the next crash. + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) + integration.onRemoteConfig(loaded = true) + + // Only the original install ever set the handler; no re-link. + verify(mockAdapter, times(1)).setDefaultUncaughtExceptionHandler(integration) + + // Capturing is back on, and defaultExceptionHandler is still the original app handler. + val thread = Thread.currentThread() + val throwable = RuntimeException("crash after re-enable") + integration.uncaughtException(thread, throwable) + + verify(mockPostHog).captureException(any(), anyOrNull()) + verify(mockExceptionHandler).uncaughtException(thread, throwable) + + // Clear the process-wide install flag so it doesn't leak into the next test. + currentHandler = integration + integration.uninstall() + } + @Test fun `onRemoteConfig keeps default install when remote config fetch fails`() { // Offline / failed first-launch fetch (loaded = false) must not tear down the default install. From 61d5024bdc872217f1d0d729225fae001712c1be Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 14:01:01 +0300 Subject: [PATCH 5/9] fix(error-tracking): guard reentrant install resume on ownership and local gate --- .../PostHogErrorTrackingAutoCaptureIntegration.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index c6e6aaa55..a3f169ccc 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -42,7 +42,13 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th // we're a mid-chain delegate would point defaultExceptionHandler back at a handler that // delegates to us, looping uncaughtException until it StackOverflows. if (integrationInstalled.get()) { - captureEnabled = true + // Resume only if we own the chain link and the local gate is still on. A non-owning + // instance isn't wired into the handler chain, so setting captureEnabled here would be + // inert and misleading; and a same-instance resume must honor autoCapture toggled off + // since install. + if (ownsInstallation && config.errorTrackingConfig.autoCapture) { + captureEnabled = true + } return } From c170cccf736ef9f9599e0cabb9093cbb7c512fb6 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 14:10:18 +0300 Subject: [PATCH 6/9] fix(error-tracking): clarify dormant-vs-unlinked uninstall and harden test isolation --- .../PostHogErrorTrackingAutoCaptureIntegration.kt | 9 ++++++++- ...PostHogErrorTrackingAutoCaptureIntegrationTest.kt | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index a3f169ccc..a6c9a5f58 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -106,8 +106,15 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) ownsInstallation = false integrationInstalled.set(false) + // We're out of the chain now, so drop the delegate ref (a re-install re-reads it). + // postHog is kept: onRemoteConfig re-enable calls install(postHog) on this instance. + defaultExceptionHandler = null + config.logger.log("Exception autocapture is disabled.") + } else { + // Can't unlink — a handler installed after us keeps us as its delegate. Stay dormant + // (captureEnabled is already false) and keep delegating. + config.logger.log("Exception autocapture is dormant (still linked below another handler).") } - config.logger.log("Exception autocapture is disabled.") } override fun onRemoteConfig(loaded: Boolean) { diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index 10c21f4c4..2ad90044d 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -13,6 +13,8 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -39,6 +41,16 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { } } + @AfterTest + fun tearDown() { + // Force-reset the process-static install flag so a test that throws before its own + // uninstall() can't leak install state into the next test's compareAndSet. + PostHogErrorTrackingAutoCaptureIntegration::class.java + .getDeclaredField("integrationInstalled") + .apply { isAccessible = true } + .let { (it.get(null) as AtomicBoolean).set(false) } + } + private fun getSut(autoCapture: Boolean = true): PostHogErrorTrackingAutoCaptureIntegration { whenever(mockConfig.errorTrackingConfig).thenReturn( PostHogErrorTrackingConfig().apply { From 1fc6e03818d9206e2248f8b068e3b0c32331b97a Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 15:50:24 +0300 Subject: [PATCH 7/9] fix(review): replace reflection test reset with internal resetForTests hook --- .../PostHogErrorTrackingAutoCaptureIntegration.kt | 6 +++++- .../PostHogErrorTrackingAutoCaptureIntegrationTest.kt | 6 +----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index a6c9a5f58..977ccde2e 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -30,8 +30,12 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th this.adapterExceptionHandler = adapterExceptionHandler } - private companion object { + internal companion object { private val integrationInstalled = AtomicBoolean(false) + + internal fun resetForTests() { + integrationInstalled.set(false) + } } @Synchronized diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index 2ad90044d..fe8ff6b6e 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -13,7 +13,6 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -45,10 +44,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { fun tearDown() { // Force-reset the process-static install flag so a test that throws before its own // uninstall() can't leak install state into the next test's compareAndSet. - PostHogErrorTrackingAutoCaptureIntegration::class.java - .getDeclaredField("integrationInstalled") - .apply { isAccessible = true } - .let { (it.get(null) as AtomicBoolean).set(false) } + PostHogErrorTrackingAutoCaptureIntegration.resetForTests() } private fun getSut(autoCapture: Boolean = true): PostHogErrorTrackingAutoCaptureIntegration { From fe2078c53d3ece11dcef1c20b8dbc4c267c7aac4 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 16:17:07 +0300 Subject: [PATCH 8/9] fix(error-tracking): honor remote kill-switch on install resume path --- ...tHogErrorTrackingAutoCaptureIntegration.kt | 30 ++++++++++-------- ...ErrorTrackingAutoCaptureIntegrationTest.kt | 31 +++++++++++++++++++ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index 977ccde2e..cb675245b 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -46,11 +46,12 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th // we're a mid-chain delegate would point defaultExceptionHandler back at a handler that // delegates to us, looping uncaughtException until it StackOverflows. if (integrationInstalled.get()) { - // Resume only if we own the chain link and the local gate is still on. A non-owning - // instance isn't wired into the handler chain, so setting captureEnabled here would be - // inert and misleading; and a same-instance resume must honor autoCapture toggled off - // since install. - if (ownsInstallation && config.errorTrackingConfig.autoCapture) { + // Resume only if we own the chain link and both gates still allow capturing. A + // non-owning instance isn't wired into the handler chain, so setting captureEnabled + // here would be inert and misleading; and a same-instance resume must honor both + // autoCapture toggled off since install and a remote config that has since disabled + // autocapture (the same kill-switch the fresh-install path checks). + if (ownsInstallation && config.errorTrackingConfig.autoCapture && !remoteKillSwitchActive()) { captureEnabled = true } return @@ -61,14 +62,7 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th return } - // Remote config is a kill-switch, not a gate: install by default and skip only when a - // config that already exists — fetched this session or cached from a prior launch — - // explicitly disables autocapture, keeping the first-launch window (before /flags) covered. - val remoteConfig = config.remoteConfigHolder - val hasRemoteConfig = - remoteConfig?.hasRemoteConfigFetched() == true || - remoteConfig?.hasCachedErrorTrackingConfig() == true - if (hasRemoteConfig && remoteConfig?.isAutocaptureExceptionsEnabled() == false) { + if (remoteKillSwitchActive()) { return } @@ -86,6 +80,16 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th } } + // Remote config is a kill-switch, not a gate: it blocks capture only when a config that + // already exists — fetched this session or cached from a prior launch — explicitly disables + // autocapture, keeping the first-launch window (before /flags) covered. + private fun remoteKillSwitchActive(): Boolean { + val remoteConfig = config.remoteConfigHolder ?: return false + val hasRemoteConfig = + remoteConfig.hasRemoteConfigFetched() || remoteConfig.hasCachedErrorTrackingConfig() + return hasRemoteConfig && !remoteConfig.isAutocaptureExceptionsEnabled() + } + private fun installHandler() { if (!integrationInstalled.compareAndSet(false, true)) { return diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index fe8ff6b6e..f0211a256 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -486,6 +486,37 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uninstall() } + @Test + fun `a direct re-install on the owning instance honors the remote kill-switch`() { + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) + currentHandler = mockExceptionHandler + + val integration = getSut() + integration.install(mockPostHog) + + // Overlay installs after us, then remote disables → dormant (flags held, capture off). + currentHandler = mock() + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(true) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) + integration.onRemoteConfig(loaded = true) + + // A direct install() (e.g. re-init re-running config.integrations) must NOT resume + // capture while the fetched/cached remote config still disables autocapture. + integration.install(mockPostHog) + + val thread = Thread.currentThread() + val throwable = RuntimeException("crash while remote-disabled") + integration.uncaughtException(thread, throwable) + + verify(mockPostHog, never()).captureException(any(), anyOrNull()) + verify(mockExceptionHandler).uncaughtException(thread, throwable) + + // Clear the process-wide install flag so it doesn't leak into the next test. + currentHandler = integration + integration.uninstall() + } + @Test fun `onRemoteConfig keeps default install when remote config fetch fails`() { // Offline / failed first-launch fetch (loaded = false) must not tear down the default install. From 5153f88b06a353d8d45414670c52e1edec82eee9 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 29 Jul 2026 16:23:31 +0300 Subject: [PATCH 9/9] refactor(error-tracking): extract capture gates and dedupe overlay test fixture --- ...tHogErrorTrackingAutoCaptureIntegration.kt | 19 +++---- .../posthog/internal/PostHogRemoteConfig.kt | 7 ++- ...ErrorTrackingAutoCaptureIntegrationTest.kt | 49 ++++++------------- 3 files changed, 26 insertions(+), 49 deletions(-) diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt index cb675245b..89d5c6218 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -46,23 +46,15 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th // we're a mid-chain delegate would point defaultExceptionHandler back at a handler that // delegates to us, looping uncaughtException until it StackOverflows. if (integrationInstalled.get()) { - // Resume only if we own the chain link and both gates still allow capturing. A - // non-owning instance isn't wired into the handler chain, so setting captureEnabled - // here would be inert and misleading; and a same-instance resume must honor both - // autoCapture toggled off since install and a remote config that has since disabled - // autocapture (the same kill-switch the fresh-install path checks). - if (ownsInstallation && config.errorTrackingConfig.autoCapture && !remoteKillSwitchActive()) { + // Resume only if we own the link and both gates still allow capture; a non-owner + // isn't wired into the chain, so setting captureEnabled here would be inert. + if (ownsInstallation && canCapture()) { captureEnabled = true } return } - // Local config is the primary gate (the remote check below is only a kill-switch). - if (!config.errorTrackingConfig.autoCapture) { - return - } - - if (remoteKillSwitchActive()) { + if (!canCapture()) { return } @@ -80,6 +72,9 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th } } + // Local config is the primary gate; remote config is only a kill-switch (below). + private fun canCapture(): Boolean = config.errorTrackingConfig.autoCapture && !remoteKillSwitchActive() + // Remote config is a kill-switch, not a gate: it blocks capture only when a config that // already exists — fetched this session or cached from a prior launch — explicitly disables // autocapture, keeping the first-launch window (before /flags) covered. diff --git a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index e01a2d3d4..a078101e7 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt @@ -530,10 +530,9 @@ public class PostHogRemoteConfig( ) { when (errorTracking) { is Boolean -> { - // errorTracking as a Boolean means disabled. Persist the disabled stance instead of - // evicting the key, so the next launch skips the default first-launch install before - // its live /config lands. Only persist when actually disabled, so a truthy value never - // caches the disabled stance permanently. + // A Boolean errorTracking means disabled; cache that stance so a future launch + // skips the default first-launch install before /config responds. Persisted only + // when actually disabled. autoCaptureExceptions = false if (persist && !errorTracking) { config.cachePreferences?.setValue(ERROR_TRACKING, mapOf("autocaptureExceptions" to false)) diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index f0211a256..83f849399 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -413,27 +413,31 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uninstall() } - // Mid-chain disable/re-enable (discussion_r3667142339): when a handler installs after us we - // can't unlink, so a disabled instance must stay dormant, keep delegating, and never re-link. + // Mid-chain disable/re-enable: when a handler installs after us we can't unlink, so a + // disabled instance must stay dormant, keep delegating, and never re-link. - @Test - fun `uninstall stops capturing but keeps delegating when another handler installed after us`() { + // Install with the app's handler present, let a third-party overlay take the top slot, then + // have remote config disable autocapture — the integration goes mid-chain-dormant. + private fun installedThenOverlaidAndRemoteDisabled(): PostHogErrorTrackingAutoCaptureIntegration { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) currentHandler = mockExceptionHandler val integration = getSut() integration.install(mockPostHog) - verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) - - // A third party installs on top of us; we're now a mid-chain delegate. - val overlay = mock() - currentHandler = overlay - // Remote disables autocapture → we can't unlink (we're not the active handler). + currentHandler = mock() + whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(true) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) integration.onRemoteConfig(loaded = true) + return integration + } + + @Test + fun `uninstall stops capturing but keeps delegating when another handler installed after us`() { + val integration = installedThenOverlaidAndRemoteDisabled() + verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) // We did not clobber the overlay by restoring our saved handler. verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(mockExceptionHandler) @@ -452,17 +456,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { @Test fun `onRemoteConfig re-enable resumes capturing without re-linking when mid-chain`() { - whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) - whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) - currentHandler = mockExceptionHandler - - val integration = getSut() - integration.install(mockPostHog) - - // Overlay installs after us, then remote disables. - currentHandler = mock() - whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) - integration.onRemoteConfig(loaded = true) + val integration = installedThenOverlaidAndRemoteDisabled() // Remote re-enables. We must resume capturing WITHOUT re-linking — re-linking here would // point defaultExceptionHandler at the overlay (which delegates to us) and loop to a @@ -488,18 +482,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { @Test fun `a direct re-install on the owning instance honors the remote kill-switch`() { - whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) - whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) - currentHandler = mockExceptionHandler - - val integration = getSut() - integration.install(mockPostHog) - - // Overlay installs after us, then remote disables → dormant (flags held, capture off). - currentHandler = mock() - whenever(mockRemoteConfig.hasRemoteConfigFetched()).thenReturn(true) - whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(false) - integration.onRemoteConfig(loaded = true) + val integration = installedThenOverlaidAndRemoteDisabled() // A direct install() (e.g. re-init re-running config.integrations) must NOT resume // capture while the fetched/cached remote config still disables autocapture.