Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/error-tracking-first-launch-crashes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog": patch
"posthog-android": patch
Comment thread
turnipdabeets marked this conversation as resolved.
---

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`.
1 change: 1 addition & 0 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th
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()
Expand All @@ -32,12 +38,27 @@ 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.get()) {
captureEnabled = true
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
Comment thread
ioannisj marked this conversation as resolved.
}

Expand All @@ -61,6 +82,7 @@ public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Th
}
ownsInstallation = true
adapterExceptionHandler.setDefaultUncaughtExceptionHandler(this)
captureEnabled = true
config.logger.log("Exception autocapture is enabled.")
}

Expand All @@ -69,17 +91,23 @@ 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)
}
config.logger.log("Exception autocapture is disabled.")
}

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
Comment thread
turnipdabeets marked this conversation as resolved.
// default first-launch install in place rather than tearing it down until a real config
// says otherwise.
if (!loaded) {
return
}
Expand All @@ -95,11 +123,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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -519,19 +524,20 @@ 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 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 && !errorTracking) {
config.cachePreferences?.setValue(ERROR_TRACKING, mapOf("autocaptureExceptions" to false))
}
}
is Map<*, *> -> {
@Suppress("UNCHECKED_CAST")
Expand All @@ -555,6 +561,7 @@ public class PostHogRemoteConfig(
@Suppress("UNCHECKED_CAST")
val errorTracking = preferences.getValue(ERROR_TRACKING) as? Map<String, Any>
if (errorTracking != null) {
errorTrackingConfigCached = true
val autocaptureExceptions = errorTracking["autocaptureExceptions"]
autoCaptureExceptions = autocaptureExceptions as? Boolean ?: false
}
Expand Down Expand Up @@ -634,6 +641,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.
Expand Down
Loading
Loading