From bb0b13c3932160d81b924c7aaca9bef59c150ccf Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 16:04:18 -0400 Subject: [PATCH 01/43] chore!: adopt posthog-ios 4.0 - Require posthog-ios 4.x. - Adapt to the reloadFeatureFlags(_:) callback that now passes a PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913). - Note the payload decoding changes from PostHog/posthog-ios#903. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v6-posthog-ios-4.md | 7 +++++++ posthog_flutter/darwin/posthog_flutter.podspec | 2 +- posthog_flutter/darwin/posthog_flutter/Package.swift | 2 +- .../Sources/posthog_flutter/PosthogFlutterPlugin.swift | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .changeset/v6-posthog-ios-4.md diff --git a/.changeset/v6-posthog-ios-4.md b/.changeset/v6-posthog-ios-4.md new file mode 100644 index 00000000..ae84b6fe --- /dev/null +++ b/.changeset/v6-posthog-ios-4.md @@ -0,0 +1,7 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** adopt `posthog-ios` 4.x, which requires Xcode 26 +- **Breaking:** return `null` instead of the raw string for a malformed, empty, or whitespace-only `getFeatureFlagResult()` payload on iOS and macOS +- **Breaking:** keep string payloads passed to `PostHogBootstrapConfig.featureFlagPayloads`, such as `"123"`, as strings on iOS and macOS instead of decoding them to numbers or booleans diff --git a/posthog_flutter/darwin/posthog_flutter.podspec b/posthog_flutter/darwin/posthog_flutter.podspec index 98e3a7cf..d50d58d6 100644 --- a/posthog_flutter/darwin/posthog_flutter.podspec +++ b/posthog_flutter/darwin/posthog_flutter.podspec @@ -21,7 +21,7 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - s.dependency 'PostHog', '>= 3.84.0', '< 4.0.0' + s.dependency 'PostHog', '>= 4.0.0', '< 5.0.0' s.ios.deployment_target = '15.0' s.osx.deployment_target = '11.0' diff --git a/posthog_flutter/darwin/posthog_flutter/Package.swift b/posthog_flutter/darwin/posthog_flutter/Package.swift index 1cf4470c..5d2d9474 100644 --- a/posthog_flutter/darwin/posthog_flutter/Package.swift +++ b/posthog_flutter/darwin/posthog_flutter/Package.swift @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), - .package(url: "https://github.com/PostHog/posthog-ios", "3.84.0" ..< "4.0.0"), + .package(url: "https://github.com/PostHog/posthog-ios", "4.0.0" ..< "5.0.0"), ], targets: [ .target( diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 1fa099c4..7f9f6d01 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -1622,7 +1622,7 @@ extension PosthogFlutterPlugin { private func reloadFeatureFlags(_ result: @escaping FlutterResult) { // Resolve the Dart Future only once flags have actually finished loading. // The native callback fires on a background thread, so hop to main for Flutter. - PostHogSDK.shared.reloadFeatureFlags { + PostHogSDK.shared.reloadFeatureFlags { _ in DispatchQueue.main.async { result(nil) } From dd988febfea3639accab0903d0f00538a0703707 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 16:33:41 -0400 Subject: [PATCH 02/43] docs(changeset): note posthog-ios 4.0 drops lifecycle version and build PostHog/posthog-ios#905 removes the legacy version/build properties from lifecycle events in 4.0, so Flutter iOS and macOS apps stop sending them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v6-posthog-ios-4.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/v6-posthog-ios-4.md b/.changeset/v6-posthog-ios-4.md index ae84b6fe..39b171fa 100644 --- a/.changeset/v6-posthog-ios-4.md +++ b/.changeset/v6-posthog-ios-4.md @@ -5,3 +5,4 @@ - **Breaking:** adopt `posthog-ios` 4.x, which requires Xcode 26 - **Breaking:** return `null` instead of the raw string for a malformed, empty, or whitespace-only `getFeatureFlagResult()` payload on iOS and macOS - **Breaking:** keep string payloads passed to `PostHogBootstrapConfig.featureFlagPayloads`, such as `"123"`, as strings on iOS and macOS instead of decoding them to numbers or booleans +- **Breaking:** stop sending the legacy `version` and `build` properties on `Application Installed`, `Application Updated` and `Application Opened` on iOS and macOS — use `$app_version` and `$app_build`. Android still sends them until `posthog-android` drops them From 852c2c905a235015bcc23486e01d568cd35a0a20 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 12:38:30 -0400 Subject: [PATCH 03/43] chore(ios): drive onFeatureFlags from the posthog-ios onFeatureFlags listener posthog-ios 4.0 removes the didReceiveFeatureFlags notification. --- .../posthog_flutter/PosthogFlutterPlugin.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 7f9f6d01..3f26a15b 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -43,6 +43,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { private static var instance: PosthogFlutterPlugin? private var channel: FlutterMethodChannel? + private var featureFlagsSubscription: PostHogFeatureFlagsSubscription? // Mint route for pushIdentityProvider, anchored to the engine whose setup() // installed the provider. A live owner is never displaced; provider-enabled @@ -61,15 +62,13 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { override init() { super.init() - NotificationCenter.default.addObserver( - self, - selector: #selector(featureFlagsDidUpdate), - name: PostHogSDK.didReceiveFeatureFlags, - object: nil - ) + featureFlagsSubscription = PostHogSDK.shared.onFeatureFlags { [weak self] _ in + self?.featureFlagsDidUpdate() + } } deinit { + featureFlagsSubscription?.unsubscribe() NotificationCenter.default.removeObserver(self) } @@ -88,7 +87,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: methodChannel) } - @objc func featureFlagsDidUpdate() { + func featureFlagsDidUpdate() { invokeFlutterMethod("onFeatureFlagsCallback", arguments: [String: Any]()) } From da3dedddc821f8f8b0b2ea1c795739974d4beb68 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 12:50:15 -0400 Subject: [PATCH 04/43] chore!: adopt posthog-android 4.0 --- .changeset/v6-posthog-android-4.md | 5 +++++ posthog_flutter/android/build.gradle | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/v6-posthog-android-4.md diff --git a/.changeset/v6-posthog-android-4.md b/.changeset/v6-posthog-android-4.md new file mode 100644 index 00000000..3ec91401 --- /dev/null +++ b/.changeset/v6-posthog-android-4.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** adopt `posthog-android` 4.x diff --git a/posthog_flutter/android/build.gradle b/posthog_flutter/android/build.gradle index 2ae6e792..fd123302 100644 --- a/posthog_flutter/android/build.gradle +++ b/posthog_flutter/android/build.gradle @@ -67,7 +67,7 @@ android { testImplementation 'org.robolectric:robolectric:4.14.1' testImplementation 'junit:junit:4.13.2' testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.1' - implementation 'com.posthog:posthog-android:[3.71.0,4.0.0)' + implementation 'com.posthog:posthog-android:[4.0.0,5.0.0)' } testOptions { From 82c095869c9541b56cbb2c5153613ccf8723d63e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 15:08:04 -0400 Subject: [PATCH 05/43] feat: add per-event capture options and captureAi Passes options and captureAi through to posthog-ios and posthog-android 4.0; Windows, Linux and web send known options as their legacy properties. --- .changeset/v6-capture-options-ai.md | 7 + api/posthog_flutter.api.json | 383 ++++++++++++++++++ .../posthog/flutter/PosthogFlutterPlugin.kt | 50 ++- .../flutter/EventOptionsChannelTest.kt | 111 +++++ .../PosthogFlutterPlugin.swift | 51 ++- posthog_flutter/lib/posthog_flutter_web.dart | 80 +++- .../lib/src/core/posthog_core.dart | 47 ++- posthog_flutter/lib/src/posthog.dart | 122 +++++- posthog_flutter/lib/src/posthog_event.dart | 12 +- .../lib/src/posthog_flutter_desktop.dart | 70 +++- .../lib/src/posthog_flutter_io.dart | 88 +++- .../posthog_flutter_platform_interface.dart | 20 +- .../lib/src/utils/event_options.dart | 46 +++ .../test/posthog_flutter_desktop_test.dart | 146 +++++++ .../test/posthog_flutter_io_test.dart | 127 ++++++ ...sthog_flutter_platform_interface_fake.dart | 65 +++ posthog_flutter/test/posthog_test.dart | 44 ++ .../test/web_before_send_test.dart | 41 ++ .../lib/adapter_server.dart | 1 + 19 files changed, 1458 insertions(+), 53 deletions(-) create mode 100644 .changeset/v6-capture-options-ai.md create mode 100644 posthog_flutter/android/src/test/kotlin/com/posthog/flutter/EventOptionsChannelTest.kt create mode 100644 posthog_flutter/lib/src/utils/event_options.dart diff --git a/.changeset/v6-capture-options-ai.md b/.changeset/v6-capture-options-ai.md new file mode 100644 index 00000000..4eecfdbb --- /dev/null +++ b/.changeset/v6-capture-options-ai.md @@ -0,0 +1,7 @@ +--- +"posthog_flutter": minor +--- + +- Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS and Android other keys are sent as-is; on Windows, Linux and web the known keys are sent as their legacy `$` properties and other keys are dropped +- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID. On Windows, Linux and web it sends nothing and returns `null` +- On Windows and Linux, a `$process_person_profile` property passed with an event now applies to that event instead of being replaced by the SDK's value diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index d3767214..87f925f9 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -236,6 +236,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -267,6 +276,15 @@ "name": "userPropertiesToSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -316,6 +334,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -323,6 +350,64 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "captureAi", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "eventName", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "properties", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userProperties", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userPropertiesSetOnce", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + } + ], + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "returnTypeName": "Future", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -347,6 +432,15 @@ "name": "properties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -559,6 +653,15 @@ "name": "alias", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -948,6 +1051,15 @@ "name": "groupProperties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1074,6 +1186,15 @@ "name": "properties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1289,6 +1410,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1320,6 +1450,15 @@ "name": "userPropertiesToSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1369,6 +1508,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1376,6 +1524,64 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "captureAi", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "eventName", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "properties", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userProperties", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userPropertiesSetOnce", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + } + ], + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "returnTypeName": "Future", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -1400,6 +1606,15 @@ "name": "properties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1612,6 +1827,15 @@ "name": "alias", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -2001,6 +2225,15 @@ "name": "groupProperties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -2127,6 +2360,15 @@ "name": "properties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -4141,6 +4383,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4172,6 +4423,15 @@ "name": "userPropertiesToSetOnce", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4221,6 +4481,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4228,6 +4497,64 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "captureAi", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "eventName", + "relativePath": "lib/src/posthog.dart", + "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "properties", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userProperties", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userPropertiesSetOnce", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" + } + ], + "relativePath": "lib/src/posthog.dart", + "returnTypeName": "Future", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -4252,6 +4579,15 @@ "name": "properties", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4341,6 +4677,15 @@ "name": "alias", "relativePath": "lib/src/posthog.dart", "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4683,6 +5028,15 @@ "name": "groupProperties", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4972,6 +5326,15 @@ "name": "properties", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -5226,6 +5589,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_event.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_event.dart", @@ -5278,6 +5650,17 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_event.dart", "typeName": "Map?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "options", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "Map?" } ], "isDeprecated": false, diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index f81e073c..266d3a3a 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -247,6 +247,10 @@ class PosthogFlutterPlugin : capture(call, result) } + "captureAi" -> { + captureAi(call, result) + } + "captureFeatureView" -> { captureFeatureView(call, result) } @@ -1730,7 +1734,8 @@ class PosthogFlutterPlugin : val userId: String = call.argument("userId")!! val userProperties: Map? = call.argument("userProperties") val userPropertiesSetOnce: Map? = call.argument("userPropertiesSetOnce") - PostHog.identify(userId, userProperties, userPropertiesSetOnce) + val options: Map? = call.argument("options") + PostHog.identify(userId, userProperties, userPropertiesSetOnce, options = options) result.success(null) } catch (e: Throwable) { result.error("PosthogFlutterException", e.localizedMessage, null) @@ -1744,7 +1749,8 @@ class PosthogFlutterPlugin : try { val userPropertiesToSet: Map? = call.argument("userPropertiesToSet") val userPropertiesToSetOnce: Map? = call.argument("userPropertiesToSetOnce") - PostHog.setPersonProperties(userPropertiesToSet, userPropertiesToSetOnce) + val options: Map? = call.argument("options") + PostHog.setPersonProperties(userPropertiesToSet, userPropertiesToSetOnce, options = options) result.success(null) } catch (e: Throwable) { result.error("PosthogFlutterException", e.localizedMessage, null) @@ -1760,11 +1766,13 @@ class PosthogFlutterPlugin : val properties: Map? = call.argument("properties") val userProperties: Map? = call.argument("userProperties") val userPropertiesSetOnce: Map? = call.argument("userPropertiesSetOnce") + val options: Map? = call.argument("options") PostHog.capture( eventName, properties = properties, userProperties = userProperties, userPropertiesSetOnce = userPropertiesSetOnce, + options = options, ) result.success(null) } catch (e: Throwable) { @@ -1772,6 +1780,30 @@ class PosthogFlutterPlugin : } } + private fun captureAi( + call: MethodCall, + result: Result, + ) { + try { + val eventName: String = call.argument("eventName")!! + val properties: Map? = call.argument("properties") + val userProperties: Map? = call.argument("userProperties") + val userPropertiesSetOnce: Map? = call.argument("userPropertiesSetOnce") + val options: Map? = call.argument("options") + val uuid = + PostHog.captureAi( + eventName, + properties = properties, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + options = options, + ) + result.success(uuid?.toString()) + } catch (e: Throwable) { + result.error("PosthogFlutterException", e.localizedMessage, null) + } + } + private fun screen( call: MethodCall, result: Result, @@ -1779,7 +1811,8 @@ class PosthogFlutterPlugin : try { val screenName: String = call.argument("screenName")!! val properties: Map? = call.argument("properties") - PostHog.screen(screenName, properties) + val options: Map? = call.argument("options") + PostHog.screen(screenName, properties, options = options) result.success(null) } catch (e: Throwable) { result.error("PosthogFlutterException", e.localizedMessage, null) @@ -1820,7 +1853,8 @@ class PosthogFlutterPlugin : ) { try { val alias: String = call.argument("alias")!! - PostHog.alias(alias) + val options: Map? = call.argument("options") + PostHog.alias(alias, options = options) result.success(null) } catch (e: Throwable) { result.error("PosthogFlutterException", e.localizedMessage, null) @@ -1998,7 +2032,8 @@ class PosthogFlutterPlugin : val groupType: String = call.argument("groupType")!! val groupKey: String = call.argument("groupKey")!! val groupProperties: Map? = call.argument("groupProperties") - PostHog.group(groupType, groupKey, groupProperties) + val options: Map? = call.argument("options") + PostHog.group(groupType, groupKey, groupProperties, options = options) result.success(null) } catch (e: Throwable) { result.error("PosthogFlutterException", e.localizedMessage, null) @@ -2130,6 +2165,9 @@ class PosthogFlutterPlugin : val properties = arguments["properties"] as? Map val timestampMs = arguments["timestamp"] as? Long + @Suppress("UNCHECKED_CAST") + val options = arguments["options"] as? Map + // Extract timestamp from Flutter val timestamp: Date? = timestampMs?.let { @@ -2137,7 +2175,7 @@ class PosthogFlutterPlugin : Date(timestampMs) } - PostHog.capture("\$exception", properties = properties, timestamp = timestamp) + PostHog.capture("\$exception", properties = properties, timestamp = timestamp, options = options) result.success(null) } catch (e: Throwable) { result.error("CAPTURE_EXCEPTION_ERROR", "Failed to capture exception: ${e.message}", null) diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/EventOptionsChannelTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/EventOptionsChannelTest.kt new file mode 100644 index 00000000..e20b016d --- /dev/null +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/EventOptionsChannelTest.kt @@ -0,0 +1,111 @@ +package com.posthog.flutter + +import com.posthog.PostHog +import com.posthog.PostHogEvent +import com.posthog.android.PostHogAndroid +import com.posthog.android.PostHogAndroidConfig +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class EventOptionsChannelTest { + private val options = mapOf("process_person_profile" to true, "custom_key" to "kept") + + @Test + fun forwardsOptionsToEachNativeMethod() { + val events = mutableListOf() + val config = + PostHogAndroidConfig("event-options-test", "http://127.0.0.1:1").apply { + preloadFeatureFlags = false + captureApplicationLifecycleEvents = false + captureDeepLinks = false + captureScreenViews = false + addBeforeSend { event -> + events.add(event) + // Keep AI events so captureAi returns their uuid. + if (event.event.startsWith("\$ai_")) event else null + } + } + PostHogAndroid.setup(RuntimeEnvironment.getApplication(), config) + try { + val plugin = PosthogFlutterPlugin() + val calls = + listOf( + "capture" to mapOf("eventName" to "clicked"), + "screen" to mapOf("screenName" to "Home"), + "captureException" to mapOf("properties" to mapOf("\$exception_list" to emptyList())), + "alias" to mapOf("alias" to "alias-id"), + "identify" to mapOf("userId" to "user-id"), + "setPersonProperties" to mapOf("userPropertiesToSet" to mapOf("plan" to "pro")), + "group" to mapOf("groupType" to "company", "groupKey" to "acme"), + ) + for ((method, arguments) in calls) { + val result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(MethodCall(method, arguments + ("options" to options)), result) + Mockito.verify(result).success(null) + } + + assertEquals( + listOf("clicked", "\$screen", "\$exception", "\$create_alias", "\$identify", "\$set", "\$groupidentify"), + events.map { it.event }, + ) + for (event in events) { + assertEquals?>(options, event.options, event.event) + } + } finally { + PostHog.close() + } + } + + @Test + fun captureAiReturnsTheQueuedEventUuid() { + val events = mutableListOf() + val config = + PostHogAndroidConfig("capture-ai-test", "http://127.0.0.1:1").apply { + preloadFeatureFlags = false + captureApplicationLifecycleEvents = false + captureDeepLinks = false + addBeforeSend { event -> + events.add(event) + event + } + } + PostHogAndroid.setup(RuntimeEnvironment.getApplication(), config) + try { + val result = Mockito.mock(MethodChannel.Result::class.java) + PosthogFlutterPlugin().onMethodCall( + MethodCall( + "captureAi", + mapOf( + "eventName" to "\$ai_generation", + "properties" to mapOf("\$ai_model" to "gpt-4o"), + "options" to options, + ), + ), + result, + ) + + val captor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(result).success(captor.capture()) + val uuid = assertNotNull(captor.value as? String) + UUID.fromString(uuid) + val event = events.single() + assertEquals("\$ai_generation", event.event) + assertEquals("gpt-4o", event.properties?.get("\$ai_model")) + assertEquals?>(options, event.options) + } finally { + PostHog.close() + } + } +} diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 3f26a15b..bd0764ad 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -477,6 +477,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { setPersonProperties(call, result: result) case "capture": capture(call, result: result) + case "captureAi": + captureAi(call, result: result) case "screen": screen(call, result: result) case "captureLog": @@ -1423,11 +1425,13 @@ extension PosthogFlutterPlugin { { let userProperties = args["userProperties"] as? [String: Any] let userPropertiesSetOnce = args["userPropertiesSetOnce"] as? [String: Any] + let options = args["options"] as? [String: Any] PostHogSDK.shared.identify( userId, userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce + userPropertiesSetOnce: userPropertiesSetOnce, + options: options ) result(nil) } else { @@ -1442,10 +1446,12 @@ extension PosthogFlutterPlugin { if let args = call.arguments as? [String: Any] { let userPropertiesToSet = args["userPropertiesToSet"] as? [String: Any] let userPropertiesToSetOnce = args["userPropertiesToSetOnce"] as? [String: Any] + let options = args["options"] as? [String: Any] PostHogSDK.shared.setPersonProperties( userPropertiesToSet: userPropertiesToSet, - userPropertiesToSetOnce: userPropertiesToSetOnce + userPropertiesToSetOnce: userPropertiesToSetOnce, + options: options ) result(nil) } else { @@ -1463,11 +1469,13 @@ extension PosthogFlutterPlugin { let properties = args["properties"] as? [String: Any] let userProperties = args["userProperties"] as? [String: Any] let userPropertiesSetOnce = args["userPropertiesSetOnce"] as? [String: Any] + let options = args["options"] as? [String: Any] PostHogSDK.shared.capture( eventName, properties: properties, userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce + userPropertiesSetOnce: userPropertiesSetOnce, + options: options ) result(nil) } else { @@ -1475,6 +1483,30 @@ extension PosthogFlutterPlugin { } } + private func captureAi( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + if let args = call.arguments as? [String: Any], + let eventName = args["eventName"] as? String + { + let properties = args["properties"] as? [String: Any] + let userProperties = args["userProperties"] as? [String: Any] + let userPropertiesSetOnce = args["userPropertiesSetOnce"] as? [String: Any] + let options = args["options"] as? [String: Any] + let uuid = PostHogSDK.shared.captureAi( + eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options + ) + result(uuid) + } else { + _badArgumentError(result) + } + } + private func screen( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -1483,9 +1515,11 @@ extension PosthogFlutterPlugin { let screenName = args["screenName"] as? String { let properties = args["properties"] as? [String: Any] + let options = args["options"] as? [String: Any] PostHogSDK.shared.screen( screenName, - properties: properties + properties: properties, + options: options ) result(nil) } else { @@ -1544,7 +1578,8 @@ extension PosthogFlutterPlugin { if let args = call.arguments as? [String: Any], let alias = args["alias"] as? String { - PostHogSDK.shared.alias(alias) + let options = args["options"] as? [String: Any] + PostHogSDK.shared.alias(alias, options: options) result(nil) } else { _badArgumentError(result) @@ -1686,7 +1721,8 @@ extension PosthogFlutterPlugin { let groupKey = args["groupKey"] as? String { let groupProperties = args["groupProperties"] as? [String: Any] - PostHogSDK.shared.group(type: groupType, key: groupKey, groupProperties: groupProperties) + let options = args["options"] as? [String: Any] + PostHogSDK.shared.group(type: groupType, key: groupKey, groupProperties: groupProperties, options: options) result(nil) } else { _badArgumentError(result) @@ -1790,6 +1826,7 @@ extension PosthogFlutterPlugin { } let properties = arguments["properties"] as? [String: Any] + let options = arguments["options"] as? [String: Any] // Extract timestamp from Flutter and convert to Date var timestamp: Date? = nil @@ -1798,7 +1835,7 @@ extension PosthogFlutterPlugin { } // Use capture method with timestamp to ensure Flutter timestamp is used - PostHogSDK.shared.capture("$exception", properties: properties, timestamp: timestamp) + PostHogSDK.shared.capture("$exception", properties: properties, timestamp: timestamp, options: options) result(nil) } diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 88047d82..1587413c 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -20,6 +20,7 @@ import 'src/posthog_flutter_web_handler.dart'; import 'src/utils/before_send.dart'; import 'src/replay/web/web_canvas_mask_provider.dart'; import 'src/utils/capture_utils.dart'; +import 'src/utils/event_options.dart'; import 'src/utils/feature_event.dart'; /// A web implementation of the PosthogFlutterPlatform of the PosthogFlutter plugin. @@ -44,6 +45,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { return applyBeforeSend( _beforeSendCallbacks, @@ -52,10 +54,22 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { properties: properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options == null ? null : {...options}, ), ); } + /// posthog-js sends these calls' events without event properties, so + /// options can't be attached. + static void _logUnsupportedOptions( + String method, + Map? options, + ) { + if (cleanEventOptions(options) == null) return; + printIfDebug('[PostHog] $method options are not supported on web and ' + 'were dropped.'); + } + static void registerWith(Registrar registrar) { final channel = MethodChannel( 'posthog_flutter', @@ -144,7 +158,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String userId, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) async { + _logUnsupportedOptions('identify', options); return handleWebMethodCall( MethodCall('identify', { 'userId': userId, @@ -159,7 +175,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) async { + _logUnsupportedOptions('setPersonProperties', options); if ((userPropertiesToSet?.isEmpty ?? true) && (userPropertiesToSetOnce?.isEmpty ?? true)) { return; @@ -181,12 +199,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) async { final processedEvent = await _runBeforeSend( eventName, properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options, ); if (processedEvent == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); @@ -199,7 +219,11 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { userPropertiesSetOnce: processedEvent.userPropertiesSetOnce, ); - final extractedProperties = extracted.properties; + final extractedProperties = _withOptionProperties( + extracted.properties, + processedEvent.options, + processedEvent.event, + ); final extractedUserProperties = extracted.userProperties; final extractedUserPropertiesSetOnce = extracted.userPropertiesSetOnce; @@ -226,6 +250,33 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { ); } + /// posthog-js has no AI capture, so AI events are not sent on web. + @override + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) async { + printIfDebug('[PostHog] captureAi is not supported on web. ' + 'The event "$eventName" was not sent.'); + return null; + } + + /// posthog-js has no options for these keys, so they are sent as their + /// legacy properties, winning over a legacy property set on the event. + static Map? _withOptionProperties( + Map? properties, + Map? options, + String eventName, + ) { + final optionProperties = + eventOptionsToLegacyProperties(options, method: eventName); + if (optionProperties.isEmpty) return properties; + return {...?properties, ...optionProperties}; + } + @override Future captureLog({ required String body, @@ -255,6 +306,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Future screen({ required String screenName, Map? properties, + Map? options, }) async { // The screen name argument wins over a properties `$screen_name`. // beforeSend still runs after this and can change the name. @@ -266,6 +318,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.screen, propsWithScreenName, + options: options, ); if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -276,6 +329,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } @@ -285,7 +339,11 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { screenName; _currentScreen = finalScreenName; processedEvent.properties?.remove(PostHogPropertyName.screenName); - final remaining = processedEvent.properties; + final remaining = _withOptionProperties( + processedEvent.properties, + processedEvent.options, + PostHogEventName.screen, + ); return handleWebMethodCall( MethodCall('screen', { @@ -296,7 +354,11 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { } @override - Future alias({required String alias}) async { + Future alias({ + required String alias, + Map? options, + }) async { + _logUnsupportedOptions('alias', options); return handleWebMethodCall(MethodCall('alias', {'alias': alias})); } @@ -442,7 +504,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String groupType, required String groupKey, Map? groupProperties, + Map? options, }) async { + _logUnsupportedOptions('group', options); return handleWebMethodCall( MethodCall('group', { 'groupType': groupType, @@ -544,6 +608,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) async { try { final exceptionData = DartExceptionProcessor.processException( @@ -558,6 +623,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.exception, exceptionData.cast(), + options: options, ); if (processedEvent == null) { printIfDebug( @@ -570,12 +636,18 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } final normalizedData = PropertyNormalizer.normalize( - processedEvent.properties?.cast() ?? {}, + _withOptionProperties( + processedEvent.properties?.cast(), + processedEvent.options, + PostHogEventName.exception, + ) ?? + {}, ); return await handleWebMethodCall( diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 43f7ab84..8276a380 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -160,7 +160,12 @@ abstract class PostHogCore extends PostHogCoreStateless { if (timezone != null) r'$timezone': timezone, ...getCommonEventProperties(), r'$session_id': sessionId, - r'$process_person_profile': _hasPersonProcessing(), + // A `$process_person_profile` passed for this event, such as the + // `process_person_profile` option, wins for this event only. + r'$process_person_profile': + properties?[r'$process_person_profile'] is bool + ? properties![r'$process_person_profile'] + : _hasPersonProcessing(), r'$is_identified': _isIdentified(), ..._sessionDebugProperties(now), }; @@ -292,10 +297,13 @@ abstract class PostHogCore extends PostHogCoreStateless { /// /// An empty [distinctId] is ignored, as are all calls while opted out or /// when `personProfiles` is `never`. + /// + /// The optional [properties] are added to the event this call sends. void identify( String distinctId, { Map? userProperties, Map? userPropertiesSetOnce, + Map? properties, }) { wrap(() { if (distinctId.trim().isEmpty) { @@ -330,13 +338,15 @@ abstract class PostHogCore extends PostHogCoreStateless { identifyStateless( distinctId, properties: _enrichProperties({ + ...?properties, 'distinct_id': distinctId, r'$anon_distinct_id': previousDistinctId, ...maybeAdd(r'$set', userProperties), ...maybeAdd(r'$set_once', userPropertiesSetOnce), }), ); - if (userProperties != null || userPropertiesSetOnce != null) { + if ((userProperties != null || userPropertiesSetOnce != null) && + !_isPersonless(properties)) { _cachedPersonProperties = _personPropertiesHash( distinctId, userProperties, userPropertiesSetOnce); } @@ -346,6 +356,7 @@ abstract class PostHogCore extends PostHogCoreStateless { // anonymous one, so the transition is recorded with a $set. setPersistedProperty(PostHogPersistedProperty.personMode, 'identified'); capture(r'$set', properties: { + ...?properties, r'$set': userProperties ?? {}, r'$set_once': userPropertiesSetOnce ?? {}, }); @@ -356,6 +367,7 @@ abstract class PostHogCore extends PostHogCoreStateless { setPersonProperties( userPropertiesToSet: userProperties, userPropertiesToSetOnce: userPropertiesSetOnce, + properties: properties, ); } else { logger.info('identify was called with the current distinct id of an ' @@ -387,8 +399,9 @@ abstract class PostHogCore extends PostHogCoreStateless { ); final allProperties = _enrichProperties(properties); - // Once an event is person-processed, all later ones are too. - if (allProperties[r'$process_person_profile'] == true) { + // Once an event is person-processed, all later ones are too. A value + // passed for this event alone doesn't count. + if (_hasPersonProcessing()) { _enablePersonProcessing(); } @@ -396,7 +409,7 @@ abstract class PostHogCore extends PostHogCoreStateless { if (event == r'$set' || event == r'$identify') { final set = allProperties[r'$set']; final setOnce = allProperties[r'$set_once']; - if (set is Map || setOnce is Map) { + if ((set is Map || setOnce is Map) && !_isPersonless(allProperties)) { _cachedPersonProperties = _personPropertiesHash(getDistinctId(), set, setOnce); } @@ -405,7 +418,9 @@ abstract class PostHogCore extends PostHogCoreStateless { } /// Creates an alias for a user. An empty [alias] is ignored. - void alias(String alias) { + /// + /// The optional [properties] are added to the `$create_alias` event. + void alias(String alias, {Map? properties}) { wrap(() { if (alias.trim().isEmpty) { logger.warn('alias was called with an empty alias. ' @@ -415,7 +430,8 @@ abstract class PostHogCore extends PostHogCoreStateless { if (_ignoredWhileOptedOut('posthog.alias')) return; if (!_requirePersonProcessing('posthog.alias')) return; - aliasStateless(alias, getDistinctId(), properties: _enrichProperties({})); + aliasStateless(alias, getDistinctId(), + properties: _enrichProperties({...?properties})); }); } @@ -440,10 +456,13 @@ abstract class PostHogCore extends PostHogCoreStateless { /// key changes, the properties stored for the previous key are dropped and /// flags are reloaded. A call with an empty [groupType] /// or [groupKey] is ignored. + /// + /// The optional [properties] are added to the `$groupidentify` event. void group( String groupType, String groupKey, { Map? groupProperties, + Map? properties, }) { wrap(() { if (groupType.trim().isEmpty || groupKey.trim().isEmpty) { @@ -468,7 +487,7 @@ abstract class PostHogCore extends PostHogCoreStateless { groupType, groupKey, distinctId: getDistinctId(), - eventProperties: _enrichProperties({}), + eventProperties: _enrichProperties({...?properties}), groupProperties: groupProperties, ); }); @@ -989,9 +1008,12 @@ abstract class PostHogCore extends PostHogCoreStateless { /// Sets properties on the person profile. They also feed feature flag /// evaluation from the next flags reload on. + /// + /// The optional [properties] are added to the `$set` event. void setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? properties, }) { wrap(() { final isSetEmpty = @@ -1006,7 +1028,9 @@ abstract class PostHogCore extends PostHogCoreStateless { final hash = _personPropertiesHash( getDistinctId(), userPropertiesToSet, userPropertiesToSetOnce); - if (_cachedPersonProperties == hash) { + // A personless `$set` doesn't update the person, so it is never a + // duplicate. + if (_cachedPersonProperties == hash && !_isPersonless(properties)) { logger.info( 'A duplicate setPersonProperties call was made. It has been ignored.'); return; @@ -1019,6 +1043,7 @@ abstract class PostHogCore extends PostHogCoreStateless { setPersonPropertiesForFlags(mergedProperties); capture(r'$set', properties: { + ...?properties, r'$set': userPropertiesToSet ?? {}, r'$set_once': userPropertiesToSetOnce ?? {}, }); @@ -1042,6 +1067,10 @@ abstract class PostHogCore extends PostHogCoreStateless { static bool _isNonEmptyMap(Object? value) => value is Map && value.isNotEmpty; + /// Whether [properties] turn person processing off for their event. + static bool _isPersonless(Map? properties) => + properties?[r'$process_person_profile'] == false; + static Map? _asPropertyMap(Object? value) { if (value is! Map) return null; return { diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 2c134fe6..4d97a4d5 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -139,6 +139,9 @@ class Posthog { /// The optional [userPropertiesSetOnce] are person properties set with /// `$set_once`. /// + /// The optional [options] tell PostHog how to process the `$identify` or + /// `$set` event this call sends, as described on [capture]. + /// /// Returns a [Future] that completes when the identify call has been queued. /// /// Note that identifying a user reloads feature flags, which issues a @@ -156,11 +159,13 @@ class Posthog { required String userId, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) => _posthog.identify( userId: userId, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options, ); /// Sets person properties for the current user without requiring identify. @@ -174,6 +179,9 @@ class Posthog { /// The optional [userPropertiesToSetOnce] values are set with `$set_once` and /// do not overwrite existing values. /// + /// The optional [options] tell PostHog how to process the `$set` event this + /// call sends, as described on [capture]. + /// /// Returns a [Future] that completes when the update has been queued. /// If both property maps are null or empty, no event is queued. /// @@ -187,10 +195,12 @@ class Posthog { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) => _posthog.setPersonProperties( userPropertiesToSet: userPropertiesToSet, userPropertiesToSetOnce: userPropertiesToSetOnce, + options: options, ); /// Captures a custom event. @@ -209,6 +219,29 @@ class Posthog { /// The optional [userPropertiesSetOnce] are person properties set with /// `$set_once`. /// + /// The optional [options] tell PostHog how to process this event and are + /// sent apart from its properties. Known keys: + /// - `process_person_profile` (`bool`): whether PostHog creates or updates a + /// person profile for this event. + /// - `cookieless_mode` (`bool`): whether PostHog processes this event in + /// cookieless mode. + /// - `disable_skew_correction` (`bool`): whether PostHog keeps the event + /// timestamp as sent instead of correcting it for device clock skew. + /// - `product_tour_id` (`String`): the product tour the event belongs to. + /// + /// These replace the `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` and `$product_tour_id` properties, and an option wins + /// over its property. A null value means unset. [PostHogConfig.beforeSend] + /// can change them through [PostHogEvent.options]. + /// + /// On iOS, macOS and Android, other keys are sent unchanged. On Windows, + /// Linux and web, the known keys are sent as their legacy properties and + /// other keys are dropped. On web, posthog-js sets + /// `$process_person_profile` itself, so `process_person_profile` has no + /// effect there, and the options of [identify], [setPersonProperties], + /// [alias] and [group] are dropped because posthog-js sends those events + /// without event properties. + /// /// Returns a [Future] that completes when the event has been queued. /// /// **Example** @@ -226,7 +259,65 @@ class Posthog { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, + }) { + return _posthog.capture( + eventName: eventName, + properties: _withCurrentScreen(properties), + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + } + + /// Captures an AI observability event, such as `$ai_generation`, + /// `$ai_span`, `$ai_trace` or `$ai_embedding`, and sends it to PostHog's AI + /// endpoint, which accepts much larger events than [capture]. + /// + /// Takes the same arguments as [capture] and goes through the same + /// [PostHogConfig.beforeSend] callbacks. The event is queued separately from + /// other events and sent unchanged: the SDK doesn't redact or truncate + /// prompts, outputs or media. PostHog drops an event sent here whose name + /// doesn't start with `$ai_`. [capture] never sends events here, even ones + /// named `$ai_*`. + /// + /// Returns the event's UUID, or `null` if the event wasn't queued: for + /// example, the SDK is opted out or a [PostHogConfig.beforeSend] callback + /// dropped it. An event over the AI size limit is dropped: iOS and macOS + /// then return `null`, while Android returns the UUID and logs the drop. + /// + /// **Note:** supported on iOS, macOS and Android. On Windows, Linux and web + /// the event is not sent, a debug message is logged, and `null` is + /// returned. + /// + /// **Example** + /// ```dart + /// await Posthog().captureAi( + /// eventName: r'$ai_generation', + /// properties: { + /// r'$ai_trace_id': traceId, + /// r'$ai_model': 'gpt-4o', + /// r'$ai_provider': 'openai', + /// }, + /// ); + /// ``` + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, }) { + return _posthog.captureAi( + eventName: eventName, + properties: _withCurrentScreen(properties), + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + } + + Map? _withCurrentScreen(Map? properties) { final propertiesCopy = properties == null ? null : {...properties}; final currentScreen = _currentScreen; @@ -235,12 +326,7 @@ class Posthog { currentScreen != null) { propertiesCopy['\$screen_name'] = currentScreen; } - return _posthog.capture( - eventName: eventName, - properties: propertiesCopy, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ); + return propertiesCopy; } /// Captures a screen view event. @@ -249,13 +335,20 @@ class Posthog { /// /// The optional [properties] are additional screen event properties. /// + /// The optional [options] tell PostHog how to process the `$screen` event, as described on [capture]. + /// /// Returns a [Future] that completes when the screen event has been queued. Future screen({ required String screenName, Map? properties, + Map? options, }) { _currentScreen = screenName; - return _posthog.screen(screenName: screenName, properties: properties); + return _posthog.screen( + screenName: screenName, + properties: properties, + options: options, + ); } /// Captures a structured log record. @@ -371,8 +464,12 @@ class Posthog { /// /// The [alias] is the additional distinct ID to associate with the user. /// + /// The optional [options] tell PostHog how to process the `$create_alias` + /// event, as described on [capture]. + /// /// Returns a [Future] that completes when the alias call has been queued. - Future alias({required String alias}) => _posthog.alias(alias: alias); + Future alias({required String alias, Map? options}) => + _posthog.alias(alias: alias, options: options); /// Returns the registered `distinctId` property. /// @@ -614,6 +711,9 @@ class Posthog { /// /// The optional [groupProperties] are group properties set with `$group_set`. /// + /// The optional [options] tell PostHog how to process the `$groupidentify` + /// event, as described on [capture]. + /// /// Returns a [Future] that completes when the group call has been queued. /// /// Note that setting a group reloads feature flags, which issues a `/flags` @@ -632,11 +732,13 @@ class Posthog { required String groupType, required String groupKey, Map? groupProperties, + Map? options, }) => _posthog.group( groupType: groupType, groupKey: groupKey, groupProperties: groupProperties, + options: options, ); /// Registers this device's push token so PostHog Workflows can target it. @@ -886,16 +988,20 @@ class Posthog { /// /// The optional [properties] are added to the `$exception` event. /// + /// The optional [options] tell PostHog how to process the `$exception` event, as described on [capture]. + /// /// Returns a [Future] that completes when the exception event has been queued. Future captureException({ required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) => _posthog.captureException( error: error, stackTrace: stackTrace, properties: properties, + options: options, ); /// Captures a `runZonedGuarded` error with optional custom properties. diff --git a/posthog_flutter/lib/src/posthog_event.dart b/posthog_flutter/lib/src/posthog_event.dart index 1484986b..e7d20b5c 100644 --- a/posthog_flutter/lib/src/posthog_event.dart +++ b/posthog_flutter/lib/src/posthog_event.dart @@ -22,6 +22,13 @@ class PostHogEvent { /// These properties will only be set if they don't already exist on the user profile. Map? userPropertiesSetOnce; + /// Settings that tell PostHog how to process this event, sent apart from + /// its properties. See `Posthog.capture` for the known keys. + /// + /// A callback can read, add, change or remove them. A null value means + /// unset. + Map? options; + /// Creates an event passed to a before-send callback. /// /// The [event] is the event name to capture. @@ -33,15 +40,18 @@ class PostHogEvent { /// /// The optional [userPropertiesSetOnce] are person properties to set with /// `$set_once`. + /// + /// The optional [options] are the event's per-event options. PostHogEvent({ required this.event, this.properties, this.userProperties, this.userPropertiesSetOnce, + this.options, }); @override String toString() { - return 'PostHogEvent(event: $event, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce)'; + return 'PostHogEvent(event: $event, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce, options: $options)'; } } diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index f8baec36..e2e8b03b 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -20,6 +20,7 @@ import 'posthog_flutter_platform_interface.dart'; import 'util/logging.dart'; import 'utils/before_send.dart'; import 'utils/capture_utils.dart'; +import 'utils/event_options.dart'; import 'utils/feature_event.dart'; import 'utils/property_normalizer.dart'; @@ -217,12 +218,17 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { required String userId, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) => _guard('identify', (client) { client.identify( userId, userProperties: _normalize(userProperties), userPropertiesSetOnce: _normalize(userPropertiesSetOnce), + properties: eventOptionsToLegacyProperties( + options, + method: 'identify', + ), ); }); @@ -230,11 +236,16 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) => _guard('setPersonProperties', (client) { client.setPersonProperties( userPropertiesToSet: _normalize(userPropertiesToSet), userPropertiesToSetOnce: _normalize(userPropertiesToSetOnce), + properties: eventOptionsToLegacyProperties( + options, + method: 'setPersonProperties', + ), ); }); @@ -244,6 +255,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) => _guard('capture', (client) async { if (client.optedOut) return; @@ -258,6 +270,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options, ); final processed = hooked is Future ? await hooked : hooked; @@ -271,10 +284,25 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { _captureProcessed(current, processed, exceptionSteps); }); + /// AI events are not sent on desktop. + @override + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) async { + printIfDebug('[PostHog] captureAi is not supported on Windows and Linux. ' + 'The event "$eventName" was not sent.'); + return null; + } + @override Future screen({ required String screenName, Map? properties, + Map? options, }) => _guard('screen', (client) async { // Opted out, nothing happens: no callback runs, and the screen does @@ -291,6 +319,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ...?properties, PostHogPropertyName.screenName: screenName, }, + options: options, ); final processed = hooked is Future ? await hooked : hooked; @@ -365,8 +394,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { } @override - Future alias({required String alias}) => - _guard('alias', (client) => client.alias(alias)); + Future alias({required String alias, Map? options}) => + _guard( + 'alias', + (client) => client.alias( + alias, + properties: eventOptionsToLegacyProperties(options, method: 'alias'), + ), + ); @override Future captureFeatureView({ @@ -489,12 +524,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { required String groupType, required String groupKey, Map? groupProperties, + Map? options, }) => _guard('group', (client) { client.group( groupType, groupKey, groupProperties: _normalize(groupProperties), + properties: eventOptionsToLegacyProperties(options, method: 'group'), ); }); @@ -534,6 +571,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) => _guard('captureException', (client) async { if (client.optedOut) return; @@ -554,6 +592,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final hooked = _runBeforeSend( PostHogEventName.exception, exceptionProps.cast(), + options: options, ); final processed = hooked is Future ? await hooked : hooked; @@ -661,12 +700,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { final event = PostHogEvent( event: eventName, properties: properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options == null ? null : {...options}, ); final callbacks = _config?.beforeSend ?? const []; return callbacks.isEmpty ? event : _applyCallbacks(callbacks, event); @@ -691,18 +732,25 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ExceptionStepsBuffer? exceptionSteps, ) { if (!_hasName(event.event)) return; - client.capture( + final properties = _withExceptionSteps( event.event, - properties: _withExceptionSteps( - event.event, - exceptionSteps, - _mergeUserProps( - event.properties, - event.userProperties, - event.userPropertiesSetOnce, - ), + exceptionSteps, + _mergeUserProps( + event.properties, + event.userProperties, + event.userPropertiesSetOnce, ), ); + // Options go out as their legacy properties, winning over a legacy + // property set on the event. + final optionProperties = + eventOptionsToLegacyProperties(event.options, method: event.event); + client.capture( + event.event, + properties: optionProperties.isEmpty + ? properties + : {...?properties, ...optionProperties}, + ); } /// Whether [event] has a name; an event without one is dropped. diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index f5a1b2ad..087360d5 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -13,6 +13,7 @@ import 'error_tracking/dart_exception_processor.dart'; import 'utils/before_send.dart'; import 'utils/capture_utils.dart'; import 'utils/channel_serialization.dart'; +import 'utils/event_options.dart'; import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; @@ -50,6 +51,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { return applyBeforeSend( _beforeSendCallbacks, @@ -58,6 +60,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { properties: properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options == null ? null : {...options}, ), ); } @@ -235,12 +238,14 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { required String userId, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) async { if (!isSupportedPlatform()) { return; } try { + final cleanedOptions = cleanEventOptions(options); final normalizedUserProperties = userProperties != null ? PropertyNormalizer.normalize(userProperties) : null; @@ -254,6 +259,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { 'userProperties': normalizedUserProperties, if (normalizedUserPropertiesSetOnce != null) 'userPropertiesSetOnce': normalizedUserPropertiesSetOnce, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { printIfDebug('Exeption on identify: $exception'); @@ -264,12 +270,14 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) async { if (!isSupportedPlatform()) { return; } try { + final cleanedOptions = cleanEventOptions(options); final normalizedUserPropertiesToSet = userPropertiesToSet != null ? PropertyNormalizer.normalize(userPropertiesToSet) : null; @@ -282,6 +290,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { 'userPropertiesToSet': normalizedUserPropertiesToSet, if (normalizedUserPropertiesToSetOnce != null) 'userPropertiesToSetOnce': normalizedUserPropertiesToSetOnce, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { printIfDebug('Exception on setPersonProperties: $exception'); @@ -294,9 +303,50 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, + }) async { + await _captureEvent( + 'capture', + eventName: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + } + + @override + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) async { + final uuid = await _captureEvent( + 'captureAi', + eventName: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + return uuid is String ? uuid : null; + } + + /// Runs beforeSend and sends the event through [method], `capture` or + /// `captureAi`. Returns what the native SDK returned, or null when the + /// event was dropped. + Future _captureEvent( + String method, { + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, }) async { if (!isSupportedPlatform()) { - return; + return null; } // Apply beforeSend callback @@ -305,11 +355,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options, ); if (processedEvent == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); - return; + return null; } try { @@ -334,17 +385,20 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { extractedUserPropertiesSetOnce != null ? PropertyNormalizer.normalize(extractedUserPropertiesSetOnce) : null; + final cleanedOptions = cleanEventOptions(processedEvent.options); - await _methodChannel.invokeMethod('capture', { + return await _methodChannel.invokeMethod(method, { 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, if (normalizedUserProperties != null) 'userProperties': normalizedUserProperties, if (normalizedUserPropertiesSetOnce != null) 'userPropertiesSetOnce': normalizedUserPropertiesSetOnce, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { - printIfDebug('Exeption on capture: $exception'); + printIfDebug('Exeption on $method: $exception'); + return null; } } @@ -352,6 +406,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Future screen({ required String screenName, Map? properties, + Map? options, }) async { if (!isSupportedPlatform()) { return; @@ -368,6 +423,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.screen, propsWithScreenName, + options: options, ); if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -379,6 +435,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } @@ -397,9 +454,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ) : null; + final cleanedOptions = cleanEventOptions(processedEvent.options); + await _methodChannel.invokeMethod('screen', { 'screenName': finalScreenName, if (normalizedProperties != null) 'properties': normalizedProperties, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { printIfDebug('Exeption on screen: $exception'); @@ -440,13 +500,20 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } @override - Future alias({required String alias}) async { + Future alias({ + required String alias, + Map? options, + }) async { if (!isSupportedPlatform()) { return; } try { - await _methodChannel.invokeMethod('alias', {'alias': alias}); + final cleanedOptions = cleanEventOptions(options); + await _methodChannel.invokeMethod('alias', { + 'alias': alias, + if (cleanedOptions != null) 'options': cleanedOptions, + }); } on PlatformException catch (exception) { printIfDebug('Exeption on alias: $exception'); } @@ -672,12 +739,14 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { required String groupType, required String groupKey, Map? groupProperties, + Map? options, }) async { if (!isSupportedPlatform()) { return; } try { + final cleanedOptions = cleanEventOptions(options); final normalizedGroupProperties = groupProperties != null ? PropertyNormalizer.normalize(groupProperties) : null; @@ -687,6 +756,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { 'groupKey': groupKey, if (normalizedGroupProperties != null) 'groupProperties': normalizedGroupProperties, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { printIfDebug('Exeption on group: $exception'); @@ -802,6 +872,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) async { if (!isSupportedPlatform()) { return; @@ -821,6 +892,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.exception, exceptionProps.cast(), + options: options, ); if (processedEvent == null) { printIfDebug( @@ -834,6 +906,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } @@ -846,9 +919,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ) : {}; + final cleanedOptions = cleanEventOptions(processedEvent.options); + await _methodChannel.invokeMethod('captureException', { 'timestamp': timestamp, 'properties': normalizedData, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { printIfDebug('Exception in captureException: $exception'); diff --git a/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart b/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart index 993ac9a3..56114b01 100644 --- a/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart +++ b/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart @@ -47,6 +47,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { required String userId, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { throw UnimplementedError('identify() has not been implemented.'); } @@ -54,6 +55,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) { throw UnimplementedError('setPersonProperties() has not been implemented.'); } @@ -63,13 +65,27 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { throw UnimplementedError('capture() has not been implemented.'); } + /// Captures an AI observability event on the AI lane. Returns the event's + /// UUID, or null when the event was not queued. + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) { + throw UnimplementedError('captureAi() has not been implemented.'); + } + Future screen({ required String screenName, Map? properties, + Map? options, }) { throw UnimplementedError('screen() has not been implemented.'); } @@ -117,7 +133,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { ); } - Future alias({required String alias}) { + Future alias({required String alias, Map? options}) { throw UnimplementedError('alias() has not been implemented.'); } @@ -212,6 +228,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { required String groupType, required String groupKey, Map? groupProperties, + Map? options, }) { throw UnimplementedError('group() has not been implemented.'); } @@ -244,6 +261,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) { throw UnimplementedError('captureException() has not been implemented.'); } diff --git a/posthog_flutter/lib/src/utils/event_options.dart b/posthog_flutter/lib/src/utils/event_options.dart new file mode 100644 index 00000000..e72a7683 --- /dev/null +++ b/posthog_flutter/lib/src/utils/event_options.dart @@ -0,0 +1,46 @@ +import '../util/logging.dart'; +import 'property_normalizer.dart'; + +/// The known per-event option keys and the legacy event property each one +/// replaces. +const Map _legacyPropertyForOption = { + 'process_person_profile': r'$process_person_profile', + 'cookieless_mode': r'$cookieless_mode', + 'disable_skew_correction': r'$ignore_sent_at', + 'product_tour_id': r'$product_tour_id', +}; + +/// Returns a copy of [options] without null values, normalized for the +/// platform channel, or null when nothing is left. A null value means unset. +Map? cleanEventOptions(Map? options) { + if (options == null) return null; + final cleaned = PropertyNormalizer.normalize(options); + if (cleaned.isEmpty) return null; + return Map.from(cleaned); +} + +/// Converts [options] to the legacy event properties for transports that +/// have no options field (the desktop client and posthog-js). +/// +/// Known keys become their `$` property. Other keys have no legacy property, +/// so they are dropped with a debug log naming [method]. +Map eventOptionsToLegacyProperties( + Map? options, { + required String method, +}) { + final cleaned = cleanEventOptions(options); + if (cleaned == null) return const {}; + final properties = {}; + for (final entry in cleaned.entries) { + final legacyKey = _legacyPropertyForOption[entry.key]; + if (legacyKey != null) { + properties[legacyKey] = entry.value; + } else { + printIfDebug( + '[PostHog] $method option "${entry.key}" is not supported on this ' + 'platform and was dropped.', + ); + } + } + return properties; +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index c2f51696..35973d53 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -953,6 +953,152 @@ void main() { }); }); + group('Event options', () { + test('are sent as their legacy properties, winning over them', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + final lines = await _printsOf(() => platform.capture( + eventName: 'tour step', + properties: {r'$product_tour_id': 'from-property'}, + options: { + 'process_person_profile': false, + 'cookieless_mode': true, + 'disable_skew_correction': true, + 'product_tour_id': 'from-option', + 'unknown_option': 'x', + 'unset_option': null, + }, + )); + + final properties = _propertiesOf(await server.waitForEvent('tour step')); + expect(properties[r'$process_person_profile'], isFalse); + expect(properties[r'$cookieless_mode'], isTrue); + expect(properties[r'$ignore_sent_at'], isTrue); + expect(properties[r'$product_tour_id'], 'from-option'); + expect(properties, isNot(contains('unknown_option'))); + expect(properties, isNot(contains('unset_option'))); + expect(lines, [contains('option "unknown_option" is not supported')]); + }); + + test('a process_person_profile option applies to its event only', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.capture( + eventName: 'processed', + options: {'process_person_profile': true}, + ); + await platform.capture(eventName: 'anonymous'); + + final processed = await server.waitForEvent('processed'); + final anonymous = await server.waitForEvent('anonymous'); + expect(_propertiesOf(processed)[r'$process_person_profile'], isTrue); + expect(_propertiesOf(anonymous)[r'$process_person_profile'], isFalse); + }); + + test('beforeSend can change them', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) { + event.options = { + ...?event.options, + 'product_tour_id': 'from-hook', + }; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.screen( + screenName: 'Home', + options: {'product_tour_id': 'from-call'}, + ); + await platform.captureException( + error: StateError('boom'), + options: {'disable_skew_correction': true}, + ); + + final screen = _propertiesOf(await server.waitForEvent(r'$screen')); + final exception = _propertiesOf(await server.waitForEvent(r'$exception')); + expect(screen[r'$product_tour_id'], 'from-hook'); + expect(exception[r'$product_tour_id'], 'from-hook'); + expect(exception[r'$ignore_sent_at'], isTrue); + }); + + test('reach the events identify, setPersonProperties, group and alias send', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + Map options(String id) => + {'product_tour_id': id, 'cookieless_mode': false}; + + await platform.identify(userId: 'user-1', options: options('identify')); + await platform.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + options: options('set'), + ); + await platform.group( + groupType: 'company', + groupKey: 'acme', + options: options('group'), + ); + await platform.alias(alias: 'alias-1', options: options('alias')); + + for (final (name, id) in [ + (r'$identify', 'identify'), + (r'$set', 'set'), + (r'$groupidentify', 'group'), + (r'$create_alias', 'alias'), + ]) { + final properties = _propertiesOf(await server.waitForEvent(name)); + expect(properties[r'$product_tour_id'], id, reason: name); + expect(properties[r'$cookieless_mode'], isFalse, reason: name); + } + }); + + test(r'a personless $set does not mark the person properties as sent', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + await platform.identify(userId: 'user-1'); + + await platform.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + options: {'process_person_profile': false}, + ); + await platform.setPersonProperties(userPropertiesToSet: {'plan': 'pro'}); + await platform.capture(eventName: 'done'); + await server.waitForEvent('done'); + + final sets = [ + for (final event in server.events) + if (event['event'] == r'$set') _propertiesOf(event), + ]; + expect( + [for (final set in sets) set[r'$process_person_profile']], + [isFalse, isTrue], + ); + }); + + test('captureAi sends nothing and returns null', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + String? uuid = 'not called'; + final lines = await _printsOf(() async { + uuid = await platform.captureAi(eventName: r'$ai_generation'); + }); + await platform.capture(eventName: 'after'); + await server.waitForEvent('after'); + + expect(uuid, isNull); + expect(lines, [contains('captureAi is not supported')]); + expect(server.eventNames, isNot(contains(r'$ai_generation'))); + }); + }); + group('Application lifecycle', () { /// A launch of the app with [build], keeping its state in [appDirectory]. PosthogFlutterDesktop launch( diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index b33e9db0..b9a70cd5 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -452,6 +452,133 @@ void main() { }); }); + group('PosthogFlutterIO event options', () { + const options = { + 'process_person_profile': false, + 'custom_key': 'kept', + 'unset_key': null, + }; + const sent = {'process_person_profile': false, 'custom_key': 'kept'}; + + Map argsOf(String method) => Map.from( + log.singleWhere((c) => c.method == method).arguments as Map, + ); + + setUp(() async { + await posthogFlutterIO.setup(PostHogConfig('test_project_token')); + }); + + final callsByMethod = Function(PosthogFlutterIO)>{ + 'capture': (io) => io.capture(eventName: 'clicked', options: options), + 'screen': (io) => io.screen(screenName: 'Home', options: options), + 'captureException': (io) => + io.captureException(error: StateError('boom'), options: options), + 'identify': (io) => io.identify(userId: 'user-1', options: options), + 'setPersonProperties': (io) => io.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + options: options, + ), + 'alias': (io) => io.alias(alias: 'alias-1', options: options), + 'group': (io) => + io.group(groupType: 'company', groupKey: 'acme', options: options), + 'captureAi': (io) => + io.captureAi(eventName: r'$ai_generation', options: options), + }; + + for (final entry in callsByMethod.entries) { + test('${entry.key} sends the options without unset keys', () async { + await entry.value(posthogFlutterIO); + expect(argsOf(entry.key)['options'], sent); + }); + } + + test('calls without options send no options argument', () async { + await posthogFlutterIO.capture(eventName: 'clicked'); + await posthogFlutterIO.alias(alias: 'alias-1', options: {'k': null}); + expect(argsOf('capture'), isNot(contains('options'))); + expect(argsOf('alias'), isNot(contains('options'))); + }); + + test('captureAi sends the event and returns the native uuid', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return call.method == 'captureAi' ? 'uuid-1' : null; + }); + + final uuid = await posthogFlutterIO.captureAi( + eventName: r'$ai_generation', + properties: {r'$ai_model': 'gpt-4o'}, + userProperties: {'plan': 'pro'}, + ); + + expect(uuid, 'uuid-1'); + expect(argsOf('captureAi'), { + 'eventName': r'$ai_generation', + 'properties': {r'$ai_model': 'gpt-4o'}, + 'userProperties': {'plan': 'pro'}, + }); + expect(log.where((c) => c.method == 'capture'), isEmpty); + }); + + test('beforeSend sees and can change the options', () async { + final seen = ?>[]; + await posthogFlutterIO.setup(PostHogConfig( + 'test_project_token', + beforeSend: [ + (event) { + seen.add({...?event.options}); + event.options = {...?event.options, 'product_tour_id': 'tour-1'} + ..remove('custom_key'); + return event; + }, + ], + )); + + await posthogFlutterIO.captureAi( + eventName: r'$ai_span', options: options); + await posthogFlutterIO.screen(screenName: 'Home', options: options); + + expect(seen, [options, options]); + const changed = { + 'process_person_profile': false, + 'product_tour_id': 'tour-1', + }; + expect(argsOf('captureAi')['options'], changed); + expect(argsOf('screen')['options'], changed); + }); + + test('a screen renamed by beforeSend keeps its options', () async { + await posthogFlutterIO.setup(PostHogConfig( + 'test_project_token', + beforeSend: [ + (event) { + if (event.event == r'$screen') event.event = 'screen viewed'; + return event; + }, + ], + )); + + await posthogFlutterIO.screen(screenName: 'Home', options: options); + + expect(argsOf('capture')['eventName'], 'screen viewed'); + expect(argsOf('capture')['options'], sent); + }); + + test('captureAi dropped by beforeSend returns null', () async { + await posthogFlutterIO.setup(PostHogConfig( + 'test_project_token', + beforeSend: [(event) => null], + )); + + expect( + await posthogFlutterIO.captureAi(eventName: r'$ai_generation'), + isNull, + ); + expect(log.where((c) => c.method == 'captureAi'), isEmpty); + }); + }); + group('PosthogFlutterIO beforeSend callback', () { test( 'capture sends event unchanged when no beforeSend registered', diff --git a/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart index e4ad6a2b..979eadeb 100644 --- a/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart +++ b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart @@ -8,11 +8,13 @@ class CapturedExceptionCall { final Object error; final StackTrace? stackTrace; final Map? properties; + final Map? options; CapturedExceptionCall({ required this.error, this.stackTrace, this.properties, + this.options, }); } @@ -163,12 +165,71 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { // Tracking for captureLog calls (after Dart-side beforeSend has run) final List capturedLogs = []; + /// The options each event call received, as (method, options). + final List<(String, Map?)> optionsCalls = []; + + /// Returned by [captureAi]. + String? captureAiResult; + + @override + Future capture({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) async { + optionsCalls.add(('capture', options)); + } + + @override + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) async { + optionsCalls.add(('captureAi', options)); + return captureAiResult; + } + + @override + Future identify({ + required String userId, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) async { + optionsCalls.add(('identify', options)); + } + + @override + Future alias({ + required String alias, + Map? options, + }) async { + optionsCalls.add(('alias', options)); + } + + @override + Future group({ + required String groupType, + required String groupKey, + Map? groupProperties, + Map? options, + }) async { + optionsCalls.add(('group', options)); + } + @override Future screen({ required String screenName, Map? properties, + Map? options, }) async { this.screenName = screenName; + optionsCalls.add(('screen', options)); } @override @@ -205,12 +266,14 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) async { capturedExceptions.add( CapturedExceptionCall( error: error, stackTrace: stackTrace, properties: properties, + options: options, ), ); } @@ -244,10 +307,12 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) async { setPersonPropertiesCalls.add({ 'userPropertiesToSet': userPropertiesToSet, 'userPropertiesToSetOnce': userPropertiesToSetOnce, + if (options != null) 'options': options, }); } diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index 4ddb2fa8..04df6c46 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -214,6 +214,50 @@ void main() { ); }); + group('Posthog event options', () { + late PosthogFlutterPlatformFake fake; + const options = {'process_person_profile': false}; + + setUp(() async { + fake = PosthogFlutterPlatformFake(); + PosthogFlutterPlatformInterface.instance = fake; + await Posthog().close(); + }); + + test('each event method passes its options to the platform', () async { + final posthog = Posthog(); + await posthog.capture(eventName: 'clicked', options: options); + await posthog.screen(screenName: 'Home', options: options); + await posthog.identify(userId: 'user-1', options: options); + await posthog.alias(alias: 'alias-1', options: options); + await posthog.group( + groupType: 'company', groupKey: 'acme', options: options); + await posthog.captureAi(eventName: r'$ai_span', options: options); + await posthog.captureException( + error: StateError('boom'), options: options); + await posthog.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + options: options, + ); + + expect(fake.optionsCalls, [ + ('capture', options), + ('screen', options), + ('identify', options), + ('alias', options), + ('group', options), + ('captureAi', options), + ]); + expect(fake.capturedExceptions.single.options, options); + expect(fake.setPersonPropertiesCalls.single['options'], options); + }); + + test('captureAi returns the platform result', () async { + fake.captureAiResult = 'uuid-1'; + expect(await Posthog().captureAi(eventName: r'$ai_trace'), 'uuid-1'); + }); + }); + group('PostHogConfig', () { test('toMap forwards evaluationContexts only when set', () { final config = PostHogConfig('test_project_token'); diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index ad57000a..f3a2cc3e 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -103,4 +103,45 @@ void main() { expect(captures, isEmpty); }); + + test('event options are sent as their legacy properties on web', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) { + event.options = {...?event.options, 'cookieless_mode': true}; + return event; + }, + ])); + const options = { + 'disable_skew_correction': true, + 'product_tour_id': 'tour-1', + 'unknown_option': 'dropped', + }; + + await web.capture( + eventName: 'tour step', + properties: {r'$product_tour_id': 'from-property'}, + options: options, + ); + await web.screen(screenName: 'Home', options: options); + await web.captureException(error: StateError('boom'), options: options); + + expect(captures.map((c) => c['event']), + ['tour step', r'$screen', r'$exception']); + for (final capture in captures) { + final properties = capture['properties'] as Map; + expect(properties[r'$ignore_sent_at'], isTrue); + expect(properties[r'$product_tour_id'], 'tour-1'); + expect(properties[r'$cookieless_mode'], isTrue); + expect(properties, isNot(contains('unknown_option'))); + } + }); + + test('captureAi sends nothing on web and returns null', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token')); + + expect(await web.captureAi(eventName: r'$ai_generation'), isNull); + expect(captures, isEmpty); + }); } diff --git a/sdk_compliance_adapter/lib/adapter_server.dart b/sdk_compliance_adapter/lib/adapter_server.dart index f0682d96..43858465 100644 --- a/sdk_compliance_adapter/lib/adapter_server.dart +++ b/sdk_compliance_adapter/lib/adapter_server.dart @@ -287,6 +287,7 @@ class _CompliancePlatform extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) async { if (!isInitialized) { throw StateError('SDK not initialized'); From 270c83e978664326782359d7fa5e9ae9b9f2ba2b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:02:58 -0400 Subject: [PATCH 06/43] feat: add captureAi queue and request settings to PostHogConfig Add captureAiCompression, captureAiMaxQueueSize, captureAiTimeout and captureAiMaxEventBytes. Set values are sent to native setup and applied to the posthog-ios and posthog-android config; unset values are omitted so the native defaults apply. They have no effect on Windows, Linux and web. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-capture-options-ai.md | 1 + api/posthog_flutter.api.json | 44 +++++++++++++++++ .../posthog/flutter/PosthogFlutterPlugin.kt | 15 ++++++ .../flutter/PosthogFlutterPluginTest.kt | 43 ++++++++++++++++ .../PosthogFlutterPlugin.swift | 19 +++++++ posthog_flutter/lib/src/posthog_config.dart | 49 +++++++++++++++++++ .../lib/src/utils/channel_serialization.dart | 14 ++++++ .../test/event_shape_snapshot_test.dart | 35 +++++++++++++ 8 files changed, 220 insertions(+) diff --git a/.changeset/v6-capture-options-ai.md b/.changeset/v6-capture-options-ai.md index 4eecfdbb..78b2de6b 100644 --- a/.changeset/v6-capture-options-ai.md +++ b/.changeset/v6-capture-options-ai.md @@ -4,4 +4,5 @@ - Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS and Android other keys are sent as-is; on Windows, Linux and web the known keys are sent as their legacy `$` properties and other keys are dropped - Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID. On Windows, Linux and web it sends nothing and returns `null` +- Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` for the `captureAi` queue and requests on iOS, macOS and Android. Unset, they keep the native SDK defaults. They have no effect on Windows, Linux and web - On Windows and Linux, a `$process_person_profile` property passed with an event now applies to that event instead of being replaced by the SDK's value diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 87f925f9..1e85210c 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -2727,6 +2727,50 @@ "relativePath": "lib/src/posthog_config.dart", "typeName": "PostHogCompression" }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "captureAiCompression", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "PostHogCompression?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "captureAiMaxQueueSize", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "int?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "captureAiTimeout", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "Duration?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "captureAiMaxEventBytes", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "int?" + }, { "entryPoints": [], "isDeprecated": false, diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 266d3a3a..de8bbeaa 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -612,6 +612,21 @@ class PosthogFlutterPlugin : "none" -> compression = PostHogCompression.NONE } } + posthogConfig.getIfNotNull("captureAiCompression") { + when (it) { + "gzip" -> captureAiCompression = PostHogCompression.GZIP + "none" -> captureAiCompression = PostHogCompression.NONE + } + } + posthogConfig.getIfNotNull("captureAiMaxQueueSize") { + captureAiMaxQueueSize = it + } + posthogConfig.getIfNotNull("captureAiTimeoutSeconds") { + captureAiTimeout = it + } + posthogConfig.getIfNotNull("captureAiMaxEventBytes") { + captureAiMaxEventBytes = it + } posthogConfig.getIfNotNull("sessionReplay") { sessionReplay = it } diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index fc333ff8..a82b7734 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.os.BadParcelableException import com.google.firebase.FirebaseApp import com.posthog.PostHogCompression +import com.posthog.PostHogConfig import com.posthog.PostHogEvent import com.posthog.android.replay.PostHogScreenshotColorMode import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -225,6 +226,48 @@ internal class PosthogFlutterPluginTest { assertEquals(PostHogCompression.NONE, assertNotNull(plugin.lastBuiltConfig).compression) } + @Test + fun setup_captureAiSettings_forwardToNativeConfig() { + val plugin = PosthogFlutterPlugin() + attach(plugin, Mockito.mock(BinaryMessenger::class.java)) + + val call = + MethodCall( + "setup", + mapOf( + "projectToken" to "test-token", + "captureAiCompression" to "none", + "captureAiMaxQueueSize" to 7, + "captureAiTimeoutSeconds" to 90, + "captureAiMaxEventBytes" to 1024, + ), + ) + plugin.onMethodCall(call, Mockito.mock(MethodChannel.Result::class.java)) + + val config = assertNotNull(plugin.lastBuiltConfig) + assertEquals(PostHogCompression.NONE, config.captureAiCompression) + assertEquals(7, config.captureAiMaxQueueSize) + assertEquals(90, config.captureAiTimeout) + assertEquals(1024, config.captureAiMaxEventBytes) + } + + @Test + fun setup_captureAiSettings_unsetKeepNativeDefaults() { + val plugin = PosthogFlutterPlugin() + attach(plugin, Mockito.mock(BinaryMessenger::class.java)) + + plugin.onMethodCall( + MethodCall("setup", mapOf("projectToken" to "test-token")), + Mockito.mock(MethodChannel.Result::class.java), + ) + + val config = assertNotNull(plugin.lastBuiltConfig) + assertEquals(PostHogCompression.GZIP, config.captureAiCompression) + assertEquals(PostHogConfig.DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE, config.captureAiMaxQueueSize) + assertEquals(PostHogConfig.DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS, config.captureAiTimeout) + assertEquals(PostHogConfig.DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES, config.captureAiMaxEventBytes) + } + @Test fun setup_flutterVersion_addedOnlyToInstallAndUpdateEvents() { val plugin = PosthogFlutterPlugin() diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index bd0764ad..50e667ae 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -239,6 +239,25 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { break } } + if let captureAiCompression = posthogConfig["captureAiCompression"] as? String { + switch captureAiCompression { + case "gzip": + config.captureAiCompression = .gzip + case "none": + config.captureAiCompression = .none + default: + break + } + } + if let captureAiMaxQueueSize = posthogConfig["captureAiMaxQueueSize"] as? Int { + config.captureAiMaxQueueSize = captureAiMaxQueueSize + } + if let captureAiTimeoutSeconds = posthogConfig["captureAiTimeoutSeconds"] as? Int { + config.captureAiTimeout = TimeInterval(captureAiTimeoutSeconds) + } + if let captureAiMaxEventBytes = posthogConfig["captureAiMaxEventBytes"] as? Int { + config.captureAiMaxEventBytes = captureAiMaxEventBytes + } if let dataMode = posthogConfig["dataMode"] as? String { switch dataMode { case "wifi": diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index fcbd806f..fee546c1 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -196,6 +196,55 @@ class PostHogConfig { /// Defaults to [PostHogCompression.gzip]. var compression = PostHogCompression.gzip; + /// How [Posthog.captureAi] request bodies are compressed, separately from + /// [compression]. + /// + /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, + /// where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the native SDK default (gzip). + PostHogCompression? captureAiCompression; + + /// Maximum number of [Posthog.captureAi] events kept in their on-disk queue + /// before the oldest are dropped. + /// + /// AI events can be megabytes, so the native default (100) is lower than + /// [maxQueueSize]. The native queue also keeps at most 50 MiB of AI events, + /// whatever this is set to. A value of `0` or less uses the native default. + /// + /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, + /// where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the native SDK default. + int? captureAiMaxQueueSize; + + /// Timeout of a [Posthog.captureAi] request, sent to the native SDK in whole + /// seconds (a positive value under one second is sent as one second). + /// + /// AI requests can carry several MB, so the native default (60 seconds) is + /// longer than for other requests. [Duration.zero] or less uses the native + /// default. + /// + /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, + /// where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the native SDK default. + Duration? captureAiTimeout; + + /// Maximum size, in bytes, of a [Posthog.captureAi] event. The native SDK + /// drops a larger event and [Posthog.captureAi] returns `null`. + /// + /// Use it to lower the limit. PostHog drops AI events whose properties are + /// over 8 MiB, so a larger value, or a value of `0` or less, uses the native + /// default (8 MiB on Android, 8 MiB plus 64 KiB for the rest of the event on + /// iOS and macOS). + /// + /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, + /// where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the native SDK default. + int? captureAiMaxEventBytes; + /// Whether the SDK is enabled on Windows and Linux. /// /// Windows and Linux support is experimental. Defaults to `true`. diff --git a/posthog_flutter/lib/src/utils/channel_serialization.dart b/posthog_flutter/lib/src/utils/channel_serialization.dart index 8fc7f2ec..3aea876a 100644 --- a/posthog_flutter/lib/src/utils/channel_serialization.dart +++ b/posthog_flutter/lib/src/utils/channel_serialization.dart @@ -21,6 +21,15 @@ extension PostHogConfigSerialization on PostHogConfig { 'surveys': surveys, 'personProfiles': personProfiles.name, 'compression': compression.name, + // Unset AI lane settings are omitted so the native defaults apply. + if (captureAiCompression != null) + 'captureAiCompression': captureAiCompression!.name, + if (captureAiMaxQueueSize != null) + 'captureAiMaxQueueSize': captureAiMaxQueueSize, + if (captureAiTimeout != null) + 'captureAiTimeoutSeconds': _captureAiTimeoutSeconds(captureAiTimeout!), + if (captureAiMaxEventBytes != null) + 'captureAiMaxEventBytes': captureAiMaxEventBytes, 'sessionReplay': sessionReplay, 'dataMode': dataMode.name, 'sessionReplayConfig': sessionReplayConfig.toMap(), @@ -36,6 +45,11 @@ extension PostHogConfigSerialization on PostHogConfig { if (bootstrap != null) 'bootstrap': bootstrap!.toMap(), }; } + + /// Android takes whole seconds. A positive sub-second timeout is sent as 1s + /// instead of 0, which the native SDKs read as "use the default". + static int _captureAiTimeoutSeconds(Duration timeout) => + timeout > Duration.zero && timeout.inSeconds < 1 ? 1 : timeout.inSeconds; } extension PostHogBootstrapConfigSerialization on PostHogBootstrapConfig { diff --git a/posthog_flutter/test/event_shape_snapshot_test.dart b/posthog_flutter/test/event_shape_snapshot_test.dart index d06a6711..0320d7e0 100644 --- a/posthog_flutter/test/event_shape_snapshot_test.dart +++ b/posthog_flutter/test/event_shape_snapshot_test.dart @@ -90,6 +90,41 @@ void main() { expect(arguments['compression'], 'none'); }); + test('captureAi settings are omitted when unset', () async { + await Posthog().setup(PostHogConfig('snapshot_project_token')); + + final arguments = calls.single.arguments as Map; + expect( + arguments.keys.where((key) => '$key'.startsWith('captureAi')), + isEmpty, + ); + }); + + test('captureAi settings are sent to native setup when set', () async { + final config = PostHogConfig('snapshot_project_token') + ..captureAiCompression = PostHogCompression.none + ..captureAiMaxQueueSize = 7 + ..captureAiTimeout = const Duration(seconds: 90) + ..captureAiMaxEventBytes = 1024; + + await Posthog().setup(config); + + final arguments = calls.single.arguments as Map; + expect(arguments['captureAiCompression'], 'none'); + expect(arguments['captureAiMaxQueueSize'], 7); + expect(arguments['captureAiTimeoutSeconds'], 90); + expect(arguments['captureAiMaxEventBytes'], 1024); + }); + + test('a sub-second captureAi timeout is sent as one second', () async { + final config = PostHogConfig('snapshot_project_token') + ..captureAiTimeout = const Duration(milliseconds: 500); + + await Posthog().setup(config); + + expect((calls.single.arguments as Map)['captureAiTimeoutSeconds'], 1); + }); + test('snapshots event and identity channel shapes', () async { final config = PostHogConfig( 'snapshot_project_token', From 011a3dff5de209bc2cbb229b9b4049beb3848294 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:02:58 -0400 Subject: [PATCH 07/43] docs: spell out when captureAi returns null null means the event wasn't admitted: dropped by beforeSend, oversized on iOS and macOS, SDK disabled or opted out, or an unsupported platform. On Windows, Linux and web captureAi is a no-op that sends nothing. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-capture-options-ai.md | 2 +- posthog_flutter/lib/src/posthog.dart | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.changeset/v6-capture-options-ai.md b/.changeset/v6-capture-options-ai.md index 78b2de6b..b172c360 100644 --- a/.changeset/v6-capture-options-ai.md +++ b/.changeset/v6-capture-options-ai.md @@ -3,6 +3,6 @@ --- - Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS and Android other keys are sent as-is; on Windows, Linux and web the known keys are sent as their legacy `$` properties and other keys are dropped -- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID. On Windows, Linux and web it sends nothing and returns `null` +- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID, or `null` when the event isn't admitted (dropped by `beforeSend`, oversized on iOS and macOS, SDK disabled or opted out). On Windows, Linux and web `captureAi` is a no-op: it sends nothing and returns `null` - Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` for the `captureAi` queue and requests on iOS, macOS and Android. Unset, they keep the native SDK defaults. They have no effect on Windows, Linux and web - On Windows and Linux, a `$process_person_profile` property passed with an event now applies to that event instead of being replaced by the SDK's value diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 4d97a4d5..71b7d4ba 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -281,14 +281,18 @@ class Posthog { /// doesn't start with `$ai_`. [capture] never sends events here, even ones /// named `$ai_*`. /// - /// Returns the event's UUID, or `null` if the event wasn't queued: for - /// example, the SDK is opted out or a [PostHogConfig.beforeSend] callback - /// dropped it. An event over the AI size limit is dropped: iOS and macOS - /// then return `null`, while Android returns the UUID and logs the drop. - /// - /// **Note:** supported on iOS, macOS and Android. On Windows, Linux and web - /// the event is not sent, a debug message is logged, and `null` is - /// returned. + /// Returns the event's UUID once the SDK has admitted it to the AI queue. + /// `null` means the event was not admitted and will not be sent: + /// - a [PostHogConfig.beforeSend] callback dropped it, + /// - it is over [PostHogConfig.captureAiMaxEventBytes] (iOS and macOS only: + /// Android checks the size after returning, so it returns the UUID and + /// logs the drop), + /// - the SDK is disabled, not set up or opted out, or + /// - the platform is Windows, Linux or web (see below). + /// + /// **Platforms:** supported on iOS, macOS and Android. On Windows, Linux and + /// web, `captureAi` is a no-op: it sends nothing, logs a debug message and + /// returns `null`. /// /// **Example** /// ```dart From 7605a8d692f86698ee144661c243e724dc937fdf Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:03:58 -0400 Subject: [PATCH 08/43] docs: captureAi returns null for an oversized event on Android too posthog-android now checks the size before queuing (f46a9711). Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/posthog.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 71b7d4ba..8aad6316 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -284,9 +284,7 @@ class Posthog { /// Returns the event's UUID once the SDK has admitted it to the AI queue. /// `null` means the event was not admitted and will not be sent: /// - a [PostHogConfig.beforeSend] callback dropped it, - /// - it is over [PostHogConfig.captureAiMaxEventBytes] (iOS and macOS only: - /// Android checks the size after returning, so it returns the UUID and - /// logs the drop), + /// - it is over [PostHogConfig.captureAiMaxEventBytes], /// - the SDK is disabled, not set up or opted out, or /// - the platform is Windows, Linux or web (see below). /// From 78793d08d8a4014f970d6c2059313aa8efca0ca6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 14:23:21 -0400 Subject: [PATCH 09/43] fix: retry desktop capture v1 requests that get 404 or 429 A 404 usually means a misconfigured proxy and a 429 an overloaded Envoy; both are worth retrying with the same backoff, Retry-After handling and retry budget. Redirects stay terminal. --- .changeset/v6-desktop-capture-v1.md | 4 +-- posthog_flutter/lib/src/core/capture_v1.dart | 4 ++- .../test/core/capture_v1_test.dart | 29 ++++++++++++------- .../test/core/queue_flush_test.dart | 8 ++--- 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/.changeset/v6-desktop-capture-v1.md b/.changeset/v6-desktop-capture-v1.md index 0f3c6c91..4d1fafbd 100644 --- a/.changeset/v6-desktop-capture-v1.md +++ b/.changeset/v6-desktop-capture-v1.md @@ -2,5 +2,5 @@ "posthog_flutter": major --- -- **Breaking:** on Windows and Linux, events now go to capture v1 (`/i/v1/analytics/events`) instead of `/batch/`, like iOS and Android in 6.0. A reverse proxy or self-hosted instance must serve the new path: a 404 drops the events. Events queued before the upgrade are sent through the new endpoint -- **Breaking:** on Windows and Linux, a 429 or a redirect drops the batch instead of retrying it (only same-origin 307/308 redirects are followed), and a `Retry-After` wait is capped at 30 seconds +- **Breaking:** on Windows and Linux, events now go to capture v1 (`/i/v1/analytics/events`) instead of `/batch/`, like iOS and Android in 6.0. A reverse proxy or self-hosted instance must serve the new path: a 404 is retried and the events stay queued. Events queued before the upgrade are sent through the new endpoint +- **Breaking:** on Windows and Linux, a redirect drops the batch instead of retrying it (only same-origin 307/308 redirects are followed, while a 404 or 429 is retried), and a `Retry-After` wait is capped at 30 seconds diff --git a/posthog_flutter/lib/src/core/capture_v1.dart b/posthog_flutter/lib/src/core/capture_v1.dart index fd174f19..16acfc22 100644 --- a/posthog_flutter/lib/src/core/capture_v1.dart +++ b/posthog_flutter/lib/src/core/capture_v1.dart @@ -6,9 +6,11 @@ import 'logger.dart'; const captureV1Path = '/i/v1/analytics/events'; /// Capture v1 statuses worth retrying. Everything else is terminal, -/// including 429 and 3xx; 413 halves the batch instead. +/// including 3xx; 413 halves the batch instead. bool isRetriableCaptureV1Status(int status) => + status == 404 || status == 408 || + status == 429 || status == 500 || status == 502 || status == 503 || diff --git a/posthog_flutter/test/core/capture_v1_test.dart b/posthog_flutter/test/core/capture_v1_test.dart index 585979ed..79c9dd89 100644 --- a/posthog_flutter/test/core/capture_v1_test.dart +++ b/posthog_flutter/test/core/capture_v1_test.dart @@ -318,20 +318,27 @@ void main() { expect(getQueue(storage), isEmpty); }); - test('drops the batch on 404 without falling back to /batch', () async { - final server = await LocalPostHogServer.start(); + test('retries a 404 without falling back to /batch', () { + final api = InProcessPostHogApi() + ..respond = (request) => request.isFlags + ? PostHogResponse.json({'flags': {}}) + : const PostHogResponse(HttpStatus.notFound); final storage = tempStorage(); - final client = testClient(server, storage: storage); - server.respond = (request) => request.isFlags - ? PostHogResponse.json({'flags': {}}) - : const PostHogResponse(HttpStatus.notFound); + fakeAsync((async) { + final client = testClient(api, storage: storage); + Object? error; - client.capture('evt'); - await expectLater(client.flush(), throwsA(isA())); + client.capture('evt'); + client.flush().catchError((Object e) { + error = e; + }); + async.elapse(const Duration(seconds: 9)); - expect([for (final r in server.requests) r.path], - ['/i/v1/analytics/events']); - expect(getQueue(storage), isEmpty); + expect(error, isA()); + expect([for (final r in api.requests) r.path], + List.filled(4, '/i/v1/analytics/events')); + expect(getQueue(storage), hasLength(1)); + }); }); }); diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart index eefb746d..b437a82f 100644 --- a/posthog_flutter/test/core/queue_flush_test.dart +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -148,7 +148,7 @@ void main() { }); }); - for (final status in [408, 500, 502, 503, 504]) { + for (final status in [404, 408, 429, 500, 502, 503, 504]) { test('HTTP $status keeps the batch queued after the retries', () { final api = InProcessPostHogApi() ..respond = (_) => PostHogResponse(status); @@ -170,9 +170,7 @@ void main() { }); } - // Capture v1 retries only the statuses above: a rate limit, a redirect - // it does not follow, or a missing route fails the same way again. - for (final status in [301, 302, 307, 308, 401, 403, 404, 429, 501]) { + for (final status in [301, 302, 307, 308, 401, 403, 501]) { test('HTTP $status drops the batch without a retry', () { final api = InProcessPostHogApi() ..respond = (_) => PostHogResponse(status, @@ -256,7 +254,7 @@ void main() { }); group('Retry-After', () { - for (final status in [503]) { + for (final status in [429, 503]) { test('HTTP $status pauses every send path and resumes the queue', () { final api = InProcessPostHogApi(); final storage = tempStorage(); From fef83755c7fec96c8ba1076fe42cd61f1c5bfa76 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 14:48:31 -0400 Subject: [PATCH 10/43] fix: back off across failed desktop flushes and warn once on a capture 404 After a batch fails its retries, later flushes make one attempt each and pause 6s doubling to 30s until a batch is delivered. Before, every flush, including the one each capture starts at flushAt, sent 4 requests, so a missing capture route kept resending the batch. A 404 prints one warning per client even without debug, naming the URL and the path a proxy or self-hosted instance must serve. --- .changeset/v6-desktop-capture-v1.md | 3 +- .../lib/src/core/posthog_core_stateless.dart | 35 +++++++- .../test/core/queue_flush_test.dart | 90 +++++++++++++++++-- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/.changeset/v6-desktop-capture-v1.md b/.changeset/v6-desktop-capture-v1.md index 4d1fafbd..30641472 100644 --- a/.changeset/v6-desktop-capture-v1.md +++ b/.changeset/v6-desktop-capture-v1.md @@ -2,5 +2,6 @@ "posthog_flutter": major --- -- **Breaking:** on Windows and Linux, events now go to capture v1 (`/i/v1/analytics/events`) instead of `/batch/`, like iOS and Android in 6.0. A reverse proxy or self-hosted instance must serve the new path: a 404 is retried and the events stay queued. Events queued before the upgrade are sent through the new endpoint +- **Breaking:** on Windows and Linux, events now go to capture v1 (`/i/v1/analytics/events`) instead of `/batch/`, like iOS and Android in 6.0. A reverse proxy or self-hosted instance must serve the new path: a 404 is retried, the events stay queued, and the SDK prints a warning once, also without `debug`. Events queued before the upgrade are sent through the new endpoint - **Breaking:** on Windows and Linux, a redirect drops the batch instead of retrying it (only same-origin 307/308 redirects are followed, while a 404 or 429 is retried), and a `Retry-After` wait is capped at 30 seconds +- On Windows and Linux, once a batch has failed after its retries, later sends make one attempt each and wait longer between them, from 6 up to 30 seconds, until a batch is delivered diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index ff78b29f..525a4c18 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:meta/meta.dart'; import '../posthog_config.dart'; @@ -84,6 +85,15 @@ abstract class PostHogCoreStateless { /// retried. Kept in memory: after a restart the retry is a new request. CaptureV1Identity? _pendingCaptureIdentity; + /// Flushes in a row whose batch failed with a transient error. Once one + /// has failed, later flushes send once and wait longer, so a failure that + /// lasts, like a missing capture route, doesn't resend the batch with + /// every capture. A delivered batch resets it. + int _failedFlushes = 0; + + /// Whether this client already warned that capture returned a 404. + bool _warnedCaptureNotFound = false; + /// The latest consent decision made through this client. It wins over the /// stored one: [clearPersistedOptOut] removes the stored decision while /// this client keeps honoring it, and a decision the storage cannot @@ -476,8 +486,9 @@ abstract class PostHogCoreStateless { _flushTimer = null; } - /// Stops sending events for [delay], the server's `Retry-After`, kept - /// between the retry delay and [_maxRetryAfter]. + /// Stops sending events for [delay], the server's `Retry-After` or the + /// backoff after failed flushes, kept between the retry delay and + /// [_maxRetryAfter]. void _pauseFlush(Duration delay) { _clearFlushTimer(); _retryAfterTimer?.cancel(); @@ -558,6 +569,7 @@ abstract class PostHogCoreStateless { final Duration? retryAfter; try { (:retryUuids, :retryAfter) = await _postCaptureV1(pending); + _failedFlushes = 0; } catch (e) { if (_closed) rethrow; if (e is PostHogFetchHttpError && @@ -567,9 +579,23 @@ abstract class PostHogCoreStateless { logger.warn('Received 413, reducing batch size to $_maxBatchSize'); return true; } + if (e is PostHogFetchHttpError && + e.status == HttpStatus.notFound && + !_warnedCaptureNotFound) { + _warnedCaptureNotFound = true; + // Printed even without debug: the events stay queued, and nothing + // else tells the app that the route is missing. + debugPrint('[PostHog] [WARN] $_host$captureV1Path returned 404. ' + 'Events stay queued and are retried. If you use a reverse ' + 'proxy or self-host PostHog, make sure it serves ' + '$captureV1Path.'); + } final transient = _isTransientCaptureError(e); final retryAfter = e is PostHogFetchHttpError ? e.retryAfter : null; - if (transient && retryAfter == null && attempt < _fetchRetryCount) { + if (transient && + retryAfter == null && + _failedFlushes == 0 && + attempt < _fetchRetryCount) { continue; } if (!transient) { @@ -579,6 +605,9 @@ abstract class PostHogCoreStateless { // next capture (forever in an idle app after an offline failure). if (transient && retryAfter != null) { _pauseFlush(retryAfter); + } else if (transient) { + _failedFlushes++; + _pauseFlush(_fetchRetryDelay * (1 << _failedFlushes.clamp(1, 4))); } else { _scheduleFlush(); } diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart index b437a82f..9d95728f 100644 --- a/posthog_flutter/test/core/queue_flush_test.dart +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:fake_async/fake_async.dart'; +import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/core/file_storage.dart'; import 'package:posthog_flutter/src/core/persistence.dart'; @@ -217,16 +218,87 @@ void main() { }); }); + test('after a failed flush, later flushes send once and back off longer', + () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.notFound); + final storage = tempStorage(); + fakeAsync((async) { + final client = + testClient(api, storage: storage, config: testConfig(flushAt: 1)); + + client.capture('first'); + async.elapse(const Duration(seconds: 9)); + expect(api.batchRequests, hasLength(4)); + client.capture('second'); + + var now = const Duration(seconds: 9); + var requests = 4; + for (final next in [15, 27, 51, 81, 111]) { + async.elapse(Duration(seconds: next) - now - _ms); + expect(api.batchRequests, hasLength(requests)); + async.elapse(_ms); + expect(api.batchRequests, hasLength(++requests)); + now = Duration(seconds: next); + } + expect(getQueue(storage), hasLength(2)); + }); + }); + + test('a delivered batch restores the retries', () { + final api = InProcessPostHogApi(); + var status = HttpStatus.notFound; + api.respond = (_) => PostHogResponse(status); + final storage = tempStorage(); + fakeAsync((async) { + final client = + testClient(api, storage: storage, config: testConfig(flushAt: 1)); + + client.capture('first'); + async.elapse(const Duration(seconds: 9)); + status = HttpStatus.ok; + async.elapse(const Duration(seconds: 6)); + expect(api.batchRequests, hasLength(5)); + expect(getQueue(storage), isEmpty); + + status = HttpStatus.serviceUnavailable; + client.capture('second'); + async.elapse(const Duration(seconds: 9)); + expect(api.batchRequests, hasLength(9)); + }); + }); + + test('warns once about a 404, also without debug', () { + final printed = []; + final originalDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) => printed.add(message); + addTearDown(() => debugPrint = originalDebugPrint); + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.notFound); + fakeAsync((async) { + final client = testClient(api, config: testConfig(flushAt: 1)); + + client.capture('evt'); + async.elapse(const Duration(seconds: 60)); + + expect(api.batchRequests.length, greaterThan(4)); + expect(printed, [ + '[PostHog] [WARN] ${api.url}/i/v1/analytics/events returned 404. ' + 'Events stay queued and are retried. If you use a reverse ' + 'proxy or self-host PostHog, make sure it serves ' + '/i/v1/analytics/events.', + ]); + }); + }); + test('the timer firing while a flush is in flight stays re-armable', () { final api = InProcessPostHogApi(); final storage = tempStorage(); fakeAsync((async) { final release = Completer(); - var online = false; api.respond = (_) async { await release.future; - return PostHogResponse( - online ? HttpStatus.ok : HttpStatus.serviceUnavailable); + return const PostHogResponse(HttpStatus.ok); }; final client = testClient(api, config: testConfig(flushInterval: const Duration(milliseconds: 20)), @@ -239,16 +311,14 @@ void main() { client.capture('second'); async.elapse(const Duration(milliseconds: 60)); release.complete(); - // The flush and its retries fail. - async.elapse(const Duration(seconds: 9)); - expect(api.batchRequests, hasLength(4)); + async.elapse(Duration.zero); + expect(getQueue(storage), isEmpty); - online = true; + client.capture('third'); async.elapse(const Duration(milliseconds: 20)); - expect(api.batchRequests.last.eventNames, ['first', 'second'], + expect(api.batchRequests.last.eventNames, ['third'], reason: 'only a re-armed timer sends the queue again'); - expect(getQueue(storage), isEmpty); }); }); }); @@ -812,3 +882,5 @@ void main() { }); }); } + +const _ms = Duration(milliseconds: 1); From 7a5320b2544db5dd0e357c8a8c051ed80d21b8e1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 15:05:34 -0400 Subject: [PATCH 11/43] fix: leave the host out of the capture 404 warning A host with proxy credentials in its userinfo would print them to app logs. --- posthog_flutter/lib/src/core/posthog_core_stateless.dart | 2 +- posthog_flutter/test/core/queue_flush_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 525a4c18..549ba39d 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -585,7 +585,7 @@ abstract class PostHogCoreStateless { _warnedCaptureNotFound = true; // Printed even without debug: the events stay queued, and nothing // else tells the app that the route is missing. - debugPrint('[PostHog] [WARN] $_host$captureV1Path returned 404. ' + debugPrint('[PostHog] [WARN] $captureV1Path returned 404. ' 'Events stay queued and are retried. If you use a reverse ' 'proxy or self-host PostHog, make sure it serves ' '$captureV1Path.'); diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart index 9d95728f..f366af20 100644 --- a/posthog_flutter/test/core/queue_flush_test.dart +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -283,7 +283,7 @@ void main() { expect(api.batchRequests.length, greaterThan(4)); expect(printed, [ - '[PostHog] [WARN] ${api.url}/i/v1/analytics/events returned 404. ' + '[PostHog] [WARN] /i/v1/analytics/events returned 404. ' 'Events stay queued and are retried. If you use a reverse ' 'proxy or self-host PostHog, make sure it serves ' '/i/v1/analytics/events.', From 2939aa908733281decf7e03febc9f573d29ca22c Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:55:03 -0400 Subject: [PATCH 12/43] chore!: pass the loaded flags to onFeatureFlags and reloadFeatureFlags (#664) * chore!: pass the loaded flags to onFeatureFlags and reloadFeatureFlags onFeatureFlags and reloadFeatureFlags now carry a PostHogFeatureFlagsLoaded with the enabled flags, their values and whether loading failed, matching posthog-ios 4.0 and posthog-android 4.0. On web, reloadFeatureFlags completes right away with the flags loaded so far, because posthog-js doesn't report when a reload finishes. * fix: complete the web reloadFeatureFlags with the reloaded flags Wait for the next posthog-js onFeatureFlags call instead of returning the flags already loaded, so a failed reload reports errorsLoading. Complete right away when flags are disabled, and after twice the flags request timeout when posthog-js skips the request without calling back. * chore: use PostHogFeatureFlagsCallback from posthog-android 4.0 * chore: fix ktlint function-signature * fix: await posthog-js reloadFeatureFlagsAsync on web The next onFeatureFlags callback can carry a response from a request already in flight, so reloadFeatureFlags() could complete with flags from before the reload. posthog-js 1.438.2+ settles reloadFeatureFlagsAsync() after this reload's own request; use it when present and keep the callback path for older posthog-js. * docs: pass the loaded flags in the setup example * docs: drop best-effort web note from the person and group property methods * fix(web): wait for posthog-js flags retries before reporting a reload failure posthog-js retries a timed-out /flags request once by default, 500 ms later, inside the same reload. The older-JS deadline of twice the request timeout reported cached flags with errorsLoading set before a successful retry landed. * docs: note where onFeatureFlags and errorsLoading differ on Android and web * docs: describe the older posthog-js reload timeout as the full retry budget Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changeset/v6-flags-loaded-result.md | 6 + api/posthog_flutter.api.json | 93 ++++++++++- example/lib/main.dart | 7 +- .../posthog/flutter/PosthogFlutterPlugin.kt | 13 +- .../PosthogFlutterPlugin.swift | 16 +- posthog_flutter/lib/posthog_flutter.dart | 1 + posthog_flutter/lib/posthog_flutter_web.dart | 12 +- .../lib/src/core/posthog_core.dart | 7 + .../lib/src/feature_flags_loaded.dart | 53 +++++++ posthog_flutter/lib/src/posthog.dart | 47 ++++-- posthog_flutter/lib/src/posthog_config.dart | 20 ++- .../lib/src/posthog_flutter_desktop.dart | 14 +- .../lib/src/posthog_flutter_io.dart | 12 +- .../posthog_flutter_platform_interface.dart | 10 +- .../lib/src/posthog_flutter_web_handler.dart | 92 ++++++++++- .../lib/src/utils/channel_serialization.dart | 16 ++ .../test/posthog_flutter_desktop_test.dart | 36 ++++- .../test/posthog_flutter_io_test.dart | 106 ++++--------- ...sthog_flutter_platform_interface_fake.dart | 4 +- .../posthog_flutter_web_handler_test.dart | 146 ++++++++++++++++++ posthog_flutter/test/posthog_test.dart | 2 +- pubspec.lock | 28 ++-- 22 files changed, 590 insertions(+), 151 deletions(-) create mode 100644 .changeset/v6-flags-loaded-result.md create mode 100644 posthog_flutter/lib/src/feature_flags_loaded.dart diff --git a/.changeset/v6-flags-loaded-result.md b/.changeset/v6-flags-loaded-result.md new file mode 100644 index 00000000..03501e51 --- /dev/null +++ b/.changeset/v6-flags-loaded-result.md @@ -0,0 +1,6 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** `PostHogConfig.onFeatureFlags` now receives a `PostHogFeatureFlagsLoaded` with the enabled flags, their values and whether loading failed. Update callbacks from `() { ... }` to `(loaded) { ... }` (or `(_) { ... }`) +- **Breaking:** `reloadFeatureFlags()` now returns a `Future` with the reloaded flags diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index d3767214..c107ecea 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -802,7 +802,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -1855,7 +1855,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -2301,7 +2301,7 @@ "isRequired": false, "name": "onFeatureFlags", "relativePath": "lib/src/posthog_config.dart", - "typeName": "void Function()?" + "typeName": "void Function(PostHogFeatureFlagsLoaded)?" }, { "isDeprecated": false, @@ -2582,7 +2582,7 @@ "isWriteable": true, "name": "onFeatureFlags", "relativePath": "lib/src/posthog_config.dart", - "typeName": "void Function()?" + "typeName": "void Function(PostHogFeatureFlagsLoaded)?" }, { "entryPoints": [], @@ -4522,7 +4522,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -6026,6 +6026,89 @@ "Object" ], "typeParameterNames": [] + }, + { + "entryPoints": [ + "posthog_flutter.dart" + ], + "executableDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "new", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "variants", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "Map" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "errorsLoading", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "bool" + } + ], + "relativePath": "lib/src/feature_flags_loaded.dart", + "returnTypeName": "PostHogFeatureFlagsLoaded", + "type": "constructor", + "typeParameterNames": [] + } + ], + "fieldDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "flags", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "List" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "variants", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "Map" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "errorsLoading", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "bool" + } + ], + "isDeprecated": false, + "isExperimental": false, + "isRequired": false, + "isSealed": false, + "name": "PostHogFeatureFlagsLoaded", + "relativePath": "lib/src/feature_flags_loaded.dart", + "superTypeNames": [ + "Object" + ], + "typeParameterNames": [] } ], "iosPlatformConstraints": null, diff --git a/example/lib/main.dart b/example/lib/main.dart index 378b7c3c..e4599572 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -21,8 +21,11 @@ Future main() async { final config = PostHogConfig( 'phc_6lqCaCDCBEWdIGieihq5R2dZpPVbAUFISA75vFZow06', ); - config.onFeatureFlags = () { - debugPrint('[PostHog] Feature flags loaded!'); + config.onFeatureFlags = (loaded) { + debugPrint( + '[PostHog] Feature flags loaded: ${loaded.variants}, ' + 'errorsLoading: ${loaded.errorsLoading}', + ); }; config.beforeSend = [ diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index f81e073c..8f1ed0eb 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -27,7 +27,8 @@ import com.posthog.PostHogBeforeSend import com.posthog.PostHogBootstrapConfig import com.posthog.PostHogCompression import com.posthog.PostHogConfig -import com.posthog.PostHogOnFeatureFlags +import com.posthog.PostHogFeatureFlagsCallback +import com.posthog.PostHogFeatureFlagsLoaded import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.getApplicationInfo @@ -814,9 +815,9 @@ class PosthogFlutterPlugin : } onFeatureFlags = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { loaded -> Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - invokeFlutterMethod("onFeatureFlagsCallback", emptyMap()) + invokeFlutterMethod("onFeatureFlagsCallback", loaded.toFlutterMap()) } } @@ -1929,9 +1930,9 @@ class PosthogFlutterPlugin : // Resolve the Dart Future only once flags have actually finished // loading. The native callback fires on a background thread, so the // result must be posted back to the main thread for Flutter. - PostHog.reloadFeatureFlags { + PostHog.reloadFeatureFlags { loaded -> Handler(Looper.getMainLooper()).post { - result.success(null) + result.success(loaded.toFlutterMap()) } } } catch (e: Throwable) { @@ -2232,6 +2233,8 @@ class PosthogFlutterPlugin : } } + private fun PostHogFeatureFlagsLoaded.toFlutterMap(): Map = mapOf("variants" to variants, "errorsLoading" to errorsLoading) + private fun invokeFlutterMethod( method: String, arguments: Any? = null, diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 3f26a15b..3e3c3449 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -62,8 +62,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { override init() { super.init() - featureFlagsSubscription = PostHogSDK.shared.onFeatureFlags { [weak self] _ in - self?.featureFlagsDidUpdate() + featureFlagsSubscription = PostHogSDK.shared.onFeatureFlags { [weak self] loaded in + self?.featureFlagsDidUpdate(loaded) } } @@ -87,8 +87,12 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: methodChannel) } - func featureFlagsDidUpdate() { - invokeFlutterMethod("onFeatureFlagsCallback", arguments: [String: Any]()) + func featureFlagsDidUpdate(_ loaded: PostHogFeatureFlagsLoaded) { + invokeFlutterMethod("onFeatureFlagsCallback", arguments: Self.featureFlagsLoadedMap(loaded)) + } + + private static func featureFlagsLoadedMap(_ loaded: PostHogFeatureFlagsLoaded) -> [String: Any] { + ["variants": loaded.variants, "errorsLoading": loaded.errorsLoading] } private let dispatchQueue = DispatchQueue(label: "com.posthog.PosthogFlutterPlugin", @@ -1621,9 +1625,9 @@ extension PosthogFlutterPlugin { private func reloadFeatureFlags(_ result: @escaping FlutterResult) { // Resolve the Dart Future only once flags have actually finished loading. // The native callback fires on a background thread, so hop to main for Flutter. - PostHogSDK.shared.reloadFeatureFlags { _ in + PostHogSDK.shared.reloadFeatureFlags { loaded in DispatchQueue.main.async { - result(nil) + result(Self.featureFlagsLoadedMap(loaded)) } } } diff --git a/posthog_flutter/lib/posthog_flutter.dart b/posthog_flutter/lib/posthog_flutter.dart index a95b37a5..13307a0e 100644 --- a/posthog_flutter/lib/posthog_flutter.dart +++ b/posthog_flutter/lib/posthog_flutter.dart @@ -2,6 +2,7 @@ library posthog_flutter; export 'src/feature_flag_result.dart'; +export 'src/feature_flags_loaded.dart'; export 'src/logs/posthog_log_record.dart'; export 'src/logs/posthog_log_severity.dart'; export 'src/logs/posthog_logger.dart' hide CaptureLog; diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 88047d82..a22c2f50 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -11,6 +11,7 @@ import 'package:posthog_flutter/src/utils/channel_serialization.dart'; import 'package:posthog_flutter/src/utils/property_normalizer.dart'; import 'src/feature_flag_result.dart'; +import 'src/feature_flags_loaded.dart'; import 'src/logs/posthog_log_severity.dart'; import 'src/posthog_config.dart'; import 'src/posthog_constants.dart'; @@ -129,10 +130,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final dartCallback = config.onFeatureFlags!; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) - // We ignore the JS parameters and just invoke the void callback final jsCallback = (JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) { - dartCallback(); + dartCallback(featureFlagsLoadedFromMap({ + 'variants': jsFlagVariants.dartify(), + 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], + })); }.toJS; ph.onFeatureFlags(jsCallback); @@ -393,8 +396,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { } @override - Future reloadFeatureFlags() async { - return handleWebMethodCall(const MethodCall('reloadFeatureFlags')); + Future reloadFeatureFlags() async { + return featureFlagsLoadedFromMap( + await handleWebMethodCall(const MethodCall('reloadFeatureFlags'))); } @override diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 43f7ab84..70a407cb 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:meta/meta.dart'; import '../feature_flag_result.dart'; +import '../feature_flags_loaded.dart'; import '../posthog_config.dart'; import 'feature_flag_utils.dart'; import 'feature_flags.dart'; @@ -923,6 +924,12 @@ abstract class PostHogCore extends PostHogCoreStateless { await wrap(_flagsAsync); } + /// The enabled flags currently served, and whether the latest load failed. + PostHogFeatureFlagsLoaded featureFlagsLoaded() => PostHogFeatureFlagsLoaded( + variants: _getFeatureFlags() ?? const {}, + errorsLoading: _getStoredFlagDetails()?.requestError != null, + ); + /// Registers a callback for when feature flags are loaded. A callback /// registered right after the client is created also hears about the flags /// it starts with, bootstrapped or cached. Returns an unsubscribe function. diff --git a/posthog_flutter/lib/src/feature_flags_loaded.dart b/posthog_flutter/lib/src/feature_flags_loaded.dart new file mode 100644 index 00000000..0a8c196b --- /dev/null +++ b/posthog_flutter/lib/src/feature_flags_loaded.dart @@ -0,0 +1,53 @@ +/// The feature flags passed to [PostHogConfig.onFeatureFlags] and returned by +/// [Posthog.reloadFeatureFlags]. +/// +/// Only enabled flags are included. When the latest load failed, +/// [errorsLoading] is `true` and the flags are the last known values, which +/// can be empty. +/// +/// **Example:** +/// ```dart +/// final config = PostHogConfig('') +/// ..onFeatureFlags = (loaded) { +/// if (loaded.errorsLoading) { +/// // The values are the last known (cached) flags. +/// } +/// if (loaded.variants['new-checkout'] == 'test') { +/// // Show the test variant. +/// } +/// }; +/// ``` +class PostHogFeatureFlagsLoaded { + /// The keys of the enabled feature flags. + List get flags => List.unmodifiable(variants.keys); + + /// The values of the enabled feature flags, keyed by flag key. + /// + /// A value is `true` for an enabled boolean flag, or the variant [String] + /// for a multivariate flag. Disabled flags are not included. + final Map variants; + + /// Whether the latest attempt to load feature flags failed. + /// + /// When it did, [flags] and [variants] hold the last known values. + /// + /// **Flutter web:** when [PostHogConfig.onFeatureFlags] is first set, it is + /// called right away with the current flags and `false`, even if + /// posthog-js's last load before that failed. + final bool errorsLoading; + + /// Creates the loaded feature flags from [variants], keeping only enabled + /// flags: `true`, or a non-empty variant string. + PostHogFeatureFlagsLoaded({ + required Map variants, + required this.errorsLoading, + }) : variants = Map.unmodifiable({ + for (final MapEntry(:key, :value) in variants.entries) + if (value == true || (value is String && value.isNotEmpty)) + key: value!, + }); + + @override + String toString() => + 'PostHogFeatureFlagsLoaded(variants: $variants, errorsLoading: $errorsLoading)'; +} diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 2c134fe6..6504267b 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_record.dart'; import 'logs/posthog_log_severity.dart'; import 'logs/posthog_logger.dart'; @@ -58,7 +59,7 @@ class Posthog { /// ```dart /// final config = PostHogConfig('YOUR_PROJECT_TOKEN'); /// config.host = 'YOUR_POSTHOG_HOST'; - /// config.onFeatureFlags = () { + /// config.onFeatureFlags = (loaded) { /// // Feature flags are now loaded, you can read flag values here /// }; /// await Posthog().setup(config); @@ -489,8 +490,30 @@ class Posthog { /// Reloads feature flags for the current user. /// - /// Returns a [Future] that completes when the reload request has been queued. - Future reloadFeatureFlags() => _posthog.reloadFeatureFlags(); + /// Returns a [Future] that completes when the reload finishes, with the + /// enabled flags and whether loading failed. When it failed, + /// [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the flags are the + /// last known values. + /// + /// **Flutter web:** with posthog-js 1.438.2 or later, completes once this + /// reload's request settles; if posthog-js skips it (flags disabled, + /// reloading paused, or after repeated network failures), it completes right + /// away with the current flags and `errorsLoading: true`. Older posthog-js + /// completes with the next flags it reports, and when it skips the request, + /// with `errorsLoading: true` once every request attempt posthog-js may + /// make could have timed out: `feature_flag_request_max_retries + 2` times + /// its `feature_flag_request_timeout_ms`, plus 500 ms per retry (9.5 s with + /// the defaults; right away when flags are disabled). + /// + /// **Example:** + /// ```dart + /// final loaded = await Posthog().reloadFeatureFlags(); + /// if (!loaded.errorsLoading && loaded.flags.contains('new-checkout')) { + /// // The flag is enabled. + /// } + /// ``` + Future reloadFeatureFlags() => + _posthog.reloadFeatureFlags(); /// Sets person properties that are used only for feature flag evaluation. /// @@ -506,9 +529,9 @@ class Posthog { /// /// Set [reloadFeatureFlags] to `false` to skip reloading flags after updating /// the properties (defaults to `true`). When `true`, the returned [Future] - /// awaits the reload before completing; on iOS and Android this means flags - /// have finished loading, so the next [getFeatureFlag] / [getFeatureFlagResult] - /// reflects the updated properties. On web the reload is best-effort. + /// awaits the reload before completing, so the next [getFeatureFlag] / + /// [getFeatureFlagResult] reflects the updated properties. On web, see + /// [Posthog.reloadFeatureFlags] for which posthog-js versions wait for it. /// /// **Example:** /// ```dart @@ -536,8 +559,8 @@ class Posthog { /// /// Set [reloadFeatureFlags] to `false` to skip reloading flags after clearing /// the properties (defaults to `true`). When `true`, the returned [Future] - /// awaits the reload before completing (on iOS/Android, after flags finish - /// loading; on web, best-effort). + /// awaits the reload before completing. On web, see + /// [Posthog.reloadFeatureFlags] for which posthog-js versions wait for it. Future resetPersonPropertiesForFlags({ bool reloadFeatureFlags = true, }) async { @@ -560,8 +583,8 @@ class Posthog { /// /// Set [reloadFeatureFlags] to `false` to skip reloading flags after updating /// the properties (defaults to `true`). When `true`, the returned [Future] - /// awaits the reload before completing (on iOS/Android, after flags finish - /// loading; on web, best-effort). + /// awaits the reload before completing. On web, see + /// [Posthog.reloadFeatureFlags] for which posthog-js versions wait for it. /// /// **Example:** /// ```dart @@ -592,8 +615,8 @@ class Posthog { /// /// Set [reloadFeatureFlags] to `false` to skip reloading flags after clearing /// the properties (defaults to `true`). When `true`, the returned [Future] - /// awaits the reload before completing (on iOS/Android, after flags finish - /// loading; on web, best-effort). + /// awaits the reload before completing. On web, see + /// [Posthog.reloadFeatureFlags] for which posthog-js versions wait for it. Future resetGroupPropertiesForFlags({ String? groupType, bool reloadFeatureFlags = true, diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index fcbd806f..b5225fd7 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -264,8 +264,24 @@ class PostHogConfig { /// Callback to be invoked when feature flags are loaded. /// - /// Use [Posthog.getFeatureFlag] or [Posthog.isFeatureEnabled] within this - /// callback to access the loaded flag values. + /// It receives the enabled flags and whether loading failed. It runs after + /// the flags load at startup, after [Posthog.reloadFeatureFlags], and after + /// calls that reload flags such as `identify()` or `group()`. When a load + /// fails, [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the flags + /// are the last known values. Defaults to `null`. + /// + /// **Android:** not called after [Posthog.reloadFeatureFlags]. Use the + /// flags that call returns instead. + /// + /// **Example:** + /// ```dart + /// final config = PostHogConfig('') + /// ..onFeatureFlags = (loaded) { + /// if (loaded.variants['new-checkout'] == 'test') { + /// // Show the test variant. + /// } + /// }; + /// ``` OnFeatureFlagsCallback? onFeatureFlags; /// Whether to automatically register this device's push token with PostHog. diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index f8baec36..0d810a51 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -6,6 +6,7 @@ import 'core/file_storage.dart'; import 'core/utils/utils.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; import 'posthog_constants.dart'; @@ -175,8 +176,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { projectToken: config.projectToken, host: removeTrailingSlash(config.host), ); - _featureFlagsUnsubscribe = - client.onFeatureFlags(() => _config?.onFeatureFlags?.call()); + _featureFlagsUnsubscribe = client.onFeatureFlags( + () => _config?.onFeatureFlags?.call(client.featureFlagsLoaded())); final stepsConfig = config.errorTrackingConfig.exceptionSteps; _exceptionSteps = stepsConfig.enabled @@ -451,8 +452,13 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { false); @override - Future reloadFeatureFlags() => _guard( - 'reloadFeatureFlags', (client) => client.reloadFeatureFlagsAsync()); + Future reloadFeatureFlags() => _guardWith( + 'reloadFeatureFlags', + PostHogFeatureFlagsLoaded(variants: const {}, errorsLoading: true), + (client) async { + await client.reloadFeatureFlagsAsync(); + return client.featureFlagsLoaded(); + }); @override Future setPersonPropertiesForFlags( diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index f5a1b2ad..5fe3c3d4 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -17,6 +17,7 @@ import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; import 'posthog_constants.dart'; @@ -73,7 +74,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await cleanupSurveys(); return null; case 'onFeatureFlagsCallback': - _onFeatureFlagsCallback?.call(); + _onFeatureFlagsCallback + ?.call(featureFlagsLoadedFromMap(call.arguments)); break; case 'pushIdentityProvider': // Returning a value here is the reply the native SDK awaits. @@ -585,15 +587,17 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } @override - Future reloadFeatureFlags() async { + Future reloadFeatureFlags() async { if (!isSupportedPlatform()) { - return; + return featureFlagsLoadedFromMap(null); } try { - await _methodChannel.invokeMethod('reloadFeatureFlags'); + return featureFlagsLoadedFromMap( + await _methodChannel.invokeMethod('reloadFeatureFlags')); } on PlatformException catch (exception) { printIfDebug('Exeption on reloadFeatureFlags: $exception'); + return featureFlagsLoadedFromMap(null); } } diff --git a/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart b/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart index 993ac9a3..89c7a376 100644 --- a/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart +++ b/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart @@ -1,15 +1,17 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; import 'posthog_flutter_io.dart'; /// Defines the callback signature for when feature flags are loaded. /// -/// Use [Posthog.getFeatureFlag] or [Posthog.isFeatureEnabled] within this -/// callback to access the loaded flag values. -typedef OnFeatureFlagsCallback = void Function(); +/// The callback receives the enabled flags and whether loading failed. See +/// [PostHogFeatureFlagsLoaded]. +typedef OnFeatureFlagsCallback = void Function( + PostHogFeatureFlagsLoaded loaded); abstract class PosthogFlutterPlatformInterface extends PlatformInterface { /// Constructs a PosthogFlutterPlatform. @@ -173,7 +175,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('isFeatureEnabled() has not been implemented.'); } - Future reloadFeatureFlags() { + Future reloadFeatureFlags() { throw UnimplementedError('reloadFeatureFlags() has not been implemented.'); } diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index 1e822330..fd482daa 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -1,6 +1,9 @@ // ignore_for_file: avoid_dynamic_calls, avoid_annotating_with_dynamic +import 'dart:async'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:math' show max; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; @@ -64,7 +67,7 @@ extension PostHogExtension on PostHog { external void unregister(JSAny key); // ignore: non_constant_identifier_names external JSAny? get_session_id(); - external void onFeatureFlags(JSFunction callback); + external JSFunction onFeatureFlags(JSFunction callback); external void startSessionRecording(); external void stopSessionRecording(); external bool sessionRecordingStarted(); @@ -72,10 +75,20 @@ extension PostHogExtension on PostHog { external void set_config(JSAny config); external JSObject? get config; external SessionManager? get sessionManager; + external FeatureFlags? get featureFlags; // ignore: non_constant_identifier_names external void _overrideSDKInfo(JSAny sdkName, JSAny sdkVersion); } +// Feature flags JS interop +@JS() +@staticInterop +class FeatureFlags {} + +extension FeatureFlagsExtension on FeatureFlags { + external JSObject getFlagVariants(); +} + // SessionManager JS interop @JS() @staticInterop @@ -200,6 +213,80 @@ String _exceptionMessage(Map properties) { return 'Exception'; } +/// posthog-js's pause between `/flags` retries. +const _flagsRetryDelayMs = 500; + +/// Reloads feature flags and completes with the flags that reload loaded. +/// +/// posthog-js 1.438.2+ has `reloadFeatureFlagsAsync()`, which settles after +/// this reload's own request, even when another request is already in flight. +/// Anything but a successful load reports the current flags with +/// `errorsLoading` set. +/// +/// Older posthog-js completes with the next flags posthog-js reports. It skips +/// the request without calling back when flags are disabled (completes right +/// away), and while reloading is paused or after repeated network failures +/// (completes once every request attempt posthog-js may make, including its +/// retries, would have timed out, plus one more request timeout). +Future> _reloadFeatureFlags(PostHog? ph) async { + final featureFlags = ph?.featureFlags; + if (ph == null || featureFlags == null) { + return {'variants': null, 'errorsLoading': true}; + } + Map currentFlags({required bool errorsLoading}) => { + 'variants': featureFlags.getFlagVariants().dartify(), + 'errorsLoading': errorsLoading + }; + + final flagsObject = featureFlags as JSObject; + if (flagsObject.has('reloadFeatureFlagsAsync')) { + final result = await flagsObject + .callMethod>('reloadFeatureFlagsAsync'.toJS) + .toDart; + final status = result?.getProperty('status'.toJS)?.toDart; + return currentFlags(errorsLoading: status != 'loaded'); + } + + final config = ph.config; + if (config + ?.getProperty('advanced_disable_feature_flags'.toJS) + ?.dartify() == + true) { + return currentFlags(errorsLoading: true); + } + + final reloaded = Completer>(); + var subscribed = false; + final unsubscribe = ph + .onFeatureFlags((JSArray flags, JSObject variants, [JSObject? context]) { + // onFeatureFlags replays the current flags synchronously: skip them. + if (!subscribed || reloaded.isCompleted) return; + reloaded.complete({ + 'variants': variants.dartify(), + 'errorsLoading': (context?.dartify() as Map?)?['errorsLoading'] == true, + }); + }.toJS); + subscribed = true; + ph.reloadFeatureFlags(); + + int? configInt(String key) => + (config?.getProperty(key.toJS)?.dartify() as num?)?.toInt(); + final requestTimeoutMs = configInt('feature_flag_request_timeout_ms') ?? 3000; + // posthog-js retries a timed-out /flags request within the same reload, + // 500 ms apart, so a successful retry can land well after one timeout. + final retries = max(0, configInt('feature_flag_request_max_retries') ?? 1); + try { + return await reloaded.future.timeout( + Duration( + milliseconds: + (retries + 2) * requestTimeoutMs + retries * _flagsRetryDelayMs), + onTimeout: () => currentFlags(errorsLoading: true), + ); + } finally { + unsubscribe.callAsFunction(); + } +} + Future handleWebMethodCall(MethodCall call) async { _maybeOverrideSDKInfo(); @@ -334,8 +421,7 @@ Future handleWebMethodCall(MethodCall call) async { ); break; case 'reloadFeatureFlags': - posthog?.reloadFeatureFlags(); - break; + return _reloadFeatureFlags(posthog); case 'setPersonPropertiesForFlags': final userProperties = safeMapConversion(args['userProperties']); // reload is orchestrated by the Dart layer, so disable it here. diff --git a/posthog_flutter/lib/src/utils/channel_serialization.dart b/posthog_flutter/lib/src/utils/channel_serialization.dart index 8fc7f2ec..c1bd9ebc 100644 --- a/posthog_flutter/lib/src/utils/channel_serialization.dart +++ b/posthog_flutter/lib/src/utils/channel_serialization.dart @@ -1,4 +1,5 @@ import '../feature_flag_result.dart'; +import '../feature_flags_loaded.dart'; import '../posthog_config.dart'; import '../util/logging.dart'; @@ -170,3 +171,18 @@ PostHogFeatureFlagResult? featureFlagResultFromMap( payload: result['payload'], ); } + +/// Reads a `{variants, errorsLoading}` map sent over the platform channel. +/// A missing or malformed map reads as empty flags with `errorsLoading` set. +PostHogFeatureFlagsLoaded featureFlagsLoadedFromMap(Object? map) { + if (map is! Map) { + return PostHogFeatureFlagsLoaded(variants: const {}, errorsLoading: true); + } + final variants = map['variants']; + return PostHogFeatureFlagsLoaded( + variants: variants is Map + ? {for (final e in variants.entries) '${e.key}': e.value} + : const {}, + errorsLoading: map['errorsLoading'] as bool? ?? false, + ); +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index c2f51696..8202fa0b 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; @@ -609,7 +610,7 @@ void main() { final flagsLoaded = Completer(); final seenEventNames = []; final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); } ..beforeSend = [ @@ -1728,7 +1729,7 @@ void main() { server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1758,7 +1759,7 @@ void main() { server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1794,7 +1795,7 @@ void main() { server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1813,7 +1814,7 @@ void main() { server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1840,7 +1841,7 @@ void main() { final flagsLoaded = Completer(); final config = configFor(server) ..sendFeatureFlagEvent = false - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1891,6 +1892,27 @@ void main() { expect(server.eventNames, isNot(contains(r'$feature_flag_called'))); }); + test( + 'reloadFeatureFlags returns the loaded flags, and the last known ones when loading fails', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final announced = []; + final platform = await setUpPlatform(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = announced.add); + + final loaded = await platform.reloadFeatureFlags(); + expect(loaded.variants, {'variant-flag': 'test-variant'}); + expect(loaded.errorsLoading, isFalse); + + server.respond = (_) => const PostHogResponse(HttpStatus.badRequest); + final failed = await platform.reloadFeatureFlags(); + expect(failed.variants, {'variant-flag': 'test-variant'}); + expect(failed.errorsLoading, isTrue); + expect(announced.last.errorsLoading, isTrue); + }); + test('onFeatureFlags reports the flags cached by the previous launch', () async { final server = await LocalPostHogServer.start(); @@ -1907,7 +1929,7 @@ void main() { final announced = Completer(); await platform.setup(configFor(server) ..preloadFeatureFlags = false - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { payloads.add(platform .getFeatureFlagResult( key: 'variant-flag', sendFeatureFlagEvent: false) diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index b33e9db0..6b29ef44 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; import 'package:posthog_flutter/src/logs/posthog_log_severity.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_event.dart'; import 'package:posthog_flutter/src/posthog_flutter_io.dart'; @@ -162,7 +163,7 @@ void main() { 'setup initializes method call handler and registers callback if provided', () async { bool callbackInvoked = false; - void testCallback() { + void testCallback(PostHogFeatureFlagsLoaded _) { callbackInvoked = true; } @@ -212,94 +213,45 @@ void main() { }, ); - test( - 'invokes callback when native sends onFeatureFlagsCallback event', - () async { - bool callbackInvoked = false; - - void testCallback() { - callbackInvoked = true; - } - - testConfig = PostHogConfig( - 'test_project_token', - onFeatureFlags: testCallback, - ); - await posthogFlutterIO.setup(testConfig); - - // Native sends empty map (iOS/Android behavior) - final mockNativeArgs = {}; - - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .handlePlatformMessage( - channel.name, - channel.codec.encodeMethodCall( - MethodCall('onFeatureFlagsCallback', mockNativeArgs), - ), - (ByteData? data) {}, - ); - - expect(callbackInvoked, isTrue); - }, - ); - - test( - 'invokes callback when native sends onFeatureFlagsCallback with empty map (mobile behavior)', - () async { - bool callbackInvoked = false; - - void testCallback() { - callbackInvoked = true; - } - - testConfig = PostHogConfig( - 'test_project_token', - onFeatureFlags: testCallback, - ); - await posthogFlutterIO.setup(testConfig); - - // Simulate mobile sending an empty map - final mockNativeArgs = {}; - - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .handlePlatformMessage( - channel.name, - channel.codec.encodeMethodCall( - MethodCall('onFeatureFlagsCallback', mockNativeArgs), - ), - (ByteData? data) {}, - ); - - expect(callbackInvoked, isTrue); - }, - ); - - test('invokes callback even with malformed native args', () async { - bool callbackInvoked = false; + test('passes the native flags to the callback', () async { + final received = []; + await posthogFlutterIO.setup( + PostHogConfig('test_project_token', onFeatureFlags: received.add)); - void testCallback() { - callbackInvoked = true; - } + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'onFeatureFlagsCallback', + { + 'variants': {'on': true, 'variant': 'test', 'off': false}, + 'errorsLoading': true, + }, + )), + (ByteData? data) {}, + ); - testConfig = - PostHogConfig('test_project_token', onFeatureFlags: testCallback); - await posthogFlutterIO.setup(testConfig); + expect(received.single.variants, {'on': true, 'variant': 'test'}); + expect(received.single.flags, ['on', 'variant']); + expect(received.single.errorsLoading, isTrue); + }); - // Simulate native sending malformed arguments - callback should still be invoked - final mockNativeArgsMalformed = { - 'flags': 123, // Invalid type, but callback is void so it doesn't matter - }; + test('passes empty flags to the callback for malformed native args', + () async { + final received = []; + await posthogFlutterIO.setup( + PostHogConfig('test_project_token', onFeatureFlags: received.add)); await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed), + const MethodCall('onFeatureFlagsCallback', {'variants': 123}), ), (ByteData? data) {}, ); - expect(callbackInvoked, isTrue); + expect(received.single.variants, isEmpty); }); test('does not invoke callback when no callback is registered', () async { diff --git a/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart index e4ad6a2b..6d6a6c5d 100644 --- a/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart +++ b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart @@ -1,4 +1,5 @@ import 'package:posthog_flutter/src/feature_flag_result.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/logs/posthog_log_severity.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; @@ -128,8 +129,9 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { Future stopSessionRecording() async {} @override - Future reloadFeatureFlags() async { + Future reloadFeatureFlags() async { reloadFeatureFlagsCount++; + return PostHogFeatureFlagsLoaded(variants: const {}, errorsLoading: false); } @override diff --git a/posthog_flutter/test/posthog_flutter_web_handler_test.dart b/posthog_flutter/test/posthog_flutter_web_handler_test.dart index 35ab74c0..97c48b9f 100644 --- a/posthog_flutter/test/posthog_flutter_web_handler_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_handler_test.dart @@ -80,6 +80,152 @@ void main() { }); }); + group('handleWebMethodCall reloadFeatureFlags', () { + late JSFunction? listener; + late int reloads; + late int unsubscribes; + + /// Installs a fake posthog-js whose reload answers with [answer], if any. + void installFake({ + Map config = const {}, + ({Map variants, bool errorsLoading})? answer, + Duration answerAfter = Duration.zero, + String? asyncStatus, + }) { + listener = null; + reloads = 0; + unsubscribes = 0; + var current = {'cached': true}.jsify()!; + final featureFlags = JSObject() + ..setProperty('getFlagVariants'.toJS, (() => current).toJS); + if (asyncStatus != null) { + featureFlags.setProperty( + 'reloadFeatureFlagsAsync'.toJS, + (() => Future(() { + current = {'fresh': 'v2'}.jsify()!; + return {'status': asyncStatus}.jsify(); + }).toJS).toJS, + ); + } + final fake = JSObject() + ..setProperty('featureFlags'.toJS, featureFlags) + ..setProperty('config'.toJS, config.jsify()) + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listener = callback; + callback.callAsFunction(null, [].jsify(), current); + return (() => unsubscribes++).toJS; + }).toJS, + ) + ..setProperty( + 'reloadFeatureFlags'.toJS, + (() { + reloads++; + if (answer == null) return; + Future.delayed( + answerAfter, + () => listener?.callAsFunction( + null, + answer.variants.keys.toList().jsify(), + answer.variants.jsify(), + {'errorsLoading': answer.errorsLoading}.jsify(), + )); + }).toJS, + ); + globalContext.setProperty('posthog'.toJS, fake); + } + + test('completes with the flags the reload delivers, not the replayed ones', + () async { + installFake(answer: (variants: {'fresh': 'v2'}, errorsLoading: true)); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'fresh': 'v2'}, + 'errorsLoading': true, + }); + expect(unsubscribes, 1); + }); + + test('awaits reloadFeatureFlagsAsync when posthog-js has it', () async { + installFake(asyncStatus: 'loaded'); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'fresh': 'v2'}, + 'errorsLoading': false, + }); + expect(reloads, 0); + }); + + test('reports an error when reloadFeatureFlagsAsync skips the request', + () async { + installFake(asyncStatus: 'skipped'); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result['errorsLoading'], isTrue); + }); + + test('completes right away with an error when flags are disabled', + () async { + installFake(config: {'advanced_disable_feature_flags': true}); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'cached': true}, + 'errorsLoading': true, + }); + expect(reloads, 0); + }); + + test('waits for a reload that posthog-js retries after a timeout', + () async { + // Past twice the request timeout, but within one retry after 500 ms. + installFake( + config: {'feature_flag_request_timeout_ms': 20}, + answer: (variants: {'fresh': 'v2'}, errorsLoading: false), + answerAfter: const Duration(milliseconds: 300), + ); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'fresh': 'v2'}, + 'errorsLoading': false, + }); + }); + + test('completes with an error when posthog-js never calls back', () async { + installFake(config: {'feature_flag_request_timeout_ms': 5}); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'cached': true}, + 'errorsLoading': true, + }); + expect(reloads, 1); + expect(unsubscribes, 1); + }); + }); + group('handleWebMethodCall addExceptionStep', () { String? capturedMessage; JSAny? capturedProperties; diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index 4ddb2fa8..f175b2cc 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -27,7 +27,7 @@ void main() { test( 'setup passes config and onFeatureFlags callback to platform interface', () async { - void testCallback() {} + void testCallback(PostHogFeatureFlagsLoaded _) {} final config = PostHogConfig( 'test_project_token', diff --git a/pubspec.lock b/pubspec.lock index 97a86f5c..1bd0cbe4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: google_maps_flutter - sha256: fc084ddabc22645bebbe9df51baeec4949e5d14fc37492dce80501863425a82e + sha256: "392492775b6928b7419bd1d04c7562dab73a543e71078088a40ae7324acd3641" url: "https://pub.dev" source: hosted - version: "2.19.0" + version: "2.18.1" google_maps_flutter_android: dependency: transitive description: @@ -347,10 +347,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -363,10 +363,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" package_config: dependency: transitive description: @@ -520,10 +520,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.2" + version: "1.12.1" stream_channel: dependency: transitive description: @@ -576,10 +576,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" tuple: dependency: transitive description: @@ -608,10 +608,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: @@ -701,5 +701,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.13.0 <4.0.0" - flutter: ">=3.47.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" From 8e5b267302dc505699f66b91a0f55ae3816be765 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:02:16 -0400 Subject: [PATCH 13/43] feat(desktop): send event options unchanged in the capture v1 event On Windows and Linux, the options of capture, screen, captureException, identify, setPersonProperties, alias and group now go into the event's capture v1 `options` as they are, unknown keys included, instead of being mapped to legacy `$` properties. An option still wins over its legacy property, which is moved into `options` only when the option is unset. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core.dart | 77 +++++++++++-------- .../lib/src/core/posthog_core_stateless.dart | 6 ++ posthog_flutter/lib/src/posthog.dart | 6 +- .../lib/src/posthog_flutter_desktop.dart | 25 ++---- .../lib/src/utils/event_options.dart | 4 +- .../test/posthog_flutter_desktop_test.dart | 74 ++++++++++-------- 6 files changed, 106 insertions(+), 86 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 8276a380..34e760bb 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -298,12 +298,12 @@ abstract class PostHogCore extends PostHogCoreStateless { /// An empty [distinctId] is ignored, as are all calls while opted out or /// when `personProfiles` is `never`. /// - /// The optional [properties] are added to the event this call sends. + /// The optional [options] are sent with the event this call sends. void identify( String distinctId, { Map? userProperties, Map? userPropertiesSetOnce, - Map? properties, + Map? options, }) { wrap(() { if (distinctId.trim().isEmpty) { @@ -338,15 +338,15 @@ abstract class PostHogCore extends PostHogCoreStateless { identifyStateless( distinctId, properties: _enrichProperties({ - ...?properties, 'distinct_id': distinctId, r'$anon_distinct_id': previousDistinctId, ...maybeAdd(r'$set', userProperties), ...maybeAdd(r'$set_once', userPropertiesSetOnce), }), + options: options, ); if ((userProperties != null || userPropertiesSetOnce != null) && - !_isPersonless(properties)) { + !_isPersonless(null, options)) { _cachedPersonProperties = _personPropertiesHash( distinctId, userProperties, userPropertiesSetOnce); } @@ -355,11 +355,12 @@ abstract class PostHogCore extends PostHogCoreStateless { // There is no anonymous history to link: the id already is the // anonymous one, so the transition is recorded with a $set. setPersistedProperty(PostHogPersistedProperty.personMode, 'identified'); - capture(r'$set', properties: { - ...?properties, - r'$set': userProperties ?? {}, - r'$set_once': userPropertiesSetOnce ?? {}, - }); + capture(r'$set', + properties: { + r'$set': userProperties ?? {}, + r'$set_once': userPropertiesSetOnce ?? {}, + }, + options: options); if (hasUserProperties) _reloadFeatureFlags(); } else if (hasUserProperties) { // Person property changes are processed asynchronously by PostHog, @@ -367,7 +368,7 @@ abstract class PostHogCore extends PostHogCoreStateless { setPersonProperties( userPropertiesToSet: userProperties, userPropertiesToSetOnce: userPropertiesSetOnce, - properties: properties, + options: options, ); } else { logger.info('identify was called with the current distinct id of an ' @@ -382,7 +383,13 @@ abstract class PostHogCore extends PostHogCoreStateless { /// for person processing even for an anonymous user. `$groups` in /// [properties] apply to this event only; use [group] to associate the /// user with a group. - void capture(String event, {Map? properties}) { + /// + /// The capture v1 [options] are sent with the event as they are. + void capture( + String event, { + Map? properties, + Map? options, + }) { wrap(() { if (_ignoredWhileOptedOut('posthog.capture')) return; @@ -405,11 +412,13 @@ abstract class PostHogCore extends PostHogCoreStateless { _enablePersonProcessing(); } - captureStateless(getDistinctId(), event, properties: allProperties); + captureStateless(getDistinctId(), event, + properties: allProperties, options: options); if (event == r'$set' || event == r'$identify') { final set = allProperties[r'$set']; final setOnce = allProperties[r'$set_once']; - if ((set is Map || setOnce is Map) && !_isPersonless(allProperties)) { + if ((set is Map || setOnce is Map) && + !_isPersonless(allProperties, options)) { _cachedPersonProperties = _personPropertiesHash(getDistinctId(), set, setOnce); } @@ -419,8 +428,8 @@ abstract class PostHogCore extends PostHogCoreStateless { /// Creates an alias for a user. An empty [alias] is ignored. /// - /// The optional [properties] are added to the `$create_alias` event. - void alias(String alias, {Map? properties}) { + /// The optional [options] are sent with the `$create_alias` event. + void alias(String alias, {Map? options}) { wrap(() { if (alias.trim().isEmpty) { logger.warn('alias was called with an empty alias. ' @@ -431,7 +440,7 @@ abstract class PostHogCore extends PostHogCoreStateless { if (!_requirePersonProcessing('posthog.alias')) return; aliasStateless(alias, getDistinctId(), - properties: _enrichProperties({...?properties})); + properties: _enrichProperties(null), options: options); }); } @@ -457,12 +466,12 @@ abstract class PostHogCore extends PostHogCoreStateless { /// flags are reloaded. A call with an empty [groupType] /// or [groupKey] is ignored. /// - /// The optional [properties] are added to the `$groupidentify` event. + /// The optional [options] are sent with the `$groupidentify` event. void group( String groupType, String groupKey, { Map? groupProperties, - Map? properties, + Map? options, }) { wrap(() { if (groupType.trim().isEmpty || groupKey.trim().isEmpty) { @@ -487,8 +496,9 @@ abstract class PostHogCore extends PostHogCoreStateless { groupType, groupKey, distinctId: getDistinctId(), - eventProperties: _enrichProperties({...?properties}), + eventProperties: _enrichProperties(null), groupProperties: groupProperties, + options: options, ); }); } @@ -1009,11 +1019,11 @@ abstract class PostHogCore extends PostHogCoreStateless { /// Sets properties on the person profile. They also feed feature flag /// evaluation from the next flags reload on. /// - /// The optional [properties] are added to the `$set` event. + /// The optional [options] are sent with the `$set` event. void setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, - Map? properties, + Map? options, }) { wrap(() { final isSetEmpty = @@ -1030,7 +1040,7 @@ abstract class PostHogCore extends PostHogCoreStateless { // A personless `$set` doesn't update the person, so it is never a // duplicate. - if (_cachedPersonProperties == hash && !_isPersonless(properties)) { + if (_cachedPersonProperties == hash && !_isPersonless(null, options)) { logger.info( 'A duplicate setPersonProperties call was made. It has been ignored.'); return; @@ -1042,11 +1052,12 @@ abstract class PostHogCore extends PostHogCoreStateless { }; setPersonPropertiesForFlags(mergedProperties); - capture(r'$set', properties: { - ...?properties, - r'$set': userPropertiesToSet ?? {}, - r'$set_once': userPropertiesToSetOnce ?? {}, - }); + capture(r'$set', + properties: { + r'$set': userPropertiesToSet ?? {}, + r'$set_once': userPropertiesToSetOnce ?? {}, + }, + options: options); }); } @@ -1067,9 +1078,15 @@ abstract class PostHogCore extends PostHogCoreStateless { static bool _isNonEmptyMap(Object? value) => value is Map && value.isNotEmpty; - /// Whether [properties] turn person processing off for their event. - static bool _isPersonless(Map? properties) => - properties?[r'$process_person_profile'] == false; + /// Whether [options], or else [properties], turn person processing off + /// for their event. An option wins over the property. + static bool _isPersonless( + Map? properties, + Map? options, + ) => + (options?['process_person_profile'] ?? + properties?[r'$process_person_profile']) == + false; static Map? _asPropertyMap(Object? value) { if (value is! Map) return null; diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 026328ac..18bc94c2 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -265,6 +265,7 @@ abstract class PostHogCoreStateless { void identifyStateless( String distinctId, { required Map properties, + Map? options, }) { _enqueue( 'identify', @@ -273,6 +274,7 @@ abstract class PostHogCoreStateless { event: r'$identify', properties: properties, ), + options: options, ); } @@ -299,6 +301,7 @@ abstract class PostHogCoreStateless { String alias, String distinctId, { required Map properties, + Map? options, }) { _enqueue( 'alias', @@ -311,6 +314,7 @@ abstract class PostHogCoreStateless { 'alias': alias, }, ), + options: options, ); } @@ -321,6 +325,7 @@ abstract class PostHogCoreStateless { required String distinctId, required Map eventProperties, Map? groupProperties, + Map? options, }) { _enqueue( 'capture', @@ -334,6 +339,7 @@ abstract class PostHogCoreStateless { ...eventProperties, }, ), + options: options, ); } diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 8aad6316..47f661c2 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -234,9 +234,9 @@ class Posthog { /// over its property. A null value means unset. [PostHogConfig.beforeSend] /// can change them through [PostHogEvent.options]. /// - /// On iOS, macOS and Android, other keys are sent unchanged. On Windows, - /// Linux and web, the known keys are sent as their legacy properties and - /// other keys are dropped. On web, posthog-js sets + /// On iOS, macOS, Android, Windows and Linux, other keys are sent + /// unchanged. On web, the known keys are sent as their legacy properties + /// and other keys are dropped. On web, posthog-js sets /// `$process_person_profile` itself, so `process_person_profile` has no /// effect there, and the options of [identify], [setPersonProperties], /// [alias] and [group] are dropped because posthog-js sends those events diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 5f3da6f0..b62e6cc8 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -225,10 +225,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { userId, userProperties: _normalize(userProperties), userPropertiesSetOnce: _normalize(userPropertiesSetOnce), - properties: eventOptionsToLegacyProperties( - options, - method: 'identify', - ), + options: cleanEventOptions(options), ); }); @@ -242,10 +239,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { client.setPersonProperties( userPropertiesToSet: _normalize(userPropertiesToSet), userPropertiesToSetOnce: _normalize(userPropertiesToSetOnce), - properties: eventOptionsToLegacyProperties( - options, - method: 'setPersonProperties', - ), + options: cleanEventOptions(options), ); }); @@ -399,7 +393,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { 'alias', (client) => client.alias( alias, - properties: eventOptionsToLegacyProperties(options, method: 'alias'), + options: cleanEventOptions(options), ), ); @@ -531,7 +525,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { groupType, groupKey, groupProperties: _normalize(groupProperties), - properties: eventOptionsToLegacyProperties(options, method: 'group'), + options: cleanEventOptions(options), ); }); @@ -741,15 +735,12 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { event.userPropertiesSetOnce, ), ); - // Options go out as their legacy properties, winning over a legacy - // property set on the event. - final optionProperties = - eventOptionsToLegacyProperties(event.options, method: event.event); + // Options are sent as they are, unknown keys included. An option wins + // over its legacy property on the event. client.capture( event.event, - properties: optionProperties.isEmpty - ? properties - : {...?properties, ...optionProperties}, + properties: properties, + options: cleanEventOptions(event.options), ); } diff --git a/posthog_flutter/lib/src/utils/event_options.dart b/posthog_flutter/lib/src/utils/event_options.dart index e72a7683..8f33f24c 100644 --- a/posthog_flutter/lib/src/utils/event_options.dart +++ b/posthog_flutter/lib/src/utils/event_options.dart @@ -19,8 +19,8 @@ Map? cleanEventOptions(Map? options) { return Map.from(cleaned); } -/// Converts [options] to the legacy event properties for transports that -/// have no options field (the desktop client and posthog-js). +/// Converts [options] to the legacy event properties for posthog-js, which +/// has no options field. /// /// Known keys become their `$` property. Other keys have no legacy property, /// so they are dropped with a debug log naming [method]. diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 754274a0..daaab319 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -954,31 +954,33 @@ void main() { }); group('Event options', () { - test('are sent as their legacy properties, winning over them', () async { + test('are sent as they are, winning over their legacy properties', + () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)); - final lines = await _printsOf(() => platform.capture( - eventName: 'tour step', - properties: {r'$product_tour_id': 'from-property'}, - options: { - 'process_person_profile': false, - 'cookieless_mode': true, - 'disable_skew_correction': true, - 'product_tour_id': 'from-option', - 'unknown_option': 'x', - 'unset_option': null, - }, - )); + await platform.capture( + eventName: 'tour step', + properties: {r'$product_tour_id': 'from-property'}, + options: { + 'process_person_profile': false, + 'cookieless_mode': true, + 'disable_skew_correction': true, + 'product_tour_id': 'from-option', + 'unknown_option': {'nested': 'x'}, + 'unset_option': null, + }, + ); - final properties = _propertiesOf(await server.waitForEvent('tour step')); - expect(properties[r'$process_person_profile'], isFalse); - expect(properties[r'$cookieless_mode'], isTrue); - expect(properties[r'$ignore_sent_at'], isTrue); - expect(properties[r'$product_tour_id'], 'from-option'); - expect(properties, isNot(contains('unknown_option'))); - expect(properties, isNot(contains('unset_option'))); - expect(lines, [contains('option "unknown_option" is not supported')]); + final event = await server.waitForEvent('tour step'); + expect(event['options'], { + 'process_person_profile': false, + 'cookieless_mode': true, + 'disable_skew_correction': true, + 'product_tour_id': 'from-option', + 'unknown_option': {'nested': 'x'}, + }); + expect(_propertiesOf(event), isNot(contains(r'$product_tour_id'))); }); test('a process_person_profile option applies to its event only', () async { @@ -993,8 +995,8 @@ void main() { final processed = await server.waitForEvent('processed'); final anonymous = await server.waitForEvent('anonymous'); - expect(_propertiesOf(processed)[r'$process_person_profile'], isTrue); - expect(_propertiesOf(anonymous)[r'$process_person_profile'], isFalse); + expect(_optionsOf(processed)['process_person_profile'], isTrue); + expect(_optionsOf(anonymous)['process_person_profile'], isFalse); }); test('beforeSend can change them', () async { @@ -1020,11 +1022,11 @@ void main() { options: {'disable_skew_correction': true}, ); - final screen = _propertiesOf(await server.waitForEvent(r'$screen')); - final exception = _propertiesOf(await server.waitForEvent(r'$exception')); - expect(screen[r'$product_tour_id'], 'from-hook'); - expect(exception[r'$product_tour_id'], 'from-hook'); - expect(exception[r'$ignore_sent_at'], isTrue); + final screen = _optionsOf(await server.waitForEvent(r'$screen')); + final exception = _optionsOf(await server.waitForEvent(r'$exception')); + expect(screen['product_tour_id'], 'from-hook'); + expect(exception['product_tour_id'], 'from-hook'); + expect(exception['disable_skew_correction'], isTrue); }); test('reach the events identify, setPersonProperties, group and alias send', @@ -1032,7 +1034,7 @@ void main() { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)); Map options(String id) => - {'product_tour_id': id, 'cookieless_mode': false}; + {'product_tour_id': id, 'cookieless_mode': false, 'custom': id}; await platform.identify(userId: 'user-1', options: options('identify')); await platform.setPersonProperties( @@ -1052,9 +1054,10 @@ void main() { (r'$groupidentify', 'group'), (r'$create_alias', 'alias'), ]) { - final properties = _propertiesOf(await server.waitForEvent(name)); - expect(properties[r'$product_tour_id'], id, reason: name); - expect(properties[r'$cookieless_mode'], isFalse, reason: name); + final options = _optionsOf(await server.waitForEvent(name)); + expect(options['product_tour_id'], id, reason: name); + expect(options['cookieless_mode'], isFalse, reason: name); + expect(options['custom'], id, reason: name); } }); @@ -1074,10 +1077,10 @@ void main() { final sets = [ for (final event in server.events) - if (event['event'] == r'$set') _propertiesOf(event), + if (event['event'] == r'$set') _optionsOf(event), ]; expect( - [for (final set in sets) set[r'$process_person_profile']], + [for (final set in sets) set['process_person_profile']], [isFalse, isTrue], ); }); @@ -2074,6 +2077,9 @@ void main() { Map _propertiesOf(Map event) => Map.from(event['properties']! as Map); +Map _optionsOf(Map event) => + Map.from((event['options'] ?? const {}) as Map); + /// Runs [body] and returns the lines it printed. Future> _printsOf(Future Function() body) async { final lines = []; From 1a4b73bd17abd1d36f9d07d99340c00f5788d873 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:07:40 -0400 Subject: [PATCH 14/43] feat(desktop): build event properties in the native 4.0 order On Windows and Linux, an event's properties are now the ones the SDK sets, then the registered ones, then the event's own, each winning over the ones before, and the Dart beforeSend callbacks run after that merge. They see and can change every property and option the SDK adds, and the event is queued as they leave it. A caller or registered value changes that event only. Person processing is no longer a `$process_person_profile` property: the event carries `process_person_profile: false` in its options only while person processing is off and neither an option nor a property sets it, and omits it otherwise. The event's timestamp is taken before the callbacks run. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core.dart | 159 +++++++++++++----- .../lib/src/core/posthog_core_stateless.dart | 8 +- posthog_flutter/lib/src/posthog_config.dart | 3 +- posthog_flutter/lib/src/posthog_event.dart | 7 +- .../lib/src/posthog_flutter_desktop.dart | 120 +++++++++---- .../test/core/context_properties_test.dart | 11 +- .../core/desktop_parity_regressions_test.dart | 6 +- .../test/core/feature_flags_test.dart | 1 - .../test/core/posthog_core_test.dart | 30 ++-- posthog_flutter/test/core/test_client.dart | 6 + .../test/posthog_flutter_desktop_test.dart | 63 +++++-- 11 files changed, 294 insertions(+), 120 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 34e760bb..ba42549c 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -14,6 +14,14 @@ import 'session_manager.dart'; import 'utils/utils.dart'; import 'uuid.dart'; +/// An event built by [PostHogCore.prepareEvent]: its properties, its +/// capture v1 options and the time it happened, as an ISO 8601 string. +typedef PostHogPreparedEvent = ({ + Map properties, + Map? options, + String timestamp, +}); + /// Stateful PostHog client with session management, identity, and feature flags. /// /// It extends [PostHogCoreStateless] with state management for sessions, @@ -142,33 +150,51 @@ abstract class PostHogCore extends PostHogCoreStateless { @protected String? getTimezone(); + /// The properties of an event: the ones the SDK sets, then the + /// registered ones, then the event's own [properties], each winning over + /// the ones before. A value set for an event changes that event only. + /// + /// Person processing is not a property: see [_personProcessingOptions]. Map _enrichProperties(Map? properties) { final now = DateTime.now().millisecondsSinceEpoch; // Resolved first: it may start a new session, which the debug // properties below then describe. final managedSessionId = _session.touch(now); - final callerSessionId = properties?[r'$session_id']; - final sessionId = callerSessionId is String && callerSessionId.isNotEmpty - ? callerSessionId - : managedSessionId; final timezone = getTimezone(); - return { - ...props, - ..._sessionProps, - ...?properties, + final enriched = { ...getContextProperties(), if (timezone != null) r'$timezone': timezone, ...getCommonEventProperties(), - r'$session_id': sessionId, - // A `$process_person_profile` passed for this event, such as the - // `process_person_profile` option, wins for this event only. - r'$process_person_profile': - properties?[r'$process_person_profile'] is bool - ? properties![r'$process_person_profile'] - : _hasPersonProcessing(), + r'$session_id': managedSessionId, r'$is_identified': _isIdentified(), ..._sessionDebugProperties(now), + ..._sessionProps, + ...props, + ...?properties, }; + // An empty or non-string session id would leave the event out of its + // session. + final sessionId = enriched[r'$session_id']; + if (sessionId is! String || sessionId.isEmpty) { + enriched[r'$session_id'] = managedSessionId; + } + return enriched; + } + + /// The [options] of an event with [properties], with + /// `process_person_profile: false` added when person processing is off + /// and neither an option nor a `$process_person_profile` property says + /// otherwise. + Map? _personProcessingOptions( + Map? options, + Map properties, + ) { + if (_hasPersonProcessing() || + properties[r'$process_person_profile'] != null || + options?['process_person_profile'] != null) { + return options; + } + return {...?options, 'process_person_profile': false}; } /// The state of the session and the queue, attached to every event to @@ -184,8 +210,8 @@ abstract class PostHogCore extends PostHogCoreStateless { } /// Adds [properties] to every later event until [reset], without - /// persisting them. They win over registered properties, and the event's - /// own properties win over them. + /// persisting them. Registered properties and the event's own win over + /// them. void registerForSession(Map properties) { wrap(() => _sessionProps.addAll(properties)); } @@ -390,40 +416,83 @@ abstract class PostHogCore extends PostHogCoreStateless { Map? properties, Map? options, }) { + wrap(() { + final prepared = + _prepareEvent(event, properties: properties, options: options); + if (prepared != null) _queuePrepared(event, prepared); + }); + } + + /// Builds the event [capture] would send, with every property and option + /// the SDK adds, so that it can still be changed before + /// [capturePrepared] queues it. Returns null when the client is closed + /// or opted out. + /// + /// Like [capture], person properties in [properties] ask for person + /// processing and feed flag evaluation, and the event starts or extends + /// the session. + PostHogPreparedEvent? prepareEvent( + String event, { + Map? properties, + Map? options, + }) => + wrap( + () => _prepareEvent(event, properties: properties, options: options)); + + /// Queues an event built by [prepareEvent], possibly changed since. + /// Unlike [capture], it adds nothing to it. + void capturePrepared(String event, PostHogPreparedEvent prepared) { wrap(() { if (_ignoredWhileOptedOut('posthog.capture')) return; + _queuePrepared(event, prepared); + }); + } - final userProperties = properties?[r'$set']; - final userPropertiesSetOnce = properties?[r'$set_once']; - if (_personProfiles != PostHogPersonProfiles.never && - (_isNonEmptyMap(userProperties) || - _isNonEmptyMap(userPropertiesSetOnce))) { - _enablePersonProcessing(); - } - _setPersonPropertiesForFlagsIfNeeded( - _asPropertyMap(userProperties), - _asPropertyMap(userPropertiesSetOnce), - ); + PostHogPreparedEvent? _prepareEvent( + String event, { + Map? properties, + Map? options, + }) { + if (_ignoredWhileOptedOut('posthog.capture')) return null; + + final userProperties = properties?[r'$set']; + final userPropertiesSetOnce = properties?[r'$set_once']; + if (_personProfiles != PostHogPersonProfiles.never && + (_isNonEmptyMap(userProperties) || + _isNonEmptyMap(userPropertiesSetOnce))) { + _enablePersonProcessing(); + } + _setPersonPropertiesForFlagsIfNeeded( + _asPropertyMap(userProperties), + _asPropertyMap(userPropertiesSetOnce), + ); - final allProperties = _enrichProperties(properties); - // Once an event is person-processed, all later ones are too. A value - // passed for this event alone doesn't count. - if (_hasPersonProcessing()) { - _enablePersonProcessing(); - } + final allProperties = _enrichProperties(properties); + // Once an event is person-processed, all later ones are too. A value + // passed for this event alone doesn't count. + if (_hasPersonProcessing()) { + _enablePersonProcessing(); + } + return ( + properties: allProperties, + options: _personProcessingOptions(options, allProperties), + timestamp: currentISOTime(), + ); + } - captureStateless(getDistinctId(), event, - properties: allProperties, options: options); - if (event == r'$set' || event == r'$identify') { - final set = allProperties[r'$set']; - final setOnce = allProperties[r'$set_once']; - if ((set is Map || setOnce is Map) && - !_isPersonless(allProperties, options)) { - _cachedPersonProperties = - _personPropertiesHash(getDistinctId(), set, setOnce); - } + void _queuePrepared(String event, PostHogPreparedEvent prepared) { + final (:properties, :options, :timestamp) = prepared; + captureStateless(getDistinctId(), event, + properties: properties, options: options, timestamp: timestamp); + if (event == r'$set' || event == r'$identify') { + final set = properties[r'$set']; + final setOnce = properties[r'$set_once']; + if ((set is Map || setOnce is Map) && + !_isPersonless(properties, options)) { + _cachedPersonProperties = + _personPropertiesHash(getDistinctId(), set, setOnce); } - }); + } } /// Creates an alias for a user. An empty [alias] is ignored. diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 18bc94c2..a2dbae45 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -236,9 +236,10 @@ abstract class PostHogCoreStateless { required String event, required Map properties, }) { + // The event's properties win over the SDK's. var eventProperties = { - ...properties, ...getCommonEventProperties(), + ...properties, }; // Minimized once all SDK properties are in. if (event == r'$feature_flag_called' && @@ -284,6 +285,7 @@ abstract class PostHogCoreStateless { String event, { required Map properties, Map? options, + String? timestamp, }) { _enqueue( 'capture', @@ -293,6 +295,7 @@ abstract class PostHogCoreStateless { properties: properties, ), options: options, + timestamp: timestamp, ); } @@ -429,6 +432,7 @@ abstract class PostHogCoreStateless { String type, Map message, { Map? options, + String? timestamp, }) { final prepared = { ...message, @@ -439,7 +443,7 @@ abstract class PostHogCoreStateless { 'type': type, 'library': postHogFlutterSdkName, 'library_version': postHogFlutterVersion, - 'timestamp': currentISOTime(), + 'timestamp': timestamp ?? currentISOTime(), 'uuid': generateUuidV7(), }; final queue = storage.queue; diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index fee546c1..df217c18 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -439,7 +439,8 @@ class PostHogConfig { /// - Survey events (`survey shown`, etc.) when `config.surveys` is enabled /// - Only user-provided properties are available; system properties (like /// `$device_type`, `$session_id`) are added by the native SDK at a later - /// stage. + /// stage. On Windows and Linux, the callbacks see the system properties + /// and the options the SDK adds, and can change them for that event. /// /// **Note:** /// - Callbacks can be synchronous or asynchronous (via diff --git a/posthog_flutter/lib/src/posthog_event.dart b/posthog_flutter/lib/src/posthog_event.dart index e7d20b5c..7063dd89 100644 --- a/posthog_flutter/lib/src/posthog_event.dart +++ b/posthog_flutter/lib/src/posthog_event.dart @@ -8,8 +8,11 @@ class PostHogEvent { /// User-provided properties for this event. /// - /// Note: System properties (like $device_type, $session_id, etc.) are added - /// by the native SDK at a later stage and are not available in this map. + /// On iOS, macOS, Android and web, system properties (like $device_type, + /// $session_id, etc.) are added by the native SDK at a later stage and are + /// not available in this map. On Windows and Linux, the map already holds + /// them, then the registered properties, then the event's own, and the + /// event is sent as the callbacks leave it. Map? properties; /// User properties to set on the user profile ($set). diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index b62e6cc8..964ddfcf 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -259,13 +259,16 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final distinctId = client.getDistinctId(); final exceptionSteps = _exceptionSteps; - final hooked = _runBeforeSend( + final prepared = _prepare( + client, eventName, properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, options: options, ); + if (prepared == null) return; + final hooked = _runBeforeSend(prepared.event); final processed = hooked is Future ? await hooked : hooked; if (processed == null) { @@ -275,7 +278,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('capture', scope, distinctId); if (current == null) return; - _captureProcessed(current, processed, exceptionSteps); + _captureProcessed( + current, processed, exceptionSteps, prepared.timestamp); }); /// AI events are not sent on desktop. @@ -307,7 +311,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final distinctId = client.getDistinctId(); final exceptionSteps = _exceptionSteps; - final hooked = _runBeforeSend( + final prepared = _prepare( + client, PostHogEventName.screen, { ...?properties, @@ -315,6 +320,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { }, options: options, ); + if (prepared == null) return; + final hooked = _runBeforeSend(prepared.event); final processed = hooked is Future ? await hooked : hooked; if (processed == null) { @@ -328,7 +335,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { // A renamed event is no longer a screen view. if (processed.event != PostHogEventName.screen) { - _captureProcessed(current, processed, exceptionSteps); + _captureProcessed( + current, processed, exceptionSteps, prepared.timestamp); return; } @@ -349,7 +357,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ...?processed.properties, PostHogPropertyName.screenName: finalScreenName, }; - _captureProcessed(current, processed, exceptionSteps); + _captureProcessed( + current, processed, exceptionSteps, prepared.timestamp); }); /// Structured logs are not supported on the desktop implementation. @@ -583,11 +592,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - final hooked = _runBeforeSend( + final prepared = _prepare( + client, PostHogEventName.exception, exceptionProps.cast(), options: options, ); + if (prepared == null) return; + final hooked = _runBeforeSend(prepared.event); final processed = hooked is Future ? await hooked : hooked; if (processed == null) { @@ -600,7 +612,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('captureException', scope, distinctId); if (current == null) return; - _captureProcessed(current, processed, exceptionSteps); + _captureProcessed( + current, processed, exceptionSteps, prepared.timestamp); }); @override @@ -677,10 +690,54 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { @override Future isSessionReplayActive() async => false; + /// Builds [eventName] on [client] as it would be sent, with the + /// properties and options the SDK adds, for the beforeSend callbacks. + /// The SDK's properties come first, then the registered ones, then the + /// event's own. Returns null when the client is closed or opted out. + /// + /// `$set` and `$set_once` are handed to the callbacks as + /// [PostHogEvent.userProperties] and [PostHogEvent.userPropertiesSetOnce]. + /// The event's time is taken now, before the callbacks run. + ({PostHogEvent event, String timestamp})? _prepare( + DesktopPostHog client, + String eventName, + Map? properties, { + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) { + final prepared = client.prepareEvent( + eventName, + properties: + _mergeUserProps(properties, userProperties, userPropertiesSetOnce), + options: cleanEventOptions(options), + ); + if (prepared == null) return null; + final eventProperties = _nonNull(prepared.properties)!; + final preparedOptions = prepared.options; + return ( + event: PostHogEvent( + event: eventName, + properties: eventProperties, + userProperties: _nonNull(eventProperties.remove(r'$set')), + userPropertiesSetOnce: _nonNull(eventProperties.remove(r'$set_once')), + options: preparedOptions == null ? null : {...preparedOptions}, + ), + timestamp: prepared.timestamp, + ); + } + + /// A copy of [map] without its null values, or null when it is not a map. + static Map? _nonNull(Object? map) => map is Map + ? { + for (final MapEntry(:key, :value) in map.entries) + if (value != null) '$key': value, + } + : null; + /// Applies the beforeSend callbacks to an event in order. Running them here /// rather than in the client keeps SDK-internal events ($identify, - /// $feature_flag_called, ...) and the properties the SDK adds out of the - /// callbacks' sight. + /// $feature_flag_called, ...) out of the callbacks' sight. /// /// Returns the possibly modified event, or null if any callback drops it /// or throws. @@ -689,20 +746,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// must not await it: an identify(), reset() or opt-out made right after /// an unawaited capture would otherwise run first, and the event would be /// dropped as belonging to another user. - FutureOr _runBeforeSend( - String eventName, - Map? properties, { - Map? userProperties, - Map? userPropertiesSetOnce, - Map? options, - }) { - final event = PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - options: options == null ? null : {...options}, - ); + FutureOr _runBeforeSend(PostHogEvent event) { final callbacks = _config?.beforeSend ?? const []; return callbacks.isEmpty ? event : _applyCallbacks(callbacks, event); } @@ -720,28 +764,32 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return processed; } + /// Queues [event] as the beforeSend callbacks left it. The client adds + /// nothing to it; an `$exception` event gets the exception steps recorded + /// up to now. void _captureProcessed( DesktopPostHog client, PostHogEvent event, ExceptionStepsBuffer? exceptionSteps, + String timestamp, ) { if (!_hasName(event.event)) return; - final properties = _withExceptionSteps( - event.event, - exceptionSteps, - _mergeUserProps( - event.properties, - event.userProperties, - event.userPropertiesSetOnce, - ), - ); // Options are sent as they are, unknown keys included. An option wins // over its legacy property on the event. - client.capture( - event.event, - properties: properties, + client.capturePrepared(event.event, ( + properties: _withExceptionSteps( + event.event, + exceptionSteps, + _mergeUserProps( + event.properties, + event.userProperties, + event.userPropertiesSetOnce, + ), + ) ?? + const {}, options: cleanEventOptions(event.options), - ); + timestamp: timestamp, + )); } /// Whether [event] has a name; an event without one is dropped. diff --git a/posthog_flutter/test/core/context_properties_test.dart b/posthog_flutter/test/core/context_properties_test.dart index 2586102f..01994098 100644 --- a/posthog_flutter/test/core/context_properties_test.dart +++ b/posthog_flutter/test/core/context_properties_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:posthog_flutter/src/posthog_flutter_version.dart'; import '../posthog_api_fake.dart'; import 'test_client.dart'; @@ -24,7 +23,7 @@ void main() { expect(props['foo'], 'bar'); }); - test('win over super properties and event properties', () { + test('lose to super properties and event properties', () { final storage = tempStorage(); final client = testClient(server, storage: storage, context: _context, timezone: 'Europe/Berlin'); @@ -37,10 +36,10 @@ void main() { }); final props = queuedProps(storage, 0); - expect(props[r'$os_name'], 'TestOS'); - expect(props[r'$app_version'], '1.0.0'); - expect(props[r'$timezone'], 'Europe/Berlin'); - expect(props[r'$lib'], postHogFlutterSdkName); + expect(props[r'$os_name'], 'FromSuperProps'); + expect(props[r'$app_version'], '2.0.0'); + expect(props[r'$timezone'], 'UTC'); + expect(props[r'$lib'], 'custom'); }); test('absent by default', () { diff --git a/posthog_flutter/test/core/desktop_parity_regressions_test.dart b/posthog_flutter/test/core/desktop_parity_regressions_test.dart index 05a79ac4..8c75ac01 100644 --- a/posthog_flutter/test/core/desktop_parity_regressions_test.dart +++ b/posthog_flutter/test/core/desktop_parity_regressions_test.dart @@ -53,7 +53,7 @@ void main() { client.capture('purchase'); await client.reloadFeatureFlagsAsync(); - expect(queuedProps(storage, 0)[r'$process_person_profile'], isTrue); + expect(queuedOptions(storage, 0)['process_person_profile'], isNull); expect(server.flagsRequests.last.body['groups'], {'company': 'acme'}); }); @@ -151,7 +151,7 @@ void main() { }); test( - 'session properties take precedence after caller properties and before registered properties', + 'session properties lose to registered properties and to caller properties', () { final storage = tempStorage(); final client = testClient(server, storage: storage); @@ -159,6 +159,6 @@ void main() { client.registerForSession({'plan': 'session', 'screen': 'Checkout'}); client.capture('purchase', properties: {'plan': 'event'}); expect(queuedProps(storage, 0)['plan'], 'event'); - expect(queuedProps(storage, 0)['screen'], 'Checkout'); + expect(queuedProps(storage, 0)['screen'], 'registered'); }); } diff --git a/posthog_flutter/test/core/feature_flags_test.dart b/posthog_flutter/test/core/feature_flags_test.dart index 7ae6601a..3ee7c794 100644 --- a/posthog_flutter/test/core/feature_flags_test.dart +++ b/posthog_flutter/test/core/feature_flags_test.dart @@ -687,7 +687,6 @@ void main() { r'$feature_flag', r'$feature_flag_response', r'$feature_flag_has_experiment', - r'$process_person_profile', r'$session_id', r'$lib', r'$lib_version', diff --git a/posthog_flutter/test/core/posthog_core_test.dart b/posthog_flutter/test/core/posthog_core_test.dart index dae2cb47..51b5f47c 100644 --- a/posthog_flutter/test/core/posthog_core_test.dart +++ b/posthog_flutter/test/core/posthog_core_test.dart @@ -40,7 +40,7 @@ void main() { test('keeps an anonymous user personless', () { client.capture('test_event'); - expect(queuedProps(storage, 0)[r'$process_person_profile'], isFalse); + expect(queuedOptions(storage, 0)['process_person_profile'], isFalse); expect(queuedProps(storage, 0)[r'$is_identified'], isFalse); }); @@ -52,9 +52,9 @@ void main() { }); client.capture('next_event'); - expect(queuedProps(storage, 0)[r'$process_person_profile'], isTrue, + expect(queuedOptions(storage, 0)['process_person_profile'], isNull, reason: 'sending person properties asks for a person profile'); - expect(queuedProps(storage, 1)[r'$process_person_profile'], isTrue); + expect(queuedOptions(storage, 1)['process_person_profile'], isNull); }); } @@ -68,7 +68,7 @@ void main() { r'$set': {'plan': 'pro'}, }); - expect(queuedProps(storage, 0)[r'$process_person_profile'], isFalse); + expect(queuedOptions(storage, 0)['process_person_profile'], isFalse); }); test(r'$groups apply to that event only', () async { @@ -81,7 +81,7 @@ void main() { expect(queuedProps(storage, 0)[r'$groups'], {'company': 'acme'}); expect(queuedProps(storage, 1).containsKey(r'$groups'), isFalse); - expect(queuedProps(storage, 1)[r'$process_person_profile'], isFalse); + expect(queuedOptions(storage, 1)['process_person_profile'], isFalse); expect(reloads, 0, reason: 'an event-level group is not a group change'); }); @@ -103,8 +103,9 @@ void main() { expect(first[r'$sdk_debug_current_session_duration'], greaterThanOrEqualTo(10)); expect(first[r'$sdk_debug_pending_queue_size'], 0); - expect(queuedProps(storage, 1)[r'$sdk_debug_pending_queue_size'], 1, - reason: 'SDK debug values win over a registered super property'); + expect(queuedProps(storage, 1)[r'$sdk_debug_pending_queue_size'], + 'registered', + reason: 'a registered super property wins over SDK values'); }); }); @@ -187,7 +188,7 @@ void main() { expect(queuedEvents(storage), [r'$set'], reason: r'there is no anonymous history to link, so no $identify'); expect(queuedProps(storage, 0)[r'$is_identified'], isTrue); - expect(queuedProps(storage, 0)[r'$process_person_profile'], isTrue); + expect(queuedOptions(storage, 0)['process_person_profile'], isNull); expect(reloads, 0, reason: 'the identified state alone does not affect flags'); }); @@ -253,7 +254,10 @@ void main() { for (final event in [r'$identify', r'$create_alias', r'$groupidentify']) { final props = queuedPropsOf(storage, event); - expect(props[r'$process_person_profile'], isTrue, reason: event); + final message = getQueue(storage) + .singleWhere((message) => message['event'] == event); + expect(message, isNot(contains('options')), + reason: '$event: person-processed events omit the option'); expect(props[r'$is_identified'], isTrue, reason: event); expect(props, contains(r'$sdk_debug_session_start'), reason: event); } @@ -367,14 +371,14 @@ void main() { client = testClient(server, storage: storage); }); - test("win over registered properties, not over the event's own", () { - client.register({'screen': 'registered', 'plan': 'free'}); + test("lose to registered properties and to the event's own", () { + client.register({'screen': 'registered'}); client.registerForSession({'screen': 'Checkout', 'plan': 'trial'}); client.capture('first'); client.capture('second', properties: {'plan': 'pro'}); - expect(queuedProps(storage, 0)['screen'], 'Checkout'); + expect(queuedProps(storage, 0)['screen'], 'registered'); expect(queuedProps(storage, 0)['plan'], 'trial'); expect(queuedProps(storage, 1)['plan'], 'pro'); }); @@ -425,7 +429,7 @@ void main() { client.capture('next_event'); expect(queuedEvents(storage), ['next_event']); - expect(queuedProps(storage, 0)[r'$process_person_profile'], isFalse, + expect(queuedOptions(storage, 0)['process_person_profile'], isFalse, reason: 'an ignored alias does not turn on person processing'); expect(lines, contains(contains('empty alias'))); }); diff --git a/posthog_flutter/test/core/test_client.dart b/posthog_flutter/test/core/test_client.dart index 62a96f13..023c3712 100644 --- a/posthog_flutter/test/core/test_client.dart +++ b/posthog_flutter/test/core/test_client.dart @@ -96,6 +96,12 @@ Map queuedProps(FileStorage storage, int index) { return queuedMessage(storage, index)['properties'] as Map; } +/// The capture v1 options of the queued event message at [index], empty +/// when it has none. +Map queuedOptions(FileStorage storage, int index) => + Map.from( + (queuedMessage(storage, index)['options'] ?? const {}) as Map); + /// The names of the queued events, oldest first. List queuedEvents(FileStorage storage) => [for (final message in getQueue(storage)) message['event']]; diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index daaab319..613927b0 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -7,6 +7,7 @@ import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autoca import 'package:posthog_flutter/src/feature_flag_result.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; +import 'package:posthog_flutter/src/posthog_event.dart'; import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; import 'package:posthog_flutter/src/posthog_flutter_version.dart'; @@ -282,7 +283,8 @@ void main() { expect(flags, isNot(contains('timezone'))); }); - test(r'reports $recording_status disabled on every event', () async { + test(r'reports $recording_status disabled unless the event sets it', + () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)); @@ -295,7 +297,7 @@ void main() { final identify = await server.waitForEvent(r'$identify'); expect(_propertiesOf(identify)[r'$recording_status'], 'disabled'); final captured = await server.waitForEvent('replay event'); - expect(_propertiesOf(captured)[r'$recording_status'], 'disabled'); + expect(_propertiesOf(captured)[r'$recording_status'], 'active'); }); }); @@ -576,17 +578,28 @@ void main() { expect(callOrder, ['transform', 'throw']); }); - test('callbacks see user-provided properties only', () async { + test('callbacks see and change the properties and options the SDK adds', + () async { final server = await LocalPostHogServer.start(); - Map? seenProperties; + PostHogEvent? seen; final config = configFor(server) ..beforeSend = [ (event) { - seenProperties = event.properties; + seen = PostHogEvent( + event: event.event, + properties: {...?event.properties}, + options: {...?event.options}, + ); + event.properties = { + ...?event.properties, + r'$device_type': 'Kiosk', + }..remove(r'$os_name'); + event.options = null; return event; }, ]; final platform = await setUpPlatform(config); + await platform.register('plan', 'free'); await platform.capture( eventName: 'context event', @@ -594,15 +607,43 @@ void main() { ); final event = await server.waitForEvent('context event'); - // The whole map: no $session_id, $lib or other SDK enrichment is - // visible to the hook... - expect(seenProperties, {'plan': 'pro'}); - // ...while the delivered event is still enriched after the hook ran. + final seenProperties = seen!.properties!; + expect(seenProperties, containsPair('plan', 'pro'), + reason: 'the event wins over a registered property'); + expect(seenProperties, containsPair(r'$device_type', 'Desktop')); + expect(seenProperties, contains(r'$os_name')); + expect(seenProperties, contains(r'$session_id')); + expect(seenProperties, contains(r'$lib')); + expect(seen!.options, {'process_person_profile': false}); + // The SDK adds nothing back after the hook ran. final properties = _propertiesOf(event); - expect(properties, containsPair(r'$device_type', 'Desktop')); + expect(properties, containsPair(r'$device_type', 'Kiosk')); + expect(properties, isNot(contains(r'$os_name'))); + expect(event, isNot(contains('options'))); expect(event['session_id'], isA()); }); + test('the event time is taken before the callbacks run', () async { + final server = await LocalPostHogServer.start(); + late DateTime hookStarted; + final config = configFor(server) + ..beforeSend = [ + (event) async { + hookStarted = DateTime.now().toUtc(); + // Real wait: the event must not take the time it is queued. + await Future.delayed(const Duration(milliseconds: 50)); + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.capture(eventName: 'slow hook'); + + final event = await server.waitForEvent('slow hook'); + final timestamp = DateTime.parse(event['timestamp']! as String); + expect(timestamp.isAfter(hookStarted), isFalse); + }); + test('SDK-internal events bypass the callbacks', () async { final server = await LocalPostHogServer.start(); server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); @@ -1081,7 +1122,7 @@ void main() { ]; expect( [for (final set in sets) set['process_person_profile']], - [isFalse, isTrue], + [isFalse, isNull], ); }); From ed3659242dbca43fc1fa8214325a314b09896f72 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:11:08 -0400 Subject: [PATCH 15/43] fix(desktop): back off capture v1 retries like posthog-ios and posthog-android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transient failure or a 2xx whose per-event results ask to retry no longer resends within the flush (3 tries, 3 s apart). Each one pauses the queue for 1 s doubling to 30 s, with ±25% jitter from an injectable source. A Retry-After is a floor applied after the jitter, capped at 30 s, instead of replacing the backoff, so `Retry-After: 1` no longer resends every 3 s. A delivered or dropped batch resets the backoff. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-desktop-capture-v1.md | 2 +- .../lib/src/core/posthog_core_stateless.dart | 213 +++++++++--------- .../test/core/capture_v1_test.dart | 73 +++++- .../test/core/queue_flush_test.dart | 101 +++++---- posthog_flutter/test/core/test_client.dart | 6 +- 5 files changed, 244 insertions(+), 151 deletions(-) diff --git a/.changeset/v6-desktop-capture-v1.md b/.changeset/v6-desktop-capture-v1.md index 97fdf980..644d4aa4 100644 --- a/.changeset/v6-desktop-capture-v1.md +++ b/.changeset/v6-desktop-capture-v1.md @@ -5,4 +5,4 @@ - **Breaking:** on every platform, events now go to capture v1 (`/i/v1/analytics/events`) instead of `/batch/`: iOS and macOS through `posthog-ios` 4.x, Android through `posthog-android` 4.x, and Windows and Linux in the Dart client. If you send events through your own reverse proxy, update it before you upgrade: forward `/i/v1/analytics/events` and `/i/v1/ai/events`, pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged, and accept request bodies up to 20 MB. PostHog's managed reverse proxy already does this. See the [iOS](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md#hosts-without-capture-v1) and [Android](https://github.com/PostHog/posthog-android/blob/main/docs/migration-3.x-to-4.0.md#hosts-without-capture-v1) migration guides - **Breaking:** on Windows and Linux, a reverse proxy or self-hosted instance must serve the new path: a 404 is retried, the events stay queued, and the SDK prints a warning once, also without `debug`. Events queued before the upgrade are sent through the new endpoint - **Breaking:** on Windows and Linux, a redirect drops the batch instead of retrying it (only same-origin 307/308 redirects are followed, while a 404 or 429 is retried), and a `Retry-After` wait is capped at 30 seconds -- On Windows and Linux, once a batch has failed after its retries, later sends make one attempt each and wait longer between them, from 6 up to 30 seconds, until a batch is delivered +- On Windows and Linux, a failed batch, or one whose events the server asks to retry, is no longer resent within the same flush: the queue backs off like the iOS and Android SDKs, waiting 1 second and doubling up to 30 seconds with ±25% jitter, until a batch is delivered. A `Retry-After` is a floor under that wait rather than a replacement diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 026328ac..d845ab90 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/foundation.dart' show debugPrint; import 'package:meta/meta.dart'; @@ -53,6 +54,34 @@ bool _isRetryableFlagsError(Object err) => err is PostHogFetchNetworkError || (err is PostHogFetchHttpError && (err.status == 502 || err.status == 504)); +/// The largest fraction a capture retry wait moves either way, so clients +/// refused together don't all come back at the same moment. +const _retryJitter = 0.25; + +/// The wait before capture is sent again after the [attempt]-th failure in a +/// row: 1 second doubling up to 30 seconds, scaled by a jitter factor in +/// `[1 - _retryJitter, 1 + _retryJitter)` drawn from [jitterUnit] and capped +/// again. A server [retryAfter], capped at 30 seconds, is a floor applied +/// after the jitter, so a retry never comes earlier than the server asked. +/// A [jitterUnit] outside `[0, 1)` leaves the delay unjittered. +@visibleForTesting +Duration captureRetryDelay({ + required int attempt, + required double jitterUnit, + Duration? retryAfter, +}) { + const initialMs = 1000.0; + const maxMs = 30000.0; + final computed = + min(initialMs * pow(2, (attempt < 1 ? 1 : attempt) - 1), maxMs); + final factor = jitterUnit >= 0 && jitterUnit < 1 + ? 1 - _retryJitter + 2 * _retryJitter * jitterUnit + : 1.0; + final jittered = min(computed * factor, maxMs).round(); + final floor = min(retryAfter?.inMilliseconds ?? 0, maxMs.toInt()); + return Duration(milliseconds: jittered > floor ? jittered : floor); +} + /// Base stateless PostHog client: the queue of events, consent, super /// properties and the requests to PostHog. abstract class PostHogCoreStateless { @@ -68,15 +97,13 @@ abstract class PostHogCoreStateless { static const _requestTimeout = Duration(seconds: 10); - // A batch is retried after a transient failure; a flags request only after - // a network error or a gateway timeout. - static const _fetchRetryCount = 3; - static const _fetchRetryDelay = Duration(seconds: 3); + // A flags request is retried only after a network error or a gateway + // timeout. A batch is not retried within a flush: the queue backs off. static const _flagsRetryCount = 1; static const _flagsRetryDelay = Duration(milliseconds: 300); - /// The longest `Retry-After` pause honored for events. - static const _maxRetryAfter = Duration(seconds: 30); + /// The [_failedFlushes] at which the backoff reaches its 30 second cap. + static const _maxBackoffStep = 6; /// How many same-origin 307/308 redirects a capture request follows. static const _maxCaptureRedirects = 5; @@ -85,12 +112,17 @@ abstract class PostHogCoreStateless { /// retried. Kept in memory: after a restart the retry is a new request. CaptureV1Identity? _pendingCaptureIdentity; - /// Flushes in a row whose batch failed with a transient error. Once one - /// has failed, later flushes send once and wait longer, so a failure that - /// lasts, like a missing capture route, doesn't resend the batch with - /// every capture. A delivered batch resets it. + /// Flushes in a row whose batch failed with a transient error or had + /// events the server asked to retry. Each one pauses the queue for longer, + /// see [captureRetryDelay], so a failure that lasts, like a missing + /// capture route, doesn't resend the batch with every capture. A delivered + /// or dropped batch resets it. int _failedFlushes = 0; + /// The source of the backoff jitter: a uniform value in `[0, 1)`. + @visibleForTesting + double Function() jitterUnit = Random().nextDouble; + /// Whether this client already warned that capture returned a 404. bool _warnedCaptureNotFound = false; @@ -486,16 +518,22 @@ abstract class PostHogCoreStateless { _flushTimer = null; } - /// Stops sending events for [delay], the server's `Retry-After` or the - /// backoff after failed flushes, kept between the retry delay and - /// [_maxRetryAfter]. + /// Counts one more failed flush and stops sending events for the backoff + /// it calls for, at least the server's [retryAfter]. + void _backOff(Duration? retryAfter) { + if (_failedFlushes < _maxBackoffStep) _failedFlushes++; + _pauseFlush(captureRetryDelay( + attempt: _failedFlushes, + jitterUnit: jitterUnit(), + retryAfter: retryAfter, + )); + } + + /// Stops sending events for [delay]. void _pauseFlush(Duration delay) { _clearFlushTimer(); _retryAfterTimer?.cancel(); - final pause = delay < _fetchRetryDelay - ? _fetchRetryDelay - : (delay > _maxRetryAfter ? _maxRetryAfter : delay); - _retryAfterTimer = Timer(pause, () { + _retryAfterTimer = Timer(delay, () { _retryAfterTimer = null; if (!_closed && _flushInterval > Duration.zero && @@ -545,103 +583,74 @@ abstract class PostHogCoreStateless { } } - /// Sends [batch] to capture v1, retrying what is worth it, and removes - /// from the queue the events that are done: delivered, or dropped for - /// good. Adds the delivered ones to [sent]. + /// Sends [batch] to capture v1 and removes from the queue the events that + /// are done: delivered, or dropped for good. Adds the delivered ones to + /// [sent]. /// /// Returns whether the flush goes on with the rest of the queue. It stops - /// while events of [batch] wait for a later retry; the periodic flush or - /// the server's `Retry-After` sends them again. + /// while events of [batch] wait for a later retry: the queue backs off, + /// and sends them again once the pause is over. Future _sendBatch( List batch, List> sent, ) async { final queue = storage.queue; - var pending = batch; - for (var attempt = 0;; attempt++) { - if (attempt > 0) { - await Future.delayed(_fetchRetryDelay); - // A client closed meanwhile leaves the queue to the next one. - if (_closed) return false; - } - - final Set retryUuids; - final Duration? retryAfter; - try { - (:retryUuids, :retryAfter) = await _postCaptureV1(pending); - _failedFlushes = 0; - } catch (e) { - if (_closed) rethrow; - if (e is PostHogFetchHttpError && - e.status == HttpStatus.requestEntityTooLarge && - pending.length > 1) { - _maxBatchSize = (pending.length ~/ 2).clamp(1, _maxBatchSize); - logger.warn('Received 413, reducing batch size to $_maxBatchSize'); - return true; - } - if (e is PostHogFetchHttpError && - e.status == HttpStatus.notFound && - !_warnedCaptureNotFound) { - _warnedCaptureNotFound = true; - // Printed even without debug: the events stay queued, and nothing - // else tells the app that the route is missing. - debugPrint('[PostHog] [WARN] $captureV1Path returned 404. ' - 'Events stay queued and are retried. If you use a reverse ' - 'proxy or self-host PostHog, make sure it serves ' - '$captureV1Path.'); - } - final transient = _isTransientCaptureError(e); - final retryAfter = e is PostHogFetchHttpError ? e.retryAfter : null; - if (transient && - retryAfter == null && - _failedFlushes == 0 && - attempt < _fetchRetryCount) { - continue; - } - if (!transient) { - queue.remove([for (final queued in pending) queued.id]); - } - // Re-arm the periodic flush: otherwise queued events sit until the - // next capture (forever in an idle app after an offline failure). - if (transient && retryAfter != null) { - _pauseFlush(retryAfter); - } else if (transient) { - _failedFlushes++; - _pauseFlush(_fetchRetryDelay * (1 << _failedFlushes.clamp(1, 4))); - } else { - _scheduleFlush(); - } - events.emit('error', e); - rethrow; + final Set retryUuids; + final Duration? retryAfter; + try { + (:retryUuids, :retryAfter) = await _postCaptureV1(batch); + } catch (e) { + if (_closed) rethrow; + if (e is PostHogFetchHttpError && + e.status == HttpStatus.requestEntityTooLarge && + batch.length > 1) { + _maxBatchSize = (batch.length ~/ 2).clamp(1, _maxBatchSize); + logger.warn('Received 413, reducing batch size to $_maxBatchSize'); + return true; } - - final retained = [ - for (final queued in pending) - if (retryUuids.contains(queued.event['uuid'])) queued, - ]; - final delivered = [ - for (final queued in pending) - if (!retryUuids.contains(queued.event['uuid'])) queued, - ]; - // Removed by id: the queue may have changed while the batch was in - // flight, e.g. its oldest events dropped to make room. - queue.remove([for (final queued in delivered) queued.id]); - sent.addAll([for (final queued in delivered) queued.event]); - if (retained.isEmpty) return true; - - // Counts only: uuids and server details can identify users. - logger.info('Capture asked to retry ${retained.length} of ' - '${pending.length} events.'); - pending = retained; - if (retryAfter != null) { - _pauseFlush(retryAfter); - return false; + if (e is PostHogFetchHttpError && + e.status == HttpStatus.notFound && + !_warnedCaptureNotFound) { + _warnedCaptureNotFound = true; + // Printed even without debug: the events stay queued, and nothing + // else tells the app that the route is missing. + debugPrint('[PostHog] [WARN] $captureV1Path returned 404. ' + 'Events stay queued and are retried. If you use a reverse ' + 'proxy or self-host PostHog, make sure it serves ' + '$captureV1Path.'); } - if (attempt >= _fetchRetryCount) { + // The pause re-arms the periodic flush: otherwise queued events sit + // until the next capture (forever in an idle app after an offline + // failure). + if (_isTransientCaptureError(e)) { + _backOff(e is PostHogFetchHttpError ? e.retryAfter : null); + } else { + queue.remove([for (final queued in batch) queued.id]); + _failedFlushes = 0; _scheduleFlush(); - return false; } + events.emit('error', e); + rethrow; + } + + final delivered = [ + for (final queued in batch) + if (!retryUuids.contains(queued.event['uuid'])) queued, + ]; + // Removed by id: the queue may have changed while the batch was in + // flight, e.g. its oldest events dropped to make room. + queue.remove([for (final queued in delivered) queued.id]); + sent.addAll([for (final queued in delivered) queued.event]); + if (delivered.length == batch.length) { + _failedFlushes = 0; + return true; } + + // Counts only: uuids and server details can identify users. + logger.info('Capture asked to retry ${batch.length - delivered.length} ' + 'of ${batch.length} events.'); + _backOff(retryAfter); + return false; } /// Posts [batch] to capture v1 and returns the uuids of the events the diff --git a/posthog_flutter/test/core/capture_v1_test.dart b/posthog_flutter/test/core/capture_v1_test.dart index 9ebbda47..65b35069 100644 --- a/posthog_flutter/test/core/capture_v1_test.dart +++ b/posthog_flutter/test/core/capture_v1_test.dart @@ -191,6 +191,38 @@ void main() { }); }); + group('captureRetryDelay', () { + Duration delay(int attempt, double unit, [int? retryAfterSeconds]) => + captureRetryDelay( + attempt: attempt, + jitterUnit: unit, + retryAfter: retryAfterSeconds == null + ? null + : Duration(seconds: retryAfterSeconds), + ); + + test('doubles from 1 second up to 30 seconds', () { + expect([for (var a = 1; a <= 7; a++) delay(a, 0.5).inSeconds], + [1, 2, 4, 8, 16, 30, 30]); + }); + + test('jitters by up to 25 percent and stays under 30 seconds', () { + expect(delay(1, 0), const Duration(milliseconds: 750)); + expect(delay(3, 0.75), const Duration(milliseconds: 4500)); + expect(delay(6, 0), const Duration(milliseconds: 22500)); + expect(delay(6, 0.999), const Duration(seconds: 30)); + expect(delay(1, double.nan), const Duration(seconds: 1)); + expect(delay(1, 1), const Duration(seconds: 1)); + }); + + test('takes Retry-After as a floor after the jitter, at most 30 s', () { + expect(delay(1, 0, 5), const Duration(seconds: 5)); + expect(delay(4, 0.5, 1), const Duration(seconds: 8)); + expect(delay(4, 0, 7), const Duration(seconds: 7)); + expect(delay(1, 0.5, 120), const Duration(seconds: 30)); + }); + }); + group('Capture v1 results', () { test('resends only the events the server asks to retry', () { final api = InProcessPostHogApi(); @@ -279,6 +311,43 @@ void main() { }); }); + test('a partial retry advances the backoff, a full delivery resets it', () { + final api = InProcessPostHogApi(); + final storage = tempStorage(); + var partial = 3; + api.respond = (request) => partial-- > 0 + ? PostHogResponse.results( + request, (name) => name == 'later' ? 'retry' : null) + : PostHogResponse.results(request, (_) => 'ok'); + fakeAsync((async) { + final client = testClient(api, storage: storage); + client.capture('now'); + client.capture('later'); + client.flush(); + async.elapse(Duration.zero); + + var now = Duration.zero; + var requests = 1; + for (final next in [1, 3, 7]) { + async.elapse(Duration(seconds: next) - now - _ms); + expect(api.batchRequests, hasLength(requests)); + async.elapse(_ms); + expect(api.batchRequests, hasLength(++requests)); + now = Duration(seconds: next); + } + expect(getQueue(storage), isEmpty); + + api.respond = (_) => const PostHogResponse(HttpStatus.badGateway); + client.capture('next'); + client.flush().catchError((Object _) {}); + async.elapse(const Duration(seconds: 1) - _ms); + expect(api.batchRequests, hasLength(5)); + async.elapse(_ms); + expect(api.batchRequests, hasLength(6), + reason: 'the backoff starts over at 1 second'); + }); + }); + test('starts a new request when new events join a retried batch', () { final api = InProcessPostHogApi(); final storage = tempStorage(); @@ -313,7 +382,7 @@ void main() { fakeAsync((async) { final client = testClient(api); client.capture('evt'); - client.flush(); + client.flush().catchError((Object _) {}); async.elapse(const Duration(seconds: 6)); final requests = api.batchRequests; @@ -463,3 +532,5 @@ void main() { expect(Directory('${dir.path}/posthog_queue').listSync(), isEmpty); }); } + +const _ms = Duration(milliseconds: 1); diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart index 232cfdfe..75d8911f 100644 --- a/posthog_flutter/test/core/queue_flush_test.dart +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -123,7 +123,8 @@ void main() { expect(getQueue(storage), isEmpty); }); - test('retries a transient failure three times, three seconds apart', () { + test('backs off 1, 2, 4, 8, 16, then 30 seconds after transient failures', + () { final api = InProcessPostHogApi() ..respond = (_) => const PostHogResponse(HttpStatus.serviceUnavailable); final storage = tempStorage(); @@ -137,20 +138,42 @@ void main() { }); async.elapse(Duration.zero); + expect(error, isA(), + reason: 'a flush does not wait for the backoff'); expect(api.batchRequests, hasLength(1)); - for (final attempts in [2, 3, 4]) { - async.elapse(const Duration(milliseconds: 2999)); - expect(api.batchRequests, hasLength(attempts - 1)); - async.elapse(const Duration(milliseconds: 1)); - expect(api.batchRequests, hasLength(attempts)); + var now = Duration.zero; + var requests = 1; + for (final next in [1, 3, 7, 15, 31, 61, 91]) { + async.elapse(Duration(seconds: next) - now - _ms); + expect(api.batchRequests, hasLength(requests)); + async.elapse(_ms); + expect(api.batchRequests, hasLength(++requests)); + now = Duration(seconds: next); } - expect(error, isA()); expect(getQueue(storage), hasLength(1)); }); }); + test('jitters the backoff by up to 25 percent either way', () { + for (final (unit, first) in [(0.0, 750), (0.999, 1250)]) { + final api = InProcessPostHogApi() + ..respond = + (_) => const PostHogResponse(HttpStatus.serviceUnavailable); + fakeAsync((async) { + final client = testClient(api, jitterUnit: unit); + client.capture('evt'); + client.flush().catchError((Object _) {}); + async.elapse(Duration(milliseconds: first - 1)); + expect(api.batchRequests, hasLength(1), reason: 'unit $unit'); + async.elapse(_ms); + expect(api.batchRequests, hasLength(2), reason: 'unit $unit'); + client.close(); + }); + } + }); + for (final status in [404, 408, 429, 500, 502, 503, 504]) { - test('HTTP $status keeps the batch queued after the retries', () { + test('HTTP $status keeps the batch queued and backs off', () { final api = InProcessPostHogApi() ..respond = (_) => PostHogResponse(status); final storage = tempStorage(); @@ -218,34 +241,7 @@ void main() { }); }); - test('after a failed flush, later flushes send once and back off longer', - () { - final api = InProcessPostHogApi() - ..respond = (_) => const PostHogResponse(HttpStatus.notFound); - final storage = tempStorage(); - fakeAsync((async) { - final client = - testClient(api, storage: storage, config: testConfig(flushAt: 1)); - - client.capture('first'); - async.elapse(const Duration(seconds: 9)); - expect(api.batchRequests, hasLength(4)); - client.capture('second'); - - var now = const Duration(seconds: 9); - var requests = 4; - for (final next in [15, 27, 51, 81, 111]) { - async.elapse(Duration(seconds: next) - now - _ms); - expect(api.batchRequests, hasLength(requests)); - async.elapse(_ms); - expect(api.batchRequests, hasLength(++requests)); - now = Duration(seconds: next); - } - expect(getQueue(storage), hasLength(2)); - }); - }); - - test('a delivered batch restores the retries', () { + test('a delivered batch resets the backoff', () { final api = InProcessPostHogApi(); var status = HttpStatus.notFound; api.respond = (_) => PostHogResponse(status); @@ -376,7 +372,7 @@ void main() { '', 'Sun, 06 Nov 1994 08:49:37 GMT', ]) { - test('ignores Retry-After "$header" and keeps three retries', () { + test('ignores Retry-After "$header" and backs off as without it', () { final api = InProcessPostHogApi() ..respond = (_) => PostHogResponse(HttpStatus.serviceUnavailable, headers: {'retry-after': header}); @@ -527,27 +523,40 @@ void main() { }); }); - test('a new rate limit starts a new pause with the minimum delay', () { + test('Retry-After is a floor under the backoff, not a replacement', () { final api = InProcessPostHogApi() ..respond = (_) => const PostHogResponse(HttpStatus.serviceUnavailable, - headers: {'retry-after': '20'}); + headers: {'retry-after': '5'}); final storage = tempStorage(); fakeAsync((async) { final client = testClient(api, storage: storage); client.capture('event'); client.flush().catchError((Object _) {}); async.elapse(Duration.zero); + // Over the 1 second backoff: the server's 5 seconds win. + async.elapse(const Duration(seconds: 5) - _ms); + expect(api.batchRequests, hasLength(1)); + async.elapse(_ms); + expect(api.batchRequests, hasLength(2)); + // 2, then 4 seconds of backoff are under it: 5 seconds again, then + // the 8 seconds of the 4th failure are over it. + async.elapse(const Duration(seconds: 10)); + expect(api.batchRequests, hasLength(4)); api.respond = (_) => const PostHogResponse( HttpStatus.serviceUnavailable, headers: {'retry-after': '1'}); - async.elapse(const Duration(seconds: 20)); - expect(api.batchRequests, hasLength(2)); + async.elapse(const Duration(seconds: 8) - _ms); + expect(api.batchRequests, hasLength(4)); + async.elapse(_ms); + expect(api.batchRequests, hasLength(5)); + + // A short Retry-After does not resend every second: the 5th + // failure's 16 second backoff wins. api.respond = (_) => const PostHogResponse(HttpStatus.ok); - client.flush(); - async.elapse(const Duration(milliseconds: 2999)); - expect(api.batchRequests, hasLength(2)); - async.elapse(const Duration(milliseconds: 1)); - expect(api.batchRequests, hasLength(3)); + async.elapse(const Duration(seconds: 16) - _ms); + expect(api.batchRequests, hasLength(5)); + async.elapse(_ms); + expect(api.batchRequests, hasLength(6)); expect(getQueue(storage), isEmpty); }); }); diff --git a/posthog_flutter/test/core/test_client.dart b/posthog_flutter/test/core/test_client.dart index 62a96f13..5af59e7f 100644 --- a/posthog_flutter/test/core/test_client.dart +++ b/posthog_flutter/test/core/test_client.dart @@ -44,13 +44,16 @@ FileStorage tempStorage() => FileStorage(tempDirectory().path); /// A client of [api] with [config], closed at the end of the test. It keeps /// its state in [storage], a new temporary directory by default, and -/// attaches [context] and [timezone] to its events. +/// attaches [context] and [timezone] to its events. Its backoff has no +/// jitter unless [jitterUnit] says otherwise, so retries come 1, 2, 4, 8, 16 +/// and then 30 seconds apart. DesktopPostHog testClient( PostHogApiFake api, { PostHogConfig? config, FileStorage? storage, Map context = const {}, String? timezone, + double jitterUnit = 0.5, }) { final client = api.connect( () => DesktopPostHog( @@ -60,6 +63,7 @@ DesktopPostHog testClient( timezone: timezone, ), ); + client.jitterUnit = () => jitterUnit; addTearDown(client.close); return client; } From 1ae278f459dd49d3c7fdebb02b715d69792de299 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:12:59 -0400 Subject: [PATCH 16/43] fix(desktop): time out capture requests on inactivity, not on total duration The 10 s whole-call timeout could fail a large batch on a slow uplink on every attempt. A request now fails only after 10 s without progress: connecting (HttpClient.connectionTimeout), the socket taking a 64 KiB body chunk, the response headers, or a response body chunk. This is the closest dart:io gets to the per-read/per-write idle timeouts of OkHttp and URLSession: like them, it can't see the bytes still in the kernel send buffer, so the wait for headers starts once the last chunk is handed over. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core_stateless.dart | 80 +++++++++--- posthog_flutter/test/core/requests_test.dart | 118 +++++++++++++++++- posthog_flutter/test/posthog_api_fake.dart | 6 + 3 files changed, 188 insertions(+), 16 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index d845ab90..575bfc20 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -95,8 +95,14 @@ abstract class PostHogCoreStateless { final bool _defaultOptIn; + /// How long a request may go without progress: connecting, a chunk of + /// the body taken by the socket, the response headers, or a chunk of the + /// response body. A slow but moving upload never times out. static const _requestTimeout = Duration(seconds: 10); + /// The size of the body chunks handed to the socket, each one progress. + static const _bodyChunkSize = 64 * 1024; + // A flags request is retried only after a network error or a gateway // timeout. A batch is not retried within a flush: the queue backs off. static const _flagsRetryCount = 1; @@ -149,7 +155,8 @@ abstract class PostHogCoreStateless { @protected final FileStorage storage; - final _httpClient = HttpClient(); + final _httpClient = HttpClient() + ..connectionTimeout = PostHogCoreStateless._requestTimeout; PostHogCoreStateless( PostHogConfig config, { @@ -780,10 +787,11 @@ abstract class PostHogCoreStateless { ); } - /// Sends one request, bounded by [_requestTimeout]. A transport failure - /// is thrown as [PostHogFetchNetworkError], except, with - /// [deliveredOnBodyFailure], one that happens after a 2xx status arrived: - /// the response is then returned without a body. + /// Sends one request, aborted once it makes no progress for + /// [_requestTimeout]. A transport failure is thrown as + /// [PostHogFetchNetworkError], except, with [deliveredOnBodyFailure], one + /// that happens after a 2xx status arrived: the response is then returned + /// without a body. Future< ({ int status, @@ -796,15 +804,9 @@ abstract class PostHogCoreStateless { Map headers = const {}, bool deliveredOnBodyFailure = false, }) async { - final attempt = _FetchAttempt(); + final attempt = _FetchAttempt(_requestTimeout); try { - return await _fetch(url, body, attempt, headers).timeout( - _requestTimeout, - onTimeout: () async { - await attempt.cancel(); - throw attempt.timeout; - }, - ); + return await attempt.guard(_fetch(url, body, attempt, headers)); } catch (e) { final status = attempt.status; if (deliveredOnBodyFailure && @@ -872,9 +874,18 @@ abstract class PostHogCoreStateless { payload = bytes; } request.contentLength = payload.length; - request.add(payload); + // In chunks, so the socket taking each one counts as progress: it stops + // taking them while its buffers are full. + await request.addStream(() async* { + for (var start = 0; start < payload.length; start += _bodyChunkSize) { + attempt.progress(); + yield payload.sublist( + start, min(start + _bodyChunkSize, payload.length)); + } + }()); final response = await request.close(); + attempt.progress(); final retryAfter = _parseRetryAfter(response.headers.value(HttpHeaders.retryAfterHeader)); attempt @@ -904,7 +915,12 @@ abstract class PostHogCoreStateless { } class _FetchAttempt { + _FetchAttempt(this._idleTimeout); + + final Duration _idleTimeout; final timeout = TimeoutException('PostHog request timed out'); + Timer? _idleTimer; + void Function()? _onIdle; HttpClientRequest? _request; @@ -915,7 +931,38 @@ class _FetchAttempt { Completer? _responseBody; bool _cancelled = false; + /// Completes with [work], or fails with [timeout] once the request has + /// made no [progress] for the idle timeout. The request is cancelled + /// before that failure is reported, so a retry never overlaps it. + Future guard(Future work) { + final result = Completer(); + var idle = false; + _onIdle = () async { + idle = true; + await cancel(); + if (!result.isCompleted) result.completeError(timeout); + }; + progress(); + work.then((value) { + if (!idle && !result.isCompleted) result.complete(value); + }, onError: (Object error, StackTrace stackTrace) { + if (!idle && !result.isCompleted) { + result.completeError(error, stackTrace); + } + }); + return result.future.whenComplete(() => _idleTimer?.cancel()); + } + + /// Restarts the idle timeout. + void progress() { + _idleTimer?.cancel(); + if (_cancelled) return; + final onIdle = _onIdle; + if (onIdle != null) _idleTimer = Timer(_idleTimeout, onIdle); + } + void attachRequest(HttpClientRequest request) { + progress(); _request = request; if (_cancelled) { request.abort(timeout); @@ -929,7 +976,10 @@ class _FetchAttempt { late final StreamSubscription subscription; subscription = response.transform(const Utf8Decoder(allowMalformed: true)).listen( - contents.write, + (chunk) { + progress(); + contents.write(chunk); + }, onError: (Object error, StackTrace stackTrace) { if (!body.isCompleted) body.completeError(error, stackTrace); }, diff --git a/posthog_flutter/test/core/requests_test.dart b/posthog_flutter/test/core/requests_test.dart index c5204a24..6bca795f 100644 --- a/posthog_flutter/test/core/requests_test.dart +++ b/posthog_flutter/test/core/requests_test.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/core/posthog_core_stateless.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; import 'package:posthog_flutter/src/posthog_flutter_version.dart'; import '../posthog_api_fake.dart'; @@ -163,6 +166,91 @@ void main() { }); }); + test('keeps a slow upload that makes progress going past 10 seconds', () { + fakeAsync((async) { + late final _ControlledRequest request; + final http = + _ControlledHttpClient((_) async => request = _ControlledRequest( + takeChunk: () => + Future.delayed(const Duration(seconds: 6)), + )); + final storage = tempStorage(); + late final client = HttpOverrides.runZoned( + () => testClient(server, storage: storage), + createHttpClient: (_) => http, + ); + _captureLargeEvent(client); + Object? error; + var flushed = false; + client.flush().then((_) => flushed = true, onError: (Object e) { + error = e; + }); + async.elapse(const Duration(seconds: 60)); + + expect(request.chunks, greaterThan(2)); + expect(error, isNull); + expect(flushed, isTrue); + expect(request.abortCalls, 0); + expect(getQueue(storage), isEmpty); + }); + }); + + test('times out an upload that stalls for 10 seconds', () { + fakeAsync((async) { + final stalled = Completer(); + var taken = 0; + final request = _ControlledRequest( + takeChunk: () => taken++ == 0 ? Future.value() : stalled.future, + ); + final http = _ControlledHttpClient((_) async => request); + final storage = tempStorage(); + late final client = HttpOverrides.runZoned( + () => testClient(server, storage: storage), + createHttpClient: (_) => http, + ); + _captureLargeEvent(client); + Object? error; + client.flush().catchError((Object e) { + error = e; + }); + async.elapse(const Duration(seconds: 10) - _ms); + expect(error, isNull); + async.elapse(_ms); + + expect(error, isA()); + expect(request.abortCalls, 1); + expect(getQueue(storage), hasLength(1)); + client.close(); + }); + }); + + test('keeps a response that trickles in going past 10 seconds', () { + fakeAsync((async) { + final body = StreamController>(); + final http = _ControlledHttpClient((_) async => + _ControlledRequest(response: _ControlledResponse(body.stream))); + late final client = HttpOverrides.runZoned( + () => testClient(server), + createHttpClient: (_) => http, + ); + client.capture('evt'); + Object? error; + var flushed = false; + client.flush().then((_) => flushed = true, onError: (Object e) { + error = e; + }); + for (var i = 0; i < 4; i++) { + async.elapse(const Duration(seconds: 6)); + body.add(utf8.encode(' ')); + } + body.close(); + async.flushMicrotasks(); + + expect(error, isNull); + expect(flushed, isTrue); + }); + }); + test('counts a 2xx whose body times out as delivered', () { fakeAsync((async) { // Cancelled in this zone: fake_async does not run the root zone's @@ -203,6 +291,9 @@ class _ControlledHttpClient implements HttpClient { @override Future postUrl(Uri url) => openRequest(url); + @override + Duration? connectionTimeout; + @override void close({bool force = false}) {} @@ -211,12 +302,17 @@ class _ControlledHttpClient implements HttpClient { } class _ControlledRequest implements HttpClientRequest { - _ControlledRequest({HttpClientResponse? response}) + _ControlledRequest({HttpClientResponse? response, this.takeChunk}) : _response = response ?? _ControlledResponse(Stream.value([])); final HttpClientResponse _response; + + /// How long the socket takes to accept each body chunk; immediate when + /// null. + final Future Function()? takeChunk; int abortCalls = 0; int closeCalls = 0; + int chunks = 0; @override final headers = _ControlledHeaders(); @@ -230,6 +326,14 @@ class _ControlledRequest implements HttpClientRequest { @override void add(List data) {} + @override + Future addStream(Stream> stream) async { + await for (final _ in stream) { + await takeChunk?.call(); + chunks++; + } + } + @override void abort([Object? exception, StackTrace? stackTrace]) { abortCalls++; @@ -245,6 +349,16 @@ class _ControlledRequest implements HttpClientRequest { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +/// A request whose body is several 64 KiB chunks once compressed: random +/// text does not compress much. +void _captureLargeEvent(DesktopPostHog client) { + final random = Random(1); + client.capture('large', properties: { + 'padding': + base64.encode([for (var i = 0; i < 200000; i++) random.nextInt(256)]), + }); +} + class _ControlledResponse extends StreamView> implements HttpClientResponse { _ControlledResponse(super.stream, {this.statusCode = HttpStatus.ok}); @@ -269,3 +383,5 @@ class _ControlledHeaders implements HttpHeaders { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } + +const _ms = Duration(milliseconds: 1); diff --git a/posthog_flutter/test/posthog_api_fake.dart b/posthog_flutter/test/posthog_api_fake.dart index 312e0b36..b2c7646c 100644 --- a/posthog_flutter/test/posthog_api_fake.dart +++ b/posthog_flutter/test/posthog_api_fake.dart @@ -254,6 +254,9 @@ class _InProcessHttpClient implements HttpClient { Future postUrl(Uri url) async => _InProcessRequest(_api, url); + @override + Duration? connectionTimeout; + @override void close({bool force = false}) {} @@ -283,6 +286,9 @@ class _InProcessRequest implements HttpClientRequest { @override void add(List data) => _body.addAll(data); + @override + Future addStream(Stream> stream) => stream.forEach(add); + @override void abort([Object? exception, StackTrace? stackTrace]) { if (!_aborted.isCompleted) { From 7c25cac0c4bc47de8b5153e0c7229dba850a48ab Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:13:36 -0400 Subject: [PATCH 17/43] fix(desktop): halve flushAt with the batch size on 413 and end the flush A 413 halved only the batch size and resent at once in the same flush. Like posthog-ios and posthog-android, it now also halves flushAt, kept within the batch size, and the smaller batches go out with the next flush (the next capture over flushAt, or the periodic flush). No backoff is added: posthog-ios ends the flush without one; posthog-android pauses. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core_stateless.dart | 12 ++- .../test/core/queue_flush_test.dart | 76 ++++++++++++++----- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 575bfc20..6c847bf9 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -87,7 +87,7 @@ Duration captureRetryDelay({ abstract class PostHogCoreStateless { final String _apiKey; final String _host; - final int _flushAt; + int _flushAt; int _maxBatchSize; final int _maxQueueSize; final Duration _flushInterval; @@ -596,7 +596,8 @@ abstract class PostHogCoreStateless { /// /// Returns whether the flush goes on with the rest of the queue. It stops /// while events of [batch] wait for a later retry: the queue backs off, - /// and sends them again once the pause is over. + /// and sends them again once the pause is over. It also stops after a 413 + /// halves the batch size. Future _sendBatch( List batch, List> sent, @@ -611,9 +612,14 @@ abstract class PostHogCoreStateless { if (e is PostHogFetchHttpError && e.status == HttpStatus.requestEntityTooLarge && batch.length > 1) { + // flushAt stays within the batch size, so the queue doesn't pile up + // more events than one batch can carry. _maxBatchSize = (batch.length ~/ 2).clamp(1, _maxBatchSize); + _flushAt = (_flushAt ~/ 2).clamp(1, _maxBatchSize); logger.warn('Received 413, reducing batch size to $_maxBatchSize'); - return true; + // The smaller batches go out with the next flush, not right away. + _scheduleFlush(); + return false; } if (e is PostHogFetchHttpError && e.status == HttpStatus.notFound && diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart index 75d8911f..92f121ad 100644 --- a/posthog_flutter/test/core/queue_flush_test.dart +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -69,27 +69,67 @@ void main() { expect(getQueue(storage), isEmpty); }); - test('halves the batch size on HTTP 413 until the server accepts', - () async { + test('halves the batch size and flushAt on HTTP 413, sending next flush', + () { + final api = InProcessPostHogApi() + // This server rejects anything bigger than two events per request. + ..respond = (request) => PostHogResponse(request.events.length > 2 + ? HttpStatus.requestEntityTooLarge + : HttpStatus.ok); final storage = tempStorage(); - final client = testClient(server, storage: storage); - // This server rejects anything bigger than two events per request. - server.respond = (request) => PostHogResponse(request.events.length > 2 - ? HttpStatus.requestEntityTooLarge - : HttpStatus.ok); + fakeAsync((async) { + final client = testClient(api, + config: testConfig(flushAt: 10, maxBatchSize: 10), + storage: storage); + List sizes() => + [for (final request in api.batchRequests) request.events.length]; - for (var i = 0; i < 8; i++) { - client.capture('event_$i'); - } - await client.flush(); + for (var i = 0; i < 10; i++) { + client.capture('event_$i'); + } + async.elapse(Duration.zero); + expect(sizes(), [10], reason: 'a 413 ends the flush'); - final batches = [ - for (final request in server.batchRequests) request.eventNames, - ]; - expect(batches.map((batch) => batch.length).toList(), [8, 4, 2, 2, 2, 2]); - expect(batches.skip(2).expand((batch) => batch).toList(), - [for (var i = 0; i < 8; i++) 'event_$i']); - expect(getQueue(storage), isEmpty); + // flushAt is 5 now: the next capture flushes. + client.capture('event_10'); + async.elapse(Duration.zero); + expect(sizes(), [10, 5]); + + // flushAt is 2 now, as is the batch size. + client.capture('event_11'); + async.elapse(Duration.zero); + expect(sizes(), [10, 5, 2, 2, 2, 2, 2, 2]); + expect(api.batchRequests.skip(2).expand((r) => r.eventNames).toList(), + [for (var i = 0; i < 12; i++) 'event_$i']); + expect(getQueue(storage), isEmpty); + + client.capture('event_12'); + async.elapse(Duration.zero); + expect(getQueue(storage), hasLength(1), + reason: 'flushAt stays at 2 after the deliveries'); + }); + }); + + test('sends the halved batches with the periodic flush after a 413', () { + final api = InProcessPostHogApi() + ..respond = (request) => PostHogResponse(request.events.length > 2 + ? HttpStatus.requestEntityTooLarge + : HttpStatus.ok); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + for (var i = 0; i < 4; i++) { + client.capture('event_$i'); + } + client.flush(); + async.elapse(Duration.zero); + expect(api.batchRequests, hasLength(1)); + expect(getQueue(storage), hasLength(4)); + + async.elapse(const Duration(seconds: 30)); + expect([for (final r in api.batchRequests) r.events.length], [4, 2, 2]); + expect(getQueue(storage), isEmpty); + }); }); test('shares one network cycle between concurrent calls', () async { From d3b3dea2459f7ffb60637542e99985c4e3fb66b2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:14:16 -0400 Subject: [PATCH 18/43] feat(desktop): send captureAi events to the capture v1 AI endpoint On Windows and Linux, captureAi now builds the event like capture, runs the Dart beforeSend callbacks, and queues it on disk apart from other events, in posthog_ai_queue/. It is sent to /i/v1/ai/events with the same wire format, batching, retry rules and backoff as analytics events, and returns the event's uuid, or null when the event isn't queued: dropped by beforeSend, opted out, or over the size limit. - A serialized event over captureAiMaxEventBytes, capped at and by default 8 MiB plus 64 KiB, measured before compression, is dropped with a log line naming only the event and its size. - The queue keeps captureAiMaxQueueSize events (100 by default) and at most 50 MiB, dropping the oldest first, and flushes as soon as 5 MiB is queued. - Requests use captureAiCompression (gzip by default) and are bounded by captureAiTimeout (60 seconds by default). - Each endpoint keeps its own retry identity, timers, backoff and 413 batch size, so a failing AI request doesn't hold back other events. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/core/capture_v1.dart | 17 + .../lib/src/core/file_storage.dart | 67 +++- .../lib/src/core/posthog_core.dart | 36 +- .../lib/src/core/posthog_core_stateless.dart | 334 ++++++++++++------ posthog_flutter/lib/src/posthog.dart | 4 +- posthog_flutter/lib/src/posthog_config.dart | 48 +-- .../lib/src/posthog_flutter_desktop.dart | 90 +++-- .../test/core/capture_ai_test.dart | 272 ++++++++++++++ posthog_flutter/test/posthog_api_fake.dart | 18 + .../test/posthog_flutter_desktop_test.dart | 86 ++++- 10 files changed, 783 insertions(+), 189 deletions(-) create mode 100644 posthog_flutter/test/core/capture_ai_test.dart diff --git a/posthog_flutter/lib/src/core/capture_v1.dart b/posthog_flutter/lib/src/core/capture_v1.dart index 14e6d886..aaa638ed 100644 --- a/posthog_flutter/lib/src/core/capture_v1.dart +++ b/posthog_flutter/lib/src/core/capture_v1.dart @@ -10,6 +10,23 @@ const captureV1Path = '/i/v1/analytics/events'; /// stay under proxy and capture body limits. const captureV1MaxBatchBytes = 5 * 1024 * 1024; +/// The capture v1 endpoint for AI events. +const captureV1AiPath = '/i/v1/ai/events'; + +/// The default number of AI events kept queued. +const captureAiDefaultMaxQueueSize = 100; + +/// The default timeout of an AI request. +const captureAiDefaultTimeout = Duration(seconds: 60); + +/// The largest serialized AI event the SDK queues: PostHog's 8 MiB limit on +/// an AI event's properties, plus 64 KiB for the rest of the event. +const captureAiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024; + +/// The most AI event bytes kept queued; the oldest events are dropped +/// first. +const captureAiMaxQueueBytes = 50 * 1024 * 1024; + /// The oldest events of [events] whose serialized size stays within /// [maxBytes]. Each event is checked before it is added, so one that does /// not fit starts the next batch, and the first event always goes, alone diff --git a/posthog_flutter/lib/src/core/file_storage.dart b/posthog_flutter/lib/src/core/file_storage.dart index 4c3a7402..00d519bf 100644 --- a/posthog_flutter/lib/src/core/file_storage.dart +++ b/posthog_flutter/lib/src/core/file_storage.dart @@ -9,9 +9,10 @@ import 'uuid.dart'; /// /// The persisted properties (identity, feature flags, consent, super /// properties) live in one JSON snapshot, `posthog_data.json`. Every queued -/// event is a file of its own in `posthog_queue/`, named after a UUIDv7 so -/// that the names sort in queueing order: queueing an event writes one small -/// file and leaves the others alone. +/// event is a file of its own in `posthog_queue/`, or `posthog_ai_queue/` +/// for AI events, named after a UUIDv7 so that the names sort in queueing +/// order: queueing an event writes one small file and leaves the others +/// alone. /// /// One storage at a time writes the snapshot of a directory: the first to /// use it locks `posthog.lock` until it is closed. Another storage on the @@ -42,11 +43,13 @@ class FileStorage { : _directory = '', _cache = {}, _role = _Role.secondary, - _queue = FileEventQueue._memory(); + _queue = FileEventQueue._memory(), + _aiQueue = FileEventQueue._memory(); static const _dataFileName = 'posthog_data.json'; static const _lockFileName = 'posthog.lock'; static const _queueDirectoryName = 'posthog_queue'; + static const _aiQueueDirectoryName = 'posthog_ai_queue'; /// The lock files held by storages of this isolate. A POSIX file lock /// belongs to the whole process and is released as soon as the process @@ -76,6 +79,7 @@ class FileStorage { RandomAccessFile? _lock; String? _lockPath; FileEventQueue? _queue; + FileEventQueue? _aiQueue; String _pathOf(String name) => '$_directory${Platform.pathSeparator}$name'; @@ -93,6 +97,11 @@ class FileStorage { _queue ??= FileEventQueue._(_pathOf(_queueDirectoryName), () => logger, includeExisting: _open() == _Role.primary); + /// The AI events waiting to be sent, apart from the other events. + FileEventQueue get aiQueue => _aiQueue ??= FileEventQueue._( + _pathOf(_aiQueueDirectoryName), () => logger, + includeExisting: _open() == _Role.primary); + /// Decides, on first use, whether this storage writes the snapshot. _Role _open() { final role = _role; @@ -310,6 +319,9 @@ class FileEventQueue { /// this client. final Map> _unwritten = {}; + /// The serialized size of the events whose size is known so far. + final Map _sizes = {}; + List get _ids => _loadedIds ??= _load(); String _pathOf(String id) => @@ -340,19 +352,41 @@ class FileEventQueue { /// The number of queued events. int get length => _ids.length; - /// Adds [event] at the end of the queue. - void add(Map event) { + /// The serialized size of the queued events, in bytes. The size of an + /// event found on disk is read once, from its file. + int get bytes { + var total = 0; + for (final id in _ids) { + total += _sizes[id] ??= _sizeOf(id); + } + return total; + } + + int _sizeOf(String id) { + final unwritten = _unwritten[id]; + if (unwritten != null) return utf8.encode(jsonEncode(unwritten)).length; + try { + return File(_pathOf(id)).lengthSync(); + } on FileSystemException { + return 0; + } + } + + /// Adds [event] at the end of the queue. [json] is the event already + /// encoded, and [bytes] its UTF-8 size, when the caller has them. + void add(Map event, {String? json, int? bytes}) { // Loaded before the new file exists, which would otherwise be listed // on top of being added. final ids = _ids; final id = generateUuidV7(); ids.add(id); + if (bytes != null) _sizes[id] = bytes; if (_directory.isEmpty) { _unwritten[id] = event; return; } try { - _writeAtomically(File(_pathOf(id)), jsonEncode(event)); + _writeAtomically(File(_pathOf(id)), json ?? jsonEncode(event)); } catch (e) { _unwritten[id] = event; _logger()?.warn( @@ -372,7 +406,10 @@ class FileEventQueue { final event = _unwritten[id] ?? _read(id, gone); if (event != null) events.add((id: id, event: event)); } - if (gone.isNotEmpty) _ids.removeWhere(gone.contains); + if (gone.isNotEmpty) { + _ids.removeWhere(gone.contains); + gone.forEach(_sizes.remove); + } return events; } @@ -412,7 +449,21 @@ class FileEventQueue { oldest.forEach(_forget); } + /// Removes the oldest events until the queue holds at most [maxBytes], + /// always keeping the newest event. Returns how many were removed. + int removeOldestOver(int maxBytes) { + var total = bytes; + var count = 0; + while (total > maxBytes && count < _ids.length - 1) { + total -= _sizes[_ids[count]] ?? 0; + count++; + } + removeOldest(count); + return count; + } + void _forget(String id) { + _sizes.remove(id); if (_unwritten.remove(id) == null && _directory.isNotEmpty) { _delete(File(_pathOf(id))); } diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index ba42549c..8eee615e 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -441,12 +441,21 @@ abstract class PostHogCore extends PostHogCoreStateless { /// Queues an event built by [prepareEvent], possibly changed since. /// Unlike [capture], it adds nothing to it. - void capturePrepared(String event, PostHogPreparedEvent prepared) { - wrap(() { - if (_ignoredWhileOptedOut('posthog.capture')) return; - _queuePrepared(event, prepared); - }); - } + /// + /// With [ai], the event goes to PostHog's AI endpoint from a queue of its + /// own, which drops a serialized event over its size limit. + /// + /// Returns the event's uuid, or null when it is not queued: the client + /// is closed or opted out, or the AI event is over the size limit. + String? capturePrepared( + String event, + PostHogPreparedEvent prepared, { + bool ai = false, + }) => + wrap(() { + if (_ignoredWhileOptedOut('posthog.capture')) return null; + return _queuePrepared(event, prepared, ai: ai); + }); PostHogPreparedEvent? _prepareEvent( String event, { @@ -480,11 +489,17 @@ abstract class PostHogCore extends PostHogCoreStateless { ); } - void _queuePrepared(String event, PostHogPreparedEvent prepared) { + String? _queuePrepared( + String event, + PostHogPreparedEvent prepared, { + bool ai = false, + }) { final (:properties, :options, :timestamp) = prepared; - captureStateless(getDistinctId(), event, - properties: properties, options: options, timestamp: timestamp); - if (event == r'$set' || event == r'$identify') { + final uuid = captureStateless(getDistinctId(), event, + properties: properties, options: options, timestamp: timestamp, ai: ai); + // PostHog drops an AI event that is not an `$ai_*` event, so it never + // updates the person. + if (!ai && (event == r'$set' || event == r'$identify')) { final set = properties[r'$set']; final setOnce = properties[r'$set_once']; if ((set is Map || setOnce is Map) && @@ -493,6 +508,7 @@ abstract class PostHogCore extends PostHogCoreStateless { _personPropertiesHash(getDistinctId(), set, setOnce); } } + return uuid; } /// Creates an alias for a user. An empty [alias] is ignored. diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index a2dbae45..72243a45 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -59,11 +59,15 @@ abstract class PostHogCoreStateless { final String _apiKey; final String _host; final int _flushAt; - int _maxBatchSize; - final int _maxQueueSize; final Duration _flushInterval; final PostHogCompression _compression; + /// Analytics events, sent to [captureV1Path]. + late final _CaptureLane _analytics; + + /// AI events, sent to [captureV1AiPath] from a queue of their own. + late final _CaptureLane _ai; + final bool _defaultOptIn; static const _requestTimeout = Duration(seconds: 10); @@ -81,19 +85,6 @@ abstract class PostHogCoreStateless { /// How many same-origin 307/308 redirects a capture request follows. static const _maxCaptureRedirects = 5; - /// The identity of the last capture request whose events are still to be - /// retried. Kept in memory: after a restart the retry is a new request. - CaptureV1Identity? _pendingCaptureIdentity; - - /// Flushes in a row whose batch failed with a transient error. Once one - /// has failed, later flushes send once and wait longer, so a failure that - /// lasts, like a missing capture route, doesn't resend the batch with - /// every capture. A delivered batch resets it. - int _failedFlushes = 0; - - /// Whether this client already warned that capture returned a 404. - bool _warnedCaptureNotFound = false; - /// The latest consent decision made through this client. It wins over the /// stored one: [clearPersistedOptOut] removes the stored decision while /// this client keeps honoring it, and a decision the storage cannot @@ -102,9 +93,6 @@ abstract class PostHogCoreStateless { @protected late final SimpleEventEmitter events; - Timer? _flushTimer; - Timer? _retryAfterTimer; - Future? _flushFuture; /// Set by [close]: later calls are ignored, the periodic flush stops, and /// a failed request is not retried. @@ -125,8 +113,6 @@ abstract class PostHogCoreStateless { }) : _apiKey = config.projectToken, _host = removeTrailingSlash(config.host), _flushAt = config.flushAt < 1 ? 1 : config.flushAt, - _maxBatchSize = config.maxBatchSize < 1 ? 1 : config.maxBatchSize, - _maxQueueSize = config.maxQueueSize < 1 ? 1 : config.maxQueueSize, _flushInterval = config.flushInterval, _compression = config.compression, _defaultOptIn = !config.optOut { @@ -138,16 +124,47 @@ abstract class PostHogCoreStateless { debug(true); } - final queue = storage.queue; - final overflow = queue.length - _maxQueueSize; - if (overflow > 0) { - queue.removeOldest(overflow); - } + final maxBatchSize = config.maxBatchSize < 1 ? 1 : config.maxBatchSize; + _analytics = _CaptureLane( + path: captureV1Path, + queue: storage.queue, + maxBatchSize: maxBatchSize, + maxQueueSize: config.maxQueueSize < 1 ? 1 : config.maxQueueSize, + compression: config.compression, + timeout: _requestTimeout, + ); + final aiMaxQueueSize = config.captureAiMaxQueueSize ?? 0; + final aiTimeout = config.captureAiTimeout ?? Duration.zero; + final aiMaxEventBytes = config.captureAiMaxEventBytes ?? 0; + _ai = _CaptureLane( + path: captureV1AiPath, + queue: storage.aiQueue, + maxBatchSize: maxBatchSize, + maxQueueSize: + aiMaxQueueSize > 0 ? aiMaxQueueSize : captureAiDefaultMaxQueueSize, + compression: config.captureAiCompression ?? PostHogCompression.gzip, + timeout: aiTimeout > Duration.zero ? aiTimeout : captureAiDefaultTimeout, + maxEventBytes: + aiMaxEventBytes > 0 && aiMaxEventBytes < captureAiMaxEventBytes + ? aiMaxEventBytes + : captureAiMaxEventBytes, + maxQueueBytes: captureAiMaxQueueBytes, + ); + + for (final lane in [_analytics, _ai]) { + final queue = lane.queue; + final overflow = queue.length - lane.maxQueueSize; + if (overflow > 0) { + queue.removeOldest(overflow); + } + final maxQueueBytes = lane.maxQueueBytes; + if (maxQueueBytes != null) queue.removeOldestOver(maxQueueBytes); - // Events left over from a previous run go out with the first periodic - // flush instead of waiting for the next capture. - if (queue.length > 0) { - _scheduleFlush(); + // Events left over from a previous run go out with the first periodic + // flush instead of waiting for the next capture. + if (queue.length > 0) { + _scheduleFlush(lane); + } } } @@ -269,6 +286,7 @@ abstract class PostHogCoreStateless { Map? options, }) { _enqueue( + _analytics, 'identify', _buildPayload( distinctId: distinctId, @@ -279,15 +297,20 @@ abstract class PostHogCoreStateless { ); } + /// Queues [event] and returns its uuid, or null when it is not queued. + /// With [ai], the event goes to the AI queue, which drops an event over + /// its size limit. @protected - void captureStateless( + String? captureStateless( String distinctId, String event, { required Map properties, Map? options, String? timestamp, + bool ai = false, }) { - _enqueue( + return _enqueue( + ai ? _ai : _analytics, 'capture', _buildPayload( distinctId: distinctId, @@ -307,6 +330,7 @@ abstract class PostHogCoreStateless { Map? options, }) { _enqueue( + _analytics, 'alias', _buildPayload( distinctId: distinctId, @@ -331,6 +355,7 @@ abstract class PostHogCoreStateless { Map? options, }) { _enqueue( + _analytics, 'capture', _buildPayload( distinctId: distinctId, @@ -425,10 +450,12 @@ abstract class PostHogCoreStateless { }); } - /// Queues [message]. Its capture v1 [options] are queued with it; the - /// legacy option properties are converted only when the request is built, - /// see [captureV1Event]. - void _enqueue( + /// Queues [message] on [lane] and returns its uuid, or null when it is + /// over the lane's event size limit. Its capture v1 [options] are queued + /// with it; the legacy option properties are converted only when the + /// request is built, see [captureV1Event]. + String? _enqueue( + _CaptureLane lane, String type, Map message, { Map? options, @@ -446,21 +473,44 @@ abstract class PostHogCoreStateless { 'timestamp': timestamp ?? currentISOTime(), 'uuid': generateUuidV7(), }; - final queue = storage.queue; - final overflow = queue.length - _maxQueueSize + 1; + + // Measured as queued, before compression. + String? json; + int? bytes; + final maxEventBytes = lane.maxEventBytes; + if (maxEventBytes != null) { + json = jsonEncode(prepared); + bytes = utf8.encode(json).length; + if (bytes > maxEventBytes) { + // Name and size only: AI properties can hold prompts and media. + logger.warn('Dropping AI event ${message['event']} of $bytes bytes, ' + 'over the $maxEventBytes byte limit.'); + return null; + } + } + + final queue = lane.queue; + final overflow = queue.length - lane.maxQueueSize + 1; if (overflow > 0) { queue.removeOldest(overflow); logger.warn('Queue is full, the oldest event is dropped.'); } - queue.add(prepared); + queue.add(prepared, json: json, bytes: bytes); + final maxQueueBytes = lane.maxQueueBytes; + if (maxQueueBytes != null && queue.removeOldestOver(maxQueueBytes) > 0) { + logger.warn('Queue is over $maxQueueBytes bytes, the oldest events are ' + 'dropped.'); + } events.emit(type, prepared); - if (queue.length >= _flushAt) { - _flushBackground(); + if (queue.length >= _flushAt || + (bytes != null && queue.bytes >= captureV1MaxBatchBytes)) { + _flushBackground(lane); } - _scheduleFlush(); + _scheduleFlush(lane); + return prepared['uuid'] as String; } /// The event properties as they are queued and sent: values JSON cannot @@ -477,76 +527,75 @@ abstract class PostHogCoreStateless { '$key': converted, }; - void _scheduleFlush() { + void _scheduleFlush(_CaptureLane lane) { if (!_closed && - _retryAfterTimer == null && + lane.retryAfterTimer == null && _flushInterval > Duration.zero && - _flushTimer == null) { - _flushTimer = Timer(_flushInterval, () { + lane.flushTimer == null) { + lane.flushTimer = Timer(_flushInterval, () { // Cleared first: a timer firing during a flush in flight only joins // that flush, and a stale reference would block every later re-arm. - _flushTimer = null; - _flushBackground(); + lane.flushTimer = null; + _flushBackground(lane); }); } } - void _clearFlushTimer() { - _flushTimer?.cancel(); - _flushTimer = null; - } - - /// Stops sending events for [delay], the server's `Retry-After` or the - /// backoff after failed flushes, kept between the retry delay and - /// [_maxRetryAfter]. - void _pauseFlush(Duration delay) { - _clearFlushTimer(); - _retryAfterTimer?.cancel(); + /// Stops sending the events of [lane] for [delay], the server's + /// `Retry-After` or the backoff after failed flushes, kept between the + /// retry delay and [_maxRetryAfter]. + void _pauseFlush(_CaptureLane lane, Duration delay) { + lane.clearFlushTimer(); + lane.retryAfterTimer?.cancel(); final pause = delay < _fetchRetryDelay ? _fetchRetryDelay : (delay > _maxRetryAfter ? _maxRetryAfter : delay); - _retryAfterTimer = Timer(pause, () { - _retryAfterTimer = null; - if (!_closed && - _flushInterval > Duration.zero && - storage.queue.length > 0) { - _flushBackground(); + lane.retryAfterTimer = Timer(pause, () { + lane.retryAfterTimer = null; + if (!_closed && _flushInterval > Duration.zero && lane.queue.length > 0) { + _flushBackground(lane); } }); } - void _flushBackground() { - flush().catchError((e) { + void _flushBackground(_CaptureLane lane) { + _flushLane(lane).catchError((e) { logger.error('Error while flushing PostHog', e); }); } - /// Flushes the queue of pending events. + /// Flushes the queues of pending events and AI events. /// If a flush is already in progress, returns the existing future to avoid /// concurrent flushes sending duplicate events. - Future flush() { - if (_flushFuture != null) return _flushFuture!; - if (_retryAfterTimer != null) return Future.value(); - _flushFuture = _doFlush().whenComplete(() => _flushFuture = null); - return _flushFuture!; + Future flush() async { + await Future.wait([_flushLane(_analytics), _flushLane(_ai)]); } - Future _doFlush() async { - _clearFlushTimer(); + Future _flushLane(_CaptureLane lane) { + final inFlight = lane.flushFuture; + if (inFlight != null) return inFlight; + if (lane.retryAfterTimer != null) return Future.value(); + final flush = _doFlush(lane).whenComplete(() => lane.flushFuture = null); + lane.flushFuture = flush; + return flush; + } + + Future _doFlush(_CaptureLane lane) async { + lane.clearFlushTimer(); - final queue = storage.queue; + final queue = lane.queue; final sentMessages = >[]; try { while (!_closed) { // Peeked again after every request, so events queued in the meantime // go out with the same flush. - final batch = takeCaptureV1Batch(queue.peek(_maxBatchSize)); + final batch = takeCaptureV1Batch(queue.peek(lane.maxBatchSize)); if (batch.isEmpty) { - if (queue.length > 0) _scheduleFlush(); + if (queue.length > 0) _scheduleFlush(lane); break; } - if (!await _sendBatch(batch, sentMessages)) break; + if (!await _sendBatch(lane, batch, sentMessages)) break; } } finally { if (sentMessages.isNotEmpty) { @@ -563,10 +612,11 @@ abstract class PostHogCoreStateless { /// while events of [batch] wait for a later retry; the periodic flush or /// the server's `Retry-After` sends them again. Future _sendBatch( + _CaptureLane lane, List batch, List> sent, ) async { - final queue = storage.queue; + final queue = lane.queue; var pending = batch; for (var attempt = 0;; attempt++) { if (attempt > 0) { @@ -578,33 +628,34 @@ abstract class PostHogCoreStateless { final Set retryUuids; final Duration? retryAfter; try { - (:retryUuids, :retryAfter) = await _postCaptureV1(pending); - _failedFlushes = 0; + (:retryUuids, :retryAfter) = await _postCaptureV1(lane, pending); + lane.failedFlushes = 0; } catch (e) { if (_closed) rethrow; if (e is PostHogFetchHttpError && e.status == HttpStatus.requestEntityTooLarge && pending.length > 1) { - _maxBatchSize = (pending.length ~/ 2).clamp(1, _maxBatchSize); - logger.warn('Received 413, reducing batch size to $_maxBatchSize'); + lane.maxBatchSize = (pending.length ~/ 2).clamp(1, lane.maxBatchSize); + logger.warn( + 'Received 413, reducing batch size to ${lane.maxBatchSize}'); return true; } if (e is PostHogFetchHttpError && e.status == HttpStatus.notFound && - !_warnedCaptureNotFound) { - _warnedCaptureNotFound = true; + !lane.warnedNotFound) { + lane.warnedNotFound = true; // Printed even without debug: the events stay queued, and nothing // else tells the app that the route is missing. - debugPrint('[PostHog] [WARN] $captureV1Path returned 404. ' + debugPrint('[PostHog] [WARN] ${lane.path} returned 404. ' 'Events stay queued and are retried. If you use a reverse ' 'proxy or self-host PostHog, make sure it serves ' - '$captureV1Path.'); + '${lane.path}.'); } final transient = _isTransientCaptureError(e); final retryAfter = e is PostHogFetchHttpError ? e.retryAfter : null; if (transient && retryAfter == null && - _failedFlushes == 0 && + lane.failedFlushes == 0 && attempt < _fetchRetryCount) { continue; } @@ -614,12 +665,13 @@ abstract class PostHogCoreStateless { // Re-arm the periodic flush: otherwise queued events sit until the // next capture (forever in an idle app after an offline failure). if (transient && retryAfter != null) { - _pauseFlush(retryAfter); + _pauseFlush(lane, retryAfter); } else if (transient) { - _failedFlushes++; - _pauseFlush(_fetchRetryDelay * (1 << _failedFlushes.clamp(1, 4))); + lane.failedFlushes++; + _pauseFlush( + lane, _fetchRetryDelay * (1 << lane.failedFlushes.clamp(1, 4))); } else { - _scheduleFlush(); + _scheduleFlush(lane); } events.emit('error', e); rethrow; @@ -644,11 +696,11 @@ abstract class PostHogCoreStateless { '${pending.length} events.'); pending = retained; if (retryAfter != null) { - _pauseFlush(retryAfter); + _pauseFlush(lane, retryAfter); return false; } if (attempt >= _fetchRetryCount) { - _scheduleFlush(); + _scheduleFlush(lane); return false; } } @@ -665,13 +717,14 @@ abstract class PostHogCoreStateless { /// Throws [PostHogFetchHttpError] for a non-2xx status and /// [PostHogFetchNetworkError] for a transport failure. Future<({Set retryUuids, Duration? retryAfter})> _postCaptureV1( + _CaptureLane lane, List batch, ) async { final uuids = { for (final queued in batch) if (queued.event['uuid'] case final String uuid) uuid, }; - final pending = _pendingCaptureIdentity; + final pending = lane.pendingIdentity; final identity = pending != null && sameUuids(pending.uuids, uuids) ? pending.next() : CaptureV1Identity( @@ -679,7 +732,7 @@ abstract class PostHogCoreStateless { requestId: generateUuidV7(), createdAt: currentISOTime(), ); - _pendingCaptureIdentity = identity; + lane.pendingIdentity = identity; final body = jsonEncode({ 'created_at': identity.createdAt, @@ -696,11 +749,11 @@ abstract class PostHogCoreStateless { }; // A transport failure keeps the identity, so the resend reuses it. - final response = await _fetchCaptureV1(body, headers); + final response = await _fetchCaptureV1(lane, body, headers); if (response.status < 200 || response.status >= 300) { if (!isRetriableCaptureV1Status(response.status)) { - _pendingCaptureIdentity = null; + lane.pendingIdentity = null; } throw PostHogFetchHttpError( response.status, @@ -719,7 +772,7 @@ abstract class PostHogCoreStateless { 'events for good.'); } final retryUuids = results?.retry ?? const {}; - _pendingCaptureIdentity = + lane.pendingIdentity = retryUuids.isEmpty ? null : identity.withUuids(retryUuids); return (retryUuids: retryUuids, retryAfter: response.retryAfter); } @@ -730,14 +783,18 @@ abstract class PostHogCoreStateless { /// is, and is terminal. Future<({int status, String? body, Duration? retryAfter, String? location})> _fetchCaptureV1( + _CaptureLane lane, String body, Map headers, ) async { final origin = Uri.parse(_host); - var url = Uri.parse('$_host$captureV1Path'); + var url = Uri.parse('$_host${lane.path}'); for (var redirects = 0;; redirects++) { final response = await _fetchOnce(url, body, - headers: headers, deliveredOnBodyFailure: true); + headers: headers, + deliveredOnBodyFailure: true, + timeout: lane.timeout, + compression: lane.compression); if (response.status != 307 && response.status != 308) return response; final location = response.location; final target = location == null ? null : url.resolve(location); @@ -781,7 +838,9 @@ abstract class PostHogCoreStateless { ); } - /// Sends one request, bounded by [_requestTimeout]. A transport failure + /// Sends one request, bounded by [timeout], [_requestTimeout] by default, + /// and compressed with [compression], the configured one by default. A + /// transport failure /// is thrown as [PostHogFetchNetworkError], except, with /// [deliveredOnBodyFailure], one that happens after a 2xx status arrived: /// the response is then returned without a body. @@ -796,11 +855,15 @@ abstract class PostHogCoreStateless { String body, { Map headers = const {}, bool deliveredOnBodyFailure = false, + Duration timeout = _requestTimeout, + PostHogCompression? compression, }) async { final attempt = _FetchAttempt(); try { - return await _fetch(url, body, attempt, headers).timeout( - _requestTimeout, + return await _fetch( + url, body, attempt, headers, compression ?? _compression) + .timeout( + timeout, onTimeout: () async { await attempt.cancel(); throw attempt.timeout; @@ -850,6 +913,7 @@ abstract class PostHogCoreStateless { String body, _FetchAttempt attempt, Map headers, + PostHogCompression compression, ) async { final request = await _httpClient.postUrl(url); attempt.attachRequest(request); @@ -866,7 +930,7 @@ abstract class PostHogCoreStateless { headers.forEach(request.headers.set); final bytes = utf8.encode(body); final List payload; - if (_compression == PostHogCompression.gzip) { + if (compression == PostHogCompression.gzip) { request.headers.set(HttpHeaders.contentEncodingHeader, 'gzip'); payload = gzip.encode(bytes); } else { @@ -896,14 +960,68 @@ abstract class PostHogCoreStateless { /// storage loses them. void close() { _closed = true; - _clearFlushTimer(); - _retryAfterTimer?.cancel(); - _retryAfterTimer = null; + for (final lane in [_analytics, _ai]) { + lane.clearFlushTimer(); + lane.retryAfterTimer?.cancel(); + lane.retryAfterTimer = null; + } _httpClient.close(force: true); storage.close(); } } +/// A queue of events and the state of sending it to one capture v1 +/// endpoint. +class _CaptureLane { + _CaptureLane({ + required this.path, + required this.queue, + required this.maxBatchSize, + required this.maxQueueSize, + required this.compression, + required this.timeout, + this.maxEventBytes, + this.maxQueueBytes, + }); + + final String path; + final FileEventQueue queue; + + /// Lowered by a 413. + int maxBatchSize; + final int maxQueueSize; + final PostHogCompression compression; + final Duration timeout; + + /// The largest serialized event queued; null for no limit. + final int? maxEventBytes; + + /// The most serialized event bytes kept queued; null for no limit. + final int? maxQueueBytes; + + /// The identity of the last request whose events are still to be + /// retried. Kept in memory: after a restart the retry is a new request. + CaptureV1Identity? pendingIdentity; + + /// Flushes in a row whose batch failed with a transient error. Once one + /// has failed, later flushes send once and wait longer, so a failure that + /// lasts, like a missing capture route, doesn't resend the batch with + /// every capture. A delivered batch resets it. + int failedFlushes = 0; + + /// Whether this client already warned that [path] returned a 404. + bool warnedNotFound = false; + + Timer? flushTimer; + Timer? retryAfterTimer; + Future? flushFuture; + + void clearFlushTimer() { + flushTimer?.cancel(); + flushTimer = null; + } +} + class _FetchAttempt { final timeout = TimeoutException('PostHog request timed out'); diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 47f661c2..56a489be 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -286,9 +286,9 @@ class Posthog { /// - a [PostHogConfig.beforeSend] callback dropped it, /// - it is over [PostHogConfig.captureAiMaxEventBytes], /// - the SDK is disabled, not set up or opted out, or - /// - the platform is Windows, Linux or web (see below). + /// - the platform is web (see below). /// - /// **Platforms:** supported on iOS, macOS and Android. On Windows, Linux and + /// **Platforms:** supported on iOS, macOS, Android, Windows and Linux. On /// web, `captureAi` is a no-op: it sends nothing, logs a debug message and /// returns `null`. /// diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index df217c18..e32e7d93 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -199,50 +199,50 @@ class PostHogConfig { /// How [Posthog.captureAi] request bodies are compressed, separately from /// [compression]. /// - /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, - /// where [Posthog.captureAi] sends nothing. + /// Not applied on web, where [Posthog.captureAi] sends nothing. /// - /// Defaults to `null`, which keeps the native SDK default (gzip). + /// Defaults to `null`, which keeps the SDK default (gzip). PostHogCompression? captureAiCompression; /// Maximum number of [Posthog.captureAi] events kept in their on-disk queue /// before the oldest are dropped. /// - /// AI events can be megabytes, so the native default (100) is lower than - /// [maxQueueSize]. The native queue also keeps at most 50 MiB of AI events, - /// whatever this is set to. A value of `0` or less uses the native default. + /// AI events can be megabytes, so the default (100) is lower than + /// [maxQueueSize]. The queue also keeps at most 50 MiB of AI events, + /// whatever this is set to. A value of `0` or less uses the default. /// - /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, - /// where [Posthog.captureAi] sends nothing. + /// Not applied on web, where [Posthog.captureAi] sends nothing. /// - /// Defaults to `null`, which keeps the native SDK default. + /// Defaults to `null`, which keeps the SDK default. int? captureAiMaxQueueSize; - /// Timeout of a [Posthog.captureAi] request, sent to the native SDK in whole - /// seconds (a positive value under one second is sent as one second). + /// Timeout of a [Posthog.captureAi] request. On iOS, macOS and Android it + /// is sent to the native SDK in whole seconds (a positive value under one + /// second is sent as one second) and bounds the time without network + /// activity. On Windows and Linux it bounds the whole request. /// - /// AI requests can carry several MB, so the native default (60 seconds) is - /// longer than for other requests. [Duration.zero] or less uses the native + /// AI requests can carry several MB, so the default (60 seconds) is + /// longer than for other requests. [Duration.zero] or less uses the /// default. /// - /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, - /// where [Posthog.captureAi] sends nothing. + /// Not applied on web, where [Posthog.captureAi] sends nothing. /// - /// Defaults to `null`, which keeps the native SDK default. + /// Defaults to `null`, which keeps the SDK default. Duration? captureAiTimeout; - /// Maximum size, in bytes, of a [Posthog.captureAi] event. The native SDK - /// drops a larger event and [Posthog.captureAi] returns `null`. + /// Maximum size, in bytes, of a [Posthog.captureAi] event. The SDK drops a + /// larger event and [Posthog.captureAi] returns `null`. /// /// Use it to lower the limit. PostHog drops AI events whose properties are - /// over 8 MiB, so a larger value, or a value of `0` or less, uses the native - /// default (8 MiB on Android, 8 MiB plus 64 KiB for the rest of the event on - /// iOS and macOS). + /// over 8 MiB, so a larger value, or a value of `0` or less, uses the + /// default. By default the SDK drops a serialized event over 8 MiB plus + /// 64 KiB for the rest of the event, measured before compression. A set + /// value limits the properties on Android, plus 64 KiB for the rest of the + /// event, and the whole serialized event on the other platforms. /// - /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, - /// where [Posthog.captureAi] sends nothing. + /// Not applied on web, where [Posthog.captureAi] sends nothing. /// - /// Defaults to `null`, which keeps the native SDK default. + /// Defaults to `null`, which keeps the SDK default. int? captureAiMaxEventBytes; /// Whether the SDK is enabled on Windows and Linux. diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 964ddfcf..2a1ff5b5 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -282,7 +282,6 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { current, processed, exceptionSteps, prepared.timestamp); }); - /// AI events are not sent on desktop. @override Future captureAi({ required String eventName, @@ -290,11 +289,43 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? userProperties, Map? userPropertiesSetOnce, Map? options, - }) async { - printIfDebug('[PostHog] captureAi is not supported on Windows and Linux. ' - 'The event "$eventName" was not sent.'); - return null; - } + }) => + _guardWith('captureAi', null, (client) async { + if (!eventName.startsWith(r'$ai_')) { + printIfDebug('[PostHog] captureAi event $eventName does not start ' + r'with $ai_, so PostHog will drop it. Use capture for non-AI ' + 'events.'); + } + if (client.optedOut) return null; + if (!_hasName(eventName)) return null; + final scope = _clientScope; + if (scope == null) return null; + final distinctId = client.getDistinctId(); + final exceptionSteps = _exceptionSteps; + + final prepared = _prepare( + client, + eventName, + properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + if (prepared == null) return null; + final hooked = _runBeforeSend(prepared.event); + final processed = + hooked is Future ? await hooked : hooked; + if (processed == null) { + printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); + return null; + } + + final current = _clientForContinuation('captureAi', scope, distinctId); + if (current == null) return null; + return _captureProcessed( + current, processed, exceptionSteps, prepared.timestamp, + ai: true); + }); @override Future screen({ @@ -764,32 +795,37 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return processed; } - /// Queues [event] as the beforeSend callbacks left it. The client adds - /// nothing to it; an `$exception` event gets the exception steps recorded - /// up to now. - void _captureProcessed( + /// Queues [event] as the beforeSend callbacks left it, on the AI queue + /// with [ai], and returns its uuid, or null when it is not queued. The + /// client adds nothing to it; an `$exception` event gets the exception + /// steps recorded up to now. + String? _captureProcessed( DesktopPostHog client, PostHogEvent event, ExceptionStepsBuffer? exceptionSteps, - String timestamp, - ) { - if (!_hasName(event.event)) return; + String timestamp, { + bool ai = false, + }) { + if (!_hasName(event.event)) return null; // Options are sent as they are, unknown keys included. An option wins // over its legacy property on the event. - client.capturePrepared(event.event, ( - properties: _withExceptionSteps( - event.event, - exceptionSteps, - _mergeUserProps( - event.properties, - event.userProperties, - event.userPropertiesSetOnce, - ), - ) ?? - const {}, - options: cleanEventOptions(event.options), - timestamp: timestamp, - )); + return client.capturePrepared( + event.event, + ( + properties: _withExceptionSteps( + event.event, + exceptionSteps, + _mergeUserProps( + event.properties, + event.userProperties, + event.userPropertiesSetOnce, + ), + ) ?? + const {}, + options: cleanEventOptions(event.options), + timestamp: timestamp, + ), + ai: ai); } /// Whether [event] has a name; an event without one is dropped. diff --git a/posthog_flutter/test/core/capture_ai_test.dart b/posthog_flutter/test/core/capture_ai_test.dart new file mode 100644 index 00000000..ae47de63 --- /dev/null +++ b/posthog_flutter/test/core/capture_ai_test.dart @@ -0,0 +1,272 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/core/posthog_core_stateless.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +/// Captures an AI event the way the desktop platform does, without +/// beforeSend callbacks. +String? captureAi( + DesktopPostHog client, + String event, { + Map? properties, +}) { + final prepared = client.prepareEvent(event, properties: properties); + return prepared == null + ? null + : client.capturePrepared(event, prepared, ai: true); +} + +/// The queued AI events, oldest first. +List> aiQueue(FileStorage storage) => [ + for (final queued in storage.aiQueue.peek(storage.aiQueue.length)) + queued.event, + ]; + +/// A string property that makes an event about [bytes] bytes long. +String filler(int bytes) => 'x' * bytes; + +void main() { + group('captureAi', () { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + test('sends to the AI endpoint only, and returns the event uuid', () async { + final client = testClient(server); + + final uuid = captureAi(client, r'$ai_generation', + properties: {r'$ai_model': 'gpt-5'}); + client.capture(r'$ai_generation'); + await client.flush(); + + final request = server.aiRequests.single; + final event = request.events.single; + expect(event['uuid'], uuid); + expect(event['event'], r'$ai_generation'); + expect((event['properties']! as Map)[r'$ai_model'], 'gpt-5'); + expect(event['session_id'], isA()); + expect(request.body['created_at'], isA()); + expect(request.headers['authorization'], 'Bearer k'); + expect( + request.headers['posthog-sdk-info'], startsWith('posthog-flutter/')); + expect(request.headers['posthog-attempt'], '1'); + expect(request.headers['content-encoding'], 'gzip'); + expect(server.batchRequests.single.eventNames, [r'$ai_generation'], + reason: 'capture never sends to the AI endpoint'); + }); + + test('compresses with captureAiCompression, apart from compression', + () async { + final client = testClient(server, + config: testConfig()..captureAiCompression = PostHogCompression.none); + + captureAi(client, r'$ai_generation'); + client.capture('event'); + await client.flush(); + + expect(server.aiRequests.single.headers, + isNot(contains('content-encoding'))); + expect(server.batchRequests.single.headers['content-encoding'], 'gzip'); + }); + + test('returns null and queues nothing while opted out', () { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(optOut: true), storage: storage); + + expect(captureAi(client, r'$ai_generation'), isNull); + expect(aiQueue(storage), isEmpty); + }); + + test('drops a serialized event over the limit, logging name and size only', + () { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(debug: true)..captureAiMaxEventBytes = 10000, + storage: storage); + + late String? fits; + final lines = printedLines(() { + fits = + captureAi(client, r'$ai_span', properties: {'input': filler(1000)}); + }); + expect(fits, isNotNull); + final size = utf8.encode(jsonEncode(aiQueue(storage).single)).length; + + final secret = 'secret-${filler(10000)}'; + String? oversized = 'not called'; + final dropLines = printedLines(() { + oversized = + captureAi(client, r'$ai_generation', properties: {'input': secret}); + }); + + expect(oversized, isNull); + expect(aiQueue(storage), hasLength(1)); + expect(size, lessThan(10000)); + expect(lines, isNot(contains(contains('Dropping')))); + final drop = dropLines.singleWhere((line) => line.contains('Dropping')); + expect(drop, contains(r'$ai_generation')); + expect(drop, contains('over the 10000 byte limit')); + expect(dropLines.join(), isNot(contains('secret'))); + }); + + test('caps captureAiMaxEventBytes at 8 MiB plus 64 KiB', () { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig()..captureAiMaxEventBytes = 100 * 1024 * 1024, + storage: storage); + + expect( + captureAi(client, r'$ai_generation', + properties: {'input': filler(8 * 1024 * 1024 + 64 * 1024)}), + isNull, + ); + expect( + captureAi(client, r'$ai_generation', + properties: {'input': filler(8 * 1024 * 1024)}), + isNotNull, + ); + }); + + test('keeps captureAiMaxQueueSize AI events, apart from maxQueueSize', () { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(maxQueueSize: 1)..captureAiMaxQueueSize = 3, + storage: storage); + + for (var i = 0; i < 5; i++) { + captureAi(client, r'$ai_span', properties: {'i': i}); + } + client.capture('a'); + client.capture('b'); + + expect([ + for (final event in aiQueue(storage)) (event['properties']! as Map)['i'] + ], [ + 2, + 3, + 4 + ]); + expect(queuedEvents(storage), ['b']); + }); + + test('keeps at most 50 MiB of AI events, always the newest', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + + for (var i = 0; i < 7; i++) { + captureAi(client, r'$ai_span', + properties: {'i': i, 'input': filler(8 * 1024 * 1024)}); + } + + expect([ + for (final event in aiQueue(storage)) (event['properties']! as Map)['i'] + ], [ + 1, + 2, + 3, + 4, + 5, + 6 + ]); + expect(storage.aiQueue.bytes, lessThanOrEqualTo(50 * 1024 * 1024)); + }); + + test('flushes once 5 MiB of AI events are queued, below flushAt', () async { + final client = testClient(server); + + captureAi(client, r'$ai_span', properties: {'input': filler(3 << 20)}); + expect(server.aiRequests, isEmpty); + captureAi(client, r'$ai_span', properties: {'input': filler(3 << 20)}); + + await server.waitForAiEvent(r'$ai_span'); + }); + + test('AI events of a previous run are sent by the next client', () async { + final dir = tempDirectory(); + final first = testClient(server, storage: FileStorage(dir.path)); + final uuid = captureAi(first, r'$ai_trace'); + first.close(); + final next = testClient(server, storage: FileStorage(dir.path)); + + await next.flush(); + + expect(server.aiEvents.single['uuid'], uuid); + expect(Directory('${dir.path}/posthog_ai_queue').listSync(), isEmpty); + }); + + test('a 400 drops the AI batch without a retry', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + server.respond = (request) => request.isAi + ? const PostHogResponse(HttpStatus.badRequest) + : const PostHogResponse(HttpStatus.ok); + + captureAi(client, r'$ai_generation'); + client.capture('event'); + await expectLater(client.flush(), throwsA(isA())); + + expect(server.aiRequests, hasLength(1)); + expect(aiQueue(storage), isEmpty); + expect(server.eventNames, ['event'], + reason: 'an AI failure does not hold back the other events'); + }); + + test('a per-event retry keeps only that AI event queued', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + server.respond = (request) => request.isAi + ? PostHogResponse.results( + request, (name) => name == r'$ai_span' ? 'retry' : null) + : const PostHogResponse(HttpStatus.ok); + + captureAi(client, r'$ai_generation'); + captureAi(client, r'$ai_span'); + await client.flush(); + + expect([for (final event in aiQueue(storage)) event['event']], + [r'$ai_span']); + }); + + test( + 'a transient failure keeps the AI batch queued, retried under its own ' + 'request identity', () { + final api = InProcessPostHogApi() + ..respond = (request) => request.isAi + ? const PostHogResponse(HttpStatus.serviceUnavailable) + : const PostHogResponse(HttpStatus.ok); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + + captureAi(client, r'$ai_generation'); + client.flush().catchError((Object _) {}); + async.elapse(const Duration(seconds: 1)); + client.capture('event'); + client.flush().catchError((Object _) {}); + async.elapse(const Duration(seconds: 9)); + + expect(aiQueue(storage), hasLength(1)); + final attempts = [ + for (final request in api.aiRequests) + request.headers['posthog-attempt'], + ]; + expect(attempts, ['1', '2', '3', '4']); + expect( + {for (final r in api.aiRequests) r.headers['posthog-request-id']}, + hasLength(1)); + expect(api.batchRequests.single.headers['posthog-attempt'], '1'); + }); + }); + }); +} diff --git a/posthog_flutter/test/posthog_api_fake.dart b/posthog_flutter/test/posthog_api_fake.dart index 312e0b36..abbee690 100644 --- a/posthog_flutter/test/posthog_api_fake.dart +++ b/posthog_flutter/test/posthog_api_fake.dart @@ -37,6 +37,9 @@ class PostHogRequest { /// Whether this is a capture v1 request for analytics events. bool get isBatch => path == '/i/v1/analytics/events'; + /// Whether this is a capture v1 request for AI events. + bool get isAi => path == '/i/v1/ai/events'; + bool get isFlags => path == '/flags/'; /// The events of a capture request, oldest first. @@ -120,6 +123,21 @@ abstract class PostHogApiFake { if (request.isBatch) request ]; + List get aiRequests => [ + for (final request in requests) + if (request.isAi) request + ]; + + /// Every AI event received, oldest first. + List> get aiEvents => + [for (final request in aiRequests) ...request.events]; + + /// Completes with the first AI event named [name] once it has arrived. + Future> waitForAiEvent(String name) async { + await _waitUntil(() => aiEvents.any((event) => event['event'] == name)); + return aiEvents.firstWhere((event) => event['event'] == name); + } + List get flagsRequests => [ for (final request in requests) if (request.isFlags) request diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 613927b0..d6bd98b7 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -1125,22 +1125,88 @@ void main() { [isFalse, isNull], ); }); + }); - test('captureAi sends nothing and returns null', () async { + group('captureAi', () { + test('sends to the AI endpoint through beforeSend and returns the uuid', + () async { final server = await LocalPostHogServer.start(); - final platform = await setUpPlatform(configFor(server)); + PostHogEvent? seen; + final config = configFor(server) + ..beforeSend = [ + (event) { + seen = event; + event.properties = {...?event.properties, 'from_hook': true}; + return event; + }, + ]; + final platform = await setUpPlatform(config); - String? uuid = 'not called'; - final lines = await _printsOf(() async { - uuid = await platform.captureAi(eventName: r'$ai_generation'); - }); - await platform.capture(eventName: 'after'); - await server.waitForEvent('after'); + final uuid = await platform.captureAi( + eventName: r'$ai_generation', + properties: {r'$ai_model': 'gpt-5'}, + options: {'custom': 'x'}, + ); + await platform.flush(); - expect(uuid, isNull); - expect(lines, [contains('captureAi is not supported')]); + final event = await server.waitForAiEvent(r'$ai_generation'); + expect(uuid, isNotNull); + expect(event['uuid'], uuid); + expect(_propertiesOf(event)[r'$ai_model'], 'gpt-5'); + expect(_propertiesOf(event)['from_hook'], isTrue); + expect(_optionsOf(event)['custom'], 'x'); + expect(seen!.properties, contains(r'$session_id')); expect(server.eventNames, isNot(contains(r'$ai_generation'))); }); + + test('returns null when beforeSend drops the event', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server) + ..beforeSend = [ + (event) => event.event == r'$ai_generation' ? null : event, + ]); + + final uuid = await platform.captureAi(eventName: r'$ai_generation'); + await platform.captureAi(eventName: r'$ai_span'); + + expect(uuid, isNull); + await server.waitForAiEvent(r'$ai_span'); + expect(server.aiEvents, hasLength(1)); + }); + + test('returns null when the event is over captureAiMaxEventBytes', + () async { + final server = await LocalPostHogServer.start(); + final platform = + await setUpPlatform(configFor(server)..captureAiMaxEventBytes = 4096); + + final uuid = await platform.captureAi( + eventName: r'$ai_generation', + properties: {r'$ai_input': 'x' * 5000}, + ); + + expect(uuid, isNull); + }); + + test('returns null while opted out', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + await platform.optOut(); + + expect(await platform.captureAi(eventName: r'$ai_generation'), isNull); + }); + + test('logs a non-AI event name and still sends it', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + final lines = await _printsOf( + () => platform.captureAi(eventName: 'not an AI event')); + + expect(lines, [contains(r'does not start with $ai_')]); + await platform.flush(); + expect(server.aiEvents.single['event'], 'not an AI event'); + }); }); group('Application lifecycle', () { From 280ae5e25d719024f0c932651f4568aa458f5e2c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:14:16 -0400 Subject: [PATCH 19/43] docs: describe desktop captureAi, options and property order in changesets Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-capture-options-ai.md | 8 ++++---- .changeset/v6-desktop-capture-ai.md | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .changeset/v6-desktop-capture-ai.md diff --git a/.changeset/v6-capture-options-ai.md b/.changeset/v6-capture-options-ai.md index b172c360..dc5602c9 100644 --- a/.changeset/v6-capture-options-ai.md +++ b/.changeset/v6-capture-options-ai.md @@ -2,7 +2,7 @@ "posthog_flutter": minor --- -- Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS and Android other keys are sent as-is; on Windows, Linux and web the known keys are sent as their legacy `$` properties and other keys are dropped -- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID, or `null` when the event isn't admitted (dropped by `beforeSend`, oversized on iOS and macOS, SDK disabled or opted out). On Windows, Linux and web `captureAi` is a no-op: it sends nothing and returns `null` -- Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` for the `captureAi` queue and requests on iOS, macOS and Android. Unset, they keep the native SDK defaults. They have no effect on Windows, Linux and web -- On Windows and Linux, a `$process_person_profile` property passed with an event now applies to that event instead of being replaced by the SDK's value +- Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS, Android, Windows and Linux other keys are sent as-is; on web the known keys are sent as their legacy `$` properties and other keys are dropped +- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS, Android, Windows and Linux, returning the event's UUID, or `null` when the event isn't admitted (dropped by `beforeSend`, oversized, SDK disabled or opted out). On web `captureAi` is a no-op: it sends nothing and returns `null` +- Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` for the `captureAi` queue and requests. Unset, they keep the SDK defaults. They have no effect on web +- On Windows and Linux, a `$process_person_profile` property or `process_person_profile` option passed with an event now applies to that event instead of being replaced by the SDK's value diff --git a/.changeset/v6-desktop-capture-ai.md b/.changeset/v6-desktop-capture-ai.md new file mode 100644 index 00000000..61de0af0 --- /dev/null +++ b/.changeset/v6-desktop-capture-ai.md @@ -0,0 +1,8 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** on Windows and Linux, an event's properties are now the ones the SDK sets (device and app context, `$lib`, feature flags, `$session_id`, `$is_identified`, the `$sdk_debug_*` keys), then registered properties, then the event's own, as in `posthog-ios` and `posthog-android` 4.0. A registered or event property with the same name as an SDK property now wins, for that event only. The Dart `beforeSend` callbacks run after that merge, so they see and can change every property and option the SDK adds, and the event is sent as they leave it +- **Breaking:** on Windows and Linux, person processing is sent as the `process_person_profile: false` option only while it is off, instead of a `$process_person_profile` property on every event +- On Windows and Linux, `captureAi` queues AI events on disk apart from other events. The queue keeps up to `captureAiMaxQueueSize` events (100 by default) and 50 MiB, drops a serialized event over `captureAiMaxEventBytes` (8 MiB plus 64 KiB by default, measured before compression) and is sent like other events: same batching, retries and backoff, compressed with `captureAiCompression`, each request bounded by `captureAiTimeout` (60 seconds by default) +- On Windows and Linux, an event's timestamp is taken before the `beforeSend` callbacks run From b7cd99ca779c2cd1006ab9c66aec26c91273c1a1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:15:05 -0400 Subject: [PATCH 20/43] fix(desktop): flush once 5 MiB of events are queued Like posthog-ios and posthog-android, a full capture v1 request's worth of queued bytes (captureV1MaxBatchBytes) now flushes as flushAt does, so a few large events go out before the queue starts dropping them. The file queue keeps each event's serialized size: measured when it is added, and read from the file sizes when a later process loads the queue. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/file_storage.dart | 45 +++++++++++++++++-- .../lib/src/core/posthog_core_stateless.dart | 4 +- .../test/core/file_queue_test.dart | 26 +++++++++++ .../test/core/queue_flush_test.dart | 21 +++++++++ 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/posthog_flutter/lib/src/core/file_storage.dart b/posthog_flutter/lib/src/core/file_storage.dart index 4c3a7402..96175bfc 100644 --- a/posthog_flutter/lib/src/core/file_storage.dart +++ b/posthog_flutter/lib/src/core/file_storage.dart @@ -310,6 +310,10 @@ class FileEventQueue { /// this client. final Map> _unwritten = {}; + /// The serialized size of each queued event, by id, and their total. + final Map _sizes = {}; + int _byteCount = 0; + List get _ids => _loadedIds ??= _load(); String _pathOf(String id) => @@ -319,7 +323,8 @@ class FileEventQueue { try { return [ for (final entity in Directory(_directory).listSync()) - if (entity is File && entity.path.endsWith(_extension)) _idOf(entity), + if (entity is File && entity.path.endsWith(_extension)) + _track(_idOf(entity), _lengthOf(entity)), ]..sort(); } on PathNotFoundException { return []; @@ -332,6 +337,22 @@ class FileEventQueue { } } + static int _lengthOf(File file) { + try { + return file.lengthSync(); + } on FileSystemException { + return 0; + } + } + + String _track(String id, int size) { + _sizes[id] = size; + _byteCount += size; + return id; + } + + void _untrack(String id) => _byteCount -= _sizes.remove(id) ?? 0; + static String _idOf(File file) { final name = file.uri.pathSegments.last; return name.substring(0, name.length - _extension.length); @@ -340,19 +361,31 @@ class FileEventQueue { /// The number of queued events. int get length => _ids.length; + /// The serialized size of the queued events, in bytes. + int get byteCount { + _ids; // Loads the queue, and the sizes, on first use. + return _byteCount; + } + /// Adds [event] at the end of the queue. void add(Map event) { // Loaded before the new file exists, which would otherwise be listed // on top of being added. final ids = _ids; final id = generateUuidV7(); - ids.add(id); + String? encoded; + try { + encoded = jsonEncode(event); + } catch (_) { + // Reported below when it is written. + } + ids.add(_track(id, encoded == null ? 0 : utf8.encode(encoded).length)); if (_directory.isEmpty) { _unwritten[id] = event; return; } try { - _writeAtomically(File(_pathOf(id)), jsonEncode(event)); + _writeAtomically(File(_pathOf(id)), encoded ?? jsonEncode(event)); } catch (e) { _unwritten[id] = event; _logger()?.warn( @@ -372,7 +405,10 @@ class FileEventQueue { final event = _unwritten[id] ?? _read(id, gone); if (event != null) events.add((id: id, event: event)); } - if (gone.isNotEmpty) _ids.removeWhere(gone.contains); + if (gone.isNotEmpty) { + _ids.removeWhere(gone.contains); + gone.forEach(_untrack); + } return events; } @@ -413,6 +449,7 @@ class FileEventQueue { } void _forget(String id) { + _untrack(id); if (_unwritten.remove(id) == null && _directory.isNotEmpty) { _delete(File(_pathOf(id))); } diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 6c847bf9..79546c0f 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -485,7 +485,9 @@ abstract class PostHogCoreStateless { events.emit(type, prepared); - if (queue.length >= _flushAt) { + // A full request's worth of bytes flushes like flushAt does, so large + // events go out before the queue starts dropping them. + if (queue.length >= _flushAt || queue.byteCount >= captureV1MaxBatchBytes) { _flushBackground(); } diff --git a/posthog_flutter/test/core/file_queue_test.dart b/posthog_flutter/test/core/file_queue_test.dart index 4da822af..75dfad4b 100644 --- a/posthog_flutter/test/core/file_queue_test.dart +++ b/posthog_flutter/test/core/file_queue_test.dart @@ -44,6 +44,32 @@ void main() { hasLength(1)); }); + test('counts the serialized bytes of the queued events', () { + int size(Map event) => + utf8.encode(jsonEncode(event)).length; + final first = {'event': 'first', 'padding': 'é' * 10}; + final second = {'event': 'second'}; + final third = {'event': 'third'}; + final storage = FileStorage(dir.path); + final queue = storage.queue + ..add(first) + ..add(second) + ..add(third); + expect(queue.byteCount, size(first) + size(second) + size(third)); + + queue.removeOldest(1); + expect(queue.byteCount, size(second) + size(third)); + + // Read back from the files by the next process. + storage.close(); + final next = FileStorage(dir.path); + addTearDown(next.close); + final restored = next.queue; + expect(restored.byteCount, size(second) + size(third)); + restored.remove([restored.peek(1).single.id]); + expect(restored.byteCount, size(third)); + }); + test('keeps the queue order across a restart', () async { final before = client(); for (var i = 0; i < 50; i++) { diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart index 92f121ad..f5483cc5 100644 --- a/posthog_flutter/test/core/queue_flush_test.dart +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -731,6 +731,27 @@ void main() { }); }); + test('flushes once 5 MiB of serialized events are queued', () { + final api = InProcessPostHogApi(); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + final padding = 'x' * (2 * 1024 * 1024 + 512 * 1024); + + client.capture('event_0', properties: {'padding': padding}); + async.elapse(Duration.zero); + expect(api.batchRequests, isEmpty); + + client.capture('event_1', properties: {'padding': padding}); + async.elapse(Duration.zero); + expect( + [for (final r in api.batchRequests) r.eventNames].expand((n) => n), + ['event_0', 'event_1'], + reason: 'well below flushAt and before the periodic flush'); + expect(getQueue(storage), isEmpty); + }); + }); + test('closes a batch at 5 MiB of serialized events', () { final api = InProcessPostHogApi(); final storage = tempStorage(); From a8cc69e2585e746d7bc0e07099e131d2402a686d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 13:31:14 -0400 Subject: [PATCH 21/43] fix(desktop): feed flags the person properties beforeSend keeps $set/$set_once were stored for flag evaluation while the event was prepared, before the beforeSend callbacks ran, so a redacted value or a dropped event still reached the next /flags request. They are now taken from the event as it is queued. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core.dart | 17 ++++++---- .../test/posthog_flutter_desktop_test.dart | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 8eee615e..853c1566 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -429,8 +429,8 @@ abstract class PostHogCore extends PostHogCoreStateless { /// or opted out. /// /// Like [capture], person properties in [properties] ask for person - /// processing and feed flag evaluation, and the event starts or extends - /// the session. + /// processing, and the event starts or extends the session. They feed + /// flag evaluation only as [capturePrepared] queues the event. PostHogPreparedEvent? prepareEvent( String event, { Map? properties, @@ -440,7 +440,8 @@ abstract class PostHogCore extends PostHogCoreStateless { () => _prepareEvent(event, properties: properties, options: options)); /// Queues an event built by [prepareEvent], possibly changed since. - /// Unlike [capture], it adds nothing to it. + /// Unlike [capture], it adds nothing to it. Its `$set` / `$set_once` + /// feed flag evaluation. /// /// With [ai], the event goes to PostHog's AI endpoint from a queue of its /// own, which drops a serialized event over its size limit. @@ -471,10 +472,6 @@ abstract class PostHogCore extends PostHogCoreStateless { _isNonEmptyMap(userPropertiesSetOnce))) { _enablePersonProcessing(); } - _setPersonPropertiesForFlagsIfNeeded( - _asPropertyMap(userProperties), - _asPropertyMap(userPropertiesSetOnce), - ); final allProperties = _enrichProperties(properties); // Once an event is person-processed, all later ones are too. A value @@ -495,6 +492,12 @@ abstract class PostHogCore extends PostHogCoreStateless { bool ai = false, }) { final (:properties, :options, :timestamp) = prepared; + // Taken from the event as queued, after the beforeSend callbacks, so a + // value they removed or an event they dropped never reaches /flags. + _setPersonPropertiesForFlagsIfNeeded( + _asPropertyMap(properties[r'$set']), + _asPropertyMap(properties[r'$set_once']), + ); final uuid = captureStateless(getDistinctId(), event, properties: properties, options: options, timestamp: timestamp, ai: ai); // PostHog drops an AI event that is not an `$ai_*` event, so it never diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index d6bd98b7..150b40a0 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -545,6 +545,40 @@ void main() { expect(_propertiesOf(event)[r'$set'], {'plan': 'enterprise'}); }); + test('flag evaluation gets the person properties the callbacks keep', + () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..preloadFeatureFlags = false + ..beforeSend = [ + (event) { + if (event.event == 'dropped') return null; + return event + ..userProperties = {'email': 'redacted'} + ..userPropertiesSetOnce = null; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'redacted', + userProperties: {'email': 'user@example.com'}, + userPropertiesSetOnce: {'first_seen': 'secret'}, + ); + await platform.capture( + eventName: 'dropped', + userProperties: {'dropped': 'secret'}, + ); + await platform.reloadFeatureFlags(); + + final personProperties = Map.from( + server.flagsRequests.single.body['person_properties']! as Map, + ); + expect(personProperties, containsPair('email', 'redacted')); + expect(personProperties, isNot(contains('first_seen'))); + expect(personProperties, isNot(contains('dropped'))); + }); + test('a throwing callback drops the event and stops the chain', () async { final server = await LocalPostHogServer.start(); final callOrder = []; From c26030b61d6abbaacbc573db772e0e70484856dc Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 13:32:05 -0400 Subject: [PATCH 22/43] fix(desktop): keep properties beforeSend removes off the queued event The payload builder merged the common properties ($feature/, $active_feature_flags, $recording_status, $lib) under every event again, so a property the callbacks removed came back. Every core path already builds them into the event's properties, so the payload adds nothing. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core_stateless.dart | 9 ++--- .../lib/src/posthog_desktop_client.dart | 3 +- .../test/posthog_flutter_desktop_test.dart | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 5fdcb238..7eb5c086 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -293,12 +293,9 @@ abstract class PostHogCoreStateless { required String event, required Map properties, }) { - // The event's properties win over the SDK's. - var eventProperties = { - ...getCommonEventProperties(), - ...properties, - }; - // Minimized once all SDK properties are in. + // The properties come complete, the SDK's included: nothing is added + // here, so a property the beforeSend callbacks removed stays removed. + var eventProperties = {...properties}; if (event == r'$feature_flag_called' && eventProperties[r'$feature_flag_has_experiment'] == false && isMinimalFlagCalledEventsEnabled()) { diff --git a/posthog_flutter/lib/src/posthog_desktop_client.dart b/posthog_flutter/lib/src/posthog_desktop_client.dart index a29b27f1..299e50b4 100644 --- a/posthog_flutter/lib/src/posthog_desktop_client.dart +++ b/posthog_flutter/lib/src/posthog_desktop_client.dart @@ -33,8 +33,7 @@ class DesktopPostHog extends PostHogCore { }; } - // Session replay is not available on desktop. Common properties are merged - // over the event's own, so an event cannot change it. + // Session replay is not available on desktop. @override Map getCommonEventProperties() => { ...super.getCommonEventProperties(), diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 150b40a0..ff9de27d 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -657,6 +657,40 @@ void main() { expect(event['session_id'], isA()); }); + test('properties the callbacks remove stay removed', () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final flagsLoaded = Completer(); + Map? seen; + final removed = [ + r'$feature/variant-flag', + r'$active_feature_flags', + r'$recording_status', + ]; + final config = configFor(server) + ..onFeatureFlags = () { + if (!flagsLoaded.isCompleted) flagsLoaded.complete(); + } + ..beforeSend = [ + (event) { + seen = {...?event.properties}; + event.properties = {...?event.properties} + ..removeWhere((key, _) => removed.contains(key)); + return event; + }, + ]; + final platform = await setUpPlatform(config); + await flagsLoaded.future; + + await platform.capture(eventName: 'scrubbed'); + + final properties = _propertiesOf(await server.waitForEvent('scrubbed')); + expect(seen!.keys, containsAll(removed)); + for (final key in removed) { + expect(properties, isNot(contains(key))); + } + }); + test('the event time is taken before the callbacks run', () async { final server = await LocalPostHogServer.start(); late DateTime hookStarted; From aba2752daa473061b784ca74539a312691fa7009 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 13:33:29 -0400 Subject: [PATCH 23/43] perf(desktop): size the capture batch before reading queued events The flush peeked up to maxBatchSize events, decoding each one, before the 5 MiB batch cap picked the few it sends. With 8 MiB AI events queued that read far more than one request carries, on the UI isolate. The queue now applies the cap from its tracked per-event sizes before it reads an event, always keeping the first, so only the batch is decoded. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/core/capture_v1.dart | 17 ------- .../lib/src/core/file_storage.dart | 16 ++++++- .../lib/src/core/posthog_core_stateless.dart | 6 ++- .../test/core/capture_v1_test.dart | 35 --------------- .../test/core/file_queue_test.dart | 45 +++++++++++++++++++ 5 files changed, 63 insertions(+), 56 deletions(-) diff --git a/posthog_flutter/lib/src/core/capture_v1.dart b/posthog_flutter/lib/src/core/capture_v1.dart index aaa638ed..491f76f8 100644 --- a/posthog_flutter/lib/src/core/capture_v1.dart +++ b/posthog_flutter/lib/src/core/capture_v1.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'file_storage.dart'; import 'logger.dart'; /// The capture v1 endpoint for analytics events. @@ -27,22 +26,6 @@ const captureAiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024; /// first. const captureAiMaxQueueBytes = 50 * 1024 * 1024; -/// The oldest events of [events] whose serialized size stays within -/// [maxBytes]. Each event is checked before it is added, so one that does -/// not fit starts the next batch, and the first event always goes, alone -/// when it is over the limit. -List takeCaptureV1Batch( - List events, { - int maxBytes = captureV1MaxBatchBytes, -}) { - var bytes = 0; - for (var i = 0; i < events.length; i++) { - bytes += utf8.encode(jsonEncode(events[i].event)).length; - if (i > 0 && bytes > maxBytes) return events.sublist(0, i); - } - return events; -} - /// Capture v1 statuses worth retrying. Everything else is terminal, /// including 3xx; 413 halves the batch instead. bool isRetriableCaptureV1Status(int status) => diff --git a/posthog_flutter/lib/src/core/file_storage.dart b/posthog_flutter/lib/src/core/file_storage.dart index 65e68c41..754d6a7f 100644 --- a/posthog_flutter/lib/src/core/file_storage.dart +++ b/posthog_flutter/lib/src/core/file_storage.dart @@ -410,13 +410,25 @@ class FileEventQueue { /// Returns up to [count] events from the front of the queue, oldest /// first. - List peek(int count) { + /// + /// With [maxBytes], the events also stay within that serialized size. Each + /// event is checked before it is read, so one that does not fit is left + /// for the next peek, and the first event always comes, alone when it is + /// over the limit. + List peek(int count, {int? maxBytes}) { final events = []; final gone = {}; + var bytes = 0; for (final id in _ids) { if (events.length >= count) break; + final size = _sizes[id] ?? 0; + if (maxBytes != null && events.isNotEmpty && bytes + size > maxBytes) { + break; + } final event = _unwritten[id] ?? _read(id, gone); - if (event != null) events.add((id: id, event: event)); + if (event == null) continue; + events.add((id: id, event: event)); + bytes += size; } if (gone.isNotEmpty) { _ids.removeWhere(gone.contains); diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 7eb5c086..d152f63c 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -636,8 +636,10 @@ abstract class PostHogCoreStateless { try { while (!_closed) { // Peeked again after every request, so events queued in the meantime - // go out with the same flush. - final batch = takeCaptureV1Batch(queue.peek(lane.maxBatchSize)); + // go out with the same flush. The queue sizes the batch before + // reading it, so events past the batch are not decoded. + final batch = + queue.peek(lane.maxBatchSize, maxBytes: captureV1MaxBatchBytes); if (batch.isEmpty) { if (queue.length > 0) _scheduleFlush(lane); break; diff --git a/posthog_flutter/test/core/capture_v1_test.dart b/posthog_flutter/test/core/capture_v1_test.dart index 65b35069..b5340483 100644 --- a/posthog_flutter/test/core/capture_v1_test.dart +++ b/posthog_flutter/test/core/capture_v1_test.dart @@ -95,41 +95,6 @@ void main() { }); }); - group('takeCaptureV1Batch', () { - PostHogQueuedEvent queued(String id, String padding) => - (id: id, event: {'p': padding}); - - test('closes the batch before the event that would pass the limit', () { - // Each event serializes to `{"p":""}`, 18 bytes here. - final events = [ - queued('a', 'x' * 10), - queued('b', 'x' * 10), - queued('c', 'x' * 10), - ]; - - final batch = takeCaptureV1Batch(events, maxBytes: 18 * 2); - - expect(batch.map((e) => e.id), ['a', 'b']); - }); - - test('sends an event over the limit alone', () { - final events = [queued('a', 'x' * 100), queued('b', 'x')]; - - final batch = takeCaptureV1Batch(events, maxBytes: 50); - - expect(batch.map((e) => e.id), ['a']); - }); - - test('counts UTF-8 bytes, not characters', () { - // Each event serializes to 18 characters but 28 bytes. - final events = [queued('a', 'é' * 10), queued('b', 'é' * 10)]; - - final batch = takeCaptureV1Batch(events, maxBytes: 18 * 2); - - expect(batch.map((e) => e.id), ['a']); - }); - }); - group('captureV1Event', () { final logger = CoreLogger((_) {}); diff --git a/posthog_flutter/test/core/file_queue_test.dart b/posthog_flutter/test/core/file_queue_test.dart index 75dfad4b..61ddcb43 100644 --- a/posthog_flutter/test/core/file_queue_test.dart +++ b/posthog_flutter/test/core/file_queue_test.dart @@ -70,6 +70,51 @@ void main() { expect(restored.byteCount, size(third)); }); + group('peek with maxBytes', () { + // Each event serializes to `{"p":""}`, 18 bytes here. + FileEventQueue queueOf(List paddings) { + final storage = FileStorage(dir.path); + addTearDown(storage.close); + final queue = storage.queue; + for (final padding in paddings) { + queue.add({'p': padding}); + } + return queue; + } + + List paddingsOf(List events) => + [for (final queued in events) queued.event['p']]; + + test('stops before the event that would pass the limit', () { + final queue = queueOf(['a' * 10, 'b' * 10, 'c' * 10]); + + expect( + paddingsOf(queue.peek(10, maxBytes: 18 * 2)), ['a' * 10, 'b' * 10]); + }); + + test('returns an event over the limit alone', () { + final queue = queueOf(['a' * 100, 'b']); + + expect(paddingsOf(queue.peek(10, maxBytes: 50)), ['a' * 100]); + }); + + test('counts UTF-8 bytes, not characters', () { + // 18 characters but 28 bytes each. + final queue = queueOf(['é' * 10, 'è' * 10]); + + expect(paddingsOf(queue.peek(10, maxBytes: 18 * 2)), ['é' * 10]); + }); + + test('does not read the events past the limit', () { + final queue = queueOf(['a' * 10, 'b' * 10]); + // Unreadable: a peek that read it would delete it. + final second = _queueFiles(dir).last..writeAsStringSync('not json'); + + expect(paddingsOf(queue.peek(10, maxBytes: 18)), ['a' * 10]); + expect(second.existsSync(), isTrue); + }); + }); + test('keeps the queue order across a restart', () async { final before = client(); for (var i = 0; i < 50; i++) { From c3a3c0457cb915220a2c404599ef3058e92ed392 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 13:36:25 -0400 Subject: [PATCH 24/43] fix(desktop): release delivered payloads after each capture v1 batch A flush that kept finding queued events held every delivered payload until it ended. Emit flush per batch so multi-megabyte AI events are released as soon as they are sent. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core_stateless.dart | 37 ++++++++----------- .../test/core/queue_flush_test.dart | 28 ++++++++++++++ 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index d152f63c..0295c514 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -631,31 +631,25 @@ abstract class PostHogCoreStateless { lane.clearFlushTimer(); final queue = lane.queue; - final sentMessages = >[]; - try { - while (!_closed) { - // Peeked again after every request, so events queued in the meantime - // go out with the same flush. The queue sizes the batch before - // reading it, so events past the batch are not decoded. - final batch = - queue.peek(lane.maxBatchSize, maxBytes: captureV1MaxBatchBytes); - if (batch.isEmpty) { - if (queue.length > 0) _scheduleFlush(lane); - break; - } - if (!await _sendBatch(lane, batch, sentMessages)) break; - } - } finally { - if (sentMessages.isNotEmpty) { - events.emit('flush', sentMessages); + while (!_closed) { + // Peeked again after every request, so events queued in the meantime + // go out with the same flush. The queue sizes the batch before + // reading it, so events past the batch are not decoded. + final batch = + queue.peek(lane.maxBatchSize, maxBytes: captureV1MaxBatchBytes); + if (batch.isEmpty) { + if (queue.length > 0) _scheduleFlush(lane); + break; } + if (!await _sendBatch(lane, batch)) break; } } /// Sends [batch] to capture v1 and removes from the queue the events that - /// are done: delivered, or dropped for good. Adds the delivered ones to - /// [sent]. + /// are done: delivered, or dropped for good. Emits `flush` with the + /// delivered ones right away, so a long flush doesn't hold on to the + /// payloads of every batch it sent. /// /// Returns whether the flush goes on with the rest of the queue. It stops /// while events of [batch] wait for a later retry: the queue backs off, @@ -664,7 +658,6 @@ abstract class PostHogCoreStateless { Future _sendBatch( _CaptureLane lane, List batch, - List> sent, ) async { final queue = lane.queue; final Set retryUuids; @@ -718,7 +711,9 @@ abstract class PostHogCoreStateless { // Removed by id: the queue may have changed while the batch was in // flight, e.g. its oldest events dropped to make room. queue.remove([for (final queued in delivered) queued.id]); - sent.addAll([for (final queued in delivered) queued.event]); + if (delivered.isNotEmpty) { + events.emit('flush', [for (final queued in delivered) queued.event]); + } if (delivered.length == batch.length) { lane.failedFlushes = 0; return true; diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart index f5483cc5..d839fa77 100644 --- a/posthog_flutter/test/core/queue_flush_test.dart +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -31,6 +31,34 @@ void main() { expect(getQueue(storage), isEmpty); }); + test('emits each delivered batch as soon as it is sent', () async { + final client = testClient(server, + config: testConfig(maxBatchSize: 2), storage: tempStorage()); + final emitted = >[]; + final requestsAtEmit = []; + // ignore: invalid_use_of_protected_member + client.events.on('flush', (payload) { + emitted.add([ + for (final event in payload! as List>) + event['event'], + ]); + requestsAtEmit.add(server.batchRequests.length); + }); + + client.capture('a'); + client.capture('b'); + client.capture('c'); + await client.flush(); + + expect(emitted, [ + ['a', 'b'], + ['c'], + ]); + // Emitted before the next request, so a long flush doesn't keep the + // payloads of every batch it sent. + expect(requestsAtEmit, [1, 2]); + }); + test('removes sent events by identity when the queue head shifts', () async { final storage = tempStorage(); From 83ff3bbbb6ca2b6e452ef3fbeb225abc15ac38b2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:07:42 -0400 Subject: [PATCH 25/43] fix(desktop): turn on person processing only for events beforeSend keeps Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core.dart | 37 +++++++++++-------- .../test/posthog_flutter_desktop_test.dart | 19 ++++++++++ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 853c1566..d3253301 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -182,14 +182,16 @@ abstract class PostHogCore extends PostHogCoreStateless { } /// The [options] of an event with [properties], with - /// `process_person_profile: false` added when person processing is off - /// and neither an option nor a `$process_person_profile` property says - /// otherwise. + /// `process_person_profile: false` added when person processing is off, + /// the event is not [requested] to update the person, and neither an + /// option nor a `$process_person_profile` property says otherwise. Map? _personProcessingOptions( Map? options, - Map properties, - ) { - if (_hasPersonProcessing() || + Map properties, { + required bool requested, + }) { + if (requested || + _hasPersonProcessing() || properties[r'$process_person_profile'] != null || options?['process_person_profile'] != null) { return options; @@ -465,14 +467,6 @@ abstract class PostHogCore extends PostHogCoreStateless { }) { if (_ignoredWhileOptedOut('posthog.capture')) return null; - final userProperties = properties?[r'$set']; - final userPropertiesSetOnce = properties?[r'$set_once']; - if (_personProfiles != PostHogPersonProfiles.never && - (_isNonEmptyMap(userProperties) || - _isNonEmptyMap(userPropertiesSetOnce))) { - _enablePersonProcessing(); - } - final allProperties = _enrichProperties(properties); // Once an event is person-processed, all later ones are too. A value // passed for this event alone doesn't count. @@ -481,7 +475,11 @@ abstract class PostHogCore extends PostHogCoreStateless { } return ( properties: allProperties, - options: _personProcessingOptions(options, allProperties), + options: _personProcessingOptions( + options, + allProperties, + requested: _requestsPersonProcessing(properties), + ), timestamp: currentISOTime(), ); } @@ -493,7 +491,9 @@ abstract class PostHogCore extends PostHogCoreStateless { }) { final (:properties, :options, :timestamp) = prepared; // Taken from the event as queued, after the beforeSend callbacks, so a - // value they removed or an event they dropped never reaches /flags. + // value they removed or an event they dropped neither reaches /flags + // nor turns on person processing for later events. + if (_requestsPersonProcessing(properties)) _enablePersonProcessing(); _setPersonPropertiesForFlagsIfNeeded( _asPropertyMap(properties[r'$set']), _asPropertyMap(properties[r'$set_once']), @@ -1060,6 +1060,11 @@ abstract class PostHogCore extends PostHogCoreStateless { }; } + bool _requestsPersonProcessing(Map? properties) => + _personProfiles != PostHogPersonProfiles.never && + (_isNonEmptyMap(properties?[r'$set']) || + _isNonEmptyMap(properties?[r'$set_once'])); + bool _hasPersonProcessing() { if (_personProfiles == PostHogPersonProfiles.always) return true; if (_personProfiles == PostHogPersonProfiles.never) return false; diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index ff9de27d..a3d166d0 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -579,6 +579,25 @@ void main() { expect(personProperties, isNot(contains('dropped'))); }); + test('a dropped person update does not turn on person processing', + () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) => event.event == 'dropped' ? null : event, + ]; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'dropped', + userProperties: {'email': 'user@example.com'}, + ); + await platform.capture(eventName: 'after drop'); + + final event = await server.waitForEvent('after drop'); + expect(_optionsOf(event)['process_person_profile'], isFalse); + }); + test('a throwing callback drops the event and stops the chain', () async { final server = await LocalPostHogServer.start(); final callOrder = []; From f1ed59f385c440f12b53836dfe531d543b8de1dc Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:13:07 -0400 Subject: [PATCH 26/43] fix(desktop): give beforeSend a deep copy of the event properties Co-Authored-By: Claude Opus 5.5 --- .../lib/src/posthog_flutter_desktop.dart | 11 ++++---- .../test/posthog_flutter_desktop_test.dart | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 2a1ff5b5..6cb20a76 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -758,12 +758,13 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ); } - /// A copy of [map] without its null values, or null when it is not a map. + /// A deep copy of [map] without its null values, or null when it is not a + /// map. Registered properties share their nested maps and lists with + /// storage, so a callback editing them in place would change later events. static Map? _nonNull(Object? map) => map is Map - ? { - for (final MapEntry(:key, :value) in map.entries) - if (value != null) '$key': value, - } + ? Map.from(PropertyNormalizer.normalize({ + for (final MapEntry(:key, :value) in map.entries) '$key': value, + })) : null; /// Applies the beforeSend callbacks to an event in order. Running them here diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index a3d166d0..1ef02cd5 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -528,6 +528,31 @@ void main() { expect(server.eventNames, isNot(contains('original event'))); }); + test('a callback editing a registered nested map leaves later events alone', + () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) { + if (event.event == 'mutate') { + final metadata = event.properties?['metadata']; + if (metadata is Map) metadata['plan'] = 'pro'; + } + return event; + }, + ]; + final platform = await setUpPlatform(config); + await platform.register('metadata', {'plan': 'free'}); + + await platform.capture(eventName: 'mutate'); + await platform.capture(eventName: 'after'); + + final mutated = _propertiesOf(await server.waitForEvent('mutate')); + final after = _propertiesOf(await server.waitForEvent('after')); + expect(mutated['metadata'], {'plan': 'pro'}); + expect(after['metadata'], {'plan': 'free'}); + }); + test('a callback can rewrite the person properties', () async { final server = await LocalPostHogServer.start(); final config = configFor(server) From 68c8d9f5cdfc2361fca25ee5c04a7e84c410287d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:13:12 -0400 Subject: [PATCH 27/43] docs: limit the capture v1 migration note to native and desktop platforms Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-desktop-capture-v1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/v6-desktop-capture-v1.md b/.changeset/v6-desktop-capture-v1.md index 644d4aa4..00cf869e 100644 --- a/.changeset/v6-desktop-capture-v1.md +++ b/.changeset/v6-desktop-capture-v1.md @@ -2,7 +2,7 @@ "posthog_flutter": major --- -- **Breaking:** on every platform, events now go to capture v1 (`/i/v1/analytics/events`) instead of `/batch/`: iOS and macOS through `posthog-ios` 4.x, Android through `posthog-android` 4.x, and Windows and Linux in the Dart client. If you send events through your own reverse proxy, update it before you upgrade: forward `/i/v1/analytics/events` and `/i/v1/ai/events`, pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged, and accept request bodies up to 20 MB. PostHog's managed reverse proxy already does this. See the [iOS](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md#hosts-without-capture-v1) and [Android](https://github.com/PostHog/posthog-android/blob/main/docs/migration-3.x-to-4.0.md#hosts-without-capture-v1) migration guides +- **Breaking:** on iOS, macOS, Android, Windows and Linux, events now go to capture v1 (`/i/v1/analytics/events`) instead of `/batch/`: iOS and macOS through `posthog-ios` 4.x, Android through `posthog-android` 4.x, and Windows and Linux in the Dart client. If you send events through your own reverse proxy, update it before you upgrade: forward `/i/v1/analytics/events` and `/i/v1/ai/events`, pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged, and accept request bodies up to 20 MB. PostHog's managed reverse proxy already does this. See the [iOS](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md#hosts-without-capture-v1) and [Android](https://github.com/PostHog/posthog-android/blob/main/docs/migration-3.x-to-4.0.md#hosts-without-capture-v1) migration guides - **Breaking:** on Windows and Linux, a reverse proxy or self-hosted instance must serve the new path: a 404 is retried, the events stay queued, and the SDK prints a warning once, also without `debug`. Events queued before the upgrade are sent through the new endpoint - **Breaking:** on Windows and Linux, a redirect drops the batch instead of retrying it (only same-origin 307/308 redirects are followed, while a 404 or 429 is retried), and a `Retry-After` wait is capped at 30 seconds - On Windows and Linux, a failed batch, or one whose events the server asks to retry, is no longer resent within the same flush: the queue backs off like the iOS and Android SDKs, waiting 1 second and doubling up to 30 seconds with ±25% jitter, until a batch is delivered. A `Retry-After` is a floor under that wait rather than a replacement From fea4954038a4321f645864ae05009cebbfef55c3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:13:19 -0400 Subject: [PATCH 28/43] docs(desktop): note beforeSend cannot change $lib or $lib_version Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-desktop-capture-ai.md | 2 +- posthog_flutter/lib/src/posthog_config.dart | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/v6-desktop-capture-ai.md b/.changeset/v6-desktop-capture-ai.md index 427be330..3795283f 100644 --- a/.changeset/v6-desktop-capture-ai.md +++ b/.changeset/v6-desktop-capture-ai.md @@ -2,7 +2,7 @@ "posthog_flutter": major --- -- **Breaking:** on Windows and Linux, an event's properties are now the ones the SDK sets (device and app context, `$lib`, feature flags, `$session_id`, `$is_identified`, the `$sdk_debug_*` keys), then registered properties, then the event's own, as in `posthog-ios` and `posthog-android` 4.0. A registered or event property with the same name as an SDK property now wins, for that event only. The Dart `beforeSend` callbacks run after that merge, so they see and can change every property and option the SDK adds, and the event is sent as they leave it +- **Breaking:** on Windows and Linux, an event's properties are now the ones the SDK sets (device and app context, `$lib`, feature flags, `$session_id`, `$is_identified`, the `$sdk_debug_*` keys), then registered properties, then the event's own, as in `posthog-ios` and `posthog-android` 4.0. A registered or event property with the same name as an SDK property now wins, for that event only. The Dart `beforeSend` callbacks run after that merge, so they see and can change every property and option the SDK adds, and the event is sent as they leave it, except `$lib` and `$lib_version`: PostHog always sets them from the SDK's `PostHog-Sdk-Info` header - **Breaking:** on Windows and Linux, person processing is sent as the `process_person_profile: false` option only while it is off, instead of a `$process_person_profile` property on every event - On Windows and Linux, `captureAi` queues AI events on disk apart from other events. The queue keeps up to `captureAiMaxQueueSize` events (100 by default) and 50 MiB, drops a serialized event over `captureAiMaxEventBytes` (8 MiB plus 64 KiB by default, measured before compression) and is sent like other events: same batching, retries and backoff, compressed with `captureAiCompression`, a request failing after `captureAiTimeout` (60 seconds by default) without progress - On Windows and Linux, an event's timestamp is taken before the `beforeSend` callbacks run diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 36897b6f..c3cf63d6 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -440,7 +440,8 @@ class PostHogConfig { /// - Only user-provided properties are available; system properties (like /// `$device_type`, `$session_id`) are added by the native SDK at a later /// stage. On Windows and Linux, the callbacks see the system properties - /// and the options the SDK adds, and can change them for that event. + /// and the options the SDK adds, and can change them for that event, + /// except `$lib` and `$lib_version`, which PostHog sets from the SDK. /// /// **Note:** /// - Callbacks can be synchronous or asynchronous (via From 0421258a0cceecb775c35018d6b8f44cefec5308 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:15:05 -0400 Subject: [PATCH 29/43] fix(desktop): cap the response body read at 30 seconds A body that trickles in more often than the 10 s idle timeout reset it forever, holding the flush. The body now also fails after 30 s in total. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core_stateless.dart | 20 +++++++++--- posthog_flutter/test/core/requests_test.dart | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 79546c0f..ee30b2df 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -100,6 +100,10 @@ abstract class PostHogCoreStateless { /// response body. A slow but moving upload never times out. static const _requestTimeout = Duration(seconds: 10); + /// How long the response body may take in total, so a body that trickles + /// in without end can't hold the flush. + static const _responseBodyTimeout = Duration(seconds: 30); + /// The size of the body chunks handed to the socket, each one progress. static const _bodyChunkSize = 64 * 1024; @@ -812,7 +816,7 @@ abstract class PostHogCoreStateless { Map headers = const {}, bool deliveredOnBodyFailure = false, }) async { - final attempt = _FetchAttempt(_requestTimeout); + final attempt = _FetchAttempt(_requestTimeout, _responseBodyTimeout); try { return await attempt.guard(_fetch(url, body, attempt, headers)); } catch (e) { @@ -923,11 +927,13 @@ abstract class PostHogCoreStateless { } class _FetchAttempt { - _FetchAttempt(this._idleTimeout); + _FetchAttempt(this._idleTimeout, this._bodyTimeout); final Duration _idleTimeout; + final Duration _bodyTimeout; final timeout = TimeoutException('PostHog request timed out'); Timer? _idleTimer; + Timer? _bodyTimer; void Function()? _onIdle; HttpClientRequest? _request; @@ -940,7 +946,8 @@ class _FetchAttempt { bool _cancelled = false; /// Completes with [work], or fails with [timeout] once the request has - /// made no [progress] for the idle timeout. The request is cancelled + /// made no [progress] for the idle timeout or its response body has taken + /// longer than the body timeout. The request is cancelled /// before that failure is reported, so a retry never overlaps it. Future guard(Future work) { final result = Completer(); @@ -958,7 +965,10 @@ class _FetchAttempt { result.completeError(error, stackTrace); } }); - return result.future.whenComplete(() => _idleTimer?.cancel()); + return result.future.whenComplete(() { + _idleTimer?.cancel(); + _bodyTimer?.cancel(); + }); } /// Restarts the idle timeout. @@ -979,6 +989,8 @@ class _FetchAttempt { } Future readBody(HttpClientResponse response) { + final onIdle = _onIdle; + if (onIdle != null) _bodyTimer = Timer(_bodyTimeout, onIdle); final contents = StringBuffer(); final body = Completer(); late final StreamSubscription subscription; diff --git a/posthog_flutter/test/core/requests_test.dart b/posthog_flutter/test/core/requests_test.dart index 6bca795f..004768ce 100644 --- a/posthog_flutter/test/core/requests_test.dart +++ b/posthog_flutter/test/core/requests_test.dart @@ -251,6 +251,37 @@ void main() { }); }); + test('times out a response that trickles in for 30 seconds', () { + fakeAsync((async) { + final body = + StreamController>(onCancel: () => Future.value()); + final http = _ControlledHttpClient((_) async => _ControlledRequest( + response: _ControlledResponse(body.stream, + statusCode: HttpStatus.serviceUnavailable))); + final storage = tempStorage(); + late final client = HttpOverrides.runZoned( + () => testClient(server, storage: storage), + createHttpClient: (_) => http, + ); + client.capture('evt'); + Object? error; + client.flush().catchError((Object e) { + error = e; + }); + for (var i = 0; i < 5; i++) { + async.elapse(const Duration(seconds: 6) - _ms); + body.add(utf8.encode(' ')); + } + async.flushMicrotasks(); + expect(error, isNull); + async.elapse(const Duration(seconds: 1)); + + expect(error, isA()); + expect(getQueue(storage), hasLength(1)); + client.close(); + }); + }); + test('counts a 2xx whose body times out as delivered', () { fakeAsync((async) { // Cancelled in this zone: fake_async does not run the root zone's From 612aa8ed45115f7d5c4bba63f0da30b85d33afff Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 18:32:25 -0400 Subject: [PATCH 30/43] fix(desktop): warn when captureAiMaxEventBytes is over the ceiling Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core_stateless.dart | 4 ++++ posthog_flutter/test/core/capture_ai_test.dart | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 0295c514..26f43ea4 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -175,6 +175,10 @@ abstract class PostHogCoreStateless { final aiMaxQueueSize = config.captureAiMaxQueueSize ?? 0; final aiTimeout = config.captureAiTimeout ?? Duration.zero; final aiMaxEventBytes = config.captureAiMaxEventBytes ?? 0; + if (aiMaxEventBytes > captureAiMaxEventBytes) { + logger.warn('captureAiMaxEventBytes $aiMaxEventBytes is over the ' + '$captureAiMaxEventBytes byte ceiling, using the ceiling.'); + } _ai = _CaptureLane( path: captureV1AiPath, queue: storage.aiQueue, diff --git a/posthog_flutter/test/core/capture_ai_test.dart b/posthog_flutter/test/core/capture_ai_test.dart index 3763b347..d63c33fa 100644 --- a/posthog_flutter/test/core/capture_ai_test.dart +++ b/posthog_flutter/test/core/capture_ai_test.dart @@ -120,11 +120,19 @@ void main() { expect(dropLines.join(), isNot(contains('secret'))); }); - test('caps captureAiMaxEventBytes at 8 MiB plus 64 KiB', () { + test('caps captureAiMaxEventBytes at 8 MiB plus 64 KiB, with a warning', + () { final storage = tempStorage(); - final client = testClient(server, - config: testConfig()..captureAiMaxEventBytes = 100 * 1024 * 1024, - storage: storage); + late DesktopPostHog client; + final lines = printedLines(() { + client = testClient(server, + config: testConfig(debug: true) + ..captureAiMaxEventBytes = 100 * 1024 * 1024, + storage: storage); + }); + + expect( + lines.where((line) => line.contains('byte ceiling')), hasLength(1)); expect( captureAi(client, r'$ai_generation', From 462f1eee27625c02c0ef353aed9dc7f1bfba6cb6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 18:40:29 -0400 Subject: [PATCH 31/43] style: format the merged fetch attempt Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/core/posthog_core_stateless.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 9ca47ce9..2cd81cb7 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -886,8 +886,7 @@ abstract class PostHogCoreStateless { Duration timeout = _requestTimeout, PostHogCompression? compression, }) async { - final attempt = _FetchAttempt( - timeout, + final attempt = _FetchAttempt(timeout, timeout > _responseBodyTimeout ? timeout : _responseBodyTimeout); try { return await attempt.guard( From 893daf842ea4e98115686ded2595e295404c998a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 20:00:02 -0400 Subject: [PATCH 32/43] feat!: run beforeSend on the native-built event on iOS, macOS and Android Dart beforeSend callbacks now get the event the native SDK built, with its system and registered properties, uuid, distinct id and timestamp, and the native SDK sends what they return without adding properties. Without callbacks, events still take one native call. Also adds PosthogFlutterPlugin.addBeforeSend in Swift and Kotlin for events the native SDK captures itself. --- .changeset/v6-before-send-native-event.md | 7 + api/posthog_flutter.api.json | 60 ++++ .../posthog/flutter/PosthogFlutterPlugin.kt | 151 +++++++++ .../flutter/PosthogFlutterPluginTest.kt | 26 ++ .../PosthogFlutterPlugin.swift | 135 +++++++- posthog_flutter/lib/src/posthog_config.dart | 19 +- posthog_flutter/lib/src/posthog_event.dart | 56 +++- .../lib/src/posthog_flutter_io.dart | 314 +++++++++++------- .../test/event_shape_snapshot_test.dart | 36 +- .../test/posthog_flutter_io_test.dart | 263 ++++++++++++--- .../test/snapshots/event_channel_shapes.json | 67 +++- .../special_event_channel_shapes.json | 117 ++++++- 12 files changed, 1038 insertions(+), 213 deletions(-) create mode 100644 .changeset/v6-before-send-native-event.md diff --git a/.changeset/v6-before-send-native-event.md b/.changeset/v6-before-send-native-event.md new file mode 100644 index 00000000..4fbbe518 --- /dev/null +++ b/.changeset/v6-before-send-native-event.md @@ -0,0 +1,7 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** On iOS, macOS and Android, `beforeSend` callbacks now get the event the native SDK built, with its system and registered properties, and their changes are final, matching Windows and Linux +- Add `PostHogEvent.uuid`, `distinctId` and `timestamp`, set on iOS, macOS and Android +- Add `PosthogFlutterPlugin.addBeforeSend` in Swift and Kotlin to filter or edit events the native SDK captures itself, such as crashes, app lifecycle and `$feature_flag_called` events diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 1e85210c..ad177b2b 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -5642,6 +5642,33 @@ "name": "options", "relativePath": "lib/src/posthog_event.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "uuid", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "String?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "distinctId", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "String?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "timestamp", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "DateTime?" } ], "relativePath": "lib/src/posthog_event.dart", @@ -5705,6 +5732,39 @@ "name": "options", "relativePath": "lib/src/posthog_event.dart", "typeName": "Map?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "uuid", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "String?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "distinctId", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "String?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "timestamp", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "DateTime?" } ], "isDeprecated": false, diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index de8bbeaa..4f982c32 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -27,6 +27,7 @@ import com.posthog.PostHogBeforeSend import com.posthog.PostHogBootstrapConfig import com.posthog.PostHogCompression import com.posthog.PostHogConfig +import com.posthog.PostHogEvent import com.posthog.PostHogOnFeatureFlags import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig @@ -79,6 +80,10 @@ class PosthogFlutterPlugin : private var postHogConfig: PostHogAndroidConfig? = null + // Events built for the Dart beforeSend callbacks, keyed by uuid, until Dart + // sends or drops them. Main-thread confined, like the channel callbacks. + private val builtEvents = mutableMapOf() + // Test seam: captures the built config even if setup() throws (mock Context in // unit tests). postHogConfig is only published on success, so production readers // never see an uninstalled config. @@ -263,6 +268,18 @@ class PosthogFlutterPlugin : screen(call, result) } + "buildEvent" -> { + buildEvent(call, result) + } + + "sendBuiltEvent" -> { + sendBuiltEvent(call, result) + } + + "discardBuiltEvent" -> { + discardBuiltEvent(call, result) + } + "captureLog" -> { captureLog(call, result) } @@ -831,6 +848,7 @@ class PosthogFlutterPlugin : ) } } + nativeBeforeSendSnapshot().forEach { this.addBeforeSend(it) } onFeatureFlags = PostHogOnFeatureFlags { @@ -1834,6 +1852,89 @@ class PosthogFlutterPlugin : } } + /** + * Builds the event a capture, screen or captureException call would send, for the + * Dart beforeSend callbacks, and returns it to Dart. Returns null when the SDK skips it. + */ + private fun buildEvent( + call: MethodCall, + result: Result, + ) { + try { + val timestampMs: Long? = call.argument("timestamp")?.toLong() + val built = + PostHog.buildEventForWrapper( + call.argument("eventName")!!, + properties = call.argument("properties"), + userProperties = call.argument("userProperties"), + userPropertiesSetOnce = call.argument("userPropertiesSetOnce"), + options = call.argument("options"), + ai = call.argument("ai") ?: false, + timestamp = timestampMs?.let { Date(it) }, + ) + val uuid = built?.uuid?.toString() + if (built == null || uuid == null) { + result.success(null) + return + } + builtEvents[uuid] = built + result.success( + mapOf( + "event" to built.event, + "uuid" to uuid, + "distinctId" to built.distinctId, + "timestamp" to built.timestamp.time, + "properties" to built.properties?.let(::toChannelValue), + "options" to built.options?.let(::toChannelValue), + ), + ) + } catch (e: Throwable) { + result.error("PosthogFlutterException", e.localizedMessage, null) + } + } + + /** + * Sends an event from [buildEvent] as the Dart beforeSend callbacks left it. + * Returns its uuid once queued, or null. + */ + private fun sendBuiltEvent( + call: MethodCall, + result: Result, + ) { + try { + val uuid: String = call.argument("uuid")!! + val built = builtEvents.remove(uuid) + if (built == null) { + result.success(null) + return + } + val properties: Map? = call.argument("properties") + val options: Map? = call.argument("options") + val timestampMs: Long? = call.argument("timestamp")?.toLong() + val event = + built.copy( + event = call.argument("event")!!, + distinctId = call.argument("distinctId")?.takeIf { it.isNotBlank() } ?: built.distinctId, + properties = properties?.toMutableMap() ?: mutableMapOf(), + timestamp = timestampMs?.let { Date(it) } ?: built.timestamp, + options = options?.toMutableMap() ?: mutableMapOf(), + ) + val sent = PostHog.sendBuiltEventForWrapper(event, ai = call.argument("ai") ?: false) + result.success(sent?.toString()) + } catch (e: Throwable) { + result.error("PosthogFlutterException", e.localizedMessage, null) + } + } + + /** Drops an event from [buildEvent] that a Dart beforeSend callback dropped. */ + private fun discardBuiltEvent( + call: MethodCall, + result: Result, + ) { + call.argument("uuid")?.let { builtEvents.remove(it) } + result.success(null) + } + private fun captureLog( call: MethodCall, result: Result, @@ -2317,6 +2418,42 @@ class PosthogFlutterPlugin : } companion object { + private val nativeBeforeSend = mutableListOf() + + /** + * Adds a native `beforeSend` hook that runs on every event the PostHog Android + * SDK sends, including the ones Dart never sees. + * + * Dart `PostHogConfig.beforeSend` callbacks run only on events captured from + * Dart. Events the native SDK captures itself, such as crashes, app lifecycle + * events, `$feature_flag_called`, `$identify` and session replay, reach only + * native hooks. Hooks run in the order they're added, after the Dart callbacks. + * Return the event, changed or not, to send it, or `null` to drop it. No hooks + * are added by default. + * + * Add hooks before the SDK is set up: before `Posthog().setup()` runs in Dart, + * or in `Application.onCreate` when the SDK is set up from the manifest + * (`com.posthog.posthog.AUTO_INIT`). Hooks added later apply from the next setup. + * + * ```kotlin + * // MainApplication.onCreate + * PosthogFlutterPlugin.addBeforeSend { event -> + * if (event.event == "Application Backgrounded") null else event + * } + * ``` + */ + @JvmStatic + fun addBeforeSend(beforeSend: PostHogBeforeSend) { + synchronized(nativeBeforeSend) { nativeBeforeSend.add(beforeSend) } + } + + private fun nativeBeforeSendSnapshot(): List = synchronized(nativeBeforeSend) { nativeBeforeSend.toList() } + + @VisibleForTesting + internal fun resetNativeBeforeSendForTesting() { + synchronized(nativeBeforeSend) { nativeBeforeSend.clear() } + } + // Mint route for pushIdentityProvider, anchored to the engine whose setup() // installed the provider. A live owner is never displaced; provider-enabled // engines stay candidates so detach promotes the most recent survivor instead @@ -2360,6 +2497,20 @@ class PosthogFlutterPlugin : } } +/** + * Copies [value] into the types the Flutter channel codec can send. Values the + * codec can't send, such as a `Date` registered natively, are sent as strings. + */ +internal fun toChannelValue(value: Any?): Any? = + when (value) { + null, is String, is Boolean, is Int, is Long, is Double, is ByteArray -> value + is Number -> value.toDouble() + is Map<*, *> -> value.entries.associate { (k, v) -> k.toString() to toChannelValue(v) } + is Iterable<*> -> value.map(::toChannelValue) + is Array<*> -> value.map(::toChannelValue) + else -> value.toString() + } + @Suppress("UNCHECKED_CAST") internal fun bootstrapConfigFromMap(bootstrap: Map): PostHogBootstrapConfig = PostHogBootstrapConfig( diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index a82b7734..d2b4d962 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.BadParcelableException import com.google.firebase.FirebaseApp +import com.posthog.PostHogBeforeSend import com.posthog.PostHogCompression import com.posthog.PostHogConfig import com.posthog.PostHogEvent @@ -40,6 +41,7 @@ internal class PosthogFlutterPluginTest { @BeforeTest fun resetSharedRoute() { PosthogFlutterPlugin.resetPushIdentityRouteForTesting() + PosthogFlutterPlugin.resetNativeBeforeSendForTesting() } @Test @@ -290,6 +292,30 @@ internal class PosthogFlutterPluginTest { assertNull(propertiesOf("custom")?.get("\$flutter_version")) } + @Test + fun setup_addBeforeSend_runsAfterTheFlutterVersionHook() { + val seen = mutableListOf() + PosthogFlutterPlugin.addBeforeSend { event -> + seen.add(event.properties?.get("\$flutter_version")) + null + } + val plugin = PosthogFlutterPlugin() + attach(plugin, Mockito.mock(BinaryMessenger::class.java)) + plugin.onMethodCall( + MethodCall("setup", mapOf("projectToken" to "test-token", "flutterVersion" to "3.35.0")), + Mockito.mock(MethodChannel.Result::class.java), + ) + val beforeSend = assertNotNull(plugin.lastBuiltConfig).beforeSendList + + val result = + beforeSend.fold( + PostHogEvent("Application Installed", "id", properties = mutableMapOf()), + ) { event, hook -> event?.let { hook.run(it) } } + + assertNull(result) + assertEquals(listOf("3.35.0"), seen) + } + @Test fun setup_screenshotControls_preserveDefaults() { val plugin = PosthogFlutterPlugin() diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 50e667ae..0877d9b4 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -1,4 +1,4 @@ -@_spi(PostHogInternal) import PostHog +@_spi(PostHogInternal) @_spi(PostHogWrapper) import PostHog #if os(iOS) import Flutter import UIKit @@ -43,6 +43,13 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { private static var instance: PosthogFlutterPlugin? private var channel: FlutterMethodChannel? + + // Events built for the Dart beforeSend callbacks, keyed by uuid, until Dart + // sends or drops them. Main-thread confined, like the channel callbacks. + private var builtEvents: [String: PostHogEvent] = [:] + + private static let nativeBeforeSendLock = NSLock() + private static var nativeBeforeSend: [BeforeSendBlock] = [] private var featureFlagsSubscription: PostHogFeatureFlagsSubscription? // Mint route for pushIdentityProvider, anchored to the engine whose setup() @@ -56,6 +63,32 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { private static var pushChannelCandidates: [FlutterMethodChannel] = [] private static var pushChannel: FlutterMethodChannel? + /// Adds a native `beforeSend` block that runs on every event the PostHog iOS + /// or macOS SDK sends, including the ones Dart never sees. + /// + /// Dart `PostHogConfig.beforeSend` callbacks run only on events captured from + /// Dart. Events the native SDK captures itself, such as crashes, app + /// lifecycle events, `$feature_flag_called`, `$identify` and session replay, + /// reach only native blocks. Blocks run in the order they're added, after the + /// Dart callbacks. Return the event, changed or not, to send it, or `nil` to + /// drop it. No blocks are added by default. + /// + /// Add blocks before the SDK is set up: before `Posthog().setup()` runs in + /// Dart, or before the plugin registers when the SDK is set up from + /// `Info.plist`. Blocks added later apply from the next setup. + /// + /// ```swift + /// // AppDelegate.swift, before GeneratedPluginRegistrant.register(with: self) + /// PosthogFlutterPlugin.addBeforeSend { event in + /// event.event == "Application Backgrounded" ? nil : event + /// } + /// ``` + public static func addBeforeSend(_ block: @escaping BeforeSendBlock) { + nativeBeforeSendLock.lock() + defer { nativeBeforeSendLock.unlock() } + nativeBeforeSend.append(block) + } + public static func getInstance() -> PosthogFlutterPlugin? { instance } @@ -461,8 +494,11 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { } } - if let flutterVersionBeforeSend = flutterVersionBeforeSend(posthogConfig) { - config.setBeforeSend(flutterVersionBeforeSend) + nativeBeforeSendLock.lock() + let beforeSend = [flutterVersionBeforeSend(posthogConfig)].compactMap { $0 } + nativeBeforeSend + nativeBeforeSendLock.unlock() + if !beforeSend.isEmpty { + config.setBeforeSend(beforeSend) } // Update SDK name and version @@ -500,6 +536,12 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { captureAi(call, result: result) case "screen": screen(call, result: result) + case "buildEvent": + buildEvent(call, result: result) + case "sendBuiltEvent": + sendBuiltEvent(call, result: result) + case "discardBuiltEvent": + discardBuiltEvent(call, result: result) case "captureLog": captureLog(call, result: result) case "alias": @@ -1546,6 +1588,93 @@ extension PosthogFlutterPlugin { } } + /// Builds the event a capture, screen or captureException call would send, + /// for the Dart beforeSend callbacks, and returns it to Dart. Returns nil + /// when the SDK skips it. + private func buildEvent( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard let args = call.arguments as? [String: Any] else { + _badArgumentError(result) + return + } + guard let eventName = args["eventName"] as? String else { + _badArgumentError(result) + return + } + let timestamp = (args["timestamp"] as? Int64).map { + Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) + } + let built = PostHogSDK.shared.buildEventForWrapper( + event: eventName, + distinctId: nil, + properties: args["properties"] as? [String: Any], + userProperties: args["userProperties"] as? [String: Any], + userPropertiesSetOnce: args["userPropertiesSetOnce"] as? [String: Any], + groups: nil, + options: args["options"] as? [String: Any], + timestamp: timestamp, + ai: args["ai"] as? Bool ?? false + ) + guard let built else { + result(nil) + return + } + let uuid = built.uuid.uuidString.lowercased() + builtEvents[uuid] = built + result([ + "event": built.event, + "uuid": uuid, + "distinctId": built.distinctId, + "timestamp": Int64((built.timestamp.timeIntervalSince1970 * 1000).rounded()), + "properties": built.properties, + "options": built.options, + ]) + } + + /// Sends an event from `buildEvent` as the Dart beforeSend callbacks left + /// it. Returns its uuid once queued, or nil. + private func sendBuiltEvent( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard let args = call.arguments as? [String: Any], + let uuid = args["uuid"] as? String, + let eventName = args["event"] as? String + else { + _badArgumentError(result) + return + } + guard let built = builtEvents.removeValue(forKey: uuid) else { + result(nil) + return + } + built.event = eventName + built.properties = args["properties"] as? [String: Any] ?? [:] + built.options = args["options"] as? [String: Any] ?? [:] + if let distinctId = args["distinctId"] as? String, + !distinctId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + built.distinctId = distinctId + } + if let timestampMs = args["timestamp"] as? Int64 { + built.timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000.0) + } + result(PostHogSDK.shared.sendBuiltEventForWrapper(built, ai: args["ai"] as? Bool ?? false)) + } + + /// Drops an event from `buildEvent` that a Dart beforeSend callback dropped. + private func discardBuiltEvent( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + if let uuid = (call.arguments as? [String: Any])?["uuid"] as? String { + builtEvents.removeValue(forKey: uuid) + } + result(nil) + } + private func captureLog( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index c3cf63d6..a1a3a763 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -435,13 +435,20 @@ class PostHogConfig { /// `config.captureApplicationLifecycleEvents` is enabled /// - Feature flag events (`$feature_flag_called`) when /// `config.sendFeatureFlagEvent` is enabled - /// - Identity events (`$set`) when `identify` is called + /// - Identity events (`$identify`, `$set`, `$create_alias`, + /// `$groupidentify`) from `identify`, `setPersonProperties`, `alias` + /// and `group` /// - Survey events (`survey shown`, etc.) when `config.surveys` is enabled - /// - Only user-provided properties are available; system properties (like - /// `$device_type`, `$session_id`) are added by the native SDK at a later - /// stage. On Windows and Linux, the callbacks see the system properties - /// and the options the SDK adds, and can change them for that event, - /// except `$lib` and `$lib_version`, which PostHog sets from the SDK. + /// On iOS, macOS and Android, add a native hook with + /// `PosthogFlutterPlugin.addBeforeSend` (Swift or Kotlin) to filter or + /// edit those events. + /// - On iOS, macOS, Android, Windows and Linux, the callbacks see the + /// system properties (like `$device_type` and `$session_id`), the + /// registered properties and the options the SDK adds, and can change + /// them for that event, except `$lib` and `$lib_version`, which PostHog + /// sets from the SDK. On iOS, macOS and Android, registering callbacks + /// adds one more call to the native SDK per event. On web, only the event's own + /// properties are available; posthog-js adds the rest later. /// /// **Note:** /// - Callbacks can be synchronous or asynchronous (via diff --git a/posthog_flutter/lib/src/posthog_event.dart b/posthog_flutter/lib/src/posthog_event.dart index 7063dd89..61bd72b1 100644 --- a/posthog_flutter/lib/src/posthog_event.dart +++ b/posthog_flutter/lib/src/posthog_event.dart @@ -2,17 +2,34 @@ /// /// This class is used in before-send callbacks to allow modification of events /// before they are sent to PostHog. +/// +/// On iOS, macOS, Android, Windows and Linux the callbacks get the event the +/// SDK is about to send, with its system and registered properties already +/// added. On iOS, macOS and Android it also carries its [uuid], [distinctId] +/// and [timestamp]. +/// +/// ```dart +/// config.beforeSend = [ +/// (event) { +/// event.properties?.remove(r'$device_name'); +/// return event; +/// }, +/// ]; +/// ``` class PostHogEvent { /// The name of the event (e.g., 'button_clicked', '$screen', '$exception') String event; - /// User-provided properties for this event. + /// The event's properties. + /// + /// On iOS, macOS, Android, Windows and Linux, the map already holds the + /// system properties (like `$device_type` and `$session_id`), then the + /// registered properties, then the event's own, and the event is sent as + /// the callbacks leave it. `$set` and `$set_once` are in [userProperties] + /// and [userPropertiesSetOnce] instead. /// - /// On iOS, macOS, Android and web, system properties (like $device_type, - /// $session_id, etc.) are added by the native SDK at a later stage and are - /// not available in this map. On Windows and Linux, the map already holds - /// them, then the registered properties, then the event's own, and the - /// event is sent as the callbacks leave it. + /// On web, only the event's own properties are here; posthog-js adds the + /// rest later. Map? properties; /// User properties to set on the user profile ($set). @@ -32,6 +49,25 @@ class PostHogEvent { /// unset. Map? options; + /// The event's unique id, a lowercase UUID v7. + /// + /// Set on iOS, macOS and Android, where the native SDK builds the event + /// before the callbacks run. `null` on web, Windows and Linux. The id is + /// kept even when a callback returns another [PostHogEvent]. + final String? uuid; + + /// The id of the person the event is captured for. + /// + /// Set on iOS, macOS and Android, where a callback can change it. `null` on + /// web, Windows and Linux. + String? distinctId; + + /// When the event happened. + /// + /// Set on iOS, macOS and Android, where a callback can change it. `null` on + /// web, Windows and Linux. + DateTime? timestamp; + /// Creates an event passed to a before-send callback. /// /// The [event] is the event name to capture. @@ -45,16 +81,22 @@ class PostHogEvent { /// `$set_once`. /// /// The optional [options] are the event's per-event options. + /// + /// The optional [uuid], [distinctId] and [timestamp] are set by the SDK on + /// iOS, macOS and Android. PostHogEvent({ required this.event, this.properties, this.userProperties, this.userPropertiesSetOnce, this.options, + this.uuid, + this.distinctId, + this.timestamp, }); @override String toString() { - return 'PostHogEvent(event: $event, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce, options: $options)'; + return 'PostHogEvent(event: $event, uuid: $uuid, distinctId: $distinctId, timestamp: $timestamp, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce, options: $options)'; } } diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 087360d5..f8310be2 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -44,27 +44,102 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Stored beforeSend callbacks for dropping/modifying events List _beforeSendCallbacks = []; - /// Applies the beforeSend callbacks to an event in order. - /// Returns the possibly modified event, or null if any callback drops it. - Future _runBeforeSend( - String eventName, - Map? properties, { - Map? userProperties, - Map? userPropertiesSetOnce, - Map? options, - }) { - return applyBeforeSend( - _beforeSendCallbacks, - PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - options: options == null ? null : {...options}, - ), + /// Builds the event in the native SDK, runs the beforeSend callbacks on + /// the built event, then has the native SDK send what they return, so the + /// callbacks see what the SDK adds. [build] holds the `buildEvent` + /// arguments. + /// + /// Returns the native send result (the uuid on the AI lane), or null when + /// the native SDK skips the event or a callback drops it. + Future _buildRunBeforeSendAndSend( + Map build, { + bool ai = false, + }) async { + final built = await _methodChannel.invokeMapMethod( + 'buildEvent', + {...build, 'ai': ai}, + ); + final uuid = built?['uuid']; + if (built == null || uuid is! String) return null; + + Map? send; + try { + final processed = await applyBeforeSend( + _beforeSendCallbacks, + _eventFromNative(built), + ); + if (processed == null) { + printIfDebug( + '[PostHog] Event dropped by beforeSend: ${built['event']}'); + } else { + send = _eventToNative(processed); + } + } catch (e) { + printIfDebug('[PostHog] Event dropped, it could not be read: $e'); + } + if (send == null) { + await _methodChannel.invokeMethod('discardBuiltEvent', {'uuid': uuid}); + return null; + } + return _methodChannel.invokeMethod( + 'sendBuiltEvent', + {...send, 'uuid': uuid, 'ai': ai}, ); } + static PostHogEvent _eventFromNative(Map built) { + final properties = _stringKeyedMap(built['properties']) ?? {}; + final userProperties = _stringKeyedMap(properties.remove(r'$set')); + final userPropertiesSetOnce = + _stringKeyedMap(properties.remove(r'$set_once')); + final timestampMs = built['timestamp'] as int?; + return PostHogEvent( + event: built['event'] as String? ?? '', + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: (built['options'] as Map?)?.cast(), + uuid: built['uuid'] as String?, + distinctId: built['distinctId'] as String?, + timestamp: timestampMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(timestampMs, isUtc: true), + ); + } + + static Map _eventToNative(PostHogEvent event) { + final extracted = CaptureUtils.extractUserProperties( + properties: event.properties, + userProperties: event.userProperties, + userPropertiesSetOnce: event.userPropertiesSetOnce, + ); + final properties = { + ...?extracted.properties, + if (extracted.userProperties != null) r'$set': extracted.userProperties!, + if (extracted.userPropertiesSetOnce != null) + r'$set_once': extracted.userPropertiesSetOnce!, + }; + return { + 'event': event.event, + 'properties': PropertyNormalizer.normalize(properties), + if (event.distinctId?.trim().isNotEmpty == true) + 'distinctId': event.distinctId, + if (event.timestamp != null) + 'timestamp': event.timestamp!.millisecondsSinceEpoch, + 'options': cleanEventOptions(event.options) ?? const {}, + }; + } + + /// Copies a map from the channel with its string keys and non-null values. + static Map? _stringKeyedMap(Object? value) { + if (value is! Map) return null; + return { + for (final entry in value.entries) + if (entry.key is String && entry.value != null) + entry.key as String: entry.value as Object, + }; + } + /// Native plugin calls to Flutter /// Future _handleMethodCall(MethodCall call) async { @@ -349,59 +424,72 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return null; } - // Apply beforeSend callback - final processedEvent = await _runBeforeSend( - eventName, - properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - options: options, - ); - - if (processedEvent == null) { - printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); - return null; + if (_beforeSendCallbacks.isNotEmpty) { + try { + return await _buildRunBeforeSendAndSend( + _captureArguments( + eventName: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ), + ai: method == 'captureAi', + ); + } on PlatformException catch (exception) { + printIfDebug('Exeption on $method: $exception'); + return null; + } } try { - // Use processed event properties (potentially modified by beforeSend) - final extracted = CaptureUtils.extractUserProperties( - properties: processedEvent.properties, - userProperties: processedEvent.userProperties, - userPropertiesSetOnce: processedEvent.userPropertiesSetOnce, + return await _methodChannel.invokeMethod( + method, + _captureArguments( + eventName: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ), ); - - final extractedProperties = extracted.properties; - final extractedUserProperties = extracted.userProperties; - final extractedUserPropertiesSetOnce = extracted.userPropertiesSetOnce; - - final normalizedProperties = extractedProperties != null - ? PropertyNormalizer.normalize(extractedProperties) - : null; - final normalizedUserProperties = extractedUserProperties != null - ? PropertyNormalizer.normalize(extractedUserProperties) - : null; - final normalizedUserPropertiesSetOnce = - extractedUserPropertiesSetOnce != null - ? PropertyNormalizer.normalize(extractedUserPropertiesSetOnce) - : null; - final cleanedOptions = cleanEventOptions(processedEvent.options); - - return await _methodChannel.invokeMethod(method, { - 'eventName': processedEvent.event, - if (normalizedProperties != null) 'properties': normalizedProperties, - if (normalizedUserProperties != null) - 'userProperties': normalizedUserProperties, - if (normalizedUserPropertiesSetOnce != null) - 'userPropertiesSetOnce': normalizedUserPropertiesSetOnce, - if (cleanedOptions != null) 'options': cleanedOptions, - }); } on PlatformException catch (exception) { printIfDebug('Exeption on $method: $exception'); return null; } } + /// The `capture`, `captureAi` and `buildEvent` arguments, with `$set` and + /// `$set_once` moved out of [properties] and the values normalized. + static Map _captureArguments({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) { + final extracted = CaptureUtils.extractUserProperties( + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + final extractedProperties = extracted.properties; + final extractedUserProperties = extracted.userProperties; + final extractedUserPropertiesSetOnce = extracted.userPropertiesSetOnce; + final cleanedOptions = cleanEventOptions(options); + return { + 'eventName': eventName, + if (extractedProperties != null) + 'properties': PropertyNormalizer.normalize(extractedProperties), + if (extractedUserProperties != null) + 'userProperties': PropertyNormalizer.normalize(extractedUserProperties), + if (extractedUserPropertiesSetOnce != null) + 'userPropertiesSetOnce': + PropertyNormalizer.normalize(extractedUserPropertiesSetOnce), + if (cleanedOptions != null) 'options': cleanedOptions, + }; + } + @override Future screen({ required String screenName, @@ -412,53 +500,34 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } - // The screen name argument wins over a properties `$screen_name`. - // beforeSend still runs after this and can change the name. - final propsWithScreenName = { - ...?properties, - PostHogPropertyName.screenName: screenName, - }; - - // Apply beforeSend callback - screen events are captured as $screen - final processedEvent = await _runBeforeSend( - PostHogEventName.screen, - propsWithScreenName, - options: options, - ); - if (processedEvent == null) { - printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); - return; - } - - // If event name was changed, use regular capture() instead - if (processedEvent.event != PostHogEventName.screen) { - await capture( - eventName: processedEvent.event, - properties: processedEvent.properties?.cast(), - options: processedEvent.options, - ); + if (_beforeSendCallbacks.isNotEmpty) { + try { + final cleanedOptions = cleanEventOptions(options); + await _buildRunBeforeSendAndSend({ + 'eventName': PostHogEventName.screen, + // The screen name argument wins over a properties `$screen_name`. + 'properties': PropertyNormalizer.normalize({ + ...?properties, + PostHogPropertyName.screenName: screenName, + }), + if (cleanedOptions != null) 'options': cleanedOptions, + }); + } on PlatformException catch (exception) { + printIfDebug('Exeption on screen: $exception'); + } return; } - // Get the (possibly modified) screen name from properties and remove it - final finalScreenName = - processedEvent.properties?[PostHogPropertyName.screenName] as String? ?? - screenName; - // It will be added back by native sdk - processedEvent.properties?.remove(PostHogPropertyName.screenName); - try { - final normalizedProperties = processedEvent.properties?.isNotEmpty == true - ? PropertyNormalizer.normalize( - processedEvent.properties!.cast(), - ) - : null; - - final cleanedOptions = cleanEventOptions(processedEvent.options); + // The native SDK adds the screen name back as `$screen_name`. + final screenProperties = {...?properties} + ..remove(PostHogPropertyName.screenName); + final cleanedOptions = cleanEventOptions(options); await _methodChannel.invokeMethod('screen', { - 'screenName': finalScreenName, - if (normalizedProperties != null) 'properties': normalizedProperties, + 'screenName': screenName, + if (screenProperties.isNotEmpty) + 'properties': PropertyNormalizer.normalize(screenProperties), if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { @@ -888,38 +957,25 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - // Apply beforeSend callback - exception events are captured as $exception - final processedEvent = await _runBeforeSend( - PostHogEventName.exception, - exceptionProps.cast(), - options: options, - ); - if (processedEvent == null) { - printIfDebug( - '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', - ); - return; - } - - // If event name was changed, use capture() instead - if (processedEvent.event != PostHogEventName.exception) { - await capture( - eventName: processedEvent.event, - properties: processedEvent.properties?.cast(), - options: processedEvent.options, - ); + if (_beforeSendCallbacks.isNotEmpty) { + final cleanedOptions = cleanEventOptions(options); + await _buildRunBeforeSendAndSend({ + 'eventName': PostHogEventName.exception, + 'properties': PropertyNormalizer.normalize( + exceptionProps.cast(), + ), + 'timestamp': DateTime.now().millisecondsSinceEpoch, + if (cleanedOptions != null) 'options': cleanedOptions, + }); return; } // Add timestamp from Flutter side (will be used and removed from native plugins) final timestamp = DateTime.now().millisecondsSinceEpoch; - final normalizedData = processedEvent.properties != null - ? PropertyNormalizer.normalize( - processedEvent.properties!.cast(), - ) - : {}; - - final cleanedOptions = cleanEventOptions(processedEvent.options); + final normalizedData = PropertyNormalizer.normalize( + exceptionProps.cast(), + ); + final cleanedOptions = cleanEventOptions(options); await _methodChannel.invokeMethod('captureException', { 'timestamp': timestamp, diff --git a/posthog_flutter/test/event_shape_snapshot_test.dart b/posthog_flutter/test/event_shape_snapshot_test.dart index 0320d7e0..b369d61f 100644 --- a/posthog_flutter/test/event_shape_snapshot_test.dart +++ b/posthog_flutter/test/event_shape_snapshot_test.dart @@ -21,6 +21,23 @@ void main() { setUp(() async { messenger.setMockMethodCallHandler(channel, (call) async { calls.add(call); + final arguments = call.arguments; + if (call.method == 'buildEvent' && arguments is Map) { + return { + 'event': arguments['eventName'], + 'uuid': 'snapshot-uuid', + 'distinctId': 'snapshot-user', + 'timestamp': 1709296200000, + 'properties': { + ...?(arguments['properties'] as Map?), + if (arguments['userProperties'] != null) + r'$set': arguments['userProperties'], + if (arguments['userPropertiesSetOnce'] != null) + r'$set_once': arguments['userPropertiesSetOnce'], + }, + 'options': arguments['options'] ?? {}, + }; + } return null; }); PosthogFlutterPlatformInterface.instance = PosthogFlutterIO(); @@ -268,17 +285,22 @@ void main() { ); final normalizedCalls = calls.map((call) { - if (call.method != 'captureException') { + final arguments = call.arguments is Map + ? Map.from(call.arguments as Map) + : null; + final rawProperties = arguments?['properties']; + if (arguments == null || + rawProperties is! Map || + !rawProperties.containsKey(r'$exception_list')) { return call; } - final arguments = Map.from(call.arguments as Map); - expect(arguments['timestamp'], isA()); - arguments['timestamp'] = ''; + if (call.method == 'buildEvent') { + expect(arguments['timestamp'], isA()); + arguments['timestamp'] = ''; + } - final properties = Map.from( - arguments['properties'] as Map, - ); + final properties = Map.from(rawProperties); final exceptionList = List.from( properties[r'$exception_list'] as List, ); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index b9a70cd5..5bfbfc25 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -36,6 +36,12 @@ void main() { if (methodCall.method == 'setup') { return null; } + if (methodCall.method == 'buildEvent') { + return nativeBuiltEvent(methodCall.arguments as Map); + } + if (methodCall.method == 'sendBuiltEvent') { + return (methodCall.arguments as Map)['uuid']; + } return null; }); }); @@ -539,13 +545,14 @@ void main() { eventName: r'$ai_span', options: options); await posthogFlutterIO.screen(screenName: 'Home', options: options); - expect(seen, [options, options]); + expect(seen, [sent, sent]); const changed = { 'process_person_profile': false, 'product_tour_id': 'tour-1', }; - expect(argsOf('captureAi')['options'], changed); - expect(argsOf('screen')['options'], changed); + final sends = log.where((c) => c.method == 'sendBuiltEvent').toList(); + expect(sends.map((c) => (c.arguments as Map)['options']), + [changed, changed]); }); test('a screen renamed by beforeSend keeps its options', () async { @@ -561,8 +568,8 @@ void main() { await posthogFlutterIO.screen(screenName: 'Home', options: options); - expect(argsOf('capture')['eventName'], 'screen viewed'); - expect(argsOf('capture')['options'], sent); + expect(argsOf('sendBuiltEvent')['event'], 'screen viewed'); + expect(argsOf('sendBuiltEvent')['options'], sent); }); test('captureAi dropped by beforeSend returns null', () async { @@ -575,7 +582,7 @@ void main() { await posthogFlutterIO.captureAi(eventName: r'$ai_generation'), isNull, ); - expect(log.where((c) => c.method == 'captureAi'), isEmpty); + expect(log.where((c) => c.method == 'sendBuiltEvent'), isEmpty); }); }); @@ -612,9 +619,9 @@ void main() { await posthogFlutterIO.capture(eventName: 'original_event'); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['eventName'], 'modified_event'); + expect(args['event'], 'modified_event'); }); test('beforeSend can modify properties', () async { @@ -634,7 +641,7 @@ void main() { properties: {'original': true}, ); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); expect(args['properties'], {'modified': true}); }); @@ -656,9 +663,11 @@ void main() { userProperties: {'name': 'Original Name'}, ); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['userProperties'], {'name': 'Modified Name'}); + expect(args['properties'], { + r'$set': {'name': 'Modified Name'} + }); }); test('beforeSend can modify userPropertiesSetOnce', () async { @@ -678,10 +687,10 @@ void main() { userPropertiesSetOnce: {'last_logged_in_at': '2024-01-01'}, ); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['userPropertiesSetOnce'], { - 'last_logged_in_at': '2025-01-01', + expect(args['properties'], { + r'$set_once': {'last_logged_in_at': '2025-01-01'}, }); }); @@ -692,7 +701,7 @@ void main() { await posthogFlutterIO.capture(eventName: 'dropped_event'); - final captureCalls = log.where((c) => c.method == 'capture'); + final captureCalls = log.where((c) => c.method == 'sendBuiltEvent'); expect(captureCalls, isEmpty); }); @@ -711,12 +720,13 @@ void main() { await posthogFlutterIO.capture(eventName: 'drop me'); await posthogFlutterIO.capture(eventName: 'keep me'); - final captureCalls = log.where((c) => c.method == 'capture').toList(); + final captureCalls = + log.where((c) => c.method == 'sendBuiltEvent').toList(); expect(captureCalls.length, 1); final args = Map.from( captureCalls.first.arguments as Map, ); - expect(args['eventName'], 'keep me'); + expect(args['event'], 'keep me'); }); test('beforeSend exception drops event and stops the chain', () async { @@ -752,7 +762,7 @@ void main() { ); expect(callOrder, ['transform', 'throw']); - expect(log, isEmpty); + expect(log.where((c) => c.method == 'sendBuiltEvent'), isEmpty); }); test('multiple beforeSend callbacks are applied in order', () async { @@ -786,9 +796,9 @@ void main() { await posthogFlutterIO.capture(eventName: 'original'); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['eventName'], 'original_first_second_third'); + expect(args['event'], 'original_first_second_third'); expect(callOrder, [1, 2, 3]); }); @@ -847,10 +857,12 @@ void main() { properties: {'event_prop': 'value'}, ); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['properties'], {'event_prop': 'value'}); - expect(args['userProperties'], {'developer_name': 'John'}); + expect(args['properties'], { + 'event_prop': 'value', + r'$set': {'developer_name': 'John'}, + }); }, ); @@ -876,10 +888,12 @@ void main() { properties: {'event_prop': 'value'}, ); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['properties'], {'event_prop': 'value'}); - expect(args['userPropertiesSetOnce'], {'first_seen': '2025-01-01'}); + expect(args['properties'], { + 'event_prop': 'value', + r'$set_once': {'first_seen': '2025-01-01'}, + }); }, ); @@ -904,13 +918,15 @@ void main() { userProperties: {'from_direct': 'direct_value', 'shared': 'direct'}, ); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['properties'], {'event_prop': 'value'}); - expect(args['userProperties'], { - 'from_legacy': 'legacy_value', - 'from_direct': 'direct_value', - 'shared': 'direct', + expect(args['properties'], { + 'event_prop': 'value', + r'$set': { + 'from_legacy': 'legacy_value', + 'from_direct': 'direct_value', + 'shared': 'direct', + }, }); }); @@ -931,9 +947,9 @@ void main() { userProperties: {'name': 'John'}, ); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args.containsKey('userProperties'), isFalse); + expect(args['properties'], isNot(contains(r'$set'))); }); test('async beforeSend can modify event', () async { @@ -951,9 +967,9 @@ void main() { await posthogFlutterIO.capture(eventName: 'original_event'); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['eventName'], 'async_modified_event'); + expect(args['event'], 'async_modified_event'); }); test('async beforeSend can drop event by returning null', () async { @@ -970,7 +986,7 @@ void main() { await posthogFlutterIO.capture(eventName: 'dropped_event'); - final captureCalls = log.where((c) => c.method == 'capture'); + final captureCalls = log.where((c) => c.method == 'sendBuiltEvent'); expect(captureCalls, isEmpty); }); @@ -1002,9 +1018,9 @@ void main() { await posthogFlutterIO.capture(eventName: 'original'); - final captureCall = log.firstWhere((c) => c.method == 'capture'); + final captureCall = log.firstWhere((c) => c.method == 'sendBuiltEvent'); final args = Map.from(captureCall.arguments as Map); - expect(args['eventName'], 'original_sync1_async1_sync2'); + expect(args['event'], 'original_sync1_async1_sync2'); expect(callOrder, ['sync1', 'async1', 'sync2']); }); @@ -1029,7 +1045,7 @@ void main() { completes, ); - expect(log, isEmpty); + expect(log.where((c) => c.method == 'sendBuiltEvent'), isEmpty); }); test( @@ -1055,7 +1071,7 @@ void main() { await posthogFlutterIO.capture(eventName: 'test_event'); - final captureCalls = log.where((c) => c.method == 'capture'); + final captureCalls = log.where((c) => c.method == 'sendBuiltEvent'); expect(captureCalls, isEmpty); expect(callOrder, [1]); // Only first callback should have been called }, @@ -1082,7 +1098,8 @@ void main() { await posthogFlutterIO.capture(eventName: 'event_2'); await posthogFlutterIO.capture(eventName: 'event_3'); - final captureCalls = log.where((c) => c.method == 'capture').toList(); + final captureCalls = + log.where((c) => c.method == 'sendBuiltEvent').toList(); expect(captureCalls.length, 3); final event1Args = Map.from( @@ -1096,9 +1113,9 @@ void main() { ); final eventOrder = [ - event1Args['eventName'], - event2Args['eventName'], - event3Args['eventName'], + event1Args['event'], + event2Args['event'], + event3Args['event'], ]; expect(eventOrder, ['event_1', 'event_2', 'event_3']); }, @@ -1124,11 +1141,12 @@ void main() { final first = posthogFlutterIO.capture(eventName: 'event_1'); await posthogFlutterIO.capture(eventName: 'event_2'); await posthogFlutterIO.capture(eventName: 'event_3'); - expect(log.where((c) => c.method == 'capture'), hasLength(2)); + expect(log.where((c) => c.method == 'sendBuiltEvent'), hasLength(2)); releaseFirst.complete(); await first; - final captureCalls = log.where((c) => c.method == 'capture').toList(); + final captureCalls = + log.where((c) => c.method == 'sendBuiltEvent').toList(); expect(captureCalls.length, 3); final event1Args = Map.from( @@ -1142,15 +1160,142 @@ void main() { ); final eventOrder = [ - event1Args['eventName'], - event2Args['eventName'], - event3Args['eventName'], + event1Args['event'], + event2Args['event'], + event3Args['event'], ]; expect(eventOrder, ['event_2', 'event_3', 'event_1']); }, ); }); + group('PosthogFlutterIO beforeSend on the native-built event', () { + Map argsOf(String method) => Map.from( + log.singleWhere((c) => c.method == method).arguments as Map, + ); + + Future setUpWith(BeforeSendCallback callback) => posthogFlutterIO + .setup(PostHogConfig('test_project_token', beforeSend: [callback])); + + test('without callbacks an event takes one native call', () async { + await posthogFlutterIO.setup(PostHogConfig('test_project_token')); + log.clear(); + + await posthogFlutterIO.capture(eventName: 'clicked'); + + expect(log.map((c) => c.method), ['capture']); + }); + + test('callbacks see what the native SDK added', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + if (call.method != 'buildEvent') return null; + return { + ...nativeBuiltEvent(call.arguments as Map), + 'properties': { + r'$lib': 'posthog-flutter', + 'plan': 'pro', + r'$set': {'name': 'Ada'}, + }, + }; + }); + PostHogEvent? seen; + await setUpWith((event) => seen = event); + + await posthogFlutterIO.capture(eventName: 'clicked'); + + expect(seen!.uuid, 'built-uuid'); + expect(seen!.distinctId, 'user-1'); + expect(seen!.timestamp, DateTime.utc(2026)); + expect(seen!.properties, {r'$lib': 'posthog-flutter', 'plan': 'pro'}); + expect(seen!.userProperties, {'name': 'Ada'}); + }); + + test('sends what the callbacks return with the built uuid', () async { + await setUpWith((event) => PostHogEvent( + event: 'renamed', + properties: {'a': 1}, + distinctId: 'user-2', + timestamp: DateTime.utc(2025), + )); + + await posthogFlutterIO.capture(eventName: 'clicked'); + + expect(argsOf('sendBuiltEvent'), { + 'event': 'renamed', + 'properties': {'a': 1}, + 'distinctId': 'user-2', + 'timestamp': DateTime.utc(2025).millisecondsSinceEpoch, + 'options': {}, + 'uuid': 'built-uuid', + 'ai': false, + }); + }); + + test('captureAi builds and sends on the AI lane', () async { + await setUpWith((event) => event); + + final uuid = + await posthogFlutterIO.captureAi(eventName: r'$ai_generation'); + + expect(uuid, 'built-uuid'); + expect(argsOf('buildEvent')['ai'], isTrue); + expect(argsOf('sendBuiltEvent')['ai'], isTrue); + }); + + test('screen builds a \$screen event with its name', () async { + PostHogEvent? seen; + await setUpWith((event) => seen = event); + + await posthogFlutterIO.screen(screenName: 'Home', properties: {'a': 1}); + + expect(argsOf('buildEvent'), { + 'eventName': r'$screen', + 'properties': {'a': 1, r'$screen_name': 'Home'}, + 'ai': false, + }); + expect(seen!.event, r'$screen'); + expect(seen!.properties, {'a': 1, r'$screen_name': 'Home'}); + }); + + test('captureException builds with its own timestamp', () async { + await setUpWith((event) => event); + + await posthogFlutterIO.captureException(error: StateError('boom')); + + expect(argsOf('buildEvent')['eventName'], r'$exception'); + expect(argsOf('buildEvent')['timestamp'], isA()); + }); + + test('a dropped event is discarded, not sent', () async { + await setUpWith((event) => null); + + await posthogFlutterIO.capture(eventName: 'clicked'); + + expect(argsOf('discardBuiltEvent'), {'uuid': 'built-uuid'}); + expect(log.where((c) => c.method == 'sendBuiltEvent'), isEmpty); + }); + + test('an event the native SDK skips never reaches the callbacks', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return null; + }); + var called = false; + await setUpWith((event) { + called = true; + return event; + }); + + await posthogFlutterIO.capture(eventName: 'clicked'); + + expect(called, isFalse); + expect(log.where((c) => c.method == 'sendBuiltEvent'), isEmpty); + }); + }); + group('PosthogFlutterIO captureLog', () { test('sends body, lowercase level name, and attributes', () async { await posthogFlutterIO.captureLog( @@ -1444,3 +1589,21 @@ void main() { }); }); } + +/// What the native `buildEvent` handler returns for [args], without adding +/// SDK properties. +Map nativeBuiltEvent(Map args, {String uuid = 'built-uuid'}) { + return { + 'event': args['eventName'], + 'uuid': uuid, + 'distinctId': 'user-1', + 'timestamp': args['timestamp'] ?? 1767225600000, + 'properties': { + ...?(args['properties'] as Map?), + if (args['userProperties'] != null) r'$set': args['userProperties'], + if (args['userPropertiesSetOnce'] != null) + r'$set_once': args['userPropertiesSetOnce'], + }, + 'options': args['options'] ?? {}, + }; +} diff --git a/posthog_flutter/test/snapshots/event_channel_shapes.json b/posthog_flutter/test/snapshots/event_channel_shapes.json index 2091d2e2..cac7582e 100644 --- a/posthog_flutter/test/snapshots/event_channel_shapes.json +++ b/posthog_flutter/test/snapshots/event_channel_shapes.json @@ -1,19 +1,39 @@ [ { - "method": "screen", + "method": "buildEvent", "arguments": { - "screenName": "Checkout", + "eventName": "$screen", "properties": { "cart_size": 3, "route": { "source": "email" }, + "$screen_name": "Checkout" + }, + "ai": false + } + }, + { + "method": "sendBuiltEvent", + "arguments": { + "event": "$screen", + "properties": { + "cart_size": 3, + "route": { + "source": "email" + }, + "$screen_name": "Checkout", "snapshot_before_send": "event" - } + }, + "distinctId": "snapshot-user", + "timestamp": 1709296200000, + "options": {}, + "uuid": "snapshot-uuid", + "ai": false } }, { - "method": "capture", + "method": "buildEvent", "arguments": { "eventName": "order completed", "properties": { @@ -26,8 +46,7 @@ } ], "placed_at": "2024-03-01 12:30:00.000Z", - "$screen_name": "Checkout", - "snapshot_before_send": "event" + "$screen_name": "Checkout" }, "userProperties": { "plan": "pro", @@ -37,7 +56,41 @@ "userPropertiesSetOnce": { "first_checkout_at": "2024-03-01", "signup_source": "invite" - } + }, + "ai": false + } + }, + { + "method": "sendBuiltEvent", + "arguments": { + "event": "order completed", + "properties": { + "order_id": "ord_123", + "total": 42.5, + "items": [ + { + "sku": "sku_1", + "quantity": 2 + } + ], + "placed_at": "2024-03-01 12:30:00.000Z", + "$screen_name": "Checkout", + "snapshot_before_send": "event", + "$set": { + "plan": "pro", + "legacy_only": true, + "email_verified": true + }, + "$set_once": { + "first_checkout_at": "2024-03-01", + "signup_source": "invite" + } + }, + "distinctId": "snapshot-user", + "timestamp": 1709296200000, + "options": {}, + "uuid": "snapshot-uuid", + "ai": false } }, { diff --git a/posthog_flutter/test/snapshots/special_event_channel_shapes.json b/posthog_flutter/test/snapshots/special_event_channel_shapes.json index 42be31cb..e695058e 100644 --- a/posthog_flutter/test/snapshots/special_event_channel_shapes.json +++ b/posthog_flutter/test/snapshots/special_event_channel_shapes.json @@ -15,9 +15,59 @@ } }, { - "method": "captureException", + "method": "buildEvent", "arguments": { + "eventName": "$exception", + "properties": { + "$exception_level": "error", + "$exception_list": [ + { + "type": "StateError", + "mechanism": { + "handled": true, + "synthetic": false, + "type": "generic" + }, + "value": "Bad state: checkout failed", + "stacktrace": { + "frames": [ + { + "platform": "dart", + "abs_path": "package:snapshot_app/main.dart", + "in_app": true, + "package": "snapshot_app", + "function": "main", + "filename": "main.dart", + "lineno": 10, + "colno": 3 + }, + { + "platform": "dart", + "abs_path": "package:snapshot_app/checkout.dart", + "in_app": true, + "package": "snapshot_app", + "function": "CheckoutService.submit", + "filename": "checkout.dart", + "lineno": 42, + "colno": 7 + } + ], + "type": "raw" + }, + "thread_id": "" + } + ], + "order_id": "ord_123", + "attempted_at": "2024-03-01 12:30:00.000Z" + }, "timestamp": "", + "ai": false + } + }, + { + "method": "sendBuiltEvent", + "arguments": { + "event": "$exception", "properties": { "$exception_level": "error", "$exception_list": [ @@ -60,13 +110,67 @@ "order_id": "ord_123", "attempted_at": "2024-03-01 12:30:00.000Z", "snapshot_before_send": "event" - } + }, + "distinctId": "snapshot-user", + "timestamp": 1709296200000, + "options": {}, + "uuid": "snapshot-uuid", + "ai": false } }, { - "method": "captureException", + "method": "buildEvent", "arguments": { + "eventName": "$exception", + "properties": { + "$exception_level": "error", + "$exception_list": [ + { + "type": "ArgumentError", + "mechanism": { + "handled": false, + "synthetic": false, + "type": "runZonedGuarded" + }, + "value": "Invalid argument(s): zoned checkout failure", + "stacktrace": { + "frames": [ + { + "platform": "dart", + "abs_path": "package:snapshot_app/main.dart", + "in_app": true, + "package": "snapshot_app", + "function": "main", + "filename": "main.dart", + "lineno": 12, + "colno": 3 + }, + { + "platform": "dart", + "abs_path": "package:snapshot_app/zone.dart", + "in_app": true, + "package": "snapshot_app", + "function": "CheckoutZone.run", + "filename": "zone.dart", + "lineno": 21, + "colno": 5 + } + ], + "type": "raw" + }, + "thread_id": "" + } + ], + "zone": "checkout" + }, "timestamp": "", + "ai": false + } + }, + { + "method": "sendBuiltEvent", + "arguments": { + "event": "$exception", "properties": { "$exception_level": "error", "$exception_list": [ @@ -108,7 +212,12 @@ ], "zone": "checkout", "snapshot_before_send": "event" - } + }, + "distinctId": "snapshot-user", + "timestamp": 1709296200000, + "options": {}, + "uuid": "snapshot-uuid", + "ai": false } }, { From 871d87f36130db60eeb59717e12a41733cf125a0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 20:10:47 -0400 Subject: [PATCH 33/43] fix: import only the PostHogInternal SPI from posthog-ios --- .../Sources/posthog_flutter/PosthogFlutterPlugin.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 0877d9b4..49a9d0d4 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -1,4 +1,4 @@ -@_spi(PostHogInternal) @_spi(PostHogWrapper) import PostHog +@_spi(PostHogInternal) import PostHog #if os(iOS) import Flutter import UIKit From 749478ab4f99cf6a34b3903050ee2e3f7ffc60ac Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 20:10:47 -0400 Subject: [PATCH 34/43] fix: drop built events whose beforeSend callbacks never finish --- .../com/posthog/flutter/PosthogFlutterPlugin.kt | 14 +++++++++++--- .../posthog_flutter/PosthogFlutterPlugin.swift | 12 +++++++++--- posthog_flutter/lib/src/posthog_config.dart | 3 ++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 4f982c32..28ee5cd0 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -14,6 +14,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.util.Log import android.view.PixelCopy import android.view.SurfaceView @@ -62,6 +63,8 @@ private const val END_DEBOUNCE_TICKS = 1 private const val BRIDGE_FAILURE_STRIKE_LIMIT = 3 +private const val BUILT_EVENT_TIMEOUT_MS = 60_000L + /** PosthogFlutterPlugin */ class PosthogFlutterPlugin : FlutterPlugin, @@ -82,7 +85,10 @@ class PosthogFlutterPlugin : // Events built for the Dart beforeSend callbacks, keyed by uuid, until Dart // sends or drops them. Main-thread confined, like the channel callbacks. - private val builtEvents = mutableMapOf() + // An event whose callbacks haven't finished after BUILT_EVENT_TIMEOUT_MS is + // dropped on the next build, so a callback that never completes can't keep + // it in memory. + private val builtEvents = mutableMapOf>() // Test seam: captures the built config even if setup() throws (mock Context in // unit tests). postHogConfig is only published on success, so production readers @@ -1877,7 +1883,9 @@ class PosthogFlutterPlugin : result.success(null) return } - builtEvents[uuid] = built + val now = SystemClock.elapsedRealtime() + builtEvents.values.removeAll { (_, builtAt) -> now - builtAt >= BUILT_EVENT_TIMEOUT_MS } + builtEvents[uuid] = built to now result.success( mapOf( "event" to built.event, @@ -1903,7 +1911,7 @@ class PosthogFlutterPlugin : ) { try { val uuid: String = call.argument("uuid")!! - val built = builtEvents.remove(uuid) + val built = builtEvents.remove(uuid)?.first if (built == null) { result.success(null) return diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 49a9d0d4..db297ff0 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -46,7 +46,11 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { // Events built for the Dart beforeSend callbacks, keyed by uuid, until Dart // sends or drops them. Main-thread confined, like the channel callbacks. - private var builtEvents: [String: PostHogEvent] = [:] + // An event whose callbacks haven't finished after builtEventTimeout is + // dropped on the next build, so a callback that never completes can't + // keep it in memory. + private var builtEvents: [String: (event: PostHogEvent, builtAt: Date)] = [:] + private static let builtEventTimeout: TimeInterval = 60 private static let nativeBeforeSendLock = NSLock() private static var nativeBeforeSend: [BeforeSendBlock] = [] @@ -1622,7 +1626,9 @@ extension PosthogFlutterPlugin { return } let uuid = built.uuid.uuidString.lowercased() - builtEvents[uuid] = built + let now = Date() + builtEvents = builtEvents.filter { now.timeIntervalSince($0.value.builtAt) < Self.builtEventTimeout } + builtEvents[uuid] = (built, now) result([ "event": built.event, "uuid": uuid, @@ -1646,7 +1652,7 @@ extension PosthogFlutterPlugin { _badArgumentError(result) return } - guard let built = builtEvents.removeValue(forKey: uuid) else { + guard let built = builtEvents.removeValue(forKey: uuid)?.event else { result(nil) return } diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index a1a3a763..0912d402 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -447,7 +447,8 @@ class PostHogConfig { /// registered properties and the options the SDK adds, and can change /// them for that event, except `$lib` and `$lib_version`, which PostHog /// sets from the SDK. On iOS, macOS and Android, registering callbacks - /// adds one more call to the native SDK per event. On web, only the event's own + /// adds one more call to the native SDK per event, and an event whose + /// callbacks take more than a minute can be dropped. On web, only the event's own /// properties are available; posthog-js adds the rest later. /// /// **Note:** From 5c5415d72b63805fc0238f53958a72a8d1b01e0c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 20:51:29 -0400 Subject: [PATCH 35/43] fix: clear pending built events on setup and close --- .../main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt | 3 +++ .../Sources/posthog_flutter/PosthogFlutterPlugin.swift | 3 +++ 2 files changed, 6 insertions(+) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 28ee5cd0..c2d908e9 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -88,6 +88,7 @@ class PosthogFlutterPlugin : // An event whose callbacks haven't finished after BUILT_EVENT_TIMEOUT_MS is // dropped on the next build, so a callback that never completes can't keep // it in memory. + // setup and close clear it, so a build can't be sent through a reconfigured SDK. private val builtEvents = mutableMapOf>() // Test seam: captures the built config even if setup() throws (mock Context in @@ -567,6 +568,7 @@ class PosthogFlutterPlugin : return } + builtEvents.clear() setupPostHog(args) result.success(null) @@ -2326,6 +2328,7 @@ class PosthogFlutterPlugin : private fun close(result: Result) { try { + builtEvents.clear() PostHog.close() result.success(null) } catch (e: Throwable) { diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index db297ff0..5ced548d 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -49,6 +49,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { // An event whose callbacks haven't finished after builtEventTimeout is // dropped on the next build, so a callback that never completes can't // keep it in memory. + // setup and close clear it, so a build can't be sent through a reconfigured SDK. private var builtEvents: [String: (event: PostHogEvent, builtAt: Date)] = [:] private static let builtEventTimeout: TimeInterval = 60 @@ -1398,6 +1399,7 @@ extension PosthogFlutterPlugin { result: @escaping FlutterResult ) { if let args = call.arguments as? [String: Any] { + builtEvents.removeAll() PosthogFlutterPlugin.setupPostHog(args, anchor: self) result(nil) } else { @@ -2007,6 +2009,7 @@ extension PosthogFlutterPlugin { } private func close(_ result: @escaping FlutterResult) { + builtEvents.removeAll() PostHogSDK.shared.close() result(nil) } From dff717525f7d925e917c064edea1ca8d8921a1f2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 20:51:30 -0400 Subject: [PATCH 36/43] fix: keep primitive arrays when sending built events over the channel --- .../kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt | 3 ++- .../kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index c2d908e9..e66edd72 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -2514,7 +2514,8 @@ class PosthogFlutterPlugin : */ internal fun toChannelValue(value: Any?): Any? = when (value) { - null, is String, is Boolean, is Int, is Long, is Double, is ByteArray -> value + null, is String, is Boolean, is Int, is Long, is Double -> value + is ByteArray, is IntArray, is LongArray, is FloatArray, is DoubleArray -> value is Number -> value.toDouble() is Map<*, *> -> value.entries.associate { (k, v) -> k.toString() to toChannelValue(v) } is Iterable<*> -> value.map(::toChannelValue) diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index d2b4d962..e079b75a 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -1014,4 +1014,11 @@ internal class PosthogFlutterPluginTest { .verify(backgroundMessenger, Mockito.never()) .send(Mockito.any(), Mockito.any(), Mockito.any()) } + + @Test + fun `toChannelValue keeps primitive arrays the codec can send`() { + val values = listOf(intArrayOf(1), longArrayOf(2L), floatArrayOf(3f), doubleArrayOf(4.0), byteArrayOf(5)) + + values.forEach { assertSame(it, toChannelValue(it)) } + } } From 90164305f4e4030e8e7eb4fcb092493dfaf0bbe2 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:19:48 -0400 Subject: [PATCH 37/43] ci: update SDK compliance harness to 1.26.0 (#680) * ci: update SDK compliance harness to 1.25.0 Co-Authored-By: Claude Opus 5.5 * chore(compliance): bump the test harness to 1.26.0 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/sdk-compliance.yml | 4 ++-- sdk_compliance_adapter/docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index c96f5d77..8dd7ffe7 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -14,8 +14,8 @@ on: jobs: compliance: name: PostHog SDK compliance tests - uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@27806196fa5c5ac74e9e7500bc46b74db1869f3a # main @ 2026-07-27 + uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@c90b42dbe2235c89aad3b4dddf74f520a8c6c747 # 1.26.0 with: adapter-dockerfile: "sdk_compliance_adapter/Dockerfile" adapter-context: "." - test-harness-version: "0.9.0" + test-harness-version: "1.26.0" diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index dbcadd1f..cc621dc5 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -9,7 +9,7 @@ services: - test-network test-harness: - image: ghcr.io/posthog/sdk-test-harness:0.9.0 + image: ghcr.io/posthog/sdk-test-harness:1.26.0 command: [ "run", From a2a4776b799fc5c4f825d354447c8d3b76faa099 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 10:28:33 -0400 Subject: [PATCH 38/43] fix(desktop): check registered person processing before caching identify --- posthog_flutter/lib/src/core/posthog_core.dart | 15 ++++++++------- .../core/desktop_parity_regressions_test.dart | 10 ++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index d3253301..ea333ba5 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -363,18 +363,19 @@ abstract class PostHogCore extends PostHogCoreStateless { _setPersonPropertiesForFlagsIfNeeded( userProperties, userPropertiesSetOnce); + final identifyProperties = _enrichProperties({ + 'distinct_id': distinctId, + r'$anon_distinct_id': previousDistinctId, + ...maybeAdd(r'$set', userProperties), + ...maybeAdd(r'$set_once', userPropertiesSetOnce), + }); identifyStateless( distinctId, - properties: _enrichProperties({ - 'distinct_id': distinctId, - r'$anon_distinct_id': previousDistinctId, - ...maybeAdd(r'$set', userProperties), - ...maybeAdd(r'$set_once', userPropertiesSetOnce), - }), + properties: identifyProperties, options: options, ); if ((userProperties != null || userPropertiesSetOnce != null) && - !_isPersonless(null, options)) { + !_isPersonless(identifyProperties, options)) { _cachedPersonProperties = _personPropertiesHash( distinctId, userProperties, userPropertiesSetOnce); } diff --git a/posthog_flutter/test/core/desktop_parity_regressions_test.dart b/posthog_flutter/test/core/desktop_parity_regressions_test.dart index 8c75ac01..9d9d5f04 100644 --- a/posthog_flutter/test/core/desktop_parity_regressions_test.dart +++ b/posthog_flutter/test/core/desktop_parity_regressions_test.dart @@ -121,6 +121,16 @@ void main() { expect(queuedEvents(storage), [r'$set']); }); + test('a personless identify does not update the marker', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.register({r'$process_person_profile': false}); + client.identify('user', userProperties: {'plan': 'A'}); + client.unregister(r'$process_person_profile'); + client.setPersonProperties(userPropertiesToSet: {'plan': 'A'}); + expect(queuedEvents(storage), [r'$identify', r'$set']); + }); + test('opt-out does not update the marker for the latest person update', () { final storage = tempStorage(); final client = testClient(server, storage: storage); From 268e509a7473d904c8a1ed343fd9887c6111a05e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 10:29:44 -0400 Subject: [PATCH 39/43] fix(desktop): keep a legacy $set in properties for beforeSend to remove --- .../lib/src/posthog_flutter_desktop.dart | 22 +++++++++++++++---- .../test/posthog_flutter_desktop_test.dart | 20 +++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 6cb20a76..f7bf2473 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -726,8 +726,11 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// The SDK's properties come first, then the registered ones, then the /// event's own. Returns null when the client is closed or opted out. /// - /// `$set` and `$set_once` are handed to the callbacks as - /// [PostHogEvent.userProperties] and [PostHogEvent.userPropertiesSetOnce]. + /// [userProperties] and [userPropertiesSetOnce] are handed to the + /// callbacks as [PostHogEvent.userProperties] and + /// [PostHogEvent.userPropertiesSetOnce]. Legacy `$set` and `$set_once` + /// maps in [properties] stay in [PostHogEvent.properties], so a callback + /// that removes them there still keeps them off the event. /// The event's time is taken now, before the callbacks run. ({PostHogEvent event, String timestamp})? _prepare( DesktopPostHog client, @@ -745,13 +748,24 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ); if (prepared == null) return null; final eventProperties = _nonNull(prepared.properties)!; + for (final (key, explicit) in [ + (r'$set', userProperties), + (r'$set_once', userPropertiesSetOnce), + ]) { + final legacy = _nonNull(properties?[key]); + if (legacy != null) { + eventProperties[key] = legacy; + } else if (explicit != null) { + eventProperties.remove(key); + } + } final preparedOptions = prepared.options; return ( event: PostHogEvent( event: eventName, properties: eventProperties, - userProperties: _nonNull(eventProperties.remove(r'$set')), - userPropertiesSetOnce: _nonNull(eventProperties.remove(r'$set_once')), + userProperties: _nonNull(userProperties), + userPropertiesSetOnce: _nonNull(userPropertiesSetOnce), options: preparedOptions == null ? null : {...preparedOptions}, ), timestamp: prepared.timestamp, diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 1ef02cd5..85d0becf 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -219,6 +219,26 @@ void main() { expect(_propertiesOf(event)[r'$set'], {'plan': 'pro', 'team': 'core'}); }); + test(r'a beforeSend that removes a legacy $set keeps it off the event', + () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) => event..properties?.remove(r'$set'), + ]; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'redacted', + properties: { + r'$set': {'email': 'a@b.c'}, + }, + ); + + final event = await server.waitForEvent('redacted'); + expect(_propertiesOf(event), isNot(contains(r'$set'))); + }); + test(r'reports the platform language as $locale', () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)); From 13cd1198a0bbe2c1ed5eb8b95e93f30e2c317904 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 10:30:59 -0400 Subject: [PATCH 40/43] fix(desktop): recompute person processing from the properties beforeSend keeps --- .../lib/src/core/posthog_core.dart | 10 +++ .../lib/src/posthog_flutter_desktop.dart | 61 +++++++++++-------- .../test/posthog_flutter_desktop_test.dart | 35 +++++++++++ 3 files changed, 82 insertions(+), 24 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index ea333ba5..2b28b429 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -461,6 +461,16 @@ abstract class PostHogCore extends PostHogCoreStateless { return _queuePrepared(event, prepared, ai: ai); }); + /// [options] with `process_person_profile: false` added when person + /// processing is off and [properties] neither ask for it nor set + /// `$process_person_profile`, as [prepareEvent] decides it. + Map? personProcessingOptions( + Map? options, + Map properties, + ) => + _personProcessingOptions(options, properties, + requested: _requestsPersonProcessing(properties)); + PostHogPreparedEvent? _prepareEvent( String event, { Map? properties, diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index f7bf2473..d7c56a22 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -278,8 +278,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('capture', scope, distinctId); if (current == null) return; - _captureProcessed( - current, processed, exceptionSteps, prepared.timestamp); + _captureProcessed(current, processed, exceptionSteps, prepared); }); @override @@ -322,8 +321,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('captureAi', scope, distinctId); if (current == null) return null; - return _captureProcessed( - current, processed, exceptionSteps, prepared.timestamp, + return _captureProcessed(current, processed, exceptionSteps, prepared, ai: true); }); @@ -366,8 +364,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { // A renamed event is no longer a screen view. if (processed.event != PostHogEventName.screen) { - _captureProcessed( - current, processed, exceptionSteps, prepared.timestamp); + _captureProcessed(current, processed, exceptionSteps, prepared); return; } @@ -388,8 +385,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ...?processed.properties, PostHogPropertyName.screenName: finalScreenName, }; - _captureProcessed( - current, processed, exceptionSteps, prepared.timestamp); + _captureProcessed(current, processed, exceptionSteps, prepared); }); /// Structured logs are not supported on the desktop implementation. @@ -643,8 +639,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('captureException', scope, distinctId); if (current == null) return; - _captureProcessed( - current, processed, exceptionSteps, prepared.timestamp); + _captureProcessed(current, processed, exceptionSteps, prepared); }); @override @@ -732,7 +727,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// maps in [properties] stay in [PostHogEvent.properties], so a callback /// that removes them there still keeps them off the event. /// The event's time is taken now, before the callbacks run. - ({PostHogEvent event, String timestamp})? _prepare( + _PreparedEvent? _prepare( DesktopPostHog client, String eventName, Map? properties, { @@ -769,6 +764,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { options: preparedOptions == null ? null : {...preparedOptions}, ), timestamp: prepared.timestamp, + implicitPersonProcessing: + preparedOptions?['process_person_profile'] == null, ); } @@ -818,27 +815,35 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { DesktopPostHog client, PostHogEvent event, ExceptionStepsBuffer? exceptionSteps, - String timestamp, { + _PreparedEvent prepared, { bool ai = false, }) { if (!_hasName(event.event)) return null; + final properties = _withExceptionSteps( + event.event, + exceptionSteps, + _mergeUserProps( + event.properties, + event.userProperties, + event.userPropertiesSetOnce, + ), + ) ?? + const {}; // Options are sent as they are, unknown keys included. An option wins // over its legacy property on the event. + Map? options = cleanEventOptions(event.options); + // Person properties the callbacks removed no longer ask for person + // processing, unless a callback set the option itself. + if (prepared.implicitPersonProcessing && + options?['process_person_profile'] == null) { + options = client.personProcessingOptions(options, properties); + } return client.capturePrepared( event.event, ( - properties: _withExceptionSteps( - event.event, - exceptionSteps, - _mergeUserProps( - event.properties, - event.userProperties, - event.userPropertiesSetOnce, - ), - ) ?? - const {}, - options: cleanEventOptions(event.options), - timestamp: timestamp, + properties: properties, + options: options, + timestamp: prepared.timestamp, ), ai: ai); } @@ -894,3 +899,11 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return Map.from(PropertyNormalizer.normalize(properties)); } } + +/// An event built for the beforeSend callbacks, with the time it happened +/// and whether its person processing was left to its properties. +typedef _PreparedEvent = ({ + PostHogEvent event, + String timestamp, + bool implicitPersonProcessing, +}); diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 85d0becf..860cdae3 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -643,6 +643,41 @@ void main() { expect(_optionsOf(event)['process_person_profile'], isFalse); }); + test('an event whose person properties are removed is personless', + () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [(event) => event..userProperties = null]; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'redacted', + userProperties: {'email': 'user@example.com'}, + ); + + final event = await server.waitForEvent('redacted'); + expect(_optionsOf(event)['process_person_profile'], isFalse); + }); + + test('a callback can turn person processing back on', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) => event + ..userProperties = null + ..options = {'process_person_profile': true}, + ]; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'redacted', + userProperties: {'email': 'user@example.com'}, + ); + + final event = await server.waitForEvent('redacted'); + expect(_optionsOf(event)['process_person_profile'], isTrue); + }); + test('a throwing callback drops the event and stops the chain', () async { final server = await LocalPostHogServer.start(); final callOrder = []; From 980eee498504439674bac2004c6ea572345f5ee8 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:32:19 -0400 Subject: [PATCH 41/43] feat(desktop): give beforeSend the full event and add nothing after it - On Windows and Linux, `PostHogEvent` now carries the event's uuid, distinct ID and timestamp, as on iOS, macOS and Android. A callback can change the distinct ID (a blank one keeps the current) and the timestamp; the event keeps its uuid. - `$exception_steps` are attached as the event is built, before the callbacks, as posthog-ios and posthog-android do, so a callback sees and can remove them. Steps recorded while a callback runs, or an event renamed to `$exception`, no longer get them. - The prepared core event carries its uuid and distinct ID, which the queue keeps. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-before-send-native-event.md | 3 +- .../lib/src/core/posthog_core.dart | 19 +++-- .../lib/src/core/posthog_core_stateless.dart | 9 +- posthog_flutter/lib/src/posthog_event.dart | 19 ++--- .../lib/src/posthog_flutter_desktop.dart | 83 ++++++++++--------- .../test/posthog_flutter_desktop_test.dart | 82 +++++++++++++++++- 6 files changed, 151 insertions(+), 64 deletions(-) diff --git a/.changeset/v6-before-send-native-event.md b/.changeset/v6-before-send-native-event.md index 4fbbe518..3e58dec2 100644 --- a/.changeset/v6-before-send-native-event.md +++ b/.changeset/v6-before-send-native-event.md @@ -3,5 +3,6 @@ --- - **Breaking:** On iOS, macOS and Android, `beforeSend` callbacks now get the event the native SDK built, with its system and registered properties, and their changes are final, matching Windows and Linux -- Add `PostHogEvent.uuid`, `distinctId` and `timestamp`, set on iOS, macOS and Android +- Add `PostHogEvent.uuid`, `distinctId` and `timestamp`, set on every platform but web; a callback can change the distinct ID and timestamp, and the event keeps its uuid +- On Windows and Linux, attach `$exception_steps` before the `beforeSend` callbacks run, as on iOS and Android, so they can read or remove them; steps recorded while a callback runs, or an event a callback renames to `$exception`, no longer get them - Add `PosthogFlutterPlugin.addBeforeSend` in Swift and Kotlin to filter or edit events the native SDK captures itself, such as crashes, app lifecycle and `$feature_flag_called` events diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index cbbf0c81..8b188571 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -16,11 +16,14 @@ import 'utils/utils.dart'; import 'uuid.dart'; /// An event built by [PostHogCore.prepareEvent]: its properties, its -/// capture v1 options and the time it happened, as an ISO 8601 string. +/// capture v1 options, the time it happened as an ISO 8601 string, its uuid +/// and the id of the person it is captured for. typedef PostHogPreparedEvent = ({ Map properties, Map? options, String timestamp, + String uuid, + String distinctId, }); /// Stateful PostHog client with session management, identity, and feature flags. @@ -493,6 +496,8 @@ abstract class PostHogCore extends PostHogCoreStateless { requested: _requestsPersonProcessing(properties), ), timestamp: currentISOTime(), + uuid: generateUuidV7(), + distinctId: getDistinctId(), ); } @@ -501,7 +506,7 @@ abstract class PostHogCore extends PostHogCoreStateless { PostHogPreparedEvent prepared, { bool ai = false, }) { - final (:properties, :options, :timestamp) = prepared; + final (:properties, :options, :timestamp, :uuid, :distinctId) = prepared; // Taken from the event as queued, after the beforeSend callbacks, so a // value they removed or an event they dropped neither reaches /flags // nor turns on person processing for later events. @@ -510,8 +515,12 @@ abstract class PostHogCore extends PostHogCoreStateless { _asPropertyMap(properties[r'$set']), _asPropertyMap(properties[r'$set_once']), ); - final uuid = captureStateless(getDistinctId(), event, - properties: properties, options: options, timestamp: timestamp, ai: ai); + final queuedUuid = captureStateless(distinctId, event, + properties: properties, + options: options, + timestamp: timestamp, + uuid: uuid, + ai: ai); // PostHog drops an AI event that is not an `$ai_*` event, so it never // updates the person. if (!ai && (event == r'$set' || event == r'$identify')) { @@ -523,7 +532,7 @@ abstract class PostHogCore extends PostHogCoreStateless { _personPropertiesHash(getDistinctId(), set, setOnce); } } - return uuid; + return queuedUuid; } /// Creates an alias for a user. An empty [alias] is ignored. diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 2cd81cb7..a12067f0 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -352,6 +352,7 @@ abstract class PostHogCoreStateless { required Map properties, Map? options, String? timestamp, + String? uuid, bool ai = false, }) { return _enqueue( @@ -364,6 +365,7 @@ abstract class PostHogCoreStateless { ), options: options, timestamp: timestamp, + uuid: uuid, ); } @@ -495,8 +497,8 @@ abstract class PostHogCoreStateless { }); } - /// Queues [message] on [lane] and returns its uuid, or null when it is - /// over the lane's event size limit. Its capture v1 [options] are queued + /// Queues [message] on [lane], with [uuid] or a new one, and returns its + /// uuid, or null when it is over the lane's event size limit. Its capture v1 [options] are queued /// with it; the legacy option properties are converted only when the /// request is built, see [captureV1Event]. String? _enqueue( @@ -505,6 +507,7 @@ abstract class PostHogCoreStateless { Map message, { Map? options, String? timestamp, + String? uuid, }) { final prepared = { ...message, @@ -516,7 +519,7 @@ abstract class PostHogCoreStateless { 'library': postHogFlutterSdkName, 'library_version': postHogFlutterVersion, 'timestamp': timestamp ?? currentISOTime(), - 'uuid': generateUuidV7(), + 'uuid': uuid ?? generateUuidV7(), }; // Measured as queued, before compression. diff --git a/posthog_flutter/lib/src/posthog_event.dart b/posthog_flutter/lib/src/posthog_event.dart index 61bd72b1..c8a3e556 100644 --- a/posthog_flutter/lib/src/posthog_event.dart +++ b/posthog_flutter/lib/src/posthog_event.dart @@ -5,8 +5,7 @@ /// /// On iOS, macOS, Android, Windows and Linux the callbacks get the event the /// SDK is about to send, with its system and registered properties already -/// added. On iOS, macOS and Android it also carries its [uuid], [distinctId] -/// and [timestamp]. +/// added. It also carries its [uuid], [distinctId] and [timestamp]. /// /// ```dart /// config.beforeSend = [ @@ -51,21 +50,21 @@ class PostHogEvent { /// The event's unique id, a lowercase UUID v7. /// - /// Set on iOS, macOS and Android, where the native SDK builds the event - /// before the callbacks run. `null` on web, Windows and Linux. The id is - /// kept even when a callback returns another [PostHogEvent]. + /// Set on iOS, macOS, Android, Windows and Linux, where the SDK builds the + /// event before the callbacks run. `null` on web. The id is kept even when + /// a callback returns another [PostHogEvent]. final String? uuid; /// The id of the person the event is captured for. /// - /// Set on iOS, macOS and Android, where a callback can change it. `null` on - /// web, Windows and Linux. + /// Set on iOS, macOS, Android, Windows and Linux, where a callback can + /// change it. `null` on web. String? distinctId; /// When the event happened. /// - /// Set on iOS, macOS and Android, where a callback can change it. `null` on - /// web, Windows and Linux. + /// Set on iOS, macOS, Android, Windows and Linux, where a callback can + /// change it. `null` on web. DateTime? timestamp; /// Creates an event passed to a before-send callback. @@ -83,7 +82,7 @@ class PostHogEvent { /// The optional [options] are the event's per-event options. /// /// The optional [uuid], [distinctId] and [timestamp] are set by the SDK on - /// iOS, macOS and Android. + /// every platform but web. PostHogEvent({ required this.event, this.properties, diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 26a7e094..3f6ffb16 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'core/file_storage.dart'; +import 'core/posthog_core.dart' show PostHogPreparedEvent; import 'core/utils/utils.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'feature_flag_result.dart'; @@ -258,7 +259,6 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return; final distinctId = client.getDistinctId(); - final exceptionSteps = _exceptionSteps; final prepared = _prepare( client, @@ -279,7 +279,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('capture', scope, distinctId); if (current == null) return; - _captureProcessed(current, processed, exceptionSteps, prepared); + _captureProcessed(current, processed, prepared); }); @override @@ -301,7 +301,6 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return null; final distinctId = client.getDistinctId(); - final exceptionSteps = _exceptionSteps; final prepared = _prepare( client, @@ -322,8 +321,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('captureAi', scope, distinctId); if (current == null) return null; - return _captureProcessed(current, processed, exceptionSteps, prepared, - ai: true); + return _captureProcessed(current, processed, prepared, ai: true); }); @override @@ -339,7 +337,6 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return; final distinctId = client.getDistinctId(); - final exceptionSteps = _exceptionSteps; final prepared = _prepare( client, @@ -365,7 +362,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { // A renamed event is no longer a screen view. if (processed.event != PostHogEventName.screen) { - _captureProcessed(current, processed, exceptionSteps, prepared); + _captureProcessed(current, processed, prepared); return; } @@ -386,7 +383,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ...?processed.properties, PostHogPropertyName.screenName: finalScreenName, }; - _captureProcessed(current, processed, exceptionSteps, prepared); + _captureProcessed(current, processed, prepared); }); /// Structured logs are not supported on the desktop implementation. @@ -614,7 +611,6 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return; final distinctId = client.getDistinctId(); - final exceptionSteps = _exceptionSteps; final exceptionProps = DartExceptionProcessor.processException( error: error, @@ -645,7 +641,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('captureException', scope, distinctId); if (current == null) return; - _captureProcessed(current, processed, exceptionSteps, prepared); + _captureProcessed(current, processed, prepared); }); @override @@ -732,7 +728,9 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// [PostHogEvent.userPropertiesSetOnce]. Legacy `$set` and `$set_once` /// maps in [properties] stay in [PostHogEvent.properties], so a callback /// that removes them there still keeps them off the event. - /// The event's time is taken now, before the callbacks run. + /// The event's time, uuid and distinct id are taken now, before the + /// callbacks run. An `$exception` event gets the exception steps recorded + /// up to now, unless it sets its own. _PreparedEvent? _prepare( DesktopPostHog client, String eventName, @@ -741,10 +739,18 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? userPropertiesSetOnce, Map? options, }) { + final steps = _exceptionSteps?.steps; final prepared = client.prepareEvent( eventName, - properties: - _mergeUserProps(properties, userProperties, userPropertiesSetOnce), + properties: _mergeUserProps( + eventName == PostHogEventName.exception && + steps != null && + steps.isNotEmpty + ? {r'$exception_steps': steps, ...?properties} + : properties, + userProperties, + userPropertiesSetOnce, + ), options: cleanEventOptions(options), ); if (prepared == null) return null; @@ -768,8 +774,11 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { userProperties: _nonNull(userProperties), userPropertiesSetOnce: _nonNull(userPropertiesSetOnce), options: preparedOptions == null ? null : {...preparedOptions}, + uuid: prepared.uuid, + distinctId: prepared.distinctId, + timestamp: DateTime.parse(prepared.timestamp), ), - timestamp: prepared.timestamp, + prepared: prepared, implicitPersonProcessing: preparedOptions?['process_person_profile'] == null, ); @@ -815,24 +824,20 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// Queues [event] as the beforeSend callbacks left it, on the AI queue /// with [ai], and returns its uuid, or null when it is not queued. The - /// client adds nothing to it; an `$exception` event gets the exception - /// steps recorded up to now. + /// client adds nothing to it. The event keeps the uuid it was prepared + /// with; a blank distinct id or a missing timestamp falls back to the + /// prepared one. String? _captureProcessed( DesktopPostHog client, PostHogEvent event, - ExceptionStepsBuffer? exceptionSteps, _PreparedEvent prepared, { bool ai = false, }) { if (!_hasName(event.event)) return null; - final properties = _withExceptionSteps( - event.event, - exceptionSteps, - _mergeUserProps( - event.properties, - event.userProperties, - event.userPropertiesSetOnce, - ), + final properties = _mergeUserProps( + event.properties, + event.userProperties, + event.userPropertiesSetOnce, ) ?? const {}; // Options are sent as they are, unknown keys included. An option wins @@ -844,12 +849,22 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { options?['process_person_profile'] == null) { options = client.personProcessingOptions(options, properties); } + final base = prepared.prepared; + final distinctId = event.distinctId; + final timestamp = event.timestamp; return client.capturePrepared( event.event, ( properties: properties, options: options, - timestamp: prepared.timestamp, + timestamp: timestamp == null || + timestamp.isAtSameMomentAs(DateTime.parse(base.timestamp)) + ? base.timestamp + : timestamp.toUtc().toIso8601String(), + uuid: base.uuid, + distinctId: distinctId == null || distinctId.trim().isEmpty + ? base.distinctId + : distinctId, ), ai: ai); } @@ -861,20 +876,6 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return false; } - /// Uses the originating client's steps even when a same-scope restart - /// happened during beforeSend. Explicit event steps take precedence. - Map? _withExceptionSteps( - String event, - ExceptionStepsBuffer? exceptionSteps, - Map? properties, - ) { - final steps = exceptionSteps?.steps; - if (event != PostHogEventName.exception || steps == null || steps.isEmpty) { - return properties; - } - return {r'$exception_steps': steps, ...?properties}; - } - Map? _mergeUserProps( Map? properties, Map? userProperties, @@ -910,6 +911,6 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// and whether its person processing was left to its properties. typedef _PreparedEvent = ({ PostHogEvent event, - String timestamp, + PostHogPreparedEvent prepared, bool implicitPersonProcessing, }); diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 69f09164..f61e2827 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -525,6 +525,55 @@ void main() { expect(server.eventNames, isNot(contains('dropped event'))); }); + test( + 'a callback sees the uuid, distinct id and timestamp, and can change ' + 'the distinct id and timestamp but not the uuid', () async { + final server = await LocalPostHogServer.start(); + PostHogEvent? seen; + final config = configFor(server) + ..beforeSend = [ + (event) { + seen = PostHogEvent( + event: event.event, + uuid: event.uuid, + distinctId: event.distinctId, + timestamp: event.timestamp, + ); + return PostHogEvent( + event: event.event, + properties: event.properties, + distinctId: 'changed-user', + timestamp: DateTime.utc(2025, 1, 2, 3, 4, 5, 678), + ); + }, + ]; + final platform = await setUpPlatform(config); + final distinctId = await platform.getDistinctId(); + + await platform.capture(eventName: 'identity'); + + final event = await server.waitForEvent('identity'); + expect(seen!.uuid, isNotNull); + expect(seen!.distinctId, distinctId); + expect(seen!.timestamp, isNotNull); + expect(event['uuid'], seen!.uuid); + expect(event['distinct_id'], 'changed-user'); + expect(event['timestamp'], '2025-01-02T03:04:05.678Z'); + }); + + test('a blank distinct id from a callback keeps the current one', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [(event) => event..distinctId = ' ']; + final platform = await setUpPlatform(config); + final distinctId = await platform.getDistinctId(); + + await platform.capture(eventName: 'blank'); + + final event = await server.waitForEvent('blank'); + expect(event['distinct_id'], distinctId); + }); + test('modifications made by a callback are delivered', () async { final server = await LocalPostHogServer.start(); final config = configFor(server) @@ -1016,13 +1065,19 @@ void main() { await pending; final event = await server.waitForEvent(r'$exception'); - expect(stepMessagesOf(event), enabled ? ['original client'] : null); + // Steps are attached as the event is built, before beforeSend, as + // on iOS and Android: an event renamed to `$exception` by a + // callback gets none. + expect( + stepMessagesOf(event), + enabled && call == 'captureException' + ? ['original client'] + : null); }); } } - test('includes steps recorded while the same client awaits beforeSend', - () async { + test('attaches the steps recorded before beforeSend runs', () async { final server = await LocalPostHogServer.start(); final entered = Completer(); final release = Completer(); @@ -1042,7 +1097,26 @@ void main() { await pending; expect(stepMessagesOf(await server.waitForEvent(r'$exception')), - ['before capture', 'during beforeSend']); + ['before capture']); + }); + + test('are seen by beforeSend, which can remove them', () async { + final server = await LocalPostHogServer.start(); + Object? seen; + final platform = await setUpPlatform(configFor(server) + ..beforeSend = [ + (event) { + seen = event.properties?[r'$exception_steps']; + event.properties?.remove(r'$exception_steps'); + return event; + }, + ]); + await platform.addExceptionStep('step'); + await platform.captureException(error: StateError('error')); + + final event = await server.waitForEvent(r'$exception'); + expect(seen, isA()); + expect(_propertiesOf(event), isNot(contains(r'$exception_steps'))); }); test('normalize non-finite step and event properties identically', From 24b53ecf8785ce1598cf11b203122de20d24c95f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:34:35 -0400 Subject: [PATCH 42/43] style: merge the repeated guards in buildEvent for swiftformat 0.64 Co-Authored-By: Claude Opus 5.5 --- .../Sources/posthog_flutter/PosthogFlutterPlugin.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index e9dbffe6..5c173241 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -1605,11 +1605,9 @@ extension PosthogFlutterPlugin { _ call: FlutterMethodCall, result: @escaping FlutterResult ) { - guard let args = call.arguments as? [String: Any] else { - _badArgumentError(result) - return - } - guard let eventName = args["eventName"] as? String else { + guard let args = call.arguments as? [String: Any], + let eventName = args["eventName"] as? String + else { _badArgumentError(result) return } From f1af9d73e83a16e12501f99b7cd0fc7c07e57c02 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:54:42 -0400 Subject: [PATCH 43/43] fix(desktop): keep another distinct id's person properties out of the current user's flag cache When a beforeSend callback sends the event for another distinct id, its $set and $set_once no longer update the current user's flag person properties or person-properties hash. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/core/posthog_core.dart | 16 +++++++++---- .../test/posthog_flutter_desktop_test.dart | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 8b188571..d518f131 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -511,10 +511,16 @@ abstract class PostHogCore extends PostHogCoreStateless { // value they removed or an event they dropped neither reaches /flags // nor turns on person processing for later events. if (_requestsPersonProcessing(properties)) _enablePersonProcessing(); - _setPersonPropertiesForFlagsIfNeeded( - _asPropertyMap(properties[r'$set']), - _asPropertyMap(properties[r'$set_once']), - ); + // A callback can send the event for another distinct id; its person + // properties then belong to that person, not to the current user's + // flags or person-properties hash. + final isCurrentUser = distinctId == getDistinctId(); + if (isCurrentUser) { + _setPersonPropertiesForFlagsIfNeeded( + _asPropertyMap(properties[r'$set']), + _asPropertyMap(properties[r'$set_once']), + ); + } final queuedUuid = captureStateless(distinctId, event, properties: properties, options: options, @@ -523,7 +529,7 @@ abstract class PostHogCore extends PostHogCoreStateless { ai: ai); // PostHog drops an AI event that is not an `$ai_*` event, so it never // updates the person. - if (!ai && (event == r'$set' || event == r'$identify')) { + if (!ai && isCurrentUser && (event == r'$set' || event == r'$identify')) { final set = properties[r'$set']; final setOnce = properties[r'$set_once']; if ((set is Map || setOnce is Map) && diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index f61e2827..e5cf4a3b 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -674,6 +674,29 @@ void main() { expect(personProperties, isNot(contains('dropped'))); }); + test( + 'person properties of an event a callback sends for another distinct ' + 'id stay out of flag evaluation', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..preloadFeatureFlags = false + ..beforeSend = [(event) => event..distinctId = 'other-user']; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'other', + userProperties: {'email': 'other@example.com'}, + ); + await platform.reloadFeatureFlags(); + + final event = await server.waitForEvent('other'); + expect(event['distinct_id'], 'other-user'); + final personProperties = Map.from( + server.flagsRequests.single.body['person_properties']! as Map, + ); + expect(personProperties, isNot(contains('email'))); + }); + test('a dropped person update does not turn on person processing', () async { final server = await LocalPostHogServer.start();