diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt index cf51c55..3009d63 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/MainActivity.kt @@ -80,6 +80,9 @@ class MainActivity : FragmentActivity(), SingletonImageLoader.Factory { // Initialize PostHog Analytics PostHogAnalytics.initialize(application, debug = true) // Set debug=false for release + // Install uncaught exception handler to capture crashes to PostHog + PostHogAnalytics.setupExceptionHandler() + // Handle deep link, notification navigation, and file import from intent handleDeepLink(intent) handleNotificationNavigation(intent) diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.android.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.android.kt index 873bdeb..85ccb83 100644 --- a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.android.kt +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.android.kt @@ -1,6 +1,7 @@ package com.tt.honeyDue.analytics import android.app.Application +import android.util.Log import com.posthog.PostHog import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig @@ -13,6 +14,8 @@ actual object PostHogAnalytics { private const val API_KEY = "YOUR_POSTHOG_API_KEY" private const val HOST = "https://us.i.posthog.com" + private const val TAG = "PostHogAnalytics" + private var isInitialized = false private var application: Application? = null @@ -57,6 +60,28 @@ actual object PostHogAnalytics { PostHog.capture(event, properties = properties) } + /** + * Capture an exception/crash as a PostHog `$exception` event. + * Uses PostHog's standard exception property names so exceptions + * appear correctly in the PostHog Errors dashboard. + */ + actual fun captureException(throwable: Throwable, properties: Map?) { + if (!isInitialized) return + try { + val exceptionProps = mutableMapOf( + "\$exception_type" to (throwable::class.simpleName ?: "Unknown"), + "\$exception_message" to (throwable.message ?: "No message"), + "\$exception_stack_trace_raw" to throwable.stackTraceToString() + ) + if (properties != null) { + exceptionProps.putAll(properties) + } + PostHog.capture("\$exception", properties = exceptionProps) + } catch (e: Exception) { + Log.e(TAG, "Failed to capture exception to PostHog", e) + } + } + actual fun screen(screenName: String, properties: Map?) { if (!isInitialized) return PostHog.screen(screenName, properties = properties) @@ -71,4 +96,36 @@ actual object PostHogAnalytics { if (!isInitialized) return PostHog.flush() } + + /** + * Install an uncaught exception handler that captures crashes to PostHog + * before delegating to the default handler (which shows the crash dialog). + * Call this after initialize() in MainActivity.onCreate(). + */ + actual fun setupExceptionHandler() { + if (!isInitialized) return + + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + PostHog.capture( + event = "\$exception", + properties = mapOf( + "\$exception_type" to (throwable::class.simpleName ?: "Unknown"), + "\$exception_message" to (throwable.message ?: "No message"), + "\$exception_stack_trace_raw" to throwable.stackTraceToString(), + "\$exception_thread" to thread.name, + "\$exception_is_fatal" to true + ) + ) + // Flush to ensure the event is sent before the process dies + PostHog.flush() + } catch (_: Exception) { + // Don't let our crash handler crash + } + // Call the default handler so the system crash dialog still appears + defaultHandler?.uncaughtException(thread, throwable) + } + Log.d(TAG, "Uncaught exception handler installed") + } } diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt index ed81506..1adb51d 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/analytics/Analytics.kt @@ -8,9 +8,11 @@ expect object PostHogAnalytics { fun initialize() fun identify(userId: String, properties: Map? = null) fun capture(event: String, properties: Map? = null) + fun captureException(throwable: Throwable, properties: Map? = null) fun screen(screenName: String, properties: Map? = null) fun reset() fun flush() + fun setupExceptionHandler() } /** diff --git a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.ios.kt b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.ios.kt index d65c3fe..4d6db3b 100644 --- a/composeApp/src/iosMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.ios.kt @@ -18,6 +18,10 @@ actual object PostHogAnalytics { // iOS uses Swift PostHogAnalytics.shared.capture() directly } + actual fun captureException(throwable: Throwable, properties: Map?) { + // iOS exception capture is done in Swift via AnalyticsManager + } + actual fun screen(screenName: String, properties: Map?) { // iOS uses Swift PostHogAnalytics.shared.screen() directly } @@ -29,4 +33,8 @@ actual object PostHogAnalytics { actual fun flush() { // iOS uses Swift PostHogAnalytics.shared.flush() directly } + + actual fun setupExceptionHandler() { + // iOS exception handler is set up in Swift via AnalyticsManager.setupExceptionHandler() + } } diff --git a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.js.kt b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.js.kt index 03e564a..87a7b77 100644 --- a/composeApp/src/jsMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.js.kt +++ b/composeApp/src/jsMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.js.kt @@ -17,6 +17,10 @@ actual object PostHogAnalytics { // No-op for web } + actual fun captureException(throwable: Throwable, properties: Map?) { + // No-op for web + } + actual fun screen(screenName: String, properties: Map?) { // No-op for web } @@ -28,4 +32,8 @@ actual object PostHogAnalytics { actual fun flush() { // No-op for web } + + actual fun setupExceptionHandler() { + // No-op for web + } } diff --git a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.jvm.kt b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.jvm.kt index dcc0924..07f58b6 100644 --- a/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.jvm.kt @@ -17,6 +17,10 @@ actual object PostHogAnalytics { // No-op for desktop } + actual fun captureException(throwable: Throwable, properties: Map?) { + // No-op for desktop + } + actual fun screen(screenName: String, properties: Map?) { // No-op for desktop } @@ -28,4 +32,8 @@ actual object PostHogAnalytics { actual fun flush() { // No-op for desktop } + + actual fun setupExceptionHandler() { + // No-op for desktop + } } diff --git a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.wasmJs.kt index ffac5fc..34af5cc 100644 --- a/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.wasmJs.kt +++ b/composeApp/src/wasmJsMain/kotlin/com/tt/honeyDue/analytics/PostHogAnalytics.wasmJs.kt @@ -17,6 +17,10 @@ actual object PostHogAnalytics { // No-op for web } + actual fun captureException(throwable: Throwable, properties: Map?) { + // No-op for web + } + actual fun screen(screenName: String, properties: Map?) { // No-op for web } @@ -28,4 +32,8 @@ actual object PostHogAnalytics { actual fun flush() { // No-op for web } + + actual fun setupExceptionHandler() { + // No-op for web + } } diff --git a/iosApp/iosApp/Analytics/AnalyticsManager.swift b/iosApp/iosApp/Analytics/AnalyticsManager.swift index 42de312..ae04b98 100644 --- a/iosApp/iosApp/Analytics/AnalyticsManager.swift +++ b/iosApp/iosApp/Analytics/AnalyticsManager.swift @@ -91,6 +91,47 @@ final class AnalyticsManager { PostHogSDK.shared.capture("screen_viewed", properties: props) } + // MARK: - Exception / Crash Capture + + /// Capture a Swift Error as a PostHog `$exception` event. + func captureException(_ error: Error, properties: [String: Any]? = nil) { + guard isConfigured else { return } + var props: [String: Any] = [ + "$exception_type": String(describing: type(of: error)), + "$exception_message": error.localizedDescription, + "$exception_stack_trace_raw": Thread.callStackSymbols.joined(separator: "\n") + ] + if let properties { props.merge(properties) { _, new in new } } + PostHogSDK.shared.capture("$exception", properties: props) + } + + /// Capture an NSException as a PostHog `$exception` event. + func captureNSException(_ exception: NSException, isFatal: Bool = false) { + guard isConfigured else { return } + PostHogSDK.shared.capture("$exception", properties: [ + "$exception_type": exception.name.rawValue, + "$exception_message": exception.reason ?? "No reason", + "$exception_stack_trace_raw": exception.callStackSymbols.joined(separator: "\n"), + "$exception_is_fatal": isFatal + ]) + } + + /// Install an uncaught NSException handler that captures crashes to PostHog + /// before the app terminates. Call this once after configure(). + func setupExceptionHandler() { + NSSetUncaughtExceptionHandler { exception in + // Cannot use AnalyticsManager.shared here (may deadlock on @MainActor), + // so call PostHogSDK directly. + PostHogSDK.shared.capture("$exception", properties: [ + "$exception_type": exception.name.rawValue, + "$exception_message": exception.reason ?? "No reason", + "$exception_stack_trace_raw": exception.callStackSymbols.joined(separator: "\n"), + "$exception_is_fatal": true + ]) + PostHogSDK.shared.flush() + } + } + // MARK: - Flush & Reset func flush() { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index d045cc8..4deb5fb 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -55,6 +55,9 @@ struct iOSApp: App { if !UITestRuntime.isEnabled { // Initialize PostHog Analytics (must use Swift AnalyticsManager, not the Kotlin stub) AnalyticsManager.shared.configure() + + // Install uncaught exception handler to capture crashes to PostHog + AnalyticsManager.shared.setupExceptionHandler() } }