diff --git a/posthog-android/CHANGELOG.md b/posthog-android/CHANGELOG.md index 180ab5e4..e5d9296a 100644 --- a/posthog-android/CHANGELOG.md +++ b/posthog-android/CHANGELOG.md @@ -2,6 +2,7 @@ - add evaluation tags to android SDK ([#301](https://github.com/PostHog/posthog-android/pull/301)) - feat: add manual captureException ([#300](https://github.com/PostHog/posthog-android/issues/300)) +- feat: add exception autocapture ([#305](https://github.com/PostHog/posthog-android/issues/305)) ## 3.23.0 - 2025-10-06 diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index 5754e5a1..75567be1 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -81,7 +81,7 @@ public class PostHogAndroid private constructor() { // only frames coming from the package name will be considered inApp by default if (packageName.isNotEmpty() && !packageName.startsWith("android.")) { - config.inAppIncludes.add(packageName) + config.errorTrackingConfig.inAppIncludes.add(packageName) } config.context = config.context ?: PostHogAndroidContext(context, config) diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/DoSomething.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/DoSomething.kt index a5fe0600..5de3b95a 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/DoSomething.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/DoSomething.kt @@ -7,7 +7,7 @@ class DoSomething { try { throw MyCustomException("Something went wrong") } catch (e: Throwable) { - PostHog.captureException(e, mapOf("am-i-stupid" to true)) + PostHog.captureException(e, mapOf("my-custom-error" to true)) } } } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt index e626df4a..aa775e86 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/MyApp.kt @@ -28,9 +28,10 @@ class MyApp : Application() { captureScreenViews = false sessionReplay = true preloadFeatureFlags = true + sendFeatureFlagEvent = true onFeatureFlags = PostHogOnFeatureFlags { print("feature flags loaded") } addBeforeSend { event -> - if (event.event == "test_name") { + if (event.event == "test_event") { null } else { event @@ -41,6 +42,7 @@ class MyApp : Application() { sessionReplayConfig.captureLogcat = true sessionReplayConfig.screenshot = true surveys = true + errorTrackingConfig.autoCapture = true } PostHogAndroid.setup(this, config) } diff --git a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt index 994f4bc8..a9d97982 100644 --- a/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt +++ b/posthog-samples/posthog-android-sample/src/main/java/com/posthog/android/sample/NormalActivity.kt @@ -38,11 +38,13 @@ class NormalActivity : ComponentActivity() { // finish() // Check if the "enable_network_request" feature flag is enabled - try { - throw RuntimeException("Test error") - } catch (e: Throwable) { - PostHog.captureException(e, mapOf("am-i-stupid" to true)) - } +// var str: String? = null +// if (str!!.startsWith("")) { +// str = "123" +// Toast.makeText(this, str, Toast.LENGTH_SHORT).show() +// } + val doSomething = DoSomething() + doSomething.doSomethingNow() val isNetworkRequestEnabled = PostHog.isFeatureEnabled("enable_network_request", false) diff --git a/posthog/CHANGELOG.md b/posthog/CHANGELOG.md index 628cc3d8..749663b4 100644 --- a/posthog/CHANGELOG.md +++ b/posthog/CHANGELOG.md @@ -1,6 +1,7 @@ ## Next - feat: add manual captureException ([#300](https://github.com/PostHog/posthog-android/issues/300)) +- feat: add exception autocapture ([#305](https://github.com/PostHog/posthog-android/issues/305)) ## 4.0.0 - 2025-10-03 diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index 05d5021c..bd016f15 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -86,8 +86,8 @@ public class com/posthog/PostHogConfig { public static final field DEFAULT_HOST Ljava/lang/String; public static final field DEFAULT_US_ASSETS_HOST Ljava/lang/String; public static final field DEFAULT_US_HOST Ljava/lang/String; - public fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Ljava/util/List;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLcom/posthog/PostHogPropertiesSanitizer;Lkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V public final fun getApiKey ()Ljava/lang/String; @@ -97,13 +97,13 @@ public class com/posthog/PostHogConfig { public final fun getDateProvider ()Lcom/posthog/internal/PostHogDateProvider; public final fun getDebug ()Z public final fun getEncryption ()Lcom/posthog/PostHogEncryption; + public final fun getErrorTrackingConfig ()Lcom/posthog/errortracking/PostHogErrorTrackingConfig; public final fun getEvaluationEnvironments ()Ljava/util/List; public final fun getFeatureFlagCalledCacheSize ()I public final fun getFlushAt ()I public final fun getFlushIntervalSeconds ()I public final fun getGetAnonymousId ()Lkotlin/jvm/functions/Function1; public final fun getHost ()Ljava/lang/String; - public final fun getInAppIncludes ()Ljava/util/List; public final fun getIntegrations ()Ljava/util/List; public final fun getLegacyStoragePrefix ()Ljava/lang/String; public final fun getLogger ()Lcom/posthog/internal/PostHogLogger; @@ -215,6 +215,8 @@ public final class com/posthog/PostHogEvent { public final fun getType ()Ljava/lang/String; public final fun getUuid ()Ljava/util/UUID; public fun hashCode ()I + public final fun isExceptionEvent ()Z + public final fun isFatalExceptionEvent ()Z public final fun setApiKey (Ljava/lang/String;)V public fun toString ()Ljava/lang/String; } @@ -385,6 +387,23 @@ public final class com/posthog/PostHogStatelessInterface$DefaultImpls { public abstract interface annotation class com/posthog/PostHogVisibleForTesting : java/lang/annotation/Annotation { } +public final class com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration : com/posthog/PostHogIntegration, java/lang/Thread$UncaughtExceptionHandler { + public fun (Lcom/posthog/PostHogConfig;)V + public fun install (Lcom/posthog/PostHogInterface;)V + public fun uncaughtException (Ljava/lang/Thread;Ljava/lang/Throwable;)V + public fun uninstall ()V +} + +public final class com/posthog/errortracking/PostHogErrorTrackingConfig { + public fun ()V + public fun (Z)V + public fun (ZLjava/util/List;)V + public synthetic fun (ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAutoCapture ()Z + public final fun getInAppIncludes ()Ljava/util/List; + public final fun setAutoCapture (Z)V +} + public final class com/posthog/internal/EvaluationReason { public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V public final fun component1 ()Ljava/lang/String; @@ -636,6 +655,7 @@ public final class com/posthog/internal/PostHogUtilsKt { public static final fun executeSafely (Ljava/util/concurrent/Executor;Ljava/lang/Runnable;)V public static final fun interruptSafely (Ljava/lang/Thread;)V public static final fun isNetworkingError (Ljava/lang/Throwable;)Z + public static final fun submitSyncSafely (Ljava/util/concurrent/ExecutorService;Ljava/lang/Runnable;)V } public abstract interface class com/posthog/internal/replay/PostHogSessionReplayHandler { diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index daf5b6d8..d535482e 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1,5 +1,6 @@ package com.posthog +import com.posthog.errortracking.PostHogErrorTrackingAutoCaptureIntegration import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogApiEndpoint import com.posthog.internal.PostHogNoOpLogger @@ -19,7 +20,7 @@ import com.posthog.internal.PostHogSendCachedEventsIntegration import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory -import com.posthog.internal.exceptions.ThrowableCoercer +import com.posthog.internal.errortracking.ThrowableCoercer import com.posthog.internal.replay.PostHogSessionReplayHandler import com.posthog.internal.surveys.PostHogSurveysHandler import com.posthog.vendor.uuid.TimeBasedEpochGenerator @@ -134,6 +135,7 @@ public class PostHog private constructor( } config.addIntegration(sendCachedEventsIntegration) + config.addIntegration(PostHogErrorTrackingAutoCaptureIntegration(config)) legacyPreferences(config, config.serializer) @@ -495,7 +497,7 @@ public class PostHog private constructor( val exceptionProperties = throwableCoercer.fromThrowableToPostHogProperties( throwable, - inAppIncludes = config?.inAppIncludes ?: listOf(), + inAppIncludes = config?.errorTrackingConfig?.inAppIncludes ?: listOf(), ) properties?.let { diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index cfb5c00b..8aed5a07 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -1,5 +1,6 @@ package com.posthog +import com.posthog.errortracking.PostHogErrorTrackingConfig import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogApiEndpoint import com.posthog.internal.PostHogContext @@ -199,18 +200,9 @@ public open class PostHogConfig( public val queueProvider: (PostHogConfig, PostHogApi, PostHogApiEndpoint, String?, ExecutorService) -> PostHogQueueInterface = { config, api, endpoint, storagePrefix, executor -> PostHogQueue(config, api, endpoint, storagePrefix, executor) }, /** - * List of package names to be considered inApp frames for error tracking - * - * inApp Example: - * inAppIncludes=["com.yourapp"] - * All Exception stacktrace frames that start with com.yourapp will be considered inApp* - * - * On Android only frames coming from the app's package name will be considered inApp by default - * On Android, We add your app's package name to this list automatically (read from applicationId at runtime) - * - * If this list of package names is empty, all frames will be considered inApp + * Configuration for PostHog Error Tracking feature. */ - public val inAppIncludes: MutableList = mutableListOf(), + public val errorTrackingConfig: PostHogErrorTrackingConfig = PostHogErrorTrackingConfig(), ) { @PostHogInternal public var logger: PostHogLogger = PostHogNoOpLogger() diff --git a/posthog/src/main/java/com/posthog/PostHogEvent.kt b/posthog/src/main/java/com/posthog/PostHogEvent.kt index c7e64a6d..27f8d0b8 100644 --- a/posthog/src/main/java/com/posthog/PostHogEvent.kt +++ b/posthog/src/main/java/com/posthog/PostHogEvent.kt @@ -1,6 +1,8 @@ package com.posthog import com.google.gson.annotations.SerializedName +import com.posthog.internal.errortracking.ThrowableCoercer.Companion.EXCEPTION_LEVEL_ATTRIBUTE +import com.posthog.internal.errortracking.ThrowableCoercer.Companion.EXCEPTION_LEVEL_FATAL import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.util.Date import java.util.UUID @@ -35,4 +37,18 @@ public data class PostHogEvent( // Only used for Replay @SerializedName("api_key") var apiKey: String? = null, -) +) { + /** + * Checks if the event is an exception event ($exception) + */ + public fun isExceptionEvent(): Boolean { + return event == PostHogEventName.EXCEPTION.event + } + + /** + * Checks if the event is a fatal exception event ($exception) and properties ($exception_level=fatal) + */ + public fun isFatalExceptionEvent(): Boolean { + return isExceptionEvent() && properties?.get(EXCEPTION_LEVEL_ATTRIBUTE) == EXCEPTION_LEVEL_FATAL + } +} diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt new file mode 100644 index 00000000..e43ea6d0 --- /dev/null +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt @@ -0,0 +1,82 @@ +package com.posthog.errortracking + +import com.posthog.PostHogConfig +import com.posthog.PostHogIntegration +import com.posthog.PostHogInterface +import com.posthog.internal.errortracking.PostHogThrowable +import com.posthog.internal.errortracking.UncaughtExceptionHandlerAdapter + +public class PostHogErrorTrackingAutoCaptureIntegration : PostHogIntegration, Thread.UncaughtExceptionHandler { + private val config: PostHogConfig + private val adapterExceptionHandler: UncaughtExceptionHandlerAdapter + private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null + private var postHog: PostHogInterface? = null + + public constructor(config: PostHogConfig) { + this.config = config + this.adapterExceptionHandler = UncaughtExceptionHandlerAdapter.Adapter.getInstance() + } + + internal constructor(config: PostHogConfig, adapterExceptionHandler: UncaughtExceptionHandlerAdapter) { + this.config = config + this.adapterExceptionHandler = adapterExceptionHandler + } + + private companion object { + @Volatile + private var integrationInstalled = false + } + + override fun install(postHog: PostHogInterface) { + if (integrationInstalled) { + return + } + + if (!config.errorTrackingConfig.autoCapture) { + return + } + + this.postHog = postHog + + val currentExceptionHandler = adapterExceptionHandler.getDefaultUncaughtExceptionHandler() + + if (currentExceptionHandler != null) { + if (currentExceptionHandler !is PostHogErrorTrackingAutoCaptureIntegration) { + defaultExceptionHandler = currentExceptionHandler + installHandler() + } + // we don't install if the handler is us already + } else { + defaultExceptionHandler = null + installHandler() + } + } + + private fun installHandler() { + adapterExceptionHandler.setDefaultUncaughtExceptionHandler(this) + integrationInstalled = true + config.logger.log("Exception autocapture is enabled.") + } + + override fun uninstall() { + if (!integrationInstalled) { + return + } + adapterExceptionHandler.setDefaultUncaughtExceptionHandler(defaultExceptionHandler) + integrationInstalled = false + postHog = null + config.logger.log("Exception autocapture is disabled.") + } + + override fun uncaughtException( + thread: Thread, + throwable: Throwable, + ) { + postHog?.let { postHog -> + postHog.captureException(PostHogThrowable(throwable, thread)) + postHog.flush() + } + + defaultExceptionHandler?.uncaughtException(thread, throwable) + } +} diff --git a/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt new file mode 100644 index 00000000..49f441cf --- /dev/null +++ b/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingConfig.kt @@ -0,0 +1,30 @@ +package com.posthog.errortracking + +import com.posthog.PostHog + +public class PostHogErrorTrackingConfig + @JvmOverloads + public constructor( + /** + * Enable autocapture of exceptions + * This feature installs an uncaught exception handler (Thread.UncaughtExceptionHandler) that will capture exceptions + * + * Disabled by default + * + * You can manually capture exceptions by calling [PostHog.captureException] + */ + public var autoCapture: Boolean = false, + /** + * List of package names to be considered inApp frames for error tracking + * + * inApp Example: + * inAppIncludes=["com.yourapp"] + * All Exception stacktrace frames that start with com.yourapp will be considered inApp* + * + * On Android only frames coming from the app's package name will be considered inApp by default + * On Android, We add your app's package name to this list automatically (read from applicationId at runtime) + * + * If this list of package names is empty, all frames will be considered inApp + */ + public val inAppIncludes: MutableList = mutableListOf(), + ) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 4079f9b1..a6b745e6 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -48,60 +48,84 @@ internal class PostHogQueue( private val delay: Long get() = (config.flushIntervalSeconds * 1000).toLong() - override fun add(event: PostHogEvent) { - executor.executeSafely { - var removeFirst = false - if (deque.size >= config.maxQueueSize) { - removeFirst = true + private fun addEventSync(event: PostHogEvent): Boolean { + storagePrefix?.let { + val dir = File(it, config.apiKey) + + if (!dirCreated) { + dir.mkdirs() + dirCreated = true } - if (removeFirst) { - try { - val first: File - synchronized(dequeLock) { - first = deque.removeFirst() - } - first.deleteSafely(config) - config.logger.log("Queue is full, the oldest event ${first.name} is dropped.") - } catch (ignored: NoSuchElementException) { + val uuid = event.uuid ?: TimeBasedEpochGenerator.generate() + val file = File(dir, "$uuid.event") + synchronized(dequeLock) { + deque.add(file) + } + + try { + val os = config.encryption?.encrypt(file.outputStream()) ?: file.outputStream() + os.use { theOutputStream -> + config.serializer.serialize(event, theOutputStream.writer().buffered()) } + config.logger.log("Queued Event ${event.event}: ${file.name}.") + + return true + } catch (e: Throwable) { + config.logger.log("Event ${event.event}: ${file.name} failed to parse: $e.") + + // if for some reason the file failed to serialize, lets delete it + file.deleteSafely(config) } - storagePrefix?.let { - val dir = File(it, config.apiKey) + return false + } - if (!dirCreated) { - dir.mkdirs() - dirCreated = true - } + // if there's no storagePrefix, we assume it failed + return true + } - val uuid = event.uuid ?: TimeBasedEpochGenerator.generate() - val file = File(dir, "$uuid.event") + private fun removeEventSync() { + if (deque.size >= config.maxQueueSize) { + try { + val first: File synchronized(dequeLock) { - deque.add(file) + first = deque.removeFirst() } + first.deleteSafely(config) + config.logger.log("Queue is full, the oldest event ${first.name} is dropped.") + } catch (ignored: NoSuchElementException) { + } + } + } - try { - val os = config.encryption?.encrypt(file.outputStream()) ?: file.outputStream() - os.use { theOutputStream -> - config.serializer.serialize(event, theOutputStream.writer().buffered()) - } - config.logger.log("Queued Event ${event.event}: ${file.name}.") - - flushIfOverThreshold() - } catch (e: Throwable) { - config.logger.log("Event ${event.event}: ${file.name} failed to parse: $e.") + private fun flushEventSync( + event: PostHogEvent, + isFatal: Boolean = false, + ) { + removeEventSync() + if (addEventSync(event)) { + // this is best effort since we dont know if theres + // enough time to flush events to the wire + flushIfOverThreshold(isFatal) + } + } - // if for some reason the file failed to serialize, lets delete it - file.deleteSafely(config) - } + override fun add(event: PostHogEvent) { + if (event.isFatalExceptionEvent()) { + executor.submitSyncSafely { + flushEventSync(event, true) + } + } else { + executor.executeSafely { + flushEventSync(event) } } } - private fun flushIfOverThreshold() { + private fun flushIfOverThreshold(isFatal: Boolean) { if (isAboveThreshold(config.flushAt)) { - flushBatch() + flushBatch(isFatal) } } @@ -132,8 +156,8 @@ internal class PostHogQueue( return events } - private fun flushBatch() { - if (!canFlushBatch()) { + private fun flushBatch(isFatal: Boolean) { + if (!isFatal && !canFlushBatch()) { config.logger.log("Cannot flush the Queue.") return } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogUtils.kt b/posthog/src/main/java/com/posthog/internal/PostHogUtils.kt index 316a8dc6..dae9079d 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogUtils.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogUtils.kt @@ -8,6 +8,7 @@ import java.io.InterruptedIOException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService private fun isRequestCanceled(throwable: Throwable): Boolean { return throwable is IOException && @@ -46,6 +47,15 @@ internal fun File.existsSafely(config: PostHogConfig): Boolean { } } +@PostHogInternal +public fun ExecutorService.submitSyncSafely(run: Runnable) { + try { + // can throw RejectedExecutionException, InterruptedException and more + submit(run).get() + } catch (ignored: Throwable) { + } +} + @PostHogInternal public fun Executor.executeSafely(run: Runnable) { try { diff --git a/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt b/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt new file mode 100644 index 00000000..8223502b --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/errortracking/PostHogThrowable.kt @@ -0,0 +1,7 @@ +package com.posthog.internal.errortracking + +internal class PostHogThrowable(throwable: Throwable, val thread: Thread = Thread.currentThread()) : Throwable(throwable) { + val handled: Boolean = false + val isFatal: Boolean = true + val mechanism: String = "UncaughtExceptionHandler" +} diff --git a/posthog/src/main/java/com/posthog/internal/exceptions/ThrowableCoercer.kt b/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt similarity index 81% rename from posthog/src/main/java/com/posthog/internal/exceptions/ThrowableCoercer.kt rename to posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt index 061420c4..89224d05 100644 --- a/posthog/src/main/java/com/posthog/internal/exceptions/ThrowableCoercer.kt +++ b/posthog/src/main/java/com/posthog/internal/errortracking/ThrowableCoercer.kt @@ -1,4 +1,4 @@ -package com.posthog.internal.exceptions +package com.posthog.internal.errortracking internal class ThrowableCoercer { private fun isInApp( @@ -22,21 +22,33 @@ internal class ThrowableCoercer { fun fromThrowableToPostHogProperties( throwable: Throwable, inAppIncludes: List = listOf(), - handled: Boolean = true, - isFatal: Boolean = false, ): MutableMap { val exceptions = mutableListOf>() val throwableList = mutableListOf() val circularDetector = hashSetOf() + var handled = true + var isFatal = false + var mechanismType = "generic" + var currentThrowable: Throwable? = throwable + val threadId: Long + + if (throwable is PostHogThrowable) { + handled = throwable.handled + isFatal = throwable.isFatal + mechanismType = throwable.mechanism + currentThrowable = throwable.cause + threadId = throwable.thread.id + } else { + threadId = Thread.currentThread().id + } + while (currentThrowable != null && circularDetector.add(currentThrowable)) { throwableList.add(currentThrowable) currentThrowable = currentThrowable.cause } - val threadId = Thread.currentThread().id - throwableList.forEach { theThrowable -> val thePackage = theThrowable.javaClass.`package` val theClass = theThrowable.javaClass.name @@ -84,10 +96,11 @@ internal class ThrowableCoercer { mapOf( "handled" to handled, "synthetic" to false, - "type" to "generic", + "type" to mechanismType, ), "thread_id" to threadId, ) + if (theThrowable.message?.isNotEmpty() == true) { exception["value"] = theThrowable.message } @@ -105,7 +118,7 @@ internal class ThrowableCoercer { val exceptionProperties = mutableMapOf( - "\$exception_level" to if (isFatal) "fatal" else "error", + EXCEPTION_LEVEL_ATTRIBUTE to if (isFatal) EXCEPTION_LEVEL_FATAL else "error", ) if (exceptions.isNotEmpty()) { @@ -114,4 +127,9 @@ internal class ThrowableCoercer { return exceptionProperties } + + internal companion object { + const val EXCEPTION_LEVEL_FATAL = "fatal" + const val EXCEPTION_LEVEL_ATTRIBUTE = "\$exception_level" + } } diff --git a/posthog/src/main/java/com/posthog/internal/errortracking/UncaughtExceptionHandlerAdapter.kt b/posthog/src/main/java/com/posthog/internal/errortracking/UncaughtExceptionHandlerAdapter.kt new file mode 100644 index 00000000..92c4fc9c --- /dev/null +++ b/posthog/src/main/java/com/posthog/internal/errortracking/UncaughtExceptionHandlerAdapter.kt @@ -0,0 +1,23 @@ +package com.posthog.internal.errortracking + +internal interface UncaughtExceptionHandlerAdapter { + fun getDefaultUncaughtExceptionHandler(): Thread.UncaughtExceptionHandler? + + fun setDefaultUncaughtExceptionHandler(exceptionHandler: Thread.UncaughtExceptionHandler?) + + class Adapter private constructor() : UncaughtExceptionHandlerAdapter { + companion object { + fun getInstance(): UncaughtExceptionHandlerAdapter = INSTANCE + + private val INSTANCE = Adapter() + } + + override fun getDefaultUncaughtExceptionHandler(): Thread.UncaughtExceptionHandler? { + return Thread.getDefaultUncaughtExceptionHandler() + } + + override fun setDefaultUncaughtExceptionHandler(exceptionHandler: Thread.UncaughtExceptionHandler?) { + Thread.setDefaultUncaughtExceptionHandler(exceptionHandler) + } + } +} diff --git a/posthog/src/test/java/com/posthog/PostHogEventTest.kt b/posthog/src/test/java/com/posthog/PostHogEventTest.kt new file mode 100644 index 00000000..fee0e95f --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogEventTest.kt @@ -0,0 +1,60 @@ +package com.posthog + +import com.posthog.internal.errortracking.ThrowableCoercer +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class PostHogEventTest { + @Test + fun `isExceptionEvent returns true for exception events`() { + val event = + PostHogEvent( + event = PostHogEventName.EXCEPTION.event, + distinctId = "test-user-id", + ) + + assertTrue(event.isExceptionEvent()) + } + + @Test + fun `isExceptionEvent returns false for non-exception events`() { + val event = + PostHogEvent( + event = "custom_event", + distinctId = "test-user-id", + ) + + assertFalse(event.isExceptionEvent()) + } + + @Test + fun `isFatalExceptionEvent returns true for fatal exception events`() { + val event = + PostHogEvent( + event = PostHogEventName.EXCEPTION.event, + distinctId = "test-user-id", + properties = + mutableMapOf( + ThrowableCoercer.EXCEPTION_LEVEL_ATTRIBUTE to ThrowableCoercer.EXCEPTION_LEVEL_FATAL, + ), + ) + + assertTrue(event.isFatalExceptionEvent()) + } + + @Test + fun `isFatalExceptionEvent returns false for non-fatal exception events`() { + val event = + PostHogEvent( + event = PostHogEventName.EXCEPTION.event, + distinctId = "test-user-id", + properties = + mutableMapOf( + ThrowableCoercer.EXCEPTION_LEVEL_ATTRIBUTE to "error", + ), + ) + + assertFalse(event.isFatalExceptionEvent()) + } +} diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 4e30983b..f2872149 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -9,6 +9,7 @@ import com.posthog.internal.PostHogSendCachedEventsIntegration import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory +import com.posthog.internal.errortracking.PostHogThrowable import com.posthog.vendor.uuid.TimeBasedEpochGenerator import okhttp3.mockwebserver.MockResponse import org.junit.Rule @@ -75,7 +76,7 @@ internal class PostHogTest { if (beforeSend != null) { addBeforeSend(beforeSend) } - this.inAppIncludes.add("com.posthog") + this.errorTrackingConfig.inAppIncludes.add("com.posthog") } return PostHog.withInternal( config, @@ -1776,4 +1777,73 @@ internal class PostHogTest { sut.close() } + + @Test + fun `captureException unwraps and captures exception with correct properties`() { + val http = mockHttp() + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false) + + val causeException = RuntimeException("Test exception message") + val thread = Thread() + val exception = PostHogThrowable(causeException, thread = thread) + val additionalProperties = mapOf("custom_key" to "custom_value") + + sut.captureException(exception, additionalProperties) + + queueExecutor.shutdownAndAwaitTermination() + + assertEquals(1, http.requestCount) + + val request = http.takeRequest() + val content = request.body.unGzip() + val batchEvents = serializer.deserialize(content.reader()) + + val event = batchEvents.batch.first() + assertEquals(PostHogEventName.EXCEPTION.event, event.event) + + val properties = event.properties ?: emptyMap() + + // Verify basic exception properties + assertEquals("fatal", properties["\$exception_level"]) + assertEquals("custom_value", properties["custom_key"]) + + // Verify exception list structure - should contain both main exception and cause + val exceptionList = properties["\$exception_list"] as List<*> + assertEquals(1, exceptionList.size) + + // The ThrowableCoercer processes the exception chain with main exception first, then cause + val exceptions = exceptionList.map { it as Map<*, *> } + + // The first exception should be the main RuntimeException + val mainException = exceptions[0] + assertEquals("RuntimeException", mainException["type"]) + assertEquals("Test exception message", mainException["value"]) + + assertEquals(thread.id, (mainException["thread_id"] as Number).toLong()) + + // Verify mechanism structure for main exception + val mechanism = mainException["mechanism"] as Map<*, *> + assertEquals(false, mechanism["handled"]) + assertEquals(false, mechanism["synthetic"]) + assertEquals("UncaughtExceptionHandler", mechanism["type"]) + + // Verify stack trace structure for main exception + val stackTraceMainException = mainException["stacktrace"] as Map<*, *> + assertEquals("raw", stackTraceMainException["type"]) + + val framesMainException = stackTraceMainException["frames"] as List<*> + assertTrue(framesMainException.isNotEmpty()) + + // Verify first frame structure + val firstFrameMainException = framesMainException.first() as Map<*, *> + assertTrue(firstFrameMainException.containsKey("module")) + assertTrue(firstFrameMainException.containsKey("function")) + assertEquals("java", firstFrameMainException["platform"]) + assertTrue(firstFrameMainException["in_app"] as Boolean) + assertTrue(firstFrameMainException["lineno"] is Number) + + sut.close() + } } diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt new file mode 100644 index 00000000..17b34531 --- /dev/null +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -0,0 +1,157 @@ +package com.posthog.errortracking + +import com.posthog.PostHogConfig +import com.posthog.PostHogInterface +import com.posthog.internal.PostHogPrintLogger +import com.posthog.internal.errortracking.PostHogThrowable +import com.posthog.internal.errortracking.UncaughtExceptionHandlerAdapter +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.BeforeTest +import kotlin.test.Test + +internal class PostHogErrorTrackingAutoCaptureIntegrationTest { + private val mockConfig = mock() + private val mockPostHog = mock() + private val mockAdapter = mock() + private val mockLogger = mock() + private val mockExceptionHandler = mock() + + @BeforeTest + fun setUp() { + whenever(mockConfig.logger).thenReturn(mockLogger) + } + + private fun getSut(autoCapture: Boolean = true): PostHogErrorTrackingAutoCaptureIntegration { + whenever(mockConfig.errorTrackingConfig).thenReturn( + PostHogErrorTrackingConfig().apply { + this.autoCapture = autoCapture + }, + ) + return PostHogErrorTrackingAutoCaptureIntegration(mockConfig, mockAdapter) + } + + @Test + fun `install does nothing when already installed`() { + val integration = getSut() + + // First install + integration.install(mockPostHog) + + // Second install should do nothing + integration.install(mockPostHog) + + verify(mockAdapter, times(1)).setDefaultUncaughtExceptionHandler(integration) + + integration.uninstall() + } + + @Test + fun `install does nothing when autoCapture is disabled`() { + val integration = getSut(false) + + integration.install(mockPostHog) + + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } + + @Test + fun `install sets up exception handler when current handler is null`() { + whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(null) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) + + integration.uninstall() + } + + @Test + fun `install sets up exception handler when current handler is different`() { + whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter).setDefaultUncaughtExceptionHandler(integration) + + integration.uninstall() + } + + @Test + fun `install does not replace current handler when it is already PostHogErrorTrackingAutoCaptureIntegration`() { + val existingIntegration = getSut() + whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(existingIntegration) + + val integration = getSut() + integration.install(mockPostHog) + + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } + + @Test + fun `uninstall does nothing when not installed`() { + val integration = getSut() + + integration.uninstall() + + verify(mockAdapter, never()).setDefaultUncaughtExceptionHandler(any()) + + integration.uninstall() + } + + @Test + fun `uninstall restores original exception handler and resets state`() { + whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + + val integration = getSut() + integration.install(mockPostHog) + integration.uninstall() + + verify(mockAdapter).setDefaultUncaughtExceptionHandler(mockExceptionHandler) + + integration.uninstall() + } + + @Test + fun `uncaughtException captures exception and flushes when postHog is available`() { + val thread = Thread.currentThread() + val throwable = RuntimeException("Test exception") + + val integration = getSut() + integration.install(mockPostHog) + + integration.uncaughtException(thread, throwable) + + verify(mockPostHog).captureException(any(), anyOrNull()) + + integration.uninstall() + } + + @Test + fun `uncaughtException calls default handler after capturing exception`() { + whenever(mockAdapter.getDefaultUncaughtExceptionHandler()).thenReturn(mockExceptionHandler) + + val thread = Thread.currentThread() + val throwable = RuntimeException("Test exception") + + val integration = getSut() + integration.install(mockPostHog) + + integration.uncaughtException(thread, throwable) + + verify(mockExceptionHandler).uncaughtException(thread, throwable) + + integration.uninstall() + } +} diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt index 69560916..ecd2430f 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt @@ -3,8 +3,11 @@ package com.posthog.internal import com.google.gson.internal.bind.util.ISO8601Utils import com.posthog.API_KEY import com.posthog.PostHogConfig +import com.posthog.PostHogEvent +import com.posthog.PostHogEventName import com.posthog.awaitExecution import com.posthog.generateEvent +import com.posthog.internal.errortracking.ThrowableCoercer import com.posthog.mockHttp import com.posthog.shutdownAndAwaitTermination import okhttp3.mockwebserver.MockResponse @@ -364,4 +367,24 @@ internal class PostHogQueueTest { assertTrue(deleteFilesIfAPIError(e, config)) } + + @Test + fun `flush the event right away if exception and fatal`() { + val http = mockHttp() + val url = http.url("/") + + val fakeCurrentTime = FakePostHogDateProvider() + val path = tmpDir.newFolder().absolutePath + val sut = getSut(host = url.toString(), flushAt = 1, storagePrefix = path, dateProvider = fakeCurrentTime) + + val props = mutableMapOf(ThrowableCoercer.EXCEPTION_LEVEL_ATTRIBUTE to ThrowableCoercer.EXCEPTION_LEVEL_FATAL) + val event = PostHogEvent(PostHogEventName.EXCEPTION.event, "123", properties = props) + + sut.add(event) + + // we dont call shutdownAndAwaitTermination here + + assertEquals(0, sut.dequeList.size) + assertEquals(0, File(path, API_KEY).listFiles()!!.size) + } }