diff --git a/.changeset/v6-before-send-native-event.md b/.changeset/v6-before-send-native-event.md new file mode 100644 index 00000000..3e58dec2 --- /dev/null +++ b/.changeset/v6-before-send-native-event.md @@ -0,0 +1,8 @@ +--- +"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 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/.changeset/v6-capture-options-ai.md b/.changeset/v6-capture-options-ai.md new file mode 100644 index 00000000..dc5602c9 --- /dev/null +++ b/.changeset/v6-capture-options-ai.md @@ -0,0 +1,8 @@ +--- +"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, 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..3795283f --- /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, 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/.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 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/.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/.changeset/v6-posthog-ios-4.md b/.changeset/v6-posthog-ios-4.md new file mode 100644 index 00000000..39b171fa --- /dev/null +++ b/.changeset/v6-posthog-ios-4.md @@ -0,0 +1,8 @@ +--- +"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 +- **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 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/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index d3767214..3fe46245 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", @@ -802,7 +905,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -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", @@ -1855,7 +2079,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -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", @@ -2301,7 +2543,7 @@ "isRequired": false, "name": "onFeatureFlags", "relativePath": "lib/src/posthog_config.dart", - "typeName": "void Function()?" + "typeName": "void Function(PostHogFeatureFlagsLoaded)?" }, { "isDeprecated": false, @@ -2481,9 +2723,53 @@ "isReadable": true, "isStatic": false, "isWriteable": true, - "name": "compression", + "name": "compression", + "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": "PostHogCompression" + "typeName": "int?" }, { "entryPoints": [], @@ -2582,7 +2868,7 @@ "isWriteable": true, "name": "onFeatureFlags", "relativePath": "lib/src/posthog_config.dart", - "typeName": "void Function()?" + "typeName": "void Function(PostHogFeatureFlagsLoaded)?" }, { "entryPoints": [], @@ -4141,6 +4427,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 +4467,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 +4525,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 +4541,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 +4623,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 +4721,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", @@ -4522,7 +4911,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -4683,6 +5072,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 +5370,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 +5633,42 @@ "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?" + }, + { + "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", @@ -5278,6 +5721,50 @@ "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?" + }, + { + "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, @@ -6026,6 +6513,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/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 { 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..aa886cb5 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 @@ -27,7 +28,9 @@ import com.posthog.PostHogBeforeSend import com.posthog.PostHogBootstrapConfig import com.posthog.PostHogCompression import com.posthog.PostHogConfig -import com.posthog.PostHogOnFeatureFlags +import com.posthog.PostHogEvent +import com.posthog.PostHogFeatureFlagsCallback +import com.posthog.PostHogFeatureFlagsLoaded import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.getApplicationInfo @@ -61,6 +64,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, @@ -79,6 +84,14 @@ 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. + // 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 // unit tests). postHogConfig is only published on success, so production readers // never see an uninstalled config. @@ -247,6 +260,10 @@ class PosthogFlutterPlugin : capture(call, result) } + "captureAi" -> { + captureAi(call, result) + } + "captureFeatureView" -> { captureFeatureView(call, result) } @@ -259,6 +276,18 @@ class PosthogFlutterPlugin : screen(call, result) } + "buildEvent" -> { + buildEvent(call, result) + } + + "sendBuiltEvent" -> { + sendBuiltEvent(call, result) + } + + "discardBuiltEvent" -> { + discardBuiltEvent(call, result) + } + "captureLog" -> { captureLog(call, result) } @@ -540,6 +569,7 @@ class PosthogFlutterPlugin : return } + builtEvents.clear() setupPostHog(args) result.success(null) @@ -608,6 +638,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 } @@ -812,11 +857,12 @@ class PosthogFlutterPlugin : ) } } + nativeBeforeSendSnapshot().forEach { this.addBeforeSend(it) } onFeatureFlags = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { loaded -> Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - invokeFlutterMethod("onFeatureFlagsCallback", emptyMap()) + invokeFlutterMethod("onFeatureFlagsCallback", loaded.toFlutterMap()) } } @@ -1730,7 +1776,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 +1791,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 +1808,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 +1822,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,13 +1853,99 @@ 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) } } + /** + * 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 + } + 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, + "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)?.first + 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, @@ -1820,7 +1980,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) @@ -1929,9 +2090,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) { @@ -1998,7 +2159,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 +2292,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 +2302,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) @@ -2164,6 +2329,7 @@ class PosthogFlutterPlugin : private fun close(result: Result) { try { + builtEvents.clear() PostHog.close() result.success(null) } catch (e: Throwable) { @@ -2232,6 +2398,8 @@ class PosthogFlutterPlugin : } } + private fun PostHogFeatureFlagsLoaded.toFlutterMap(): Map = mapOf("variants" to variants, "errorsLoading" to errorsLoading) + private fun invokeFlutterMethod( method: String, arguments: Any? = null, @@ -2264,6 +2432,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 @@ -2307,6 +2511,21 @@ 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 -> 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) + 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/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/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index fc333ff8..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 @@ -5,7 +5,9 @@ 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 import com.posthog.android.replay.PostHogScreenshotColorMode import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -39,6 +41,7 @@ internal class PosthogFlutterPluginTest { @BeforeTest fun resetSharedRoute() { PosthogFlutterPlugin.resetPushIdentityRouteForTesting() + PosthogFlutterPlugin.resetNativeBeforeSendForTesting() } @Test @@ -225,6 +228,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() @@ -247,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() @@ -945,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)) } + } } 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..5c173241 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -44,6 +44,19 @@ 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. + // 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 + + private static let nativeBeforeSendLock = NSLock() + private static var nativeBeforeSend: [BeforeSendBlock] = [] + 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 // engines stay candidates so detach promotes the most recent survivor instead @@ -55,21 +68,45 @@ 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 } override init() { super.init() - NotificationCenter.default.addObserver( - self, - selector: #selector(featureFlagsDidUpdate), - name: PostHogSDK.didReceiveFeatureFlags, - object: nil - ) + featureFlagsSubscription = PostHogSDK.shared.onFeatureFlags { [weak self] loaded in + self?.featureFlagsDidUpdate(loaded) + } } deinit { + featureFlagsSubscription?.unsubscribe() NotificationCenter.default.removeObserver(self) } @@ -88,8 +125,12 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: methodChannel) } - @objc 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", @@ -240,6 +281,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": @@ -443,8 +503,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 @@ -478,8 +541,16 @@ 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 "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": @@ -1332,6 +1403,7 @@ extension PosthogFlutterPlugin { result: @escaping FlutterResult ) { if let args = call.arguments as? [String: Any] { + builtEvents.removeAll() PosthogFlutterPlugin.setupPostHog(args, anchor: self) result(nil) } else { @@ -1424,11 +1496,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 { @@ -1443,10 +1517,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 { @@ -1464,11 +1540,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 { @@ -1476,6 +1554,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 @@ -1484,9 +1586,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 { @@ -1494,6 +1598,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], + 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() + let now = Date() + builtEvents = builtEvents.filter { now.timeIntervalSince($0.value.builtAt) < Self.builtEventTimeout } + builtEvents[uuid] = (built, now) + 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)?.event 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 @@ -1545,7 +1736,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) @@ -1622,9 +1814,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 { + PostHogSDK.shared.reloadFeatureFlags { loaded in DispatchQueue.main.async { - result(nil) + result(Self.featureFlagsLoadedMap(loaded)) } } } @@ -1687,7 +1879,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) @@ -1791,6 +1984,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 @@ -1799,7 +1993,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) } @@ -1817,6 +2011,7 @@ extension PosthogFlutterPlugin { } private func close(_ result: @escaping FlutterResult) { + builtEvents.removeAll() PostHogSDK.shared.close() result(nil) } 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..163dbac0 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'; @@ -20,6 +21,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 +46,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { return applyBeforeSend( _beforeSendCallbacks, @@ -52,10 +55,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', @@ -129,10 +144,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); @@ -144,7 +161,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 +178,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 +202,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 +222,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 +253,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 +309,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 +321,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 +332,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } @@ -285,7 +342,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 +357,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})); } @@ -393,8 +458,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 @@ -442,7 +508,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 +612,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) async { try { final exceptionData = DartExceptionProcessor.processException( @@ -558,6 +627,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.exception, exceptionData.cast(), + options: options, ); if (processedEvent == null) { printIfDebug( @@ -570,12 +640,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/capture_v1.dart b/posthog_flutter/lib/src/core/capture_v1.dart index 14e6d886..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. @@ -10,21 +9,22 @@ const captureV1Path = '/i/v1/analytics/events'; /// stay under proxy and capture body limits. const captureV1MaxBatchBytes = 5 * 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; -} +/// 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; /// Capture v1 statuses worth retrying. Everything else is terminal, /// including 3xx; 413 halves the batch instead. diff --git a/posthog_flutter/lib/src/core/file_storage.dart b/posthog_flutter/lib/src/core/file_storage.dart index 96175bfc..754d6a7f 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; @@ -367,19 +376,23 @@ class FileEventQueue { return _byteCount; } - /// Adds [event] at the end of the queue. - void add(Map event) { + /// 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(); - String? encoded; - try { - encoded = jsonEncode(event); - } catch (_) { - // Reported below when it is written. + var encoded = json; + if (encoded == null) { + try { + encoded = jsonEncode(event); + } catch (_) { + // Reported below when it is written. + } } - ids.add(_track(id, encoded == null ? 0 : utf8.encode(encoded).length)); + ids.add(_track( + id, bytes ?? (encoded == null ? 0 : utf8.encode(encoded).length))); if (_directory.isEmpty) { _unwritten[id] = event; return; @@ -397,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); @@ -448,6 +473,19 @@ 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 = byteCount; + var count = 0; + while (total > maxBytes && count < _ids.length - 1) { + total -= _sizes[_ids[count]] ?? 0; + count++; + } + removeOldest(count); + return count; + } + void _forget(String id) { _untrack(id); if (_unwritten.remove(id) == null && _directory.isNotEmpty) { diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 43f7ab84..d518f131 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'; @@ -14,6 +15,17 @@ import 'session_manager.dart'; import 'utils/utils.dart'; import 'uuid.dart'; +/// An event built by [PostHogCore.prepareEvent]: its properties, its +/// 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. /// /// It extends [PostHogCoreStateless] with state management for sessions, @@ -142,28 +154,53 @@ 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, - 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, + /// 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, { + required bool requested, + }) { + if (requested || + _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 @@ -179,8 +216,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)); } @@ -292,10 +329,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 [options] are sent with the event this call sends. void identify( String distinctId, { Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { wrap(() { if (distinctId.trim().isEmpty) { @@ -327,16 +367,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) { + if ((userProperties != null || userPropertiesSetOnce != null) && + !_isPersonless(identifyProperties, options)) { _cachedPersonProperties = _personPropertiesHash( distinctId, userProperties, userPropertiesSetOnce); } @@ -345,10 +388,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: { - 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, @@ -356,6 +401,7 @@ abstract class PostHogCore extends PostHogCoreStateless { setPersonProperties( userPropertiesToSet: userProperties, userPropertiesToSetOnce: userPropertiesSetOnce, + options: options, ); } else { logger.info('identify was called with the current distinct id of an ' @@ -370,42 +416,135 @@ 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; - - 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 prepared = + _prepareEvent(event, properties: properties, options: options); + if (prepared != null) _queuePrepared(event, prepared); + }); + } - final allProperties = _enrichProperties(properties); - // Once an event is person-processed, all later ones are too. - if (allProperties[r'$process_person_profile'] == true) { - _enablePersonProcessing(); - } + /// 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 the event starts or extends the session. They feed + /// flag evaluation only as [capturePrepared] queues the event. + 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. 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. + /// + /// 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); + }); - captureStateless(getDistinctId(), event, properties: allProperties); - 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) { - _cachedPersonProperties = - _personPropertiesHash(getDistinctId(), set, setOnce); - } + /// [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, + Map? options, + }) { + if (_ignoredWhileOptedOut('posthog.capture')) return null; + + 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, + requested: _requestsPersonProcessing(properties), + ), + timestamp: currentISOTime(), + uuid: generateUuidV7(), + distinctId: getDistinctId(), + ); + } + + String? _queuePrepared( + String event, + PostHogPreparedEvent prepared, { + bool ai = false, + }) { + 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. + if (_requestsPersonProcessing(properties)) _enablePersonProcessing(); + // 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, + 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 && 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) && + !_isPersonless(properties, options)) { + _cachedPersonProperties = + _personPropertiesHash(getDistinctId(), set, setOnce); } - }); + } + return queuedUuid; } /// Creates an alias for a user. An empty [alias] is ignored. - void alias(String alias) { + /// + /// 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. ' @@ -415,7 +554,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(null), options: options); }); } @@ -440,10 +580,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 [options] are sent with the `$groupidentify` event. void group( String groupType, String groupKey, { Map? groupProperties, + Map? options, }) { wrap(() { if (groupType.trim().isEmpty || groupKey.trim().isEmpty) { @@ -468,8 +611,9 @@ abstract class PostHogCore extends PostHogCoreStateless { groupType, groupKey, distinctId: getDistinctId(), - eventProperties: _enrichProperties({}), + eventProperties: _enrichProperties(null), groupProperties: groupProperties, + options: options, ); }); } @@ -923,6 +1067,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. @@ -943,6 +1093,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; @@ -989,9 +1144,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 [options] are sent with the `$set` event. void setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) { wrap(() { final isSetEmpty = @@ -1006,7 +1164,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(null, options)) { logger.info( 'A duplicate setPersonProperties call was made. It has been ignored.'); return; @@ -1018,10 +1178,12 @@ abstract class PostHogCore extends PostHogCoreStateless { }; setPersonPropertiesForFlags(mergedProperties); - capture(r'$set', properties: { - r'$set': userPropertiesToSet ?? {}, - r'$set_once': userPropertiesToSetOnce ?? {}, - }); + capture(r'$set', + properties: { + r'$set': userPropertiesToSet ?? {}, + r'$set_once': userPropertiesToSetOnce ?? {}, + }, + options: options); }); } @@ -1042,6 +1204,16 @@ abstract class PostHogCore extends PostHogCoreStateless { static bool _isNonEmptyMap(Object? value) => value is Map && value.isNotEmpty; + /// 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; return { diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index ee30b2df..a12067f0 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -87,12 +87,15 @@ Duration captureRetryDelay({ abstract class PostHogCoreStateless { final String _apiKey; final String _host; - 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; /// How long a request may go without progress: connecting, a chunk of @@ -112,30 +115,17 @@ abstract class PostHogCoreStateless { static const _flagsRetryCount = 1; static const _flagsRetryDelay = Duration(milliseconds: 300); - /// The [_failedFlushes] at which the backoff reaches its 30 second cap. + /// The [_CaptureLane.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; - /// 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 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; - /// 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 @@ -144,9 +134,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. @@ -167,9 +154,6 @@ abstract class PostHogCoreStateless { required this.storage, }) : _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 { @@ -181,16 +165,54 @@ abstract class PostHogCoreStateless { debug(true); } - final queue = storage.queue; - final overflow = queue.length - _maxQueueSize; - if (overflow > 0) { - queue.removeOldest(overflow); + final flushAt = config.flushAt < 1 ? 1 : config.flushAt; + final maxBatchSize = config.maxBatchSize < 1 ? 1 : config.maxBatchSize; + _analytics = _CaptureLane( + path: captureV1Path, + queue: storage.queue, + flushAt: flushAt, + 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; + if (aiMaxEventBytes > captureAiMaxEventBytes) { + logger.warn('captureAiMaxEventBytes $aiMaxEventBytes is over the ' + '$captureAiMaxEventBytes byte ceiling, using the ceiling.'); } + _ai = _CaptureLane( + path: captureV1AiPath, + queue: storage.aiQueue, + flushAt: flushAt, + 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); + } } } @@ -279,11 +301,9 @@ abstract class PostHogCoreStateless { required String event, required Map properties, }) { - var eventProperties = { - ...properties, - ...getCommonEventProperties(), - }; - // 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()) { @@ -308,25 +328,35 @@ abstract class PostHogCoreStateless { void identifyStateless( String distinctId, { required Map properties, + Map? options, }) { _enqueue( + _analytics, 'identify', _buildPayload( distinctId: distinctId, event: r'$identify', properties: properties, ), + options: options, ); } + /// 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, + String? uuid, + bool ai = false, }) { - _enqueue( + return _enqueue( + ai ? _ai : _analytics, 'capture', _buildPayload( distinctId: distinctId, @@ -334,6 +364,8 @@ abstract class PostHogCoreStateless { properties: properties, ), options: options, + timestamp: timestamp, + uuid: uuid, ); } @@ -342,8 +374,10 @@ abstract class PostHogCoreStateless { String alias, String distinctId, { required Map properties, + Map? options, }) { _enqueue( + _analytics, 'alias', _buildPayload( distinctId: distinctId, @@ -354,6 +388,7 @@ abstract class PostHogCoreStateless { 'alias': alias, }, ), + options: options, ); } @@ -364,8 +399,10 @@ abstract class PostHogCoreStateless { required String distinctId, required Map eventProperties, Map? groupProperties, + Map? options, }) { _enqueue( + _analytics, 'capture', _buildPayload( distinctId: distinctId, @@ -377,6 +414,7 @@ abstract class PostHogCoreStateless { ...eventProperties, }, ), + options: options, ); } @@ -459,13 +497,17 @@ 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], 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( + _CaptureLane lane, String type, Map message, { Map? options, + String? timestamp, + String? uuid, }) { final prepared = { ...message, @@ -476,26 +518,49 @@ abstract class PostHogCoreStateless { 'type': type, 'library': postHogFlutterSdkName, 'library_version': postHogFlutterVersion, - 'timestamp': currentISOTime(), - 'uuid': generateUuidV7(), + 'timestamp': timestamp ?? currentISOTime(), + 'uuid': 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); // 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(); + if (queue.length >= lane.flushAt || + queue.byteCount >= captureV1MaxBatchBytes) { + _flushBackground(lane); } - _scheduleFlush(); + _scheduleFlush(lane); + return prepared['uuid'] as String; } /// The event properties as they are queued and sent: values JSON cannot @@ -512,107 +577,104 @@ 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; - } - - /// 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, - )); + /// Counts one more failed flush of [lane] and stops sending its events + /// for the backoff it calls for, at least the server's [retryAfter]. + void _backOff(_CaptureLane lane, Duration? retryAfter) { + if (lane.failedFlushes < _maxBackoffStep) lane.failedFlushes++; + _pauseFlush( + lane, + captureRetryDelay( + attempt: lane.failedFlushes, + jitterUnit: jitterUnit(), + retryAfter: retryAfter, + )); } - /// Stops sending events for [delay]. - void _pauseFlush(Duration delay) { - _clearFlushTimer(); - _retryAfterTimer?.cancel(); - _retryAfterTimer = Timer(delay, () { - _retryAfterTimer = null; - if (!_closed && - _flushInterval > Duration.zero && - storage.queue.length > 0) { - _flushBackground(); + /// Stops sending the events of [lane] for [delay]. + void _pauseFlush(_CaptureLane lane, Duration delay) { + lane.clearFlushTimer(); + lane.retryAfterTimer?.cancel(); + lane.retryAfterTimer = Timer(delay, () { + 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; + } - final queue = storage.queue; - final sentMessages = >[]; + Future _doFlush(_CaptureLane lane) async { + lane.clearFlushTimer(); - 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)); - if (batch.isEmpty) { - if (queue.length > 0) _scheduleFlush(); - break; - } - if (!await _sendBatch(batch, sentMessages)) break; - } - } finally { - if (sentMessages.isNotEmpty) { - events.emit('flush', sentMessages); + final queue = lane.queue; + + 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, /// and sends them again once the pause is over. It also stops after a 413 /// halves the batch size. Future _sendBatch( + _CaptureLane lane, List batch, - List> sent, ) async { - final queue = storage.queue; + final queue = lane.queue; final Set retryUuids; final Duration? retryAfter; try { - (:retryUuids, :retryAfter) = await _postCaptureV1(batch); + (:retryUuids, :retryAfter) = await _postCaptureV1(lane, batch); } catch (e) { if (_closed) rethrow; if (e is PostHogFetchHttpError && @@ -620,33 +682,34 @@ abstract class PostHogCoreStateless { 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'); + lane.maxBatchSize = (batch.length ~/ 2).clamp(1, lane.maxBatchSize); + lane.flushAt = (lane.flushAt ~/ 2).clamp(1, lane.maxBatchSize); + logger + .warn('Received 413, reducing batch size to ${lane.maxBatchSize}'); // The smaller batches go out with the next flush, not right away. - _scheduleFlush(); + _scheduleFlush(lane); return false; } 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}.'); } // 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); + _backOff(lane, e is PostHogFetchHttpError ? e.retryAfter : null); } else { queue.remove([for (final queued in batch) queued.id]); - _failedFlushes = 0; - _scheduleFlush(); + lane.failedFlushes = 0; + _scheduleFlush(lane); } events.emit('error', e); rethrow; @@ -659,16 +722,18 @@ 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) { - _failedFlushes = 0; + lane.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); + _backOff(lane, retryAfter); return false; } @@ -683,13 +748,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( @@ -697,7 +763,7 @@ abstract class PostHogCoreStateless { requestId: generateUuidV7(), createdAt: currentISOTime(), ); - _pendingCaptureIdentity = identity; + lane.pendingIdentity = identity; final body = jsonEncode({ 'created_at': identity.createdAt, @@ -714,11 +780,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, @@ -737,7 +803,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); } @@ -748,14 +814,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); @@ -799,8 +869,9 @@ abstract class PostHogCoreStateless { ); } - /// Sends one request, aborted once it makes no progress for - /// [_requestTimeout]. A transport failure is thrown as + /// Sends one request, aborted once it makes no progress for [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. @@ -815,10 +886,14 @@ abstract class PostHogCoreStateless { String body, { Map headers = const {}, bool deliveredOnBodyFailure = false, + Duration timeout = _requestTimeout, + PostHogCompression? compression, }) async { - final attempt = _FetchAttempt(_requestTimeout, _responseBodyTimeout); + final attempt = _FetchAttempt(timeout, + timeout > _responseBodyTimeout ? timeout : _responseBodyTimeout); try { - return await attempt.guard(_fetch(url, body, attempt, headers)); + return await attempt.guard( + _fetch(url, body, attempt, headers, compression ?? _compression)); } catch (e) { final status = attempt.status; if (deliveredOnBodyFailure && @@ -863,6 +938,7 @@ abstract class PostHogCoreStateless { String body, _FetchAttempt attempt, Map headers, + PostHogCompression compression, ) async { final request = await _httpClient.postUrl(url); attempt.attachRequest(request); @@ -879,7 +955,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 { @@ -918,14 +994,76 @@ 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.flushAt, + required this.maxBatchSize, + required this.maxQueueSize, + required this.compression, + required this.timeout, + this.maxEventBytes, + this.maxQueueBytes, + }); + + final String path; + final FileEventQueue queue; + + /// Lowered with [maxBatchSize] by a 413, and kept within it. + int flushAt; + + /// Lowered by a 413. + int maxBatchSize; + final int maxQueueSize; + final PostHogCompression compression; + + /// How long a request may go without progress. Connecting is also bounded + /// by the HTTP client's connection timeout, shared by every endpoint. + 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 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; + + /// 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 { _FetchAttempt(this._idleTimeout, this._bodyTimeout); 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..51520dff 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); @@ -139,6 +140,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 +160,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 +180,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 +196,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 +220,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, 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 + /// without event properties. + /// /// Returns a [Future] that completes when the event has been queued. /// /// **Example** @@ -226,7 +260,67 @@ 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 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], + /// - the SDK is disabled, not set up or opted out, or + /// - the platform is web (see below). + /// + /// **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`. + /// + /// **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 +329,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 +338,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 +467,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. /// @@ -489,8 +589,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 +628,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 +658,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 +682,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 +714,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, @@ -614,6 +736,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 +757,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 +1013,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_config.dart b/posthog_flutter/lib/src/posthog_config.dart index fcbd806f..be0a81a4 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]. + /// + /// Not applied on web, where [Posthog.captureAi] sends nothing. + /// + /// 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 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. + /// + /// Not applied on web, where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the SDK default. + int? captureAiMaxQueueSize; + + /// Timeout of a [Posthog.captureAi] request: how long it may go without + /// network activity. 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). + /// + /// AI requests can carry several MB, so the default (60 seconds) is + /// longer than for other requests. [Duration.zero] or less uses the + /// default. + /// + /// Not applied on web, where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the SDK default. + Duration? captureAiTimeout; + + /// 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 + /// 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. + /// + /// Not applied on web, where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the SDK default. + int? captureAiMaxEventBytes; + /// Whether the SDK is enabled on Windows and Linux. /// /// Windows and Linux support is experimental. Defaults to `true`. @@ -264,8 +313,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. @@ -386,11 +451,21 @@ 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 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, 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:** /// - Callbacks can be synchronous or asynchronous (via 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/lib/src/posthog_event.dart b/posthog_flutter/lib/src/posthog_event.dart index 1484986b..c8a3e556 100644 --- a/posthog_flutter/lib/src/posthog_event.dart +++ b/posthog_flutter/lib/src/posthog_event.dart @@ -2,14 +2,33 @@ /// /// 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. 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. /// - /// 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, 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 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). @@ -22,6 +41,32 @@ 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; + + /// The event's unique id, a lowercase UUID v7. + /// + /// 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, Android, Windows and Linux, where a callback can + /// change it. `null` on web. + String? distinctId; + + /// When the event happened. + /// + /// 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. /// /// The [event] is the event name to capture. @@ -33,15 +78,24 @@ class PostHogEvent { /// /// The optional [userPropertiesSetOnce] are person properties to set with /// `$set_once`. + /// + /// The optional [options] are the event's per-event options. + /// + /// The optional [uuid], [distinctId] and [timestamp] are set by the SDK on + /// every platform but web. 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)'; + 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_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 7b7df894..3f6ffb16 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -3,9 +3,11 @@ 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'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; import 'posthog_constants.dart'; @@ -20,6 +22,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'; @@ -175,8 +178,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 @@ -217,12 +220,14 @@ 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), + options: cleanEventOptions(options), ); }); @@ -230,11 +235,13 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) => _guard('setPersonProperties', (client) { client.setPersonProperties( userPropertiesToSet: _normalize(userPropertiesToSet), userPropertiesToSetOnce: _normalize(userPropertiesToSetOnce), + options: cleanEventOptions(options), ); }); @@ -244,6 +251,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) => _guard('capture', (client) async { if (client.optedOut) return; @@ -251,14 +259,17 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return; 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) { @@ -268,13 +279,56 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('capture', scope, distinctId); if (current == null) return; - _captureProcessed(current, processed, exceptionSteps); + _captureProcessed(current, processed, prepared); + }); + + @override + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) => + _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 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, prepared, ai: true); }); @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 @@ -283,15 +337,18 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return; final distinctId = client.getDistinctId(); - final exceptionSteps = _exceptionSteps; - final hooked = _runBeforeSend( + final prepared = _prepare( + client, PostHogEventName.screen, { ...?properties, PostHogPropertyName.screenName: screenName, }, + options: options, ); + if (prepared == null) return; + final hooked = _runBeforeSend(prepared.event); final processed = hooked is Future ? await hooked : hooked; if (processed == null) { @@ -305,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); + _captureProcessed(current, processed, prepared); return; } @@ -326,7 +383,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ...?processed.properties, PostHogPropertyName.screenName: finalScreenName, }; - _captureProcessed(current, processed, exceptionSteps); + _captureProcessed(current, processed, prepared); }); /// Structured logs are not supported on the desktop implementation. @@ -365,8 +422,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, + options: cleanEventOptions(options), + ), + ); @override Future captureFeatureView({ @@ -451,8 +514,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( @@ -489,12 +557,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), + options: cleanEventOptions(options), ); }); @@ -534,13 +604,13 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) => _guard('captureException', (client) async { if (client.optedOut) return; final scope = _clientScope; if (scope == null) return; final distinctId = client.getDistinctId(); - final exceptionSteps = _exceptionSteps; final exceptionProps = DartExceptionProcessor.processException( error: error, @@ -551,10 +621,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) { @@ -567,7 +641,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('captureException', scope, distinctId); if (current == null) return; - _captureProcessed(current, processed, exceptionSteps); + _captureProcessed(current, processed, prepared); }); @override @@ -644,10 +718,84 @@ 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. + /// + /// [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, 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, + Map? properties, { + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) { + final steps = _exceptionSteps?.steps; + final prepared = client.prepareEvent( + eventName, + 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; + 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(userProperties), + userPropertiesSetOnce: _nonNull(userPropertiesSetOnce), + options: preparedOptions == null ? null : {...preparedOptions}, + uuid: prepared.uuid, + distinctId: prepared.distinctId, + timestamp: DateTime.parse(prepared.timestamp), + ), + prepared: prepared, + implicitPersonProcessing: + preparedOptions?['process_person_profile'] == null, + ); + } + + /// 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 + ? 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 /// 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. @@ -656,18 +804,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, - }) { - final event = PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ); + FutureOr _runBeforeSend(PostHogEvent event) { final callbacks = _config?.beforeSend ?? const []; return callbacks.isEmpty ? event : _applyCallbacks(callbacks, event); } @@ -685,24 +822,51 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return processed; } - 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. 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, - ) { - if (!_hasName(event.event)) return; - client.capture( - event.event, - properties: _withExceptionSteps( - event.event, - exceptionSteps, - _mergeUserProps( + _PreparedEvent prepared, { + bool ai = false, + }) { + if (!_hasName(event.event)) return null; + final properties = _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); + } + final base = prepared.prepared; + final distinctId = event.distinctId; + final timestamp = event.timestamp; + return client.capturePrepared( + event.event, + ( + properties: properties, + options: options, + 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); } /// Whether [event] has a name; an event without one is dropped. @@ -712,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, @@ -756,3 +906,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, + PostHogPreparedEvent prepared, + bool implicitPersonProcessing, +}); diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index f5a1b2ad..e6b4246c 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -13,10 +13,12 @@ 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'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; import 'posthog_constants.dart'; @@ -43,25 +45,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, - }) { - return applyBeforeSend( - _beforeSendCallbacks, - PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ), + /// 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 { @@ -73,7 +152,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. @@ -235,12 +315,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 +336,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 +347,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 +367,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,112 +380,157 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) async { - if (!isSupportedPlatform()) { - return; - } + await _captureEvent( + 'capture', + eventName: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + } - // Apply beforeSend callback - final processedEvent = await _runBeforeSend( - eventName, - properties, + @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; + } - if (processedEvent == null) { - printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); - return; + /// 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 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; - - await _methodChannel.invokeMethod('capture', { - 'eventName': processedEvent.event, - if (normalizedProperties != null) 'properties': normalizedProperties, - if (normalizedUserProperties != null) - 'userProperties': normalizedUserProperties, - if (normalizedUserPropertiesSetOnce != null) - 'userPropertiesSetOnce': normalizedUserPropertiesSetOnce, - }); } on PlatformException catch (exception) { - printIfDebug('Exeption on capture: $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, Map? properties, + Map? options, }) async { if (!isSupportedPlatform()) { 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, - ); - 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(), - ); + 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; + // 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) { printIfDebug('Exeption on screen: $exception'); @@ -440,13 +571,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'); } @@ -585,15 +723,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); } } @@ -672,12 +812,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 +829,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 +945,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) async { if (!isSupportedPlatform()) { return; @@ -817,38 +961,30 @@ 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(), - ); - 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(), - ); + 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 normalizedData = PropertyNormalizer.normalize( + exceptionProps.cast(), + ); + final cleanedOptions = cleanEventOptions(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..2dba870e 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. @@ -47,6 +49,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { required String userId, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { throw UnimplementedError('identify() has not been implemented.'); } @@ -54,6 +57,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) { throw UnimplementedError('setPersonProperties() has not been implemented.'); } @@ -63,13 +67,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 +135,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.'); } @@ -173,7 +191,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('isFeatureEnabled() has not been implemented.'); } - Future reloadFeatureFlags() { + Future reloadFeatureFlags() { throw UnimplementedError('reloadFeatureFlags() has not been implemented.'); } @@ -212,6 +230,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 +263,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/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..34e00c25 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'; @@ -21,6 +22,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 +46,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 { @@ -170,3 +185,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/lib/src/utils/event_options.dart b/posthog_flutter/lib/src/utils/event_options.dart new file mode 100644 index 00000000..8f33f24c --- /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 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]. +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/core/capture_ai_test.dart b/posthog_flutter/test/core/capture_ai_test.dart new file mode 100644 index 00000000..d63c33fa --- /dev/null +++ b/posthog_flutter/test/core/capture_ai_test.dart @@ -0,0 +1,316 @@ +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, with a warning', + () { + final storage = tempStorage(); + 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', + 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.byteCount, 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'); + }); + }); + + test('the AI and analytics endpoints back off independently', () { + var batchStatus = HttpStatus.serviceUnavailable; + final api = InProcessPostHogApi() + ..respond = (request) => PostHogResponse( + request.isAi ? HttpStatus.serviceUnavailable : batchStatus); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + + // AI fails at 0, 1 and 3 s: its next retry is 4 s later, at 7 s. + captureAi(client, r'$ai_generation'); + client.flush().catchError((Object _) {}); + async.elapse(const Duration(milliseconds: 3500)); + expect(api.aiRequests, hasLength(3)); + + // Analytics starts its own backoff at 1 s, not at AI's 8 s. + client.capture('event'); + client.flush().catchError((Object _) {}); + async.flushMicrotasks(); + expect(api.batchRequests, hasLength(1)); + batchStatus = HttpStatus.ok; + async.elapse(const Duration(milliseconds: 900)); + expect(api.batchRequests, hasLength(1)); + async.elapse(const Duration(milliseconds: 200)); + expect(api.batchRequests, hasLength(2)); + expect(storage.queue.length, 0); + + // The delivered analytics batch leaves AI's backoff where it was. + async.elapse(const Duration(milliseconds: 2300)); // 6.9 s + expect(api.aiRequests, hasLength(3)); + async.elapse(const Duration(milliseconds: 200)); // 7.1 s + expect(api.aiRequests, hasLength(4)); + expect(aiQueue(storage), hasLength(1)); + }); + }); + }); +} 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/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..9d9d5f04 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'}); }); @@ -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); @@ -151,7 +161,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 +169,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/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++) { 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/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(); diff --git a/posthog_flutter/test/core/test_client.dart b/posthog_flutter/test/core/test_client.dart index 5af59e7f..de35e5fd 100644 --- a/posthog_flutter/test/core/test_client.dart +++ b/posthog_flutter/test/core/test_client.dart @@ -100,6 +100,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/event_shape_snapshot_test.dart b/posthog_flutter/test/event_shape_snapshot_test.dart index d06a6711..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(); @@ -90,6 +107,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', @@ -233,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_api_fake.dart b/posthog_flutter/test/posthog_api_fake.dart index b2c7646c..d3b52fd2 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 79b1f7c7..e5cf4a3b 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -5,8 +5,10 @@ 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_event.dart'; import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; import 'package:posthog_flutter/src/posthog_flutter_version.dart'; @@ -218,6 +220,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)); @@ -282,7 +304,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 +318,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'); }); }); @@ -502,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) @@ -526,6 +598,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) @@ -543,6 +640,117 @@ 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( + '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(); + 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('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 = []; @@ -576,17 +784,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,22 +813,84 @@ 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('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; + 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()); final flagsLoaded = Completer(); final seenEventNames = []; final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); } ..beforeSend = [ @@ -807,13 +1088,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(); @@ -833,7 +1120,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', @@ -953,6 +1259,221 @@ void main() { }); }); + group('Event options', () { + test('are sent as they are, winning over their legacy properties', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + 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 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 { + 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(_optionsOf(processed)['process_person_profile'], isTrue); + expect(_optionsOf(anonymous)['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 = _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', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + Map options(String id) => + {'product_tour_id': id, 'cookieless_mode': false, 'custom': id}; + + 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 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); + } + }); + + 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') _optionsOf(event), + ]; + expect( + [for (final set in sets) set['process_person_profile']], + [isFalse, isNull], + ); + }); + }); + + group('captureAi', () { + test('sends to the AI endpoint through beforeSend and returns the uuid', + () async { + final server = await LocalPostHogServer.start(); + PostHogEvent? seen; + final config = configFor(server) + ..beforeSend = [ + (event) { + seen = event; + event.properties = {...?event.properties, 'from_hook': true}; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + final uuid = await platform.captureAi( + eventName: r'$ai_generation', + properties: {r'$ai_model': 'gpt-5'}, + options: {'custom': 'x'}, + ); + await platform.flush(); + + 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', () { /// A launch of the app with [build], keeping its state in [appDirectory]. PosthogFlutterDesktop launch( @@ -1728,7 +2249,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 +2279,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 +2315,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 +2334,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 +2361,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 +2412,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 +2449,7 @@ void main() { final announced = Completer(); await platform.setup(configFor(server) ..preloadFeatureFlags = false - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { payloads.add(platform .getFeatureFlagResult( key: 'variant-flag', sendFeatureFlagEvent: false) @@ -1928,6 +2470,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 = []; diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index b33e9db0..8594954b 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'; @@ -36,6 +37,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; }); }); @@ -162,7 +169,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 +219,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 { @@ -452,6 +410,134 @@ 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, [sent, sent]); + const changed = { + 'process_person_profile': false, + 'product_tour_id': 'tour-1', + }; + 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 { + 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('sendBuiltEvent')['event'], 'screen viewed'); + expect(argsOf('sendBuiltEvent')['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 == 'sendBuiltEvent'), isEmpty); + }); + }); + group('PosthogFlutterIO beforeSend callback', () { test( 'capture sends event unchanged when no beforeSend registered', @@ -485,9 +571,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 { @@ -507,7 +593,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}); }); @@ -529,9 +615,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 { @@ -551,10 +639,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'}, }); }); @@ -565,7 +653,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); }); @@ -584,12 +672,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 { @@ -625,7 +714,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 { @@ -659,9 +748,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]); }); @@ -720,10 +809,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'}, + }); }, ); @@ -749,10 +840,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'}, + }); }, ); @@ -777,13 +870,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', + }, }); }); @@ -804,9 +899,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 { @@ -824,9 +919,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 { @@ -843,7 +938,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); }); @@ -875,9 +970,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']); }); @@ -902,7 +997,7 @@ void main() { completes, ); - expect(log, isEmpty); + expect(log.where((c) => c.method == 'sendBuiltEvent'), isEmpty); }); test( @@ -928,7 +1023,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 }, @@ -955,7 +1050,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( @@ -969,9 +1065,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']); }, @@ -997,11 +1093,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( @@ -1015,15 +1112,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( @@ -1317,3 +1541,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/posthog_flutter_platform_interface_fake.dart b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart index e4ad6a2b..94957cda 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'; @@ -8,11 +9,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, }); } @@ -128,8 +131,9 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { Future stopSessionRecording() async {} @override - Future reloadFeatureFlags() async { + Future reloadFeatureFlags() async { reloadFeatureFlagsCount++; + return PostHogFeatureFlagsLoaded(variants: const {}, errorsLoading: false); } @override @@ -163,12 +167,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 +268,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 +309,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_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..fc89aa9e 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', @@ -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/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 } }, { 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/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" 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", 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');