diff --git a/.changeset/error-tracking-first-launch-crashes.md b/.changeset/error-tracking-first-launch-crashes.md new file mode 100644 index 000000000..9de4beebc --- /dev/null +++ b/.changeset/error-tracking-first-launch-crashes.md @@ -0,0 +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`. 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 e201b0866..6d803852a 100644 --- a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -10,10 +10,21 @@ import java.util.concurrent.atomic.AtomicBoolean public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Thread.UncaughtExceptionHandler { private val config: PostHogConfig private val adapterExceptionHandler: UncaughtExceptionHandlerAdapter + + // @Volatile: read on the crashing thread in uncaughtException with no happens-before edge to + // the install()/uninstall() writes; a pre-existing thread could otherwise see a stale null and + // skip delegating to the app/system handler. + @Volatile private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null private var postHog: PostHogInterface? = null private var ownsInstallation = false + // 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() @@ -24,20 +35,38 @@ 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 override fun install(postHog: PostHogInterface) { this.postHog = postHog + // Already linked into the chain (possibly dormant below a handler installed after us): + // resume capturing in place. 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 — so we never relink, only flip the gate. + if (ownsInstallation) { + if (canCapture()) { + captureEnabled = true + // Re-arm the process-wide flag: a dormant uninstall clears it, and while we + // capture again a fresh instance must not also link on top of us. + integrationInstalled.set(true) + } + return + } + + // A different instance is already linked and armed; don't double-link into the chain. if (integrationInstalled.get()) { return } - val autocaptureExceptionsEnabled = config.remoteConfigHolder?.isAutocaptureExceptionsEnabled() ?: false - if (!autocaptureExceptionsEnabled) { + if (!canCapture()) { return } @@ -55,12 +84,26 @@ 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. + 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 } ownsInstallation = true adapterExceptionHandler.setDefaultUncaughtExceptionHandler(this) + captureEnabled = true config.logger.log("Exception autocapture is enabled.") } @@ -69,17 +112,36 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th if (!ownsInstallation) { return } - try { + // Stop capturing regardless of whether we can unlink. + captureEnabled = false + // Only unlink (and release ownership) if we're still the active handler. If something + // installed after us, we stay linked as its delegate: captureEnabled=false keeps us dormant, + // and holding the install flags stops a later re-enable from re-linking into a loop. + if (adapterExceptionHandler.getDefaultUncaughtExceptionHandler() === this) { adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) - config.logger.log("Exception autocapture is disabled.") - } finally { 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 linked as + // a pass-through (captureEnabled is already false), but release the process-wide armed + // flag so a fresh integration can take over autocapture. We keep ownsInstallation=true + // so our own later re-enable resumes in place (above) instead of relinking into a loop. + // ponytail: if this same dormant instance is re-enabled *after* a fresh instance armed + // on top, both capture (duplicate events, not a loop). Requires a closed client to + // still receive onRemoteConfig, which doesn't happen in practice. + integrationInstalled.set(false) + config.logger.log("Exception autocapture is dormant (still linked below another handler).") } } 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 } @@ -95,11 +157,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/main/java/com/posthog/internal/PostHogRemoteConfig.kt b/posthog/src/main/java/com/posthog/internal/PostHogRemoteConfig.kt index b9b732d3b..a078101e7 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 @@ -519,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() + // 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)) + } } is Map<*, *> -> { @Suppress("UNCHECKED_CAST") @@ -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..2aefb1799 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -13,6 +13,7 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -24,9 +25,26 @@ 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 + } + } + + @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.resetForTests() } private fun getSut(autoCapture: Boolean = true): PostHogErrorTrackingAutoCaptureIntegration { @@ -71,7 +89,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 +103,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 +116,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 +141,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 +171,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 +212,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 +265,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) @@ -271,4 +289,271 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uninstall() } + + // First-launch default-on (issue #648) + + @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) + currentHandler = 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() + } + + // 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. + + // 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) + + 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) + + // 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`() { + 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 + // 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 `a direct re-install on the owning instance honors the remote kill-switch`() { + 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. + 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 `a fresh instance takes over autocapture after the owner goes dormant on close`() { + // I1 installs by default, a third-party handler overlays on top, then the client closes: + // I1 can't unlink so it goes dormant. A later setup() must let a fresh instance take over + // autocapture instead of being permanently blocked by the still-held process-wide flag. + whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) + whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) + currentHandler = mockExceptionHandler + + val first = getSut() + first.install(mockPostHog) + + // Third-party overlay grabs the top slot, keeping `first` as its delegate. + val overlay = mock() + currentHandler = overlay + + // Client closes: uninstall() can't unlink (overlay on top), so `first` goes dormant. + first.uninstall() + + // Fresh setup(): a new instance must actually install on top of the overlay and capture. + val second = getSut() + second.install(mockPostHog) + + verify(mockAdapter).setDefaultUncaughtExceptionHandler(second) + + val thread = Thread.currentThread() + val throwable = RuntimeException("crash after re-setup") + second.uncaughtException(thread, throwable) + + verify(mockPostHog).captureException(any(), anyOrNull()) + verify(overlay).uncaughtException(thread, throwable) + + currentHandler = second + second.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() + } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogRemoteConfigTest.kt index af6195e7b..7f952925f 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,13 +690,34 @@ 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() } + @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.