diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fd89ad1d..c7d1b81c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @marandaneto +* @PostHog/team-mobile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6454594..4935167e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,18 +12,23 @@ jobs: build: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: 'Set up Java' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: - java-version: 11 + java-version: 17 distribution: 'temurin' - uses: dart-lang/setup-dart@v1 - uses: subosito/flutter-action@v2 with: channel: 'stable' + + - name: Select Xcode version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable - name: Install dependencies run: | diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 68e3645b..c134c108 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -8,7 +8,7 @@ jobs: name: Changelog runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - run: npx danger ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 666d6cd7..b9927ae7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest # use dart-lang/setup-dart/.github/workflows/publish.yml@v1 when https://github.com/dart-lang/setup-dart/issues/68 is fixed steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dart-lang/setup-dart@v1 - uses: subosito/flutter-action@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3182f401..dd4feb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ ## Next +## 5.9.0 + +- feat: add autocapture exceptions ([#214](https://github.com/PostHog/posthog-flutter/pull/214)) + - **Limitations**: + - No Flutter web support + - No native iOS exception capture + - No native C/C++ exception capture on Android (Java/Kotlin only) + - No stacktrace demangling for obfuscated builds ([--obfuscate](https://docs.flutter.dev/deployment/obfuscate) and [--split-debug-info](https://docs.flutter.dev/deployment/obfuscate)) for Dart code and [isMinifyEnabled](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization) for Java/Kotlin code + - No [source code context](/docs/error-tracking/stack-traces) + - No background isolate error capture + +## 5.8.0 + +- feat: surveys GA ([#215](https://github.com/PostHog/posthog-flutter/pull/215)) +> Note: Surveys are now enabled by default. + +## 5.7.0 + +- feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212)) + - **Note**: The following features are not yet supported: + - Automatic exception capture + - De-obfuscating stacktraces from obfuscated builds ([--obfuscate](https://docs.flutter.dev/deployment/obfuscate) and [--split-debug-info](https://docs.flutter.dev/deployment/obfuscate)) + - [Source code context](/docs/error-tracking/stack-traces) associated with an exception + - Flutter web support + - **BREAKING**: Minimum Dart SDK version bumped to 3.4.0 and Flutter to 3.22.0 (required for `stack_trace` dependency compatibility) + +## 5.6.0 + +- feat: surveys use the new response question id format ([#210](https://github.com/PostHog/posthog-flutter/pull/210)) + +## 5.5.0 + +- chore: Android plugin sets compileSdkVersion to flutter.compileSdkVersion instead of hardcoded ([#207](https://github.com/PostHog/posthog-flutter/pull/207)) + +## 5.4.3 + +- fix: Android back button wasn't cleaning up the Survey resources ([#205](https://github.com/PostHog/posthog-flutter/pull/205)) + +## 5.4.2 + +- fix: mask TextField widgets automatically if obscureText is enabled ([#204](https://github.com/PostHog/posthog-flutter/pull/204)) + +## 5.4.1 + +- chore: update posthog-ios dependency to min. 3.31.0 ([#202](https://github.com/PostHog/posthog-flutter/pull/202)) + +## 5.4.0 + +- feat: surveys for Android ([#198](https://github.com/PostHog/posthog-flutter/pull/198)) + - See how to setup in [Surveys docs](https://posthog.com/docs/surveys/installation?tab=Flutter) + +## 5.3.1 + +- fix: don't render HTML content ([#196](https://github.com/PostHog/posthog-flutter/pull/196)) + +## 5.3.0 + +- chore: update languageVersion and apiVersion from 1.6 to 1.8 on Android to be compatible with Kotlin 2.2 ([#193](https://github.com/PostHog/posthog-flutter/pull/193)) + +## 5.2.0 + +- feat: add `isOptOut` method to check if the current user is opted out of data capture. ([#190](https://github.com/PostHog/posthog-flutter/pull/190)) + ## 5.1.0 - feat: surveys for iOS ([#188](https://github.com/PostHog/posthog-flutter/pull/188)) diff --git a/Makefile b/Makefile index 6144c345..5a0475c8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: formatKotlin formatSwift formatDart checkDart installLinters +.PHONY: format formatKotlin formatSwift formatDart checkDart installLinters test + +format: formatSwift formatKotlin formatDart installLinters: brew install ktlint @@ -19,3 +21,6 @@ checkFormatDart: analyzeDart: dart analyze . + +test: + flutter test -r expanded diff --git a/android/build.gradle b/android/build.gradle index 6a795091..97084418 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -29,7 +29,7 @@ android { namespace 'com.posthog.flutter' } - compileSdkVersion 33 + compileSdkVersion flutter.compileSdkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -54,8 +54,8 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - // + Version 3.+ and the versions up to 4.0, not including 4.0 and higher - implementation 'com.posthog:posthog-android:3.+' + // + Version 3.25.0 and the versions up to 4.0.0, not including 4.0.0 and higher + implementation 'com.posthog:posthog-android:[3.25.0,4.0.0]' } testOptions { diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt new file mode 100644 index 00000000..d8a64350 --- /dev/null +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt @@ -0,0 +1,89 @@ +package com.posthog.flutter + +import com.posthog.surveys.PostHogDisplayChoiceQuestion +import com.posthog.surveys.PostHogDisplayLinkQuestion +import com.posthog.surveys.PostHogDisplayOpenQuestion +import com.posthog.surveys.PostHogDisplayRatingQuestion +import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogDisplaySurveyQuestion + +// Convert the survey object to a map for communication with the Dart layer +// Native platform model -> Map -> Dart model +fun PostHogDisplaySurvey.toMap(): Map { + val map = + mutableMapOf( + "id" to id, + "name" to name, + "questions" to + questions.map { question: PostHogDisplaySurveyQuestion -> + val questionMap = + mutableMapOf( + "question" to question.question, + "isOptional" to question.isOptional, + "id" to question.id, + ) + + questionMap["questionDescription"] = question.questionDescription + questionMap["questionDescriptionContentType"] = question.questionDescriptionContentType?.value + questionMap["buttonText"] = question.buttonText + + // Add question type-specific properties + when (question) { + is PostHogDisplayLinkQuestion -> { + questionMap["type"] = "link" + questionMap["link"] = question.link + } + is PostHogDisplayRatingQuestion -> { + questionMap["type"] = "rating" + questionMap["ratingType"] = question.ratingType.value + questionMap["scaleLowerBound"] = question.scaleLowerBound + questionMap["scaleUpperBound"] = question.scaleUpperBound + questionMap["lowerBoundLabel"] = question.lowerBoundLabel + questionMap["upperBoundLabel"] = question.upperBoundLabel + } + is PostHogDisplayChoiceQuestion -> { + questionMap["type"] = if (question.isMultipleChoice) "multiple_choice" else "single_choice" + questionMap["choices"] = question.choices + questionMap["hasOpenChoice"] = question.hasOpenChoice + questionMap["shuffleOptions"] = question.shuffleOptions + } + else -> { + questionMap["type"] = "open" + } + } + + questionMap + }, + ) + + // Add appearance if available + appearance?.let { app -> + map["appearance"] = + mapOf( + "backgroundColor" to app.backgroundColor, + "submitButtonColor" to app.submitButtonColor, + "submitButtonText" to app.submitButtonText, + "submitButtonTextColor" to app.submitButtonTextColor, + "descriptionTextColor" to app.descriptionTextColor, + "ratingButtonColor" to app.ratingButtonColor, + "ratingButtonActiveColor" to app.ratingButtonActiveColor, + "borderColor" to app.borderColor, + "placeholder" to app.placeholder, + "displayThankYouMessage" to app.displayThankYouMessage, + "thankYouMessageHeader" to app.thankYouMessageHeader, + "thankYouMessageDescription" to app.thankYouMessageDescription, + "thankYouMessageDescriptionContentType" to app.thankYouMessageDescriptionContentType?.value, + ) + } + + // Add dates if available (convert to milliseconds since epoch) + startDate?.let { date -> + map["startDate"] = date.time + } + + endDate?.let { date -> + map["endDate"] = date.time + } + + return map +} diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogFlutterSurveysDelegate.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogFlutterSurveysDelegate.kt new file mode 100644 index 00000000..b1701855 --- /dev/null +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogFlutterSurveysDelegate.kt @@ -0,0 +1,146 @@ +package com.posthog.flutter + +import android.os.Handler +import android.os.Looper +import com.posthog.surveys.OnPostHogSurveyClosed +import com.posthog.surveys.OnPostHogSurveyResponse +import com.posthog.surveys.OnPostHogSurveyShown +import com.posthog.surveys.PostHogDisplayChoiceQuestion +import com.posthog.surveys.PostHogDisplayLinkQuestion +import com.posthog.surveys.PostHogDisplayOpenQuestion +import com.posthog.surveys.PostHogDisplayRatingQuestion +import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogDisplaySurveyQuestion +import com.posthog.surveys.PostHogNextSurveyQuestion +import com.posthog.surveys.PostHogSurveyResponse +import com.posthog.surveys.PostHogSurveysDelegate +import io.flutter.plugin.common.MethodChannel + +/** + * Separate surveys delegate to avoid class loading issues in the main plugin + */ +class PostHogFlutterSurveysDelegate( + private val channel: MethodChannel, +) : PostHogSurveysDelegate { + private var currentSurvey: PostHogDisplaySurvey? = null + private var onSurveyShownCallback: OnPostHogSurveyShown? = null + private var onSurveyResponseCallback: OnPostHogSurveyResponse? = null + private var onSurveyClosedCallback: OnPostHogSurveyClosed? = null + + override fun renderSurvey( + survey: PostHogDisplaySurvey, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + currentSurvey = survey + onSurveyShownCallback = onSurveyShown + onSurveyResponseCallback = onSurveyResponse + onSurveyClosedCallback = onSurveyClosed + + // Convert survey to map and send to Flutter + invokeFlutterMethod("showSurvey", survey.toMap()) + } + + override fun cleanupSurveys() { + currentSurvey = null + onSurveyShownCallback = null + onSurveyResponseCallback = null + onSurveyClosedCallback = null + } + + fun handleSurveyAction( + action: String, + payload: Map?, + result: MethodChannel.Result, + ) { + val survey = currentSurvey + if (survey == null) { + result.error("InvalidArguments", "No active survey", null) + return + } + + when (action) { + "shown" -> { + onSurveyShownCallback?.invoke(survey) + } + "response" -> { + val index = payload?.get("index") as? Int + val responsePayload = payload?.get("response") + + if (index != null && responsePayload != null && index < survey.questions.size) { + val question = survey.questions[index] + + // Create PostHogSurveyResponse based on question type + val surveyResponse = + when (question) { + is PostHogDisplayLinkQuestion -> { + // For link questions + val boolValue = responsePayload as? Boolean ?: false + PostHogSurveyResponse.Link(boolValue) + } + is PostHogDisplayRatingQuestion -> { + // For rating questions + val ratingValue = responsePayload as? Int + PostHogSurveyResponse.Rating(ratingValue) + } + is PostHogDisplayChoiceQuestion -> { + // For single/multiple choice questions + if (question.isMultipleChoice) { + // Multiple choice: accept array directly from Flutter + val selectedOptions = responsePayload as? List<*> + val stringOptions = selectedOptions?.mapNotNull { it as? String } + PostHogSurveyResponse.MultipleChoice(stringOptions ?: emptyList()) + } else { + // Single choice: Flutter sends as a list with one element + val selectedOptions = responsePayload as? List<*> + val firstOption = selectedOptions?.firstOrNull() as? String + PostHogSurveyResponse.SingleChoice(firstOption) + } + } + else -> { + // Default to open text question + val textValue = responsePayload as? String + PostHogSurveyResponse.Text(textValue) + } + } + + // Call the callback with the constructed response + onSurveyResponseCallback?.invoke(survey, index, surveyResponse)?.let { nextQuestion -> + result.success( + mapOf( + "nextIndex" to nextQuestion.questionIndex, + "isSurveyCompleted" to nextQuestion.isSurveyCompleted, + ), + ) + return + } + result.success(null) + return + } + } + "closed" -> { + onSurveyClosedCallback?.invoke(survey) + // Clear the callbacks after survey is closed + currentSurvey = null + onSurveyShownCallback = null + onSurveyResponseCallback = null + onSurveyClosedCallback = null + } + } + + result.success(null) + } + + /** + * Invoke a Flutter method on the main/UI thread + */ + private fun invokeFlutterMethod( + method: String, + arguments: Any? = null, + ) { + Handler(Looper.getMainLooper()).post { + channel.invokeMethod(method, arguments) + } + } +} diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 73e530b1..2e6d5ffe 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.1.0" +internal val postHogVersion = "5.9.0" diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 46486271..089743c2 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -1,7 +1,12 @@ package com.posthog.flutter +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import com.posthog.PersonProfiles import com.posthog.PostHog @@ -14,6 +19,7 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result +import java.util.Date /** PosthogFlutterPlugin */ class PosthogFlutterPlugin : @@ -29,6 +35,9 @@ class PosthogFlutterPlugin : private val snapshotSender = SnapshotSender() + // The surveys delegate + private var flutterSurveysDelegate: PostHogFlutterSurveysDelegate? = null + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "posthog_flutter") @@ -112,6 +121,10 @@ class PosthogFlutterPlugin : enable(result) } + "isOptOut" -> { + isOptOut(result) + } + "isFeatureEnabled" -> { isFeatureEnabled(call, result) } @@ -144,6 +157,9 @@ class PosthogFlutterPlugin : "flush" -> { flush(result) } + "captureException" -> { + captureException(call, result) + } "close" -> { close(result) } @@ -266,6 +282,27 @@ class PosthogFlutterPlugin : this.sessionReplayConfig.captureLogcat = false + // Configure surveys + posthogConfig.getIfNotNull("surveys") { + surveys = it + if (surveys) { + // If surveys are enabled, create and assign the surveys delegate + val delegate = PostHogFlutterSurveysDelegate(channel) + surveysConfig.surveysDelegate = delegate + flutterSurveysDelegate = delegate + } + } + + // Configure error tracking autocapture + posthogConfig.getIfNotNull>("errorTrackingConfig") { errorConfig -> + errorConfig.getIfNotNull("captureNativeExceptions") { + errorTrackingConfig.autoCapture = it + } + errorConfig.getIfNotNull>("inAppIncludes") { includes -> + errorTrackingConfig.inAppIncludes.addAll(includes) + } + } + sdkName = "posthog-flutter" sdkVersion = postHogVersion } @@ -427,6 +464,15 @@ class PosthogFlutterPlugin : } } + private fun isOptOut(result: Result) { + try { + val isOptedOut = PostHog.isOptOut() + result.success(isOptedOut) + } catch (e: Throwable) { + result.error("PosthogFlutterException", e.localizedMessage, null) + } + } + private fun isFeatureEnabled( call: MethodCall, result: Result, @@ -500,6 +546,34 @@ class PosthogFlutterPlugin : } } + private fun captureException( + call: MethodCall, + result: Result, + ) { + try { + val arguments = + call.arguments as? Map ?: run { + result.error("INVALID_ARGUMENTS", "Invalid arguments for captureException", null) + return + } + + val properties = arguments["properties"] as? Map + val timestampMs = arguments["timestamp"] as? Long + + // Extract timestamp from Flutter + val timestamp: Date? = + timestampMs?.let { + // timestampMs already in UTC milliseconds epoch + Date(timestampMs) + } + + PostHog.capture("\$exception", properties = properties, timestamp = timestamp) + result.success(null) + } catch (e: Throwable) { + result.error("CAPTURE_EXCEPTION_ERROR", "Failed to capture exception: ${e.message}", null) + } + } + private fun close(result: Result) { try { PostHog.close() @@ -533,15 +607,76 @@ class PosthogFlutterPlugin : call: MethodCall, result: Result, ) { - // TODO: Not implemented - result.success(null) + try { + val raw = (call.arguments as? String)?.trim() + if (raw.isNullOrEmpty()) { + result.error("InvalidArguments", "URL is null or empty", null) + return + } + + var uri = + try { + Uri.parse(raw) + } catch (e: Throwable) { + result.error("InvalidArguments", "Malformed URL: $raw", null) + return + } + + // If no scheme provided (e.g., "example.com"), default to https:// + if (uri.scheme.isNullOrEmpty()) { + uri = Uri.parse("https://$raw") + } + + val intent = + Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + applicationContext.startActivity(intent) + result.success(null) + } catch (e: ActivityNotFoundException) { + result.error("ActivityNotFound", "No application can handle ACTION_VIEW for the given URL", null) + } + } catch (e: Throwable) { + result.error("PosthogFlutterException", e.localizedMessage, null) + } } + private fun invokeFlutterMethod( + method: String, + arguments: Any? = null, + ) { + if (Looper.myLooper() == Looper.getMainLooper()) { + channel.invokeMethod(method, arguments) + } else { + Handler(Looper.getMainLooper()).post { + channel.invokeMethod(method, arguments) + } + } + } + + // MARK: - Survey Action Handling + private fun handleSurveyAction( call: MethodCall, result: Result, ) { - // TODO: Not implemented - result.success(null) + val args = call.arguments as? Map + val type = args?.get("type") as? String + + // Check for invalid arguments + if (args == null || type == null) { + result.error("InvalidArguments", "Invalid survey action arguments", null) + return + } + + if (flutterSurveysDelegate == null) { + result.error("InvalidArguments", "Survey delegate not available", null) + return + } + + flutterSurveysDelegate?.handleSurveyAction(type, args, result) } } diff --git a/ios/Classes/PostHogDisplaySurvey+Dict.swift b/ios/Classes/PostHogDisplaySurvey+Dict.swift index 55a1e6dc..6c2e961e 100644 --- a/ios/Classes/PostHogDisplaySurvey+Dict.swift +++ b/ios/Classes/PostHogDisplaySurvey+Dict.swift @@ -13,9 +13,12 @@ var questionDict: [String: Any] = [ "question": question.question, "isOptional": question.isOptional, + "id": question.id, ] + if let desc = question.questionDescription { questionDict["questionDescription"] = desc + questionDict["questionDescriptionContentType"] = question.questionDescriptionContentType.rawValue } if let buttonText = question.buttonText { questionDict["buttonText"] = buttonText @@ -84,6 +87,7 @@ } if let thankYouMessageDescription = appearance.thankYouMessageDescription { appearanceDict["thankYouMessageDescription"] = thankYouMessageDescription + appearanceDict["thankYouMessageDescriptionContentType"] = appearance.thankYouMessageDescriptionContentType?.rawValue } if let thankYouMessageCloseButtonText = appearance.thankYouMessageCloseButtonText { appearanceDict["thankYouMessageCloseButtonText"] = thankYouMessageCloseButtonText diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index b3136ee8..e5af7fcc 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.1.0" +let postHogFlutterVersion = "5.9.0" diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 56bf1996..95e840c0 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -1,4 +1,4 @@ -@_spi(Experimental) import PostHog +import PostHog #if os(iOS) import Flutter import UIKit @@ -134,12 +134,11 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { // configure surveys if #available(iOS 15.0, *) { - if let surveys: Bool = posthogConfig["surveys"] as? Bool { - config.surveys = surveys - if surveys { - // if surveys are enabled, assign this instance as the survey delegate (we'll take over rendering) - config.surveysConfig.surveysDelegate = instance - } + let surveys: Bool = posthogConfig["surveys"] as? Bool ?? false + config.surveys = surveys + if surveys { + // if surveys are enabled, assign this instance as the survey delegate (we'll take over rendering) + config.surveysConfig.surveysDelegate = instance } } #endif @@ -182,6 +181,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { enable(result) case "disable": disable(result) + case "isOptOut": + isOptOut(result) case "debug": debug(call, result: result) case "reloadFeatureFlags": @@ -194,6 +195,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { unregister(call, result: result) case "flush": flush(result) + case "captureException": + captureException(call, result: result) case "close": close(result) case "sendMetaEvent": @@ -601,6 +604,11 @@ extension PosthogFlutterPlugin { result(nil) } + private func isOptOut(_ result: @escaping FlutterResult) { + let isOptedOut = PostHogSDK.shared.isOptOut() + result(isOptedOut) + } + private func debug( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -671,6 +679,25 @@ extension PosthogFlutterPlugin { result(nil) } + private func captureException(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for captureException", details: nil)) + return + } + + let properties = arguments["properties"] as? [String: Any] + + // Extract timestamp from Flutter and convert to Date + var timestamp: Date? = nil + if let timestampMs = arguments["timestamp"] as? Int64 { + timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000.0) + } + + // Use capture method with timestamp to ensure Flutter timestamp is used + PostHogSDK.shared.capture("$exception", properties: properties, timestamp: timestamp) + result(nil) + } + private func close(_ result: @escaping FlutterResult) { PostHogSDK.shared.close() result(nil) diff --git a/ios/posthog_flutter.podspec b/ios/posthog_flutter.podspec index 6cf179e7..8771ce0f 100644 --- a/ios/posthog_flutter.podspec +++ b/ios/posthog_flutter.podspec @@ -21,8 +21,8 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - # ~> Version 3.29.0 up to, but not including, 4.0.0 - s.dependency 'PostHog', '~> 3.29' + # ~> Version 3.32.0 up to, but not including, 4.0.0 + s.dependency 'PostHog', '>= 3.32.0', '< 4.0.0' s.ios.deployment_target = '13.0' # PH iOS SDK 3.0.0 requires >= 10.15 diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart new file mode 100644 index 00000000..15451423 --- /dev/null +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -0,0 +1,303 @@ +import 'package:stack_trace/stack_trace.dart'; +import 'utils/isolate_utils.dart' as isolate_utils; +import 'posthog_exception.dart'; + +class DartExceptionProcessor { + /// Converts Dart error/exception and stack trace to PostHog exception format + static Map processException({ + required Object error, + StackTrace? stackTrace, + Map? properties, + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + StackTrace Function()? stackTraceProvider, //for testing + }) { + // Extract PostHog metadata if error is wrapped in PostHogException + var mechanismType = 'generic'; + var handled = true; + var currentError = error; + + if (error is PostHogException) { + handled = error.handled; + mechanismType = error.mechanism; + currentError = error.source; + } + + StackTrace? effectiveStackTrace = stackTrace; + bool isGeneratedStackTrace = false; + + // If it's an Error, try to use its built-in stackTrace + if (currentError is Error) { + effectiveStackTrace ??= currentError.stackTrace; + } + + // If still null or empty, get current stack trace + if (effectiveStackTrace == null || + effectiveStackTrace == StackTrace.empty) { + effectiveStackTrace = stackTraceProvider?.call() ?? StackTrace.current; + isGeneratedStackTrace = true; // Flag to remove top PostHog frames + } + + // Check if we still have an empty stack trace + final hasValidStackTrace = effectiveStackTrace != StackTrace.empty; + + // Process single exception for now + final frames = hasValidStackTrace + ? _parseStackTrace( + effectiveStackTrace, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + removeTopPostHogFrames: isGeneratedStackTrace, + ) + : >[]; + + final errorType = _getExceptionType(currentError); + + // Mark exception as synthetic if: + // - runtimeType.toString() returned empty/null (fallback to 'Error' type) + // - Stack trace was generated by PostHog (not from original exception) + // - No valid stack trace is available + final isSynthetic = + errorType == null || isGeneratedStackTrace || !hasValidStackTrace; + + final exceptionData = { + 'type': errorType ?? 'Error', + 'mechanism': { + 'handled': handled, + 'synthetic': isSynthetic, + 'type': mechanismType, + } + }; + + // Add exception message, if available + final errorMessage = currentError.toString(); + if (errorMessage.isNotEmpty) { + exceptionData['value'] = errorMessage; + } + + // Add stacktrace, if any frames are available + if (frames.isNotEmpty) { + exceptionData['stacktrace'] = { + 'frames': frames, + 'type': 'raw', + }; + } + + // Add thread ID, if available + final threadId = _getCurrentThreadId(); + if (threadId != null) { + exceptionData['thread_id'] = threadId; + } + + // Final result, merging system properties with user properties (user properties take precedence) + final result = { + '\$exception_level': 'error', // Never crashes, so always error + '\$exception_list': [exceptionData], + if (properties != null) ...properties, + }; + + return result; + } + + /// Determines if a stack frame belongs to PostHog SDK (just check package for now) + static bool _isPostHogFrame(Frame frame) { + return frame.package == 'posthog_flutter'; + } + + /// Asynchronous gap frame for separating async traces + static const _asynchronousGapFrame = { + 'platform': 'dart', + 'abs_path': '', + 'in_app': false, + 'synthetic': true + }; + + /// Parses stack trace into PostHog format + /// + /// Approach inspired by Sentry's stack trace factory implementation: + /// https://github.com/getsentry/sentry-dart/blob/a69a51fd1695dd93024be80a50ad05dd990b2b82/packages/dart/lib/src/sentry_stack_trace_factory.dart#L29-L53 + static List> _parseStackTrace( + StackTrace stackTrace, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + bool removeTopPostHogFrames = false, + }) { + final chain = Chain.forTrace(stackTrace); + final frames = >[]; + + for (final (index, trace) in chain.traces.indexed) { + bool skipNextPostHogFrame = removeTopPostHogFrames; + + for (final frame in trace.frames) { + // Skip top PostHog frames? + if (skipNextPostHogFrame) { + if (_isPostHogFrame(frame)) { + continue; + } + skipNextPostHogFrame = false; + } + + final processedFrame = _convertFrameToPostHog( + frame, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ); + if (processedFrame != null) { + frames.add(processedFrame); + } + } + + // Add asynchronous gap frame between traces (skipping last trace) + if (index < chain.traces.length - 1) { + frames.add(_asynchronousGapFrame); + } + } + + return frames; + } + + /// Converts a Frame from stack_trace package to PostHog format + static Map? _convertFrameToPostHog( + Frame frame, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + }) { + final frameData = { + 'platform': 'dart', + 'abs_path': _extractAbsolutePath(frame), + 'in_app': _isInAppFrame( + frame, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ), + }; + + // add package, if available + final package = _extractPackage(frame); + if (package != null && package.isNotEmpty) { + frameData['package'] = package; + } + + // add function, if available + final member = frame.member; + if (member != null && member.isNotEmpty) { + frameData['function'] = member; + } + + // Add filename, if available + final fileName = _extractFileName(frame); + if (fileName != null && fileName.isNotEmpty) { + frameData['filename'] = fileName; + } + + // Add line number, if available + final line = frame.line; + if (line != null && line >= 0) { + frameData['lineno'] = line; + } + + // Add column number, if available + final column = frame.column; + if (column != null && column >= 0) { + frameData['colno'] = column; + } + + return frameData; + } + + /// Determines if a frame is considered in-app + static bool _isInAppFrame( + Frame frame, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + }) { + final scheme = frame.uri.scheme; + + if (scheme.isEmpty) { + // Early bail out for unknown schemes + return inAppByDefault; + } + + final package = frame.package; + if (package != null) { + // 1. Check inAppIncludes first (highest priority) + if (inAppIncludes != null && inAppIncludes.contains(package)) { + return true; + } + + // 2. Check inAppExcludes second + if (inAppExcludes != null && inAppExcludes.contains(package)) { + return false; + } + } + + // 3. Hardcoded exclusions + if (frame.isCore) { + // dart: packages + return false; + } + + if (frame.package == 'flutter') { + // flutter package + return false; + } + + // 4. Default fallback + return inAppByDefault; + } + + static String? _extractPackage(Frame frame) { + return frame.package; + } + + static String? _extractFileName(Frame frame) { + return frame.uri.pathSegments.isNotEmpty + ? frame.uri.pathSegments.last + : null; + } + + static String _extractAbsolutePath(Frame frame) { + // For privacy, only return filename for local file paths + if (frame.uri.scheme != 'dart' && + frame.uri.scheme != 'package' && + frame.uri.pathSegments.isNotEmpty) { + return frame.uri.pathSegments.last; // Just filename for privacy + } + + // For dart: and package: URIs, full path is safe + return frame.uri.toString(); + } + + /// Gets the current thread ID using isolate-based detection + static int? _getCurrentThreadId() { + try { + // Check if we're in the root isolate (main thread) + if (isolate_utils.isRootIsolate()) { + return 'main'.hashCode; + } + + // For other isolates, use the isolate's debug name + final isolateName = isolate_utils.getIsolateName(); + if (isolateName != null && isolateName.isNotEmpty) { + return isolateName.hashCode; + } + + return null; + } catch (e) { + return null; + } + } + + static String? _getExceptionType(Object error) { + // The string is only intended for providing information to a reader while debugging. There is no guaranteed format, the string value returned for a Type instances is entirely implementation dependent. + final type = error.runtimeType.toString(); + return type.isNotEmpty ? type : null; + } +} diff --git a/lib/src/error_tracking/isolate_handler_io.dart b/lib/src/error_tracking/isolate_handler_io.dart new file mode 100644 index 00000000..56a8b987 --- /dev/null +++ b/lib/src/error_tracking/isolate_handler_io.dart @@ -0,0 +1,31 @@ +import 'dart:isolate'; + +import 'package:meta/meta.dart'; + +/// Native platform implementation of isolate error handling +@internal +class IsolateErrorHandler { + RawReceivePort? _isolateErrorPort; + + /// Add error listener to current isolate (should be main isolate) + void addErrorListener(Function(Object?) onError) { + _isolateErrorPort = RawReceivePort(onError); + final isolateErrorPort = _isolateErrorPort; + if (isolateErrorPort != null) { + Isolate.current.addErrorListener(isolateErrorPort.sendPort); + } + } + + /// Remove error listener and clean up + void removeErrorListener() { + final isolateErrorPort = _isolateErrorPort; + if (isolateErrorPort != null) { + isolateErrorPort.close(); + Isolate.current.removeErrorListener(isolateErrorPort.sendPort); + _isolateErrorPort = null; + } + } + + /// Get current isolate name + String? get isolateDebugName => Isolate.current.debugName; +} diff --git a/lib/src/error_tracking/isolate_handler_web.dart b/lib/src/error_tracking/isolate_handler_web.dart new file mode 100644 index 00000000..0f88e412 --- /dev/null +++ b/lib/src/error_tracking/isolate_handler_web.dart @@ -0,0 +1,16 @@ +/// Web platform stub implementation of isolate error handling +/// Isolates are not available on web, so this is a no-op implementation +class IsolateErrorHandler { + /// Add error listener to current isolate (no-op on web) + void addErrorListener(Function(dynamic) onError) { + // No-op: Isolates are not available on web + } + + /// Remove error listener and clean up (no-op on web) + void removeErrorListener() { + // No-op: Isolates are not available on web + } + + /// Get current isolate name (always 'main' on web) + String? get isolateDebugName => 'main'; +} diff --git a/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart b/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart new file mode 100644 index 00000000..e96f8ec0 --- /dev/null +++ b/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart @@ -0,0 +1,247 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:posthog_flutter/src/util/platform_io_stub.dart' + if (dart.library.io) 'package:posthog_flutter/src/util/platform_io_real.dart'; + +import 'isolate_handler_io.dart' + if (dart.library.html) 'isolate_handler_web.dart'; +import 'package:posthog_flutter/src/util/logging.dart'; + +import '../posthog_flutter_platform_interface.dart'; +import '../posthog_config.dart'; +import 'posthog_exception.dart'; + +/// Handles automatic capture of Flutter and Dart exceptions +class PostHogErrorTrackingAutoCaptureIntegration { + final PostHogErrorTrackingConfig _config; + final PosthogFlutterPlatformInterface _posthog; + + // Store original handlers (we'll chain with them from our handler) + FlutterExceptionHandler? _originalFlutterErrorHandler; + ErrorCallback? _originalPlatformErrorHandler; + + // Isolate error handling + final IsolateErrorHandler _isolateErrorHandler = IsolateErrorHandler(); + + bool _isEnabled = false; + + static PostHogErrorTrackingAutoCaptureIntegration? _instance; + + PostHogErrorTrackingAutoCaptureIntegration._({ + required PostHogErrorTrackingConfig config, + required PosthogFlutterPlatformInterface posthog, + }) : _config = config, + _posthog = posthog; + + /// Install the autocapture integration (can only be installed once) + static PostHogErrorTrackingAutoCaptureIntegration? install({ + required PostHogErrorTrackingConfig config, + required PosthogFlutterPlatformInterface posthog, + }) { + if (_instance != null) { + debugPrint( + 'PostHog: Error tracking autocapture integration is already installed. Call PostHogErrorTrackingAutoCaptureIntegration.uninstall() first.'); + return null; + } + + final instance = PostHogErrorTrackingAutoCaptureIntegration._( + config: config, + posthog: posthog, + ); + + _instance = instance; + + if (config.captureFlutterErrors || + config.capturePlatformDispatcherErrors || + config.captureIsolateErrors) { + instance.start(); + } + + return instance; + } + + /// Uninstall the autocapture integration + static void uninstall() { + if (_instance != null) { + _instance?.stop(); + _instance = null; + } + } + + /// Start automatic exception capture + void start() { + if (_isEnabled) return; + + _isEnabled = true; + + // Set up Flutter error handler if enabled + if (_config.captureFlutterErrors) { + _setupFlutterErrorHandler(); + } + + // Set up platform error handler if enabled + if (_config.capturePlatformDispatcherErrors) { + _setupPlatformErrorHandler(); + } + + // Set up isolate error handler if enabled + if (_config.captureIsolateErrors) { + _setupIsolateErrorHandler(); + } + } + + /// Stop automatic exception capture (restores original handlers) + void stop() { + if (!_isEnabled) return; + + _isEnabled = false; + + // Restore original handlers only if our own handler is still set + if (FlutterError.onError == _posthogFlutterErrorHandler) { + FlutterError.onError = _originalFlutterErrorHandler; + } + if (PlatformDispatcher.instance.onError == _posthogPlatformErrorHandler) { + PlatformDispatcher.instance.onError = _originalPlatformErrorHandler; + } + + // Clean up isolate error handler + _isolateErrorHandler.removeErrorListener(); + + // release refs + _originalFlutterErrorHandler = null; + _originalPlatformErrorHandler = null; + } + + /// Flutter framework error handler + void _setupFlutterErrorHandler() { + // prevent circular calls + if (FlutterError.onError == _posthogFlutterErrorHandler) { + return; + } + + _originalFlutterErrorHandler = FlutterError.onError; + + FlutterError.onError = _posthogFlutterErrorHandler; + } + + void _posthogFlutterErrorHandler(FlutterErrorDetails details) { + if (!details.silent || _config.captureSilentFlutterErrors) { + // Collect additional context information + //(see: https://github.com/getsentry/sentry-dart/blob/a69a51fd1695dd93024be80a50ad05dd990b2b82/packages/flutter/lib/src/integrations/flutter_error_integration.dart#L35-L60) + final context = details.context?.toDescription(); + final collector = details.informationCollector?.call() ?? []; + final information = collector.isNotEmpty + ? (StringBuffer()..writeAll(collector, '\n')).toString() + : null; + final library = details.library; + final errorSummary = details.toStringShort(); + + // Build additional properties with Flutter-specific details + final flutterErrorDetails = { + if (context != null) 'context': context, + if (information != null) 'information': information, + if (library != null) 'library': library, + 'error_summary': errorSummary, + 'silent': details.silent, + }; + + final wrappedError = PostHogException( + source: details.exception, mechanism: 'FlutterError', handled: false); + + _captureException( + error: wrappedError, + stackTrace: details.stack, + properties: {'flutter_error_details': flutterErrorDetails}, + ); + } else { + printIfDebug( + "Error not captured because FlutterErrorDetails.silent is true and captureSilentFlutterErrors is false"); + } + + // Call the original handler, if any + _originalFlutterErrorHandler?.call(details); + } + + /// Platform error handler for Dart runtime errors + void _setupPlatformErrorHandler() { + // On web, PlatformDispatcher.onError is not implemented. Skip for now + // See: https://github.com/flutter/flutter/issues/100277 + if (!isSupportedPlatform()) { + return; + } + + // prevent circular calls + if (PlatformDispatcher.instance.onError == _posthogPlatformErrorHandler) { + return; + } + + _originalPlatformErrorHandler = PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = _posthogPlatformErrorHandler; + } + + bool _posthogPlatformErrorHandler(Object error, StackTrace stackTrace) { + final wrappedError = PostHogException( + source: error, + mechanism: 'PlatformDispatcher', + handled: false, + ); + + _captureException(error: wrappedError, stackTrace: stackTrace); + + // Call the original handler, if any + // False otherwise, so that default fallback mechanism is used + return _originalPlatformErrorHandler?.call(error, stackTrace) ?? false; + } + + /// Isolate error handler for current isolate errors + void _setupIsolateErrorHandler() { + if (!_config.captureIsolateErrors) { + return; + } + + _isolateErrorHandler.addErrorListener(_posthogIsolateErrorHandler); + } + + void _posthogIsolateErrorHandler(Object? error) { + // Isolate errors come as List with [errorString, stackTraceString] + // See: https://api.dartlang.org/stable/2.7.0/dart-isolate/Isolate/addErrorListener.html + if (error is List && error.length == 2) { + final errorString = error.first; + final stackTraceString = error.last; + final stackTrace = _parseStackTrace(stackTraceString); + final isolateName = _isolateErrorHandler.isolateDebugName; + + final wrappedError = PostHogException( + source: errorString, + mechanism: 'isolateError', + handled: false, + ); + + _captureException( + error: wrappedError, + stackTrace: stackTrace, + properties: isolateName != null ? {'isolate_name': isolateName} : null, + ); + } + } + + StackTrace? _parseStackTrace(String? stackTraceString) { + if (stackTraceString == null) return null; + try { + return StackTrace.fromString(stackTraceString); + } catch (e) { + printIfDebug('Failed to parse isolate stack trace: $e'); + return null; + } + } + + Future _captureException({ + required PostHogException error, + required StackTrace? stackTrace, + Map? properties, + }) { + return _posthog.captureException( + error: error, stackTrace: stackTrace, properties: properties); + } +} diff --git a/lib/src/error_tracking/posthog_exception.dart b/lib/src/error_tracking/posthog_exception.dart new file mode 100644 index 00000000..4bfa2155 --- /dev/null +++ b/lib/src/error_tracking/posthog_exception.dart @@ -0,0 +1,16 @@ +import 'package:meta/meta.dart'; + +/// A wrapper exception that carries PostHog-specific metadata +@internal +class PostHogException implements Exception { + /// The original exception/error that was wrapped + final Object source; + final String mechanism; + final bool handled; + + const PostHogException({ + required this.source, + required this.mechanism, + this.handled = false, + }); +} diff --git a/lib/src/error_tracking/utils/_io_isolate_utils.dart b/lib/src/error_tracking/utils/_io_isolate_utils.dart new file mode 100644 index 00000000..d8787500 --- /dev/null +++ b/lib/src/error_tracking/utils/_io_isolate_utils.dart @@ -0,0 +1,15 @@ +import 'dart:isolate'; +import 'package:flutter/services.dart'; + +/// Gets the current isolate's debug name for IO platforms +String? getIsolateName() => Isolate.current.debugName; + +/// Determines if the current isolate is the root isolate for IO platforms +/// Uses Flutter's ServicesBinding to detect the root isolate +bool isRootIsolate() { + try { + return ServicesBinding.rootIsolateToken != null; + } catch (_) { + return true; + } +} diff --git a/lib/src/error_tracking/utils/_web_isolate_utils.dart b/lib/src/error_tracking/utils/_web_isolate_utils.dart new file mode 100644 index 00000000..e3e0797b --- /dev/null +++ b/lib/src/error_tracking/utils/_web_isolate_utils.dart @@ -0,0 +1,7 @@ +/// Gets the current isolate's debug name for web platforms +/// Web is single-threaded, so always returns 'main' +String? getIsolateName() => 'main'; + +/// Determines if the current isolate is the root isolate for web platforms +/// Web is single-threaded, so always returns true +bool isRootIsolate() => true; diff --git a/lib/src/error_tracking/utils/isolate_utils.dart b/lib/src/error_tracking/utils/isolate_utils.dart new file mode 100644 index 00000000..12c56a9d --- /dev/null +++ b/lib/src/error_tracking/utils/isolate_utils.dart @@ -0,0 +1,10 @@ +import '_io_isolate_utils.dart' + if (dart.library.js_interop) '_web_isolate_utils.dart' as platform; + +/// Gets the current isolate's debug name +/// Returns null if the name cannot be determined +String? getIsolateName() => platform.getIsolateName(); + +/// Determines if the current isolate is the root/main isolate +/// Returns true for the main isolate, false for background isolates +bool isRootIsolate() => platform.isRootIsolate(); diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 667ae96e..9c629717 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; +import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; import 'posthog_observer.dart'; @@ -24,9 +25,28 @@ class Posthog { /// com.posthog.posthog.AUTO_INIT: false Future setup(PostHogConfig config) { _config = config; // Store the config + + _installFlutterIntegrations(config); + return _posthog.setup(config); } + void _installFlutterIntegrations(PostHogConfig config) { + // Install exception autocapture if enabled + if (config.errorTrackingConfig.captureFlutterErrors || + config.errorTrackingConfig.capturePlatformDispatcherErrors) { + PostHogErrorTrackingAutoCaptureIntegration.install( + config: config.errorTrackingConfig, + posthog: _posthog, + ); + } + } + + void _uninstallFlutterIntegrations() { + // Uninstall exception autocapture integration + PostHogErrorTrackingAutoCaptureIntegration.uninstall(); + } + @internal PostHogConfig? get config => _config; @@ -85,10 +105,17 @@ class Posthog { Future reset() => _posthog.reset(); - Future disable() => _posthog.disable(); + Future disable() { + // Uninstall Flutter-specific integrations when disabling + _uninstallFlutterIntegrations(); + + return _posthog.disable(); + } Future enable() => _posthog.enable(); + Future isOptOut() => _posthog.isOptOut(); + Future debug(bool enabled) => _posthog.debug(enabled); Future register(String key, Object value) => @@ -119,6 +146,18 @@ class Posthog { Future flush() => _posthog.flush(); + /// Captures exceptions with optional custom properties + /// + /// [error] - The error/exception to capture + /// [stackTrace] - Optional stack trace (if not provided, current stack trace will be used) + /// [properties] - Optional custom properties to attach to the exception event + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) => + _posthog.captureException( + error: error, stackTrace: stackTrace, properties: properties); + /// Closes the PostHog SDK and cleans up resources. /// /// Note: Please note that after calling close(), surveys will not be rendered until the SDK is re-initialized and the next navigation event occurs. @@ -126,6 +165,10 @@ class Posthog { _config = null; _currentScreen = null; PosthogObserver.clearCurrentContext(); + + // Uninstall Flutter integrations + _uninstallFlutterIntegrations(); + return _posthog.close(); } diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 85f31988..0d008c88 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -1,5 +1,3 @@ -import 'package:meta/meta.dart'; - enum PostHogPersonProfiles { never, always, identifiedOnly } enum PostHogDataMode { wifi, cellular, any } @@ -31,13 +29,20 @@ class PostHogConfig { /// iOS only var dataMode = PostHogDataMode.any; - /// Enable Surveys (Currently for iOS only) + /// Enable Surveys /// - /// Note: Please note that after calling Posthog().close(), surveys will not be rendered until the SDK is re-initialized and the next navigation event occurs. + /// **Notes:** + /// - After calling `Posthog().close()`, surveys will not be rendered until the SDK is re-initialized and the next navigation event occurs. + /// - You must install `PosthogObserver` in your app for surveys to display + /// - See: https://posthog.com/docs/surveys/installation?tab=Flutter#step-two-install-posthogobserver + /// - For Flutter web, this setting will be ignored. Surveys on web use the JavaScript Web SDK instead. + /// - See: https://posthog.com/docs/surveys/installation?tab=Web /// - /// Experimental. Defaults to false. - @experimental - var surveys = false; + /// Defaults to true. + var surveys = true; + + /// Configuration for error tracking and exception capture + final errorTrackingConfig = PostHogErrorTrackingConfig(); // TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks // onFeatureFlags, integrations @@ -62,6 +67,7 @@ class PostHogConfig { 'sessionReplay': sessionReplay, 'dataMode': dataMode.name, 'sessionReplayConfig': sessionReplayConfig.toMap(), + 'errorTrackingConfig': errorTrackingConfig.toMap(), }; } } @@ -100,3 +106,121 @@ class PostHogSessionReplayConfig { }; } } + +class PostHogErrorTrackingConfig { + /// List of package names to be considered inApp frames for exception tracking + /// + /// inApp Example: + /// inAppIncludes = ["package:your_app", "package:your_company_utils"] + /// All exception stacktrace frames from these packages will be considered inApp + /// + /// This option takes precedence over inAppExcludes. + /// For Flutter/Dart, this typically includes: + /// - Your app's main package (e.g., "package:your_app") + /// - Any internal packages you own (e.g., "package:your_company_utils") + /// + /// **Note:** + /// - Flutter web: Not supported + /// + final inAppIncludes = []; + + /// List of package names to be excluded from inApp frames for exception tracking + /// + /// inAppExcludes Example: + /// inAppExcludes = ["package:third_party_lib", "package:analytics_package"] + /// All exception stacktrace frames from these packages will be considered external + /// + /// Note: inAppIncludes takes precedence over this setting. + /// Common packages to exclude: + /// - Third-party analytics packages + /// - External utility libraries + /// - Packages you don't control + /// + /// **Note:** + /// - Flutter web: Not supported + /// + final inAppExcludes = []; + + /// Configures whether stack trace frames are considered inApp by default + /// when the origin cannot be determined or no explicit includes/excludes match. + /// + /// - If true: Frames are inApp unless explicitly excluded (allowlist approach) + /// - If false: Frames are external unless explicitly included (denylist approach) + /// + /// Default behavior when true: + /// - Local files (no package prefix) are inApp + /// - dart and flutter packages are excluded + /// - All other packages are inApp unless in inAppExcludes + /// + /// **Note:** + /// - Flutter web: Not supported + /// + var inAppByDefault = true; + + /// Enable automatic capture of Flutter framework errors + /// + /// Controls whether `FlutterError.onError` errors are captured. + /// + /// **Note:** + /// - Flutter web: Not supported + /// + /// Default: false + var captureFlutterErrors = false; + + /// Enable capturing of silent Flutter errors + /// + /// Controls whether Flutter errors marked as silent (FlutterErrorDetails.silent = true) are captured. + /// + /// **Note:** + /// - Flutter web: Not supported + /// + /// Default: false + var captureSilentFlutterErrors = false; + + /// Enable automatic capture of Dart runtime errors + /// + /// Controls whether `PlatformDispatcher.onError errors` are captured. + /// + /// **Note:** + /// - Flutter web: Not supported + /// + /// Default: false + var capturePlatformDispatcherErrors = false; + + /// Enable automatic capture of exceptions in the native SDKs (Android only for now) + /// + /// Controls whether native exceptions are captured. + /// + /// **Note:** + /// - iOS: Not supported + /// - Android: Java/Kotlin exceptions only (no native C/C++ crashes) + /// - Android: No stacktrace demangling for minified builds + /// + /// Default: false + var captureNativeExceptions = false; + + /// Enable automatic capture of isolate errors + /// + /// Controls whether errors from the current isolate are captured. + /// This includes errors from the main isolate and any isolates spawned + /// without explicit error handling. + /// + /// **Note:** + /// - Flutter web: Not supported + /// + /// Default: false + var captureIsolateErrors = false; + + Map toMap() { + return { + 'inAppIncludes': inAppIncludes, + 'inAppExcludes': inAppExcludes, + 'inAppByDefault': inAppByDefault, + 'captureFlutterErrors': captureFlutterErrors, + 'captureSilentFlutterErrors': captureSilentFlutterErrors, + 'capturePlatformDispatcherErrors': capturePlatformDispatcherErrors, + 'captureNativeExceptions': captureNativeExceptions, + 'captureIsolateErrors': captureIsolateErrors, + }; + } +} diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 2078731b..75a25cd5 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -9,6 +9,8 @@ import 'package:posthog_flutter/src/surveys/survey_service.dart'; import 'package:posthog_flutter/src/util/logging.dart'; import 'surveys/models/posthog_display_survey.dart' as models; import 'surveys/models/survey_callbacks.dart'; +import 'error_tracking/dart_exception_processor.dart'; +import 'utils/property_normalizer.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; @@ -22,6 +24,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// The method channel used to interact with the native platform. final _methodChannel = const MethodChannel('posthog_flutter'); + /// Stored configuration for accessing inAppIncludes and other settings + PostHogConfig? _config; + /// Native plugin calls to Flutter /// Future _handleMethodCall(MethodCall call) async { @@ -116,6 +121,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// @override Future setup(PostHogConfig config) async { + // Store config for later use in exception processing + _config = config; + if (!isSupportedPlatform()) { return; } @@ -138,11 +146,19 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedUserProperties = userProperties != null + ? PropertyNormalizer.normalize(userProperties) + : null; + final normalizedUserPropertiesSetOnce = userPropertiesSetOnce != null + ? PropertyNormalizer.normalize(userPropertiesSetOnce) + : null; + await _methodChannel.invokeMethod('identify', { 'userId': userId, - if (userProperties != null) 'userProperties': userProperties, - if (userPropertiesSetOnce != null) - 'userPropertiesSetOnce': userPropertiesSetOnce, + if (normalizedUserProperties != null) + 'userProperties': normalizedUserProperties, + if (normalizedUserPropertiesSetOnce != null) + 'userPropertiesSetOnce': normalizedUserPropertiesSetOnce, }); } on PlatformException catch (exception) { printIfDebug('Exeption on identify: $exception'); @@ -159,9 +175,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedProperties = + properties != null ? PropertyNormalizer.normalize(properties) : null; + await _methodChannel.invokeMethod('capture', { 'eventName': eventName, - if (properties != null) 'properties': properties, + if (normalizedProperties != null) 'properties': normalizedProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on capture: $exception'); @@ -178,9 +197,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedProperties = + properties != null ? PropertyNormalizer.normalize(properties) : null; + await _methodChannel.invokeMethod('screen', { 'screenName': screenName, - if (properties != null) 'properties': properties, + if (normalizedProperties != null) 'properties': normalizedProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on screen: $exception'); @@ -257,6 +279,21 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } } + @override + Future isOptOut() async { + if (!isSupportedPlatform()) { + return true; + } + + try { + final result = await _methodChannel.invokeMethod('isOptOut'); + return result as bool? ?? true; + } on PlatformException catch (exception) { + printIfDebug('Exception on isOptOut: $exception'); + return true; + } + } + @override Future debug(bool enabled) async { if (!isSupportedPlatform()) { @@ -312,10 +349,15 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedGroupProperties = groupProperties != null + ? PropertyNormalizer.normalize(groupProperties) + : null; + await _methodChannel.invokeMethod('group', { 'groupType': groupType, 'groupKey': groupKey, - if (groupProperties != null) 'groupProperties': groupProperties, + if (normalizedGroupProperties != null) + 'groupProperties': normalizedGroupProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on group: $exception'); @@ -398,6 +440,37 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } } + @override + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) async { + if (!isSupportedPlatform()) { + return; + } + + try { + final exceptionData = DartExceptionProcessor.processException( + error: error, + stackTrace: stackTrace, + properties: properties, + inAppIncludes: _config?.errorTrackingConfig.inAppIncludes, + inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, + inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, + ); + + // Add timestamp from Flutter side (will be used and removed from native plugins) + final timestamp = DateTime.now().millisecondsSinceEpoch; + final normalizedData = + PropertyNormalizer.normalize(exceptionData.cast()); + + await _methodChannel.invokeMethod('captureException', + {'timestamp': timestamp, 'properties': normalizedData}); + } on PlatformException catch (exception) { + printIfDebug('Exception in captureException: $exception'); + } + } + @override Future close() async { if (!isSupportedPlatform()) { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 5123743b..151e3158 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -76,6 +76,10 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('enable() has not been implemented.'); } + Future isOptOut() { + throw UnimplementedError('isOptOut() has not been implemented.'); + } + Future debug(bool enabled) { throw UnimplementedError('debug() has not been implemented.'); } @@ -125,6 +129,13 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('flush() has not been implemented.'); } + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) { + throw UnimplementedError('captureException() has not been implemented.'); + } + Future close() { throw UnimplementedError('close() has not been implemented.'); } diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index 26f08db5..eefd456e 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -23,6 +23,8 @@ extension PostHogExtension on PostHog { external void opt_in_capturing(); // ignore: non_constant_identifier_names external void opt_out_capturing(); + // ignore: non_constant_identifier_names + external bool has_opted_out_capturing(); external JSAny? getFeatureFlag(JSAny key); external JSAny? getFeatureFlagPayload(JSAny key); external void register(JSAny properties); @@ -146,6 +148,8 @@ Future handleWebMethodCall(MethodCall call) async { case 'disable': posthog?.opt_out_capturing(); break; + case 'isOptOut': + return posthog?.has_opted_out_capturing() ?? true; case 'getFeatureFlag': final key = args['key'] as String; @@ -206,6 +210,9 @@ Future handleWebMethodCall(MethodCall call) async { case 'surveyAction': // not supported on Web break; + case 'captureException': + // not implemented on Web + break; default: throw PlatformException( code: 'Unimplemented', diff --git a/lib/src/replay/element_parsers/element_data.dart b/lib/src/replay/element_parsers/element_data.dart index 54852340..88ab1b6c 100644 --- a/lib/src/replay/element_parsers/element_data.dart +++ b/lib/src/replay/element_parsers/element_data.dart @@ -49,6 +49,11 @@ class ElementData { if (!rectList.contains(element.rect)) { if (element.widget is PostHogMaskWidget) { rectList.add(element.rect); + } else if (element.widget is TextField) { + final textField = element.widget as TextField; + if (textField.obscureText) { + rectList.add(element.rect); + } } } diff --git a/lib/src/replay/element_parsers/element_object_parser.dart b/lib/src/replay/element_parsers/element_object_parser.dart index 31103879..16ee7412 100644 --- a/lib/src/replay/element_parsers/element_object_parser.dart +++ b/lib/src/replay/element_parsers/element_object_parser.dart @@ -1,4 +1,4 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart'; @@ -30,6 +30,18 @@ class ElementObjectParser { } } + if (element.widget is TextField) { + final textField = element.widget as TextField; + if (textField.obscureText) { + final elementData = _elementParser.relate(element, activeElementData); + + if (elementData != null) { + activeElementData.addChildren(elementData); + return elementData; + } + } + } + if (element.renderObject is RenderImage) { final dataType = element.renderObject.runtimeType.toString(); diff --git a/lib/src/surveys/models/posthog_display_choice_question.dart b/lib/src/surveys/models/posthog_display_choice_question.dart index de51f0b9..5b116d72 100644 --- a/lib/src/surveys/models/posthog_display_choice_question.dart +++ b/lib/src/surveys/models/posthog_display_choice_question.dart @@ -6,12 +6,14 @@ import 'posthog_display_survey_question.dart'; @immutable class PostHogDisplayChoiceQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayChoiceQuestion({ + required super.id, required super.question, required this.choices, required this.isMultipleChoice, this.hasOpenChoice = false, this.shuffleOptions = false, super.description, + super.descriptionContentType, super.optional, super.buttonText, }) : super( diff --git a/lib/src/surveys/models/posthog_display_link_question.dart b/lib/src/surveys/models/posthog_display_link_question.dart index 825de739..5658e938 100644 --- a/lib/src/surveys/models/posthog_display_link_question.dart +++ b/lib/src/surveys/models/posthog_display_link_question.dart @@ -6,9 +6,11 @@ import 'posthog_display_survey_question.dart'; @immutable class PostHogDisplayLinkQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayLinkQuestion({ + required super.id, required super.question, required this.link, super.description, + super.descriptionContentType, super.optional, super.buttonText, }) : super( diff --git a/lib/src/surveys/models/posthog_display_open_question.dart b/lib/src/surveys/models/posthog_display_open_question.dart index 733d11bb..c8b7f07d 100644 --- a/lib/src/surveys/models/posthog_display_open_question.dart +++ b/lib/src/surveys/models/posthog_display_open_question.dart @@ -6,8 +6,10 @@ import 'posthog_display_survey_question.dart'; @immutable class PostHogDisplayOpenQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayOpenQuestion({ + required super.id, required super.question, super.description, + super.descriptionContentType, super.optional, super.buttonText, }) : super( diff --git a/lib/src/surveys/models/posthog_display_rating_question.dart b/lib/src/surveys/models/posthog_display_rating_question.dart index 271708e5..4b10c1a9 100644 --- a/lib/src/surveys/models/posthog_display_rating_question.dart +++ b/lib/src/surveys/models/posthog_display_rating_question.dart @@ -7,6 +7,7 @@ import 'posthog_display_survey_rating_type.dart'; @immutable class PostHogDisplayRatingQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayRatingQuestion({ + required super.id, required super.question, required this.ratingType, required this.scaleLowerBound, @@ -14,6 +15,7 @@ class PostHogDisplayRatingQuestion extends PostHogDisplaySurveyQuestion { required this.lowerBoundLabel, required this.upperBoundLabel, super.description, + super.descriptionContentType, super.optional, super.buttonText, }) : super( diff --git a/lib/src/surveys/models/posthog_display_survey.dart b/lib/src/surveys/models/posthog_display_survey.dart index 162aecce..194508c2 100644 --- a/lib/src/surveys/models/posthog_display_survey.dart +++ b/lib/src/surveys/models/posthog_display_survey.dart @@ -6,6 +6,7 @@ import 'posthog_display_link_question.dart'; import 'posthog_display_rating_question.dart'; import 'posthog_display_choice_question.dart'; import 'posthog_display_survey_appearance.dart'; +import 'posthog_display_survey_text_content_type.dart'; /// Main survey model containing metadata and questions @immutable @@ -14,23 +15,33 @@ class PostHogDisplaySurvey { // Native platform model -> Dictionary -> Dart model factory PostHogDisplaySurvey.fromDict(Map dict) { final questions = (dict['questions'] as List).map((q) { + final id = q['type'] as String? ?? ''; final type = q['type'] as String; final question = q['question'] as String; final optional = q['isOptional'] as bool; final questionDescription = q['questionDescription'] as String?; + // Extract content type values with fallback to text (1) + final questionContentTypeRaw = + q['questionDescriptionContentType'] as int? ?? 1; + final questionDescriptionContentType = + PostHogDisplaySurveyTextContentType.fromInt(questionContentTypeRaw); + final buttonText = q['buttonText'] as String?; switch (type) { case 'link': return PostHogDisplayLinkQuestion( + id: id, question: question, link: q['link'] as String, description: questionDescription, + descriptionContentType: questionDescriptionContentType, optional: optional, buttonText: buttonText, ); case 'rating': return PostHogDisplayRatingQuestion( + id: id, question: question, ratingType: PostHogDisplaySurveyRatingType.fromInt(q['ratingType'] as int), @@ -39,26 +50,31 @@ class PostHogDisplaySurvey { lowerBoundLabel: q['lowerBoundLabel'] as String, upperBoundLabel: q['upperBoundLabel'] as String, description: questionDescription, + descriptionContentType: questionDescriptionContentType, optional: optional, buttonText: buttonText, ); case 'multiple_choice': case 'single_choice': return PostHogDisplayChoiceQuestion( + id: id, question: question, choices: (q['choices'] as List).cast(), isMultipleChoice: type == 'multiple_choice', hasOpenChoice: q['hasOpenChoice'] as bool, shuffleOptions: q['shuffleOptions'] as bool, description: questionDescription, + descriptionContentType: questionDescriptionContentType, optional: optional, buttonText: buttonText, ); case 'open': default: return PostHogDisplayOpenQuestion( + id: id, question: question, description: questionDescription, + descriptionContentType: questionDescriptionContentType, optional: optional, buttonText: buttonText, ); @@ -68,6 +84,13 @@ class PostHogDisplaySurvey { PostHogDisplaySurveyAppearance? appearance; if (dict['appearance'] != null) { final a = Map.from(dict['appearance'] as Map); + + // Extract thank you message content type with fallback to text (1) + final thankYouContentTypeRaw = + a['thankYouMessageDescriptionContentType'] as int? ?? 1; + final thankYouMessageDescriptionContentType = + PostHogDisplaySurveyTextContentType.fromInt(thankYouContentTypeRaw); + appearance = PostHogDisplaySurveyAppearance( fontFamily: a['fontFamily'] as String?, backgroundColor: a['backgroundColor'] as String?, @@ -82,6 +105,8 @@ class PostHogDisplaySurvey { displayThankYouMessage: a['displayThankYouMessage'] as bool? ?? true, thankYouMessageHeader: a['thankYouMessageHeader'] as String?, thankYouMessageDescription: a['thankYouMessageDescription'] as String?, + thankYouMessageDescriptionContentType: + thankYouMessageDescriptionContentType, thankYouMessageCloseButtonText: a['thankYouMessageCloseButtonText'] as String?, ); diff --git a/lib/src/surveys/models/posthog_display_survey_appearance.dart b/lib/src/surveys/models/posthog_display_survey_appearance.dart index d699b6bb..40ce961c 100644 --- a/lib/src/surveys/models/posthog_display_survey_appearance.dart +++ b/lib/src/surveys/models/posthog_display_survey_appearance.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'posthog_display_survey_text_content_type.dart'; /// Appearance configuration for surveys @immutable @@ -17,6 +18,7 @@ class PostHogDisplaySurveyAppearance { this.displayThankYouMessage = true, this.thankYouMessageHeader, this.thankYouMessageDescription, + this.thankYouMessageDescriptionContentType, this.thankYouMessageCloseButtonText, }); @@ -33,5 +35,7 @@ class PostHogDisplaySurveyAppearance { final bool displayThankYouMessage; final String? thankYouMessageHeader; final String? thankYouMessageDescription; + final PostHogDisplaySurveyTextContentType? + thankYouMessageDescriptionContentType; final String? thankYouMessageCloseButtonText; } diff --git a/lib/src/surveys/models/posthog_display_survey_question.dart b/lib/src/surveys/models/posthog_display_survey_question.dart index 67d04a4b..1c97b86a 100644 --- a/lib/src/surveys/models/posthog_display_survey_question.dart +++ b/lib/src/surveys/models/posthog_display_survey_question.dart @@ -1,20 +1,25 @@ import 'package:flutter/foundation.dart'; import 'posthog_survey_question_type.dart'; +import 'posthog_display_survey_text_content_type.dart'; /// Base class for all survey questions @immutable abstract class PostHogDisplaySurveyQuestion { const PostHogDisplaySurveyQuestion({ + required this.id, required this.type, required this.question, this.description, + this.descriptionContentType = PostHogDisplaySurveyTextContentType.text, this.optional = false, this.buttonText, }); + final String id; final PostHogSurveyQuestionType type; final String question; final String? description; + final PostHogDisplaySurveyTextContentType descriptionContentType; final bool optional; final String? buttonText; } diff --git a/lib/src/surveys/models/posthog_display_survey_text_content_type.dart b/lib/src/surveys/models/posthog_display_survey_text_content_type.dart new file mode 100644 index 00000000..b99c926d --- /dev/null +++ b/lib/src/surveys/models/posthog_display_survey_text_content_type.dart @@ -0,0 +1,20 @@ +/// Content type for text-based survey elements +enum PostHogDisplaySurveyTextContentType { + /// Content should be rendered as HTML + html(0), + + /// Content should be rendered as plain text + text(1); + + const PostHogDisplaySurveyTextContentType(this.value); + + final int value; + + /// Create from raw int value + static PostHogDisplaySurveyTextContentType fromInt(int value) { + return PostHogDisplaySurveyTextContentType.values.firstWhere( + (e) => e.value == value, + orElse: () => PostHogDisplaySurveyTextContentType.text, // Default to text + ); + } +} diff --git a/lib/src/surveys/widgets/choice_question.dart b/lib/src/surveys/widgets/choice_question.dart index 7416616f..6a686776 100644 --- a/lib/src/surveys/widgets/choice_question.dart +++ b/lib/src/surveys/widgets/choice_question.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import 'question_header.dart'; import 'survey_button.dart'; import 'survey_choice_button.dart'; @@ -10,6 +11,7 @@ class ChoiceQuestionWidget extends StatefulWidget { super.key, required this.question, required this.description, + this.descriptionContentType, required this.choices, required this.appearance, this.buttonText, @@ -21,6 +23,7 @@ class ChoiceQuestionWidget extends StatefulWidget { final String question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final List choices; final SurveyAppearance appearance; final String? buttonText; @@ -104,6 +107,7 @@ class _ChoiceQuestionWidgetState extends State { QuestionHeader( question: widget.question, description: widget.description, + descriptionContentType: widget.descriptionContentType, appearance: widget.appearance, ), const SizedBox(height: 16), diff --git a/lib/src/surveys/widgets/confirmation_message.dart b/lib/src/surveys/widgets/confirmation_message.dart index a7eb7089..cd6ba032 100644 --- a/lib/src/surveys/widgets/confirmation_message.dart +++ b/lib/src/surveys/widgets/confirmation_message.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import 'survey_button.dart'; class ConfirmationMessage extends StatelessWidget { @@ -7,10 +8,13 @@ class ConfirmationMessage extends StatelessWidget { super.key, required this.onClose, this.appearance = SurveyAppearance.defaultAppearance, + this.thankYouMessageDescriptionContentType, }); final VoidCallback onClose; final SurveyAppearance appearance; + final PostHogDisplaySurveyTextContentType? + thankYouMessageDescriptionContentType; @override Widget build(BuildContext context) { @@ -27,7 +31,9 @@ class ConfirmationMessage extends StatelessWidget { ), textAlign: TextAlign.center, ), - if (appearance.thankYouMessageDescription?.isNotEmpty == true) ...[ + if (appearance.thankYouMessageDescription?.isNotEmpty == true && + thankYouMessageDescriptionContentType == + PostHogDisplaySurveyTextContentType.text) ...[ const SizedBox(height: 16), Text( appearance.thankYouMessageDescription!, diff --git a/lib/src/surveys/widgets/link_question.dart b/lib/src/surveys/widgets/link_question.dart index 2b7a8a0b..3c5d655b 100644 --- a/lib/src/surveys/widgets/link_question.dart +++ b/lib/src/surveys/widgets/link_question.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import 'question_header.dart'; import 'survey_button.dart'; @@ -10,6 +11,7 @@ class LinkQuestion extends StatelessWidget { super.key, required this.question, required this.description, + this.descriptionContentType, required this.appearance, required this.onPressed, this.buttonText, @@ -18,6 +20,7 @@ class LinkQuestion extends StatelessWidget { final String question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final SurveyAppearance appearance; final Future Function() onPressed; final String? buttonText; @@ -32,6 +35,7 @@ class LinkQuestion extends StatelessWidget { QuestionHeader( question: question, description: description, + descriptionContentType: descriptionContentType, appearance: appearance, ), const SizedBox(height: 16), diff --git a/lib/src/surveys/widgets/open_text_question.dart b/lib/src/surveys/widgets/open_text_question.dart index c7271aeb..d00caace 100644 --- a/lib/src/surveys/widgets/open_text_question.dart +++ b/lib/src/surveys/widgets/open_text_question.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import 'question_header.dart'; import 'survey_button.dart'; @@ -8,6 +9,7 @@ class OpenTextQuestion extends StatefulWidget { super.key, required this.question, required this.description, + this.descriptionContentType, required this.onSubmit, this.buttonText = 'Submit', this.optional = false, @@ -16,6 +18,7 @@ class OpenTextQuestion extends StatefulWidget { final String? question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final String buttonText; final bool optional; final SurveyAppearance appearance; @@ -53,6 +56,7 @@ class _OpenTextQuestionState extends State { QuestionHeader( question: widget.question, description: widget.description, + descriptionContentType: widget.descriptionContentType, appearance: widget.appearance, ), const SizedBox(height: 16), diff --git a/lib/src/surveys/widgets/question_header.dart b/lib/src/surveys/widgets/question_header.dart index 69449c15..9e608de4 100644 --- a/lib/src/surveys/widgets/question_header.dart +++ b/lib/src/surveys/widgets/question_header.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; class QuestionHeader extends StatelessWidget { const QuestionHeader({ super.key, required this.question, this.description, + this.descriptionContentType, required this.appearance, }); final String? question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final SurveyAppearance appearance; @override @@ -28,7 +31,9 @@ class QuestionHeader extends StatelessWidget { ), ), ], - if (description?.isNotEmpty == true) ...[ + if (description?.isNotEmpty == true && + descriptionContentType == + PostHogDisplaySurveyTextContentType.text) ...[ const SizedBox(height: 8), Text( description!, diff --git a/lib/src/surveys/widgets/rating_question.dart b/lib/src/surveys/widgets/rating_question.dart index 2acb81fd..f5162460 100644 --- a/lib/src/surveys/widgets/rating_question.dart +++ b/lib/src/surveys/widgets/rating_question.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/posthog_display_survey_rating_type.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import '../models/survey_appearance.dart'; import 'question_header.dart'; import 'survey_button.dart'; @@ -11,6 +12,7 @@ class RatingQuestion extends StatefulWidget { super.key, required this.question, required this.description, + this.descriptionContentType, required this.onSubmit, this.buttonText, this.optional = false, @@ -24,6 +26,7 @@ class RatingQuestion extends StatefulWidget { final String? question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final String? buttonText; final bool optional; final int scaleLowerBound; @@ -147,6 +150,7 @@ class _RatingQuestionState extends State { QuestionHeader( question: widget.question, description: widget.description, + descriptionContentType: widget.descriptionContentType, appearance: widget.appearance, ), const SizedBox(height: 24), diff --git a/lib/src/surveys/widgets/survey_bottom_sheet.dart b/lib/src/surveys/widgets/survey_bottom_sheet.dart index 8932e09d..024333d0 100644 --- a/lib/src/surveys/widgets/survey_bottom_sheet.dart +++ b/lib/src/surveys/widgets/survey_bottom_sheet.dart @@ -7,6 +7,7 @@ import '../models/survey_callbacks.dart'; import '../models/posthog_display_link_question.dart'; import '../models/posthog_display_rating_question.dart'; import '../models/posthog_display_choice_question.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import '../../posthog_flutter_platform_interface.dart'; import 'link_question.dart'; @@ -61,6 +62,7 @@ class _SurveyBottomSheetState extends State { key: ValueKey('open_text_question_$_currentIndex'), question: currentQuestion.question, description: currentQuestion.description, + descriptionContentType: currentQuestion.descriptionContentType, appearance: SurveyAppearance.fromPostHog(widget.survey.appearance), onSubmit: (response) async { final nextQuestion = await widget.onResponse( @@ -80,6 +82,7 @@ class _SurveyBottomSheetState extends State { key: ValueKey('link_question_$_currentIndex'), question: linkQuestion.question, description: linkQuestion.description, + descriptionContentType: linkQuestion.descriptionContentType, appearance: SurveyAppearance.fromPostHog(widget.survey.appearance), buttonText: linkQuestion.buttonText, link: linkQuestion.link, @@ -111,6 +114,7 @@ class _SurveyBottomSheetState extends State { key: ValueKey('rating_question_$_currentIndex'), question: ratingQuestion.question, description: ratingQuestion.description, + descriptionContentType: ratingQuestion.descriptionContentType, appearance: SurveyAppearance.fromPostHog(widget.survey.appearance), buttonText: ratingQuestion.buttonText, optional: ratingQuestion.optional, @@ -138,6 +142,7 @@ class _SurveyBottomSheetState extends State { key: ValueKey('choice_question_$_currentIndex'), question: choiceQuestion.question, description: choiceQuestion.description, + descriptionContentType: choiceQuestion.descriptionContentType, choices: choiceQuestion.choices, appearance: SurveyAppearance.fromPostHog(widget.survey.appearance), buttonText: choiceQuestion.buttonText, @@ -166,55 +171,70 @@ class _SurveyBottomSheetState extends State { Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); - return Container( - decoration: BoxDecoration( - color: widget.appearance.backgroundColor ?? Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SafeArea( - child: Padding( - padding: EdgeInsets.only( - bottom: mediaQuery.viewInsets.bottom, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () => _handleClose(), - ), - ], + return PopScope( + canPop: false, + // TODO: replace with onPopInvokedWithResult once set bump the min Flutter version to 3.24 + // ignore: deprecated_member_use + onPopInvoked: (didPop) { + if (!didPop) { + _handleClose(); + } + }, + child: Container( + decoration: BoxDecoration( + color: widget.appearance.backgroundColor ?? Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: mediaQuery.viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => _handleClose(), + ), + ], + ), ), - ), - // Content - Flexible( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!_isCompleted) - _buildQuestion(context) - else - ConfirmationMessage( - onClose: _handleClose, - appearance: widget.appearance, - ), - ], + // Content + Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!_isCompleted) + _buildQuestion(context) + else + ConfirmationMessage( + onClose: _handleClose, + appearance: widget.appearance, + thankYouMessageDescriptionContentType: widget + .survey + .appearance + ?.thankYouMessageDescriptionContentType ?? + PostHogDisplaySurveyTextContentType.text, + ), + ], + ), ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/src/utils/property_normalizer.dart b/lib/src/utils/property_normalizer.dart new file mode 100644 index 00000000..b7885907 --- /dev/null +++ b/lib/src/utils/property_normalizer.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; + +class PropertyNormalizer { + /// Normalizes a map of properties to ensure they are serializable through method channels. + /// + /// Unsupported types are converted to strings using toString(). + /// Nested maps and lists are recursively normalized. + /// Nulls are stripped. + static Map normalize(Map properties) { + final result = {}; + for (final entry in properties.entries) { + final normalizedValue = _normalizeValue(entry.value); + if (normalizedValue != null) { + result[entry.key] = normalizedValue; + } + } + return result; + } + + /// Normalizes a single value to ensure it's serializable through method channels. + static Object? _normalizeValue(Object? value) { + if (_isSupported(value)) { + return value; + } else if (value is List) { + return value.map((e) => _normalizeValue(e)).toList(); + } else if (value is Set) { + return value.map((e) => _normalizeValue(e)).toList(); + } else if (value is Map) { + final result = {}; + for (final entry in value.entries) { + final normalizedValue = _normalizeValue(entry.value); + if (normalizedValue != null) { + result[entry.key.toString()] = normalizedValue; + } + } + return result; + } else { + return value.toString(); + } + } + + /// Checks if a value is natively supported by StandardMessageCodec + /// see: https://api.flutter.dev/flutter/services/StandardMessageCodec-class.html + static bool _isSupported(Object? value) { + return value == null || + value is bool || + value is String || + value is num || + value is Uint8List || + value is Int32List || + value is Int64List || + value is Float64List; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index f3faf290..18775a6b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,14 +1,14 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.1.0 +version: 5.9.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues documentation: https://github.com/posthog/posthog-flutter#readme environment: - sdk: '>=3.3.0 <4.0.0' - flutter: '>=3.19.0' + sdk: '>=3.4.0 <4.0.0' + flutter: '>=3.22.0' dependencies: flutter: @@ -18,6 +18,7 @@ dependencies: plugin_platform_interface: ^2.0.2 # plugin_platform_interface depends on meta anyway meta: ^1.3.0 + stack_trace: ^1.12.0 dev_dependencies: flutter_lints: ^5.0.0 diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart new file mode 100644 index 00000000..28adc7b0 --- /dev/null +++ b/test/dart_exception_processor_test.dart @@ -0,0 +1,574 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart'; +import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; + +void main() { + group('DartExceptionProcessor', () { + test('processes exception with correct properties', () { + final mainException = StateError('Test exception message'); + final stackTrace = StackTrace.fromString(''' +#0 Object.noSuchMethod (package:posthog-flutter:1884:25) +#1 Trace.terse. (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/src/trace.dart:47:21) +#2 IterableMixinWorkaround.reduce (dart:collection:29:29) +#3 List.reduce (dart:core-patch:1247:42) +#4 Trace.terse (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/src/trace.dart:40:35) +#5 format (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/stack_trace.dart:24:28) +#6 main. (file:///usr/local/google-old/home/goog/dart/dart/test.dart:21:29) +#7 _CatchErrorFuture._sendError (dart:async:525:24) +#8 _FutureImpl._setErrorWithoutAsyncTrace (dart:async:393:26) +#9 _FutureImpl._setError (dart:async:378:31) +#10 _ThenFuture._sendValue (dart:async:490:16) +#11 _FutureImpl._handleValue. (dart:async:349:28) +#12 Timer.run. (dart:async:2402:21) +#13 Timer.Timer. (dart:async-patch:15:15) +'''); + + final additionalProperties = {'custom_key': 'custom_value'}; + + // Process the exception + final result = DartExceptionProcessor.processException( + error: mainException, + stackTrace: stackTrace, + properties: additionalProperties, + inAppIncludes: ['posthog_flutter_example'], + inAppExcludes: [], + inAppByDefault: true, + ); + + // Verify basic structure + expect(result, isA>()); + expect(result.containsKey('\$exception_level'), isTrue); + expect(result.containsKey('\$exception_list'), isTrue); + expect( + result.containsKey('custom_key'), isTrue); // Properties are in root + + // Verify custom properties are preserved + expect(result['custom_key'], equals('custom_value')); + + // Verify exception list structure + final exceptionList = + result['\$exception_list'] as List>; + expect(exceptionList, isNotEmpty); + + final mainExceptionData = exceptionList.first; + + // Verify main exception structure + expect(mainExceptionData['type'], equals('StateError')); + expect( + mainExceptionData['value'], + equals( + 'Bad state: Test exception message')); // StateError adds prefix + expect(mainExceptionData['thread_id'], + isA()); // Should be hash-based thread ID + + // Verify mechanism structure + final mechanism = mainExceptionData['mechanism'] as Map; + expect(mechanism['handled'], isTrue); + expect(mechanism['synthetic'], isFalse); + expect(mechanism['type'], equals('generic')); + + // Verify stack trace structure + final stackTraceData = + mainExceptionData['stacktrace'] as Map; + expect(stackTraceData['type'], equals('raw')); + + final frames = stackTraceData['frames'] as List>; + expect(frames, isNotEmpty); + + // Verify first frame structure (should be main function) + final firstFrame = frames.first; + expect(firstFrame.containsKey('function'), isTrue); + expect(firstFrame.containsKey('filename'), isTrue); + expect(firstFrame.containsKey('lineno'), isTrue); + expect(firstFrame['platform'], equals('dart')); + + // Verify inApp detection works - just check that the field exists and is boolean + expect(firstFrame['in_app'], isTrue); + + // Check that dart core frames are marked as not inApp + final dartFrame = frames.firstWhere( + (frame) => + frame['package'] == null && + (frame['abs_path']?.contains('dart:') == true), + orElse: () => {}, + ); + if (dartFrame.isNotEmpty) { + expect(dartFrame['in_app'], isFalse); + } + }); + + test('handles inAppIncludes configuration correctly', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString(''' +#0 main (package:my_app/main.dart:25:7) +#1 helper (package:third_party/helper.dart:10:5) +#2 core (dart:core/core.dart:100:10) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: ['my_app'], + inAppExcludes: [], + inAppByDefault: false, // third_party is not included + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find frames by package + final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); + final thirdPartyFrame = + frames.firstWhere((f) => f['package'] == 'third_party'); + + // Verify inApp detection + expect(myAppFrame['in_app'], isTrue); // Explicitly included + expect(thirdPartyFrame['in_app'], isFalse); // Not included + }); + + test('handles inAppExcludes configuration correctly', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString(''' +#0 main (package:my_app/main.dart:25:7) +#1 analytics (package:analytics_lib/tracker.dart:50:3) +#2 helper (package:helper_lib/utils.dart:15:8) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: [], + inAppExcludes: ['analytics_lib'], + inAppByDefault: true, // all inApp except inAppExcludes + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find frames by package + final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); + final analyticsFrame = + frames.firstWhere((f) => f['package'] == 'analytics_lib'); + final helperFrame = + frames.firstWhere((f) => f['package'] == 'helper_lib'); + + // Verify inApp detection + expect(myAppFrame['in_app'], isTrue); // Default true, not excluded + expect(analyticsFrame['in_app'], isFalse); // Explicitly excluded + expect(helperFrame['in_app'], isTrue); // Default true, not excluded + }); + + test('gives precedence to inAppIncludes over inAppExcludes', () { + // Test the precedence logic directly with a simple scenario + final exception = Exception('Test exception'); + final stackTrace = + StackTrace.fromString('#0 test (package:test_package/test.dart:1:1)'); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: ['test_package'], // Include test_package + inAppExcludes: ['test_package'], // But also exclude test_package + inAppByDefault: false, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find any frame from test_package + final testFrame = frames.firstWhere( + (frame) => frame['package'] == 'test_package', + orElse: () => {}, + ); + + // If we found the frame, test precedence + if (testFrame.isNotEmpty) { + expect(testFrame['in_app'], isTrue, + reason: 'inAppIncludes should take precedence over inAppExcludes'); + } else { + // Just verify that the configuration was processed without error + expect(frames, isA()); + } + }); + + test('processes exception types correctly', () { + final testCases = [ + // Real Exception/Error objects + { + 'exception': Exception('Exception test'), + 'expectedType': '_Exception' + }, + { + 'exception': StateError('StateError test'), + 'expectedType': 'StateError' + }, + { + 'exception': ArgumentError('ArgumentError test'), + 'expectedType': 'ArgumentError' + }, + { + 'exception': FormatException('FormatException test'), + 'expectedType': 'FormatException' + }, + // Primitive types + {'exception': 'Plain string error', 'expectedType': 'String'}, + {'exception': 42, 'expectedType': 'int'}, + {'exception': true, 'expectedType': 'bool'}, + {'exception': 3.14, 'expectedType': 'double'}, + {'exception': [], 'expectedType': 'List'}, + { + 'exception': ['some', 'error'], + 'expectedType': 'List' + }, + {'exception': {}, 'expectedType': '_Map'}, + ]; + + for (final testCase in testCases) { + final exception = testCase['exception']!; + final expectedType = testCase['expectedType'] as String; + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + properties: {}, + ); + + final exceptionList = + result['\$exception_list'] as List>; + final exceptionData = exceptionList.first; + + expect(exceptionData['type'], equals(expectedType), + reason: 'Exception type mismatch for: $exception'); + + // Verify the exception value is present and is a string + expect(exceptionData['value'], isA()); + expect(exceptionData['value'], isNotEmpty); + } + }); + + test('generates consistent thread IDs', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString('#0 test (test.dart:1:1)'); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final threadId = exceptionData.first['thread_id']; + + final result2 = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + ); + final exceptionData2 = + result2['\$exception_list'] as List>; + final threadId2 = exceptionData2.first['thread_id']; + + expect(threadId, equals(threadId2)); // Should be consistent + }); + + test('generates stack trace when none provided', () { + final exception = Exception('Test exception'); // will have no stack trace + + final result = DartExceptionProcessor.processException( + error: exception, + // No stackTrace provided - should generate one + ); + + final exceptionData = + result['\$exception_list'] as List>; + final stackTraceData = exceptionData.first['stacktrace']; + + // Should have generated a stack trace + expect(stackTraceData, isNotNull); + expect(stackTraceData['frames'], isA()); + expect((stackTraceData['frames'] as List).isNotEmpty, isTrue); + + // Should be marked as synthetic since we generated it + expect(exceptionData.first['mechanism']['synthetic'], isTrue); + }); + + test('uses error.stackTrace when available', () { + try { + throw StateError('Test error'); + } catch (error) { + final result = DartExceptionProcessor.processException( + error: error, + // No stackTrace provided - should generate one from error.stackTrace + ); + + final exceptionData = + result['\$exception_list'] as List>; + final stackTraceData = exceptionData.first['stacktrace']; + + // Should have a stack trace from the Error object + expect(stackTraceData, isNotNull); + expect(stackTraceData['frames'], isA()); + + // Should not be marked as synthetic since we did not generate a stack trace + expect(exceptionData.first['mechanism']['synthetic'], isFalse); + } + }); + + test('removes PostHog frames when stack trace is generated', () { + final exception = Exception('Test exception'); + + // Create a mock stack trace that includes PostHog frames + final mockStackTrace = StackTrace.fromString(''' +#0 DartExceptionProcessor.processException (package:posthog_flutter/src/error_tracking/dart_exception_processor.dart:28:7) +#1 PosthogFlutterIO.captureException (package:posthog_flutter/src/posthog_flutter_io.dart:435:29) +#2 Posthog.captureException (package:posthog_flutter/src/posthog.dart:136:7) +#3 userFunction (package:my_app/main.dart:100:5) +#4 PosthogFlutterIO.setup (package:posthog_flutter/src/posthog.dart:136:7) +#5 main (package:some_lib/lib.dart:50:3) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTraceProvider: () { + return mockStackTrace; + }, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] as List; + + // Should include frames since we provided the stack trace + expect(frames[0]['package'], 'my_app'); + expect(frames[0]['filename'], 'main.dart'); + // earlier PH frames should be untouched + expect(frames[1]['package'], 'posthog_flutter'); + expect(frames[1]['filename'], 'posthog.dart'); + expect(frames[2]['package'], 'some_lib'); + expect(frames[2]['filename'], 'lib.dart'); + }); + + test('marks generated stack frames as synthetic', () { + final exception = Exception('Test exception'); // will have no stack trace + + final result = DartExceptionProcessor.processException( + error: exception, + // No stackTrace provided - should generate one + ); + + final exceptionData = + result['\$exception_list'] as List>; + + // Should be marked as synthetic since we generated it + expect(exceptionData.first['mechanism']['synthetic'], isTrue); + }); + + test('does not mark exceptions as synthetic when stack trace is provided', + () { + final realExceptions = [ + Exception('Real exception'), + StateError('Real error'), + ArgumentError('Real argument error'), + ]; + + for (final exception in realExceptions) { + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + ); + + final exceptionData = + result['\$exception_list'] as List>; + + expect(exceptionData.first['mechanism']['synthetic'], isFalse); + } + }); + + test('allows user properties to override system properties', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString('#0 test (test.dart:1:1)'); + + // Properties that override system properties + final overrideProperties = { + '\$exception_level': 'warning', // Override default 'error' + 'custom_property': 'custom_value', // Additional custom property + }; + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: overrideProperties, + ); + + // Verify that user properties take precedence + expect(result['\$exception_level'], equals('warning')); + expect(result['custom_property'], equals('custom_value')); + }); + + test('inserts asynchronous gap frames between traces', () async { + final exception = Exception('Async test exception'); + + // Create an async stack trace by throwing from an async function + StackTrace? asyncStackTrace; + try { + await _asyncFunction1(); + } catch (e, stackTrace) { + asyncStackTrace = stackTrace; + } + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: asyncStackTrace, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Look for asynchronous gap frames + final gapFrames = frames + .where((frame) => frame['abs_path'] == '') + .toList(); + + // Should have at least one gap frame in an async stack trace + expect(gapFrames, isNotEmpty, + reason: 'Async stack traces should contain gap frames'); + + // Verify gap frame structure + final gapFrame = gapFrames.first; + expect(gapFrame['platform'], equals('dart')); + expect(gapFrame['in_app'], isFalse); + expect(gapFrame['abs_path'], equals('')); + }); + + test('processes PostHogException with different mechanism types', () { + final testCases = [ + {'mechanism': 'FlutterError', 'handled': false}, + {'mechanism': 'PlatformDispatcher', 'handled': false}, + {'mechanism': 'UncaughtExceptionHandler', 'handled': true}, + {'mechanism': 'custom_mechanism', 'handled': true}, + ]; + + for (final testCase in testCases) { + final originalError = StateError('Test error'); + final postHogException = PostHogException( + source: originalError, + mechanism: testCase['mechanism'] as String, + handled: testCase['handled'] as bool, + ); + + final result = DartExceptionProcessor.processException( + error: postHogException, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + ); + + final exceptionData = + (result['\$exception_list'] as List).first as Map; + + expect( + exceptionData['mechanism']['type'], equals(testCase['mechanism'])); + expect( + exceptionData['mechanism']['handled'], equals(testCase['handled'])); + expect(exceptionData['type'], equals('StateError')); + } + }); + + test( + 'uses original error for stack trace processing when wrapped in PostHogException', + () { + // Create an Error (not Exception) so it has a built-in stackTrace + late Error originalError; + + try { + throw StateError('Original error with stack trace'); + } catch (error) { + originalError = error as Error; + } + + // Wrap in PostHogException + final postHogException = PostHogException( + source: originalError, + mechanism: 'test_mechanism', + handled: true, + ); + + // Process without providing external stack trace - should use original error's stackTrace + final result = DartExceptionProcessor.processException( + error: postHogException, + // No stackTrace provided - should extract from original error + ); + + final exceptionData = + (result['\$exception_list'] as List).first as Map; + + // Verify it used the original error for processing + expect(exceptionData['type'], equals('StateError')); + expect(exceptionData['value'], + equals('Bad state: Original error with stack trace')); + expect(exceptionData['mechanism']['type'], equals('test_mechanism')); + expect(exceptionData['mechanism']['handled'], equals(true)); + + // Should have stacktrace frames from the original error + expect(exceptionData['stacktrace'], isNotNull); + expect(exceptionData['stacktrace']['frames'], isA()); + expect( + (exceptionData['stacktrace']['frames'] as List).isNotEmpty, isTrue); + }); + + test('processes original error type correctly when wrapped', () { + final testErrorTypes = [ + Exception('Test exception'), + StateError('State error'), + ArgumentError('Argument error'), + FormatException('Format error'), + RangeError('Range error'), + ]; + + for (final originalError in testErrorTypes) { + final postHogException = PostHogException( + source: originalError, + mechanism: 'test_mechanism', + ); + + final result = DartExceptionProcessor.processException( + error: postHogException, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + ); + + final exceptionData = + (result['\$exception_list'] as List).first as Map; + + // Should extract type from original error, not PostHogException + final expectedType = originalError.runtimeType.toString(); + expect(exceptionData['type'], equals(expectedType)); + + // Should use original error's toString for message + expect(exceptionData['value'], equals(originalError.toString())); + + // But mechanism should come from wrapper + expect(exceptionData['mechanism']['type'], equals('test_mechanism')); + } + }); + }); +} + +// Helper functions to generate async stack traces for testing +Future _asyncFunction1() async { + await _asyncFunction2(); +} + +Future _asyncFunction2() async { + await Future.delayed(Duration.zero); // Force async boundary + throw StateError('Async error for testing'); +} diff --git a/test/posthog_flutter_platform_interface_fake.dart b/test/posthog_flutter_platform_interface_fake.dart index fd679082..3c459db2 100644 --- a/test/posthog_flutter_platform_interface_fake.dart +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -1,7 +1,21 @@ import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +/// Captured exception call data +class CapturedExceptionCall { + final Object error; + final StackTrace? stackTrace; + final Map? properties; + + CapturedExceptionCall({ + required this.error, + this.stackTrace, + this.properties, + }); +} + class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { String? screenName; + final List capturedExceptions = []; @override Future screen({ @@ -10,4 +24,17 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { }) async { this.screenName = screenName; } + + @override + Future captureException({ + required Object error, + StackTrace? stackTrace, + Map? properties, + }) async { + capturedExceptions.add(CapturedExceptionCall( + error: error, + stackTrace: stackTrace, + properties: properties, + )); + } } diff --git a/test/property_normalizer_test.dart b/test/property_normalizer_test.dart new file mode 100644 index 00000000..87b93127 --- /dev/null +++ b/test/property_normalizer_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/utils/property_normalizer.dart'; + +void main() { + group('PropertyNormalizer', () { + test('normalizes supported types correctly', () { + final properties = { + 'null_value': null, + 'bool_value': true, + 'int_value': 42, + 'double_value': 3.14, + 'string_value': 'hello', + }; + + final result = PropertyNormalizer.normalize(properties); + + // Null values are filtered out by the normalizer + final expected = { + 'bool_value': true, + 'int_value': 42, + 'double_value': 3.14, + 'string_value': 'hello', + }; + + expect(result, equals(expected)); + }); + + test('converts unsupported types to strings', () { + final customObject = DateTime(2023, 1, 1); + final properties = { + 'custom_object': customObject, + 'symbol': #test, + }; + + final result = PropertyNormalizer.normalize(properties); + + expect(result['custom_object'], equals(customObject.toString())); + expect(result['symbol'], equals('Symbol("test")')); + }); + + test('normalizes multidimensional lists', () { + final properties = { + 'simple_list': [1, 2, 3], + 'mixed_list': [1, 'hello', true], + '2d_list': [ + [1, 2], + ['a', 'b'] + ], + }; + + final result = PropertyNormalizer.normalize(properties); + + expect(result['simple_list'], equals([1, 2, 3])); + final mixedList = result['mixed_list'] as List; + expect(mixedList[0], equals(1)); + expect(mixedList[1], equals('hello')); + expect(mixedList[2], equals(true)); + expect( + result['2d_list'], + equals([ + [1, 2], + ['a', 'b'] + ])); + }); + + test('normalizes nested maps', () { + final properties = { + 'nested_map': { + 'inner_string': 'value', + 'inner_number': 123, + 'deeply_nested': { + 'level2': { + 'level3': 'deep_value', + 1: 'deep_value', + }, + }, + }, + }; + + final result = PropertyNormalizer.normalize(properties); + + final nestedMap = result['nested_map'] as Map; + expect(nestedMap['inner_string'], equals('value')); + expect(nestedMap['inner_number'], equals(123)); + + final deeplyNested = nestedMap['deeply_nested'] as Map; + final level2 = deeplyNested['level2'] as Map; + expect(level2['level3'], equals('deep_value')); + expect(level2['1'], equals('deep_value')); + }); + + test('handles maps with non-string keys', () { + final properties = { + 'map_with_int_keys': { + 1: 'one', + 2: 'two', + }, + }; + + final result = PropertyNormalizer.normalize(properties); + + final normalizedMap = result['map_with_int_keys'] as Map; + expect(normalizedMap['1'], equals('one')); // Key converted to string + expect(normalizedMap['2'], equals('two')); // Key converted to string + }); + }); +}