From 264bb0de271a1de99c567698c10c8a1dfdf730f5 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 15:08:04 -0400 Subject: [PATCH 1/4] feat: add per-event capture options and captureAi Passes options and captureAi through to posthog-ios and posthog-android 4.0; Windows, Linux and web send known options as their legacy properties. --- .changeset/v6-capture-options-ai.md | 7 + api/posthog_flutter.api.json | 383 ++++++++++++++++++ .../posthog/flutter/PosthogFlutterPlugin.kt | 50 ++- .../flutter/EventOptionsChannelTest.kt | 111 +++++ .../PosthogFlutterPlugin.swift | 51 ++- posthog_flutter/lib/posthog_flutter_web.dart | 80 +++- .../lib/src/core/posthog_core.dart | 47 ++- posthog_flutter/lib/src/posthog.dart | 122 +++++- posthog_flutter/lib/src/posthog_event.dart | 12 +- .../lib/src/posthog_flutter_desktop.dart | 70 +++- .../lib/src/posthog_flutter_io.dart | 88 +++- .../posthog_flutter_platform_interface.dart | 20 +- .../lib/src/utils/event_options.dart | 46 +++ .../test/posthog_flutter_desktop_test.dart | 146 +++++++ .../test/posthog_flutter_io_test.dart | 127 ++++++ ...sthog_flutter_platform_interface_fake.dart | 65 +++ posthog_flutter/test/posthog_test.dart | 44 ++ .../test/web_before_send_test.dart | 41 ++ .../lib/adapter_server.dart | 1 + 19 files changed, 1458 insertions(+), 53 deletions(-) create mode 100644 .changeset/v6-capture-options-ai.md create mode 100644 posthog_flutter/android/src/test/kotlin/com/posthog/flutter/EventOptionsChannelTest.kt create mode 100644 posthog_flutter/lib/src/utils/event_options.dart diff --git a/.changeset/v6-capture-options-ai.md b/.changeset/v6-capture-options-ai.md new file mode 100644 index 00000000..4eecfdbb --- /dev/null +++ b/.changeset/v6-capture-options-ai.md @@ -0,0 +1,7 @@ +--- +"posthog_flutter": minor +--- + +- Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS and Android other keys are sent as-is; on Windows, Linux and web the known keys are sent as their legacy `$` properties and other keys are dropped +- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID. On Windows, Linux and web it sends nothing and returns `null` +- On Windows and Linux, a `$process_person_profile` property passed with an event now applies to that event instead of being replaced by the SDK's value diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 71c718a4..a21342e5 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -236,6 +236,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -267,6 +276,15 @@ "name": "userPropertiesToSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -316,6 +334,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -323,6 +350,64 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "captureAi", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "eventName", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "properties", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userProperties", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userPropertiesSetOnce", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + } + ], + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "returnTypeName": "Future", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -347,6 +432,15 @@ "name": "properties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -559,6 +653,15 @@ "name": "alias", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -948,6 +1051,15 @@ "name": "groupProperties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1074,6 +1186,15 @@ "name": "properties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1289,6 +1410,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1320,6 +1450,15 @@ "name": "userPropertiesToSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1369,6 +1508,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1376,6 +1524,64 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "captureAi", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "eventName", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "properties", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userProperties", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userPropertiesSetOnce", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" + } + ], + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "returnTypeName": "Future", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -1400,6 +1606,15 @@ "name": "properties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -1612,6 +1827,15 @@ "name": "alias", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -2001,6 +2225,15 @@ "name": "groupProperties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -2127,6 +2360,15 @@ "name": "properties", "relativePath": "lib/src/posthog_flutter_platform_interface.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_flutter_platform_interface.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", @@ -4141,6 +4383,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4172,6 +4423,15 @@ "name": "userPropertiesToSetOnce", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4221,6 +4481,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4228,6 +4497,64 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "captureAi", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "eventName", + "relativePath": "lib/src/posthog.dart", + "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "properties", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userProperties", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "userPropertiesSetOnce", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" + } + ], + "relativePath": "lib/src/posthog.dart", + "returnTypeName": "Future", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -4252,6 +4579,15 @@ "name": "properties", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4341,6 +4677,15 @@ "name": "alias", "relativePath": "lib/src/posthog.dart", "typeName": "String" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4683,6 +5028,15 @@ "name": "groupProperties", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -4972,6 +5326,15 @@ "name": "properties", "relativePath": "lib/src/posthog.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog.dart", @@ -5226,6 +5589,15 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_event.dart", "typeName": "Map?" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": false, + "name": "options", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "Map?" } ], "relativePath": "lib/src/posthog_event.dart", @@ -5278,6 +5650,17 @@ "name": "userPropertiesSetOnce", "relativePath": "lib/src/posthog_event.dart", "typeName": "Map?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "options", + "relativePath": "lib/src/posthog_event.dart", + "typeName": "Map?" } ], "isDeprecated": false, diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 91f4eb8e..e68dd6ee 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 @@ -248,6 +248,10 @@ class PosthogFlutterPlugin : capture(call, result) } + "captureAi" -> { + captureAi(call, result) + } + "captureFeatureView" -> { captureFeatureView(call, result) } @@ -1731,7 +1735,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) @@ -1745,7 +1750,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) @@ -1761,11 +1767,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) { @@ -1773,6 +1781,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, @@ -1780,7 +1812,8 @@ class PosthogFlutterPlugin : try { val screenName: String = call.argument("screenName")!! val properties: Map? = call.argument("properties") - PostHog.screen(screenName, properties) + val options: Map? = call.argument("options") + PostHog.screen(screenName, properties, options = options) result.success(null) } catch (e: Throwable) { result.error("PosthogFlutterException", e.localizedMessage, null) @@ -1821,7 +1854,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) @@ -1999,7 +2033,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) @@ -2131,6 +2166,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 { @@ -2138,7 +2176,7 @@ class PosthogFlutterPlugin : Date(timestampMs) } - PostHog.capture("\$exception", properties = properties, timestamp = timestamp) + PostHog.capture("\$exception", properties = properties, timestamp = timestamp, options = options) result.success(null) } catch (e: Throwable) { result.error("CAPTURE_EXCEPTION_ERROR", "Failed to capture exception: ${e.message}", null) diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/EventOptionsChannelTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/EventOptionsChannelTest.kt new file mode 100644 index 00000000..e20b016d --- /dev/null +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/EventOptionsChannelTest.kt @@ -0,0 +1,111 @@ +package com.posthog.flutter + +import com.posthog.PostHog +import com.posthog.PostHogEvent +import com.posthog.android.PostHogAndroid +import com.posthog.android.PostHogAndroidConfig +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class EventOptionsChannelTest { + private val options = mapOf("process_person_profile" to true, "custom_key" to "kept") + + @Test + fun forwardsOptionsToEachNativeMethod() { + val events = mutableListOf() + val config = + PostHogAndroidConfig("event-options-test", "http://127.0.0.1:1").apply { + preloadFeatureFlags = false + captureApplicationLifecycleEvents = false + captureDeepLinks = false + captureScreenViews = false + addBeforeSend { event -> + events.add(event) + // Keep AI events so captureAi returns their uuid. + if (event.event.startsWith("\$ai_")) event else null + } + } + PostHogAndroid.setup(RuntimeEnvironment.getApplication(), config) + try { + val plugin = PosthogFlutterPlugin() + val calls = + listOf( + "capture" to mapOf("eventName" to "clicked"), + "screen" to mapOf("screenName" to "Home"), + "captureException" to mapOf("properties" to mapOf("\$exception_list" to emptyList())), + "alias" to mapOf("alias" to "alias-id"), + "identify" to mapOf("userId" to "user-id"), + "setPersonProperties" to mapOf("userPropertiesToSet" to mapOf("plan" to "pro")), + "group" to mapOf("groupType" to "company", "groupKey" to "acme"), + ) + for ((method, arguments) in calls) { + val result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(MethodCall(method, arguments + ("options" to options)), result) + Mockito.verify(result).success(null) + } + + assertEquals( + listOf("clicked", "\$screen", "\$exception", "\$create_alias", "\$identify", "\$set", "\$groupidentify"), + events.map { it.event }, + ) + for (event in events) { + assertEquals?>(options, event.options, event.event) + } + } finally { + PostHog.close() + } + } + + @Test + fun captureAiReturnsTheQueuedEventUuid() { + val events = mutableListOf() + val config = + PostHogAndroidConfig("capture-ai-test", "http://127.0.0.1:1").apply { + preloadFeatureFlags = false + captureApplicationLifecycleEvents = false + captureDeepLinks = false + addBeforeSend { event -> + events.add(event) + event + } + } + PostHogAndroid.setup(RuntimeEnvironment.getApplication(), config) + try { + val result = Mockito.mock(MethodChannel.Result::class.java) + PosthogFlutterPlugin().onMethodCall( + MethodCall( + "captureAi", + mapOf( + "eventName" to "\$ai_generation", + "properties" to mapOf("\$ai_model" to "gpt-4o"), + "options" to options, + ), + ), + result, + ) + + val captor = ArgumentCaptor.forClass(Any::class.java) + Mockito.verify(result).success(captor.capture()) + val uuid = assertNotNull(captor.value as? String) + UUID.fromString(uuid) + val event = events.single() + assertEquals("\$ai_generation", event.event) + assertEquals("gpt-4o", event.properties?.get("\$ai_model")) + assertEquals?>(options, event.options) + } finally { + PostHog.close() + } + } +} diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index bb88b762..9b300eed 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -481,6 +481,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { setPersonProperties(call, result: result) case "capture": capture(call, result: result) + case "captureAi": + captureAi(call, result: result) case "screen": screen(call, result: result) case "captureLog": @@ -1432,11 +1434,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 { @@ -1451,10 +1455,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 { @@ -1472,11 +1478,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 { @@ -1484,6 +1492,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 @@ -1492,9 +1524,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 { @@ -1553,7 +1587,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) @@ -1695,7 +1730,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) @@ -1799,6 +1835,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 @@ -1807,7 +1844,7 @@ extension PosthogFlutterPlugin { } // Use capture method with timestamp to ensure Flutter timestamp is used - PostHogSDK.shared.capture("$exception", properties: properties, timestamp: timestamp) + PostHogSDK.shared.capture("$exception", properties: properties, timestamp: timestamp, options: options) result(nil) } diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 3f09dbfb..7759af9b 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -21,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. @@ -45,6 +46,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { return applyBeforeSend( _beforeSendCallbacks, @@ -53,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', @@ -147,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, @@ -162,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; @@ -184,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'); @@ -202,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; @@ -229,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, @@ -258,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. @@ -269,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'); @@ -279,6 +332,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } @@ -288,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', { @@ -299,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})); } @@ -446,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, @@ -550,6 +614,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) async { try { final exceptionData = DartExceptionProcessor.processException( @@ -564,6 +629,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.exception, exceptionData.cast(), + options: options, ); if (processedEvent == null) { printIfDebug( @@ -576,12 +642,18 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } final normalizedData = PropertyNormalizer.normalize( - processedEvent.properties?.cast() ?? {}, + _withOptionProperties( + processedEvent.properties?.cast(), + processedEvent.options, + PostHogEventName.exception, + ) ?? + {}, ); return await handleWebMethodCall( diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index b833f2ab..3648d4a9 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -161,7 +161,12 @@ abstract class PostHogCore extends PostHogCoreStateless { if (timezone != null) r'$timezone': timezone, ...getCommonEventProperties(), r'$session_id': sessionId, - r'$process_person_profile': _hasPersonProcessing(), + // A `$process_person_profile` passed for this event, such as the + // `process_person_profile` option, wins for this event only. + r'$process_person_profile': + properties?[r'$process_person_profile'] is bool + ? properties![r'$process_person_profile'] + : _hasPersonProcessing(), r'$is_identified': _isIdentified(), ..._sessionDebugProperties(now), }; @@ -293,10 +298,13 @@ abstract class PostHogCore extends PostHogCoreStateless { /// /// An empty [distinctId] is ignored, as are all calls while opted out or /// when `personProfiles` is `never`. + /// + /// The optional [properties] are added to the event this call sends. void identify( String distinctId, { Map? userProperties, Map? userPropertiesSetOnce, + Map? properties, }) { wrap(() { if (distinctId.trim().isEmpty) { @@ -331,13 +339,15 @@ abstract class PostHogCore extends PostHogCoreStateless { identifyStateless( distinctId, properties: _enrichProperties({ + ...?properties, 'distinct_id': distinctId, r'$anon_distinct_id': previousDistinctId, ...maybeAdd(r'$set', userProperties), ...maybeAdd(r'$set_once', userPropertiesSetOnce), }), ); - if (userProperties != null || userPropertiesSetOnce != null) { + if ((userProperties != null || userPropertiesSetOnce != null) && + !_isPersonless(properties)) { _cachedPersonProperties = _personPropertiesHash( distinctId, userProperties, userPropertiesSetOnce); } @@ -347,6 +357,7 @@ abstract class PostHogCore extends PostHogCoreStateless { // anonymous one, so the transition is recorded with a $set. setPersistedProperty(PostHogPersistedProperty.personMode, 'identified'); capture(r'$set', properties: { + ...?properties, r'$set': userProperties ?? {}, r'$set_once': userPropertiesSetOnce ?? {}, }); @@ -357,6 +368,7 @@ abstract class PostHogCore extends PostHogCoreStateless { setPersonProperties( userPropertiesToSet: userProperties, userPropertiesToSetOnce: userPropertiesSetOnce, + properties: properties, ); } else { logger.info('identify was called with the current distinct id of an ' @@ -388,8 +400,9 @@ abstract class PostHogCore extends PostHogCoreStateless { ); final allProperties = _enrichProperties(properties); - // Once an event is person-processed, all later ones are too. - if (allProperties[r'$process_person_profile'] == true) { + // Once an event is person-processed, all later ones are too. A value + // passed for this event alone doesn't count. + if (_hasPersonProcessing()) { _enablePersonProcessing(); } @@ -397,7 +410,7 @@ abstract class PostHogCore extends PostHogCoreStateless { if (event == r'$set' || event == r'$identify') { final set = allProperties[r'$set']; final setOnce = allProperties[r'$set_once']; - if (set is Map || setOnce is Map) { + if ((set is Map || setOnce is Map) && !_isPersonless(allProperties)) { _cachedPersonProperties = _personPropertiesHash(getDistinctId(), set, setOnce); } @@ -406,7 +419,9 @@ abstract class PostHogCore extends PostHogCoreStateless { } /// Creates an alias for a user. An empty [alias] is ignored. - void alias(String alias) { + /// + /// The optional [properties] are added to the `$create_alias` event. + void alias(String alias, {Map? properties}) { wrap(() { if (alias.trim().isEmpty) { logger.warn('alias was called with an empty alias. ' @@ -416,7 +431,8 @@ abstract class PostHogCore extends PostHogCoreStateless { if (_ignoredWhileOptedOut('posthog.alias')) return; if (!_requirePersonProcessing('posthog.alias')) return; - aliasStateless(alias, getDistinctId(), properties: _enrichProperties({})); + aliasStateless(alias, getDistinctId(), + properties: _enrichProperties({...?properties})); }); } @@ -441,10 +457,13 @@ abstract class PostHogCore extends PostHogCoreStateless { /// key changes, the properties stored for the previous key are dropped and /// flags are reloaded. A call with an empty [groupType] /// or [groupKey] is ignored. + /// + /// The optional [properties] are added to the `$groupidentify` event. void group( String groupType, String groupKey, { Map? groupProperties, + Map? properties, }) { wrap(() { if (groupType.trim().isEmpty || groupKey.trim().isEmpty) { @@ -469,7 +488,7 @@ abstract class PostHogCore extends PostHogCoreStateless { groupType, groupKey, distinctId: getDistinctId(), - eventProperties: _enrichProperties({}), + eventProperties: _enrichProperties({...?properties}), groupProperties: groupProperties, ); }); @@ -997,9 +1016,12 @@ abstract class PostHogCore extends PostHogCoreStateless { /// Sets properties on the person profile. They also feed feature flag /// evaluation from the next flags reload on. + /// + /// The optional [properties] are added to the `$set` event. void setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? properties, }) { wrap(() { final isSetEmpty = @@ -1014,7 +1036,9 @@ abstract class PostHogCore extends PostHogCoreStateless { final hash = _personPropertiesHash( getDistinctId(), userPropertiesToSet, userPropertiesToSetOnce); - if (_cachedPersonProperties == hash) { + // A personless `$set` doesn't update the person, so it is never a + // duplicate. + if (_cachedPersonProperties == hash && !_isPersonless(properties)) { logger.info( 'A duplicate setPersonProperties call was made. It has been ignored.'); return; @@ -1027,6 +1051,7 @@ abstract class PostHogCore extends PostHogCoreStateless { setPersonPropertiesForFlags(mergedProperties); capture(r'$set', properties: { + ...?properties, r'$set': userPropertiesToSet ?? {}, r'$set_once': userPropertiesToSetOnce ?? {}, }); @@ -1050,6 +1075,10 @@ abstract class PostHogCore extends PostHogCoreStateless { static bool _isNonEmptyMap(Object? value) => value is Map && value.isNotEmpty; + /// Whether [properties] turn person processing off for their event. + static bool _isPersonless(Map? properties) => + properties?[r'$process_person_profile'] == false; + static Map? _asPropertyMap(Object? value) { if (value is! Map) return null; return { diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index d45b5699..268ae28b 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -140,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 @@ -157,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. @@ -175,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. /// @@ -188,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. @@ -210,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 and Android, other keys are sent unchanged. On Windows, + /// Linux and web, the known keys are sent as their legacy properties and + /// other keys are dropped. On web, posthog-js sets + /// `$process_person_profile` itself, so `process_person_profile` has no + /// effect there, and the options of [identify], [setPersonProperties], + /// [alias] and [group] are dropped because posthog-js sends those events + /// without event properties. + /// /// Returns a [Future] that completes when the event has been queued. /// /// **Example** @@ -227,7 +260,65 @@ class Posthog { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, + }) { + return _posthog.capture( + eventName: eventName, + properties: _withCurrentScreen(properties), + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + } + + /// Captures an AI observability event, such as `$ai_generation`, + /// `$ai_span`, `$ai_trace` or `$ai_embedding`, and sends it to PostHog's AI + /// endpoint, which accepts much larger events than [capture]. + /// + /// Takes the same arguments as [capture] and goes through the same + /// [PostHogConfig.beforeSend] callbacks. The event is queued separately from + /// other events and sent unchanged: the SDK doesn't redact or truncate + /// prompts, outputs or media. PostHog drops an event sent here whose name + /// doesn't start with `$ai_`. [capture] never sends events here, even ones + /// named `$ai_*`. + /// + /// Returns the event's UUID, or `null` if the event wasn't queued: for + /// example, the SDK is opted out or a [PostHogConfig.beforeSend] callback + /// dropped it. An event over the AI size limit is dropped: iOS and macOS + /// then return `null`, while Android returns the UUID and logs the drop. + /// + /// **Note:** supported on iOS, macOS and Android. On Windows, Linux and web + /// the event is not sent, a debug message is logged, and `null` is + /// returned. + /// + /// **Example** + /// ```dart + /// await Posthog().captureAi( + /// eventName: r'$ai_generation', + /// properties: { + /// r'$ai_trace_id': traceId, + /// r'$ai_model': 'gpt-4o', + /// r'$ai_provider': 'openai', + /// }, + /// ); + /// ``` + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, }) { + return _posthog.captureAi( + eventName: eventName, + properties: _withCurrentScreen(properties), + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + } + + Map? _withCurrentScreen(Map? properties) { final propertiesCopy = properties == null ? null : {...properties}; final currentScreen = _currentScreen; @@ -236,12 +327,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. @@ -250,13 +336,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. @@ -372,8 +465,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. /// @@ -637,6 +734,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` @@ -655,11 +755,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. @@ -910,16 +1012,20 @@ class Posthog { /// /// The optional [properties] are added to the `$exception` event. /// + /// The optional [options] tell PostHog how to process the `$exception` event, as described on [capture]. + /// /// Returns a [Future] that completes when the exception event has been queued. Future captureException({ required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) => _posthog.captureException( error: error, stackTrace: stackTrace, properties: properties, + options: options, ); /// Captures a `runZonedGuarded` error with optional custom properties. diff --git a/posthog_flutter/lib/src/posthog_event.dart b/posthog_flutter/lib/src/posthog_event.dart index 1484986b..e7d20b5c 100644 --- a/posthog_flutter/lib/src/posthog_event.dart +++ b/posthog_flutter/lib/src/posthog_event.dart @@ -22,6 +22,13 @@ class PostHogEvent { /// These properties will only be set if they don't already exist on the user profile. Map? userPropertiesSetOnce; + /// Settings that tell PostHog how to process this event, sent apart from + /// its properties. See `Posthog.capture` for the known keys. + /// + /// A callback can read, add, change or remove them. A null value means + /// unset. + Map? options; + /// Creates an event passed to a before-send callback. /// /// The [event] is the event name to capture. @@ -33,15 +40,18 @@ class PostHogEvent { /// /// The optional [userPropertiesSetOnce] are person properties to set with /// `$set_once`. + /// + /// The optional [options] are the event's per-event options. PostHogEvent({ required this.event, this.properties, this.userProperties, this.userPropertiesSetOnce, + this.options, }); @override String toString() { - return 'PostHogEvent(event: $event, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce)'; + return 'PostHogEvent(event: $event, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce, options: $options)'; } } diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 919d6064..6e1d8061 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -21,6 +21,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'; @@ -218,12 +219,17 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { required String userId, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) => _guard('identify', (client) { client.identify( userId, userProperties: _normalize(userProperties), userPropertiesSetOnce: _normalize(userPropertiesSetOnce), + properties: eventOptionsToLegacyProperties( + options, + method: 'identify', + ), ); }); @@ -231,11 +237,16 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) => _guard('setPersonProperties', (client) { client.setPersonProperties( userPropertiesToSet: _normalize(userPropertiesToSet), userPropertiesToSetOnce: _normalize(userPropertiesToSetOnce), + properties: eventOptionsToLegacyProperties( + options, + method: 'setPersonProperties', + ), ); }); @@ -245,6 +256,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) => _guard('capture', (client) async { if (client.optedOut) return; @@ -259,6 +271,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options, ); final processed = hooked is Future ? await hooked : hooked; @@ -272,10 +285,25 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { _captureProcessed(current, processed, exceptionSteps); }); + /// AI events are not sent on desktop. + @override + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) async { + printIfDebug('[PostHog] captureAi is not supported on Windows and Linux. ' + 'The event "$eventName" was not sent.'); + return null; + } + @override Future screen({ required String screenName, Map? properties, + Map? options, }) => _guard('screen', (client) async { // Opted out, nothing happens: no callback runs, and the screen does @@ -292,6 +320,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ...?properties, PostHogPropertyName.screenName: screenName, }, + options: options, ); final processed = hooked is Future ? await hooked : hooked; @@ -366,8 +395,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { } @override - Future alias({required String alias}) => - _guard('alias', (client) => client.alias(alias)); + Future alias({required String alias, Map? options}) => + _guard( + 'alias', + (client) => client.alias( + alias, + properties: eventOptionsToLegacyProperties(options, method: 'alias'), + ), + ); @override Future captureFeatureView({ @@ -495,12 +530,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { required String groupType, required String groupKey, Map? groupProperties, + Map? options, }) => _guard('group', (client) { client.group( groupType, groupKey, groupProperties: _normalize(groupProperties), + properties: eventOptionsToLegacyProperties(options, method: 'group'), ); }); @@ -540,6 +577,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) => _guard('captureException', (client) async { if (client.optedOut) return; @@ -560,6 +598,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final hooked = _runBeforeSend( PostHogEventName.exception, exceptionProps.cast(), + options: options, ); final processed = hooked is Future ? await hooked : hooked; @@ -667,12 +706,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { final event = PostHogEvent( event: eventName, properties: properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options == null ? null : {...options}, ); final callbacks = _config?.beforeSend ?? const []; return callbacks.isEmpty ? event : _applyCallbacks(callbacks, event); @@ -697,18 +738,25 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ExceptionStepsBuffer? exceptionSteps, ) { if (!_hasName(event.event)) return; - client.capture( + final properties = _withExceptionSteps( event.event, - properties: _withExceptionSteps( - event.event, - exceptionSteps, - _mergeUserProps( - event.properties, - event.userProperties, - event.userPropertiesSetOnce, - ), + exceptionSteps, + _mergeUserProps( + event.properties, + event.userProperties, + event.userPropertiesSetOnce, ), ); + // Options go out as their legacy properties, winning over a legacy + // property set on the event. + final optionProperties = + eventOptionsToLegacyProperties(event.options, method: event.event); + client.capture( + event.event, + properties: optionProperties.isEmpty + ? properties + : {...?properties, ...optionProperties}, + ); } /// Whether [event] has a name; an event without one is dropped. diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 1ee6368c..12c008a2 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -13,6 +13,7 @@ import 'error_tracking/dart_exception_processor.dart'; import 'utils/before_send.dart'; import 'utils/capture_utils.dart'; import 'utils/channel_serialization.dart'; +import 'utils/event_options.dart'; import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; @@ -51,6 +52,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { return applyBeforeSend( _beforeSendCallbacks, @@ -59,6 +61,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { properties: properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options == null ? null : {...options}, ), ); } @@ -237,12 +240,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; @@ -256,6 +261,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'); @@ -266,12 +272,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; @@ -284,6 +292,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'); @@ -296,9 +305,50 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, + }) async { + await _captureEvent( + 'capture', + eventName: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + } + + @override + Future captureAi({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, + }) async { + final uuid = await _captureEvent( + 'captureAi', + eventName: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + options: options, + ); + return uuid is String ? uuid : null; + } + + /// Runs beforeSend and sends the event through [method], `capture` or + /// `captureAi`. Returns what the native SDK returned, or null when the + /// event was dropped. + Future _captureEvent( + String method, { + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + Map? options, }) async { if (!isSupportedPlatform()) { - return; + return null; } // Apply beforeSend callback @@ -307,11 +357,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options, ); if (processedEvent == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); - return; + return null; } try { @@ -336,17 +387,20 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { extractedUserPropertiesSetOnce != null ? PropertyNormalizer.normalize(extractedUserPropertiesSetOnce) : null; + final cleanedOptions = cleanEventOptions(processedEvent.options); - await _methodChannel.invokeMethod('capture', { + return await _methodChannel.invokeMethod(method, { 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, if (normalizedUserProperties != null) 'userProperties': normalizedUserProperties, if (normalizedUserPropertiesSetOnce != null) 'userPropertiesSetOnce': normalizedUserPropertiesSetOnce, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { - printIfDebug('Exeption on capture: $exception'); + printIfDebug('Exeption on $method: $exception'); + return null; } } @@ -354,6 +408,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Future screen({ required String screenName, Map? properties, + Map? options, }) async { if (!isSupportedPlatform()) { return; @@ -370,6 +425,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.screen, propsWithScreenName, + options: options, ); if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -381,6 +437,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } @@ -399,9 +456,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ) : null; + final cleanedOptions = cleanEventOptions(processedEvent.options); + await _methodChannel.invokeMethod('screen', { 'screenName': finalScreenName, if (normalizedProperties != null) 'properties': normalizedProperties, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { printIfDebug('Exeption on screen: $exception'); @@ -442,13 +502,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'); } @@ -676,12 +743,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; @@ -691,6 +760,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'); @@ -806,6 +876,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { required Object error, StackTrace? stackTrace, Map? properties, + Map? options, }) async { if (!isSupportedPlatform()) { return; @@ -825,6 +896,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final processedEvent = await _runBeforeSend( PostHogEventName.exception, exceptionProps.cast(), + options: options, ); if (processedEvent == null) { printIfDebug( @@ -838,6 +910,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), + options: processedEvent.options, ); return; } @@ -850,9 +923,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ) : {}; + final cleanedOptions = cleanEventOptions(processedEvent.options); + await _methodChannel.invokeMethod('captureException', { 'timestamp': timestamp, 'properties': normalizedData, + if (cleanedOptions != null) 'options': cleanedOptions, }); } on PlatformException catch (exception) { printIfDebug('Exception in captureException: $exception'); diff --git a/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart b/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart index 5643fd6c..f7823516 100644 --- a/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart +++ b/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart @@ -49,6 +49,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { required String userId, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) { throw UnimplementedError('identify() has not been implemented.'); } @@ -56,6 +57,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { Future setPersonProperties({ Map? userPropertiesToSet, Map? userPropertiesToSetOnce, + Map? options, }) { throw UnimplementedError('setPersonProperties() has not been implemented.'); } @@ -65,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.'); } @@ -119,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.'); } @@ -214,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.'); } @@ -246,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/utils/event_options.dart b/posthog_flutter/lib/src/utils/event_options.dart new file mode 100644 index 00000000..e72a7683 --- /dev/null +++ b/posthog_flutter/lib/src/utils/event_options.dart @@ -0,0 +1,46 @@ +import '../util/logging.dart'; +import 'property_normalizer.dart'; + +/// The known per-event option keys and the legacy event property each one +/// replaces. +const Map _legacyPropertyForOption = { + 'process_person_profile': r'$process_person_profile', + 'cookieless_mode': r'$cookieless_mode', + 'disable_skew_correction': r'$ignore_sent_at', + 'product_tour_id': r'$product_tour_id', +}; + +/// Returns a copy of [options] without null values, normalized for the +/// platform channel, or null when nothing is left. A null value means unset. +Map? cleanEventOptions(Map? options) { + if (options == null) return null; + final cleaned = PropertyNormalizer.normalize(options); + if (cleaned.isEmpty) return null; + return Map.from(cleaned); +} + +/// Converts [options] to the legacy event properties for transports that +/// have no options field (the desktop client and posthog-js). +/// +/// Known keys become their `$` property. Other keys have no legacy property, +/// so they are dropped with a debug log naming [method]. +Map eventOptionsToLegacyProperties( + Map? options, { + required String method, +}) { + final cleaned = cleanEventOptions(options); + if (cleaned == null) return const {}; + final properties = {}; + for (final entry in cleaned.entries) { + final legacyKey = _legacyPropertyForOption[entry.key]; + if (legacyKey != null) { + properties[legacyKey] = entry.value; + } else { + printIfDebug( + '[PostHog] $method option "${entry.key}" is not supported on this ' + 'platform and was dropped.', + ); + } + } + return properties; +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 6a532057..72109925 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -954,6 +954,152 @@ void main() { }); }); + group('Event options', () { + test('are sent as their legacy properties, winning over them', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + final lines = await _printsOf(() => platform.capture( + eventName: 'tour step', + properties: {r'$product_tour_id': 'from-property'}, + options: { + 'process_person_profile': false, + 'cookieless_mode': true, + 'disable_skew_correction': true, + 'product_tour_id': 'from-option', + 'unknown_option': 'x', + 'unset_option': null, + }, + )); + + final properties = _propertiesOf(await server.waitForEvent('tour step')); + expect(properties[r'$process_person_profile'], isFalse); + expect(properties[r'$cookieless_mode'], isTrue); + expect(properties[r'$ignore_sent_at'], isTrue); + expect(properties[r'$product_tour_id'], 'from-option'); + expect(properties, isNot(contains('unknown_option'))); + expect(properties, isNot(contains('unset_option'))); + expect(lines, [contains('option "unknown_option" is not supported')]); + }); + + test('a process_person_profile option applies to its event only', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.capture( + eventName: 'processed', + options: {'process_person_profile': true}, + ); + await platform.capture(eventName: 'anonymous'); + + final processed = await server.waitForEvent('processed'); + final anonymous = await server.waitForEvent('anonymous'); + expect(_propertiesOf(processed)[r'$process_person_profile'], isTrue); + expect(_propertiesOf(anonymous)[r'$process_person_profile'], isFalse); + }); + + test('beforeSend can change them', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) { + event.options = { + ...?event.options, + 'product_tour_id': 'from-hook', + }; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.screen( + screenName: 'Home', + options: {'product_tour_id': 'from-call'}, + ); + await platform.captureException( + error: StateError('boom'), + options: {'disable_skew_correction': true}, + ); + + final screen = _propertiesOf(await server.waitForEvent(r'$screen')); + final exception = _propertiesOf(await server.waitForEvent(r'$exception')); + expect(screen[r'$product_tour_id'], 'from-hook'); + expect(exception[r'$product_tour_id'], 'from-hook'); + expect(exception[r'$ignore_sent_at'], isTrue); + }); + + test('reach the events identify, setPersonProperties, group and alias send', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + Map options(String id) => + {'product_tour_id': id, 'cookieless_mode': false}; + + await platform.identify(userId: 'user-1', options: options('identify')); + await platform.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + options: options('set'), + ); + await platform.group( + groupType: 'company', + groupKey: 'acme', + options: options('group'), + ); + await platform.alias(alias: 'alias-1', options: options('alias')); + + for (final (name, id) in [ + (r'$identify', 'identify'), + (r'$set', 'set'), + (r'$groupidentify', 'group'), + (r'$create_alias', 'alias'), + ]) { + final properties = _propertiesOf(await server.waitForEvent(name)); + expect(properties[r'$product_tour_id'], id, reason: name); + expect(properties[r'$cookieless_mode'], isFalse, reason: name); + } + }); + + test(r'a personless $set does not mark the person properties as sent', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + await platform.identify(userId: 'user-1'); + + await platform.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + options: {'process_person_profile': false}, + ); + await platform.setPersonProperties(userPropertiesToSet: {'plan': 'pro'}); + await platform.capture(eventName: 'done'); + await server.waitForEvent('done'); + + final sets = [ + for (final event in server.events) + if (event['event'] == r'$set') _propertiesOf(event), + ]; + expect( + [for (final set in sets) set[r'$process_person_profile']], + [isFalse, isTrue], + ); + }); + + test('captureAi sends nothing and returns null', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + String? uuid = 'not called'; + final lines = await _printsOf(() async { + uuid = await platform.captureAi(eventName: r'$ai_generation'); + }); + await platform.capture(eventName: 'after'); + await server.waitForEvent('after'); + + expect(uuid, isNull); + expect(lines, [contains('captureAi is not supported')]); + expect(server.eventNames, isNot(contains(r'$ai_generation'))); + }); + }); + group('Application lifecycle', () { /// A launch of the app with [build], keeping its state in [appDirectory]. PosthogFlutterDesktop launch( diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 33a514fa..57c6e02a 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -404,6 +404,133 @@ void main() { }); }); + group('PosthogFlutterIO event options', () { + const options = { + 'process_person_profile': false, + 'custom_key': 'kept', + 'unset_key': null, + }; + const sent = {'process_person_profile': false, 'custom_key': 'kept'}; + + Map argsOf(String method) => Map.from( + log.singleWhere((c) => c.method == method).arguments as Map, + ); + + setUp(() async { + await posthogFlutterIO.setup(PostHogConfig('test_project_token')); + }); + + final callsByMethod = Function(PosthogFlutterIO)>{ + 'capture': (io) => io.capture(eventName: 'clicked', options: options), + 'screen': (io) => io.screen(screenName: 'Home', options: options), + 'captureException': (io) => + io.captureException(error: StateError('boom'), options: options), + 'identify': (io) => io.identify(userId: 'user-1', options: options), + 'setPersonProperties': (io) => io.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + options: options, + ), + 'alias': (io) => io.alias(alias: 'alias-1', options: options), + 'group': (io) => + io.group(groupType: 'company', groupKey: 'acme', options: options), + 'captureAi': (io) => + io.captureAi(eventName: r'$ai_generation', options: options), + }; + + for (final entry in callsByMethod.entries) { + test('${entry.key} sends the options without unset keys', () async { + await entry.value(posthogFlutterIO); + expect(argsOf(entry.key)['options'], sent); + }); + } + + test('calls without options send no options argument', () async { + await posthogFlutterIO.capture(eventName: 'clicked'); + await posthogFlutterIO.alias(alias: 'alias-1', options: {'k': null}); + expect(argsOf('capture'), isNot(contains('options'))); + expect(argsOf('alias'), isNot(contains('options'))); + }); + + test('captureAi sends the event and returns the native uuid', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return call.method == 'captureAi' ? 'uuid-1' : null; + }); + + final uuid = await posthogFlutterIO.captureAi( + eventName: r'$ai_generation', + properties: {r'$ai_model': 'gpt-4o'}, + userProperties: {'plan': 'pro'}, + ); + + expect(uuid, 'uuid-1'); + expect(argsOf('captureAi'), { + 'eventName': r'$ai_generation', + 'properties': {r'$ai_model': 'gpt-4o'}, + 'userProperties': {'plan': 'pro'}, + }); + expect(log.where((c) => c.method == 'capture'), isEmpty); + }); + + test('beforeSend sees and can change the options', () async { + final seen = ?>[]; + await posthogFlutterIO.setup(PostHogConfig( + 'test_project_token', + beforeSend: [ + (event) { + seen.add({...?event.options}); + event.options = {...?event.options, 'product_tour_id': 'tour-1'} + ..remove('custom_key'); + return event; + }, + ], + )); + + await posthogFlutterIO.captureAi( + eventName: r'$ai_span', options: options); + await posthogFlutterIO.screen(screenName: 'Home', options: options); + + expect(seen, [options, options]); + const changed = { + 'process_person_profile': false, + 'product_tour_id': 'tour-1', + }; + expect(argsOf('captureAi')['options'], changed); + expect(argsOf('screen')['options'], changed); + }); + + test('a screen renamed by beforeSend keeps its options', () async { + await posthogFlutterIO.setup(PostHogConfig( + 'test_project_token', + beforeSend: [ + (event) { + if (event.event == r'$screen') event.event = 'screen viewed'; + return event; + }, + ], + )); + + await posthogFlutterIO.screen(screenName: 'Home', options: options); + + expect(argsOf('capture')['eventName'], 'screen viewed'); + expect(argsOf('capture')['options'], sent); + }); + + test('captureAi dropped by beforeSend returns null', () async { + await posthogFlutterIO.setup(PostHogConfig( + 'test_project_token', + beforeSend: [(event) => null], + )); + + expect( + await posthogFlutterIO.captureAi(eventName: r'$ai_generation'), + isNull, + ); + expect(log.where((c) => c.method == 'captureAi'), isEmpty); + }); + }); + group('PosthogFlutterIO beforeSend callback', () { test( 'capture sends event unchanged when no beforeSend registered', diff --git a/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart index fbffbc3c..5313c603 100644 --- a/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart +++ b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart @@ -9,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, }); } @@ -165,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 @@ -207,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, ), ); } @@ -246,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_test.dart b/posthog_flutter/test/posthog_test.dart index 7b229e2a..0f6d6f66 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -214,6 +214,50 @@ void main() { ); }); + group('Posthog event options', () { + late PosthogFlutterPlatformFake fake; + const options = {'process_person_profile': false}; + + setUp(() async { + fake = PosthogFlutterPlatformFake(); + PosthogFlutterPlatformInterface.instance = fake; + await Posthog().close(); + }); + + test('each event method passes its options to the platform', () async { + final posthog = Posthog(); + await posthog.capture(eventName: 'clicked', options: options); + await posthog.screen(screenName: 'Home', options: options); + await posthog.identify(userId: 'user-1', options: options); + await posthog.alias(alias: 'alias-1', options: options); + await posthog.group( + groupType: 'company', groupKey: 'acme', options: options); + await posthog.captureAi(eventName: r'$ai_span', options: options); + await posthog.captureException( + error: StateError('boom'), options: options); + await posthog.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + options: options, + ); + + expect(fake.optionsCalls, [ + ('capture', options), + ('screen', options), + ('identify', options), + ('alias', options), + ('group', options), + ('captureAi', options), + ]); + expect(fake.capturedExceptions.single.options, options); + expect(fake.setPersonPropertiesCalls.single['options'], options); + }); + + test('captureAi returns the platform result', () async { + fake.captureAiResult = 'uuid-1'; + expect(await Posthog().captureAi(eventName: r'$ai_trace'), 'uuid-1'); + }); + }); + group('PostHogConfig', () { test('toMap forwards evaluationContexts only when set', () { final config = PostHogConfig('test_project_token'); diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index ad57000a..f3a2cc3e 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -103,4 +103,45 @@ void main() { expect(captures, isEmpty); }); + + test('event options are sent as their legacy properties on web', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) { + event.options = {...?event.options, 'cookieless_mode': true}; + return event; + }, + ])); + const options = { + 'disable_skew_correction': true, + 'product_tour_id': 'tour-1', + 'unknown_option': 'dropped', + }; + + await web.capture( + eventName: 'tour step', + properties: {r'$product_tour_id': 'from-property'}, + options: options, + ); + await web.screen(screenName: 'Home', options: options); + await web.captureException(error: StateError('boom'), options: options); + + expect(captures.map((c) => c['event']), + ['tour step', r'$screen', r'$exception']); + for (final capture in captures) { + final properties = capture['properties'] as Map; + expect(properties[r'$ignore_sent_at'], isTrue); + expect(properties[r'$product_tour_id'], 'tour-1'); + expect(properties[r'$cookieless_mode'], isTrue); + expect(properties, isNot(contains('unknown_option'))); + } + }); + + test('captureAi sends nothing on web and returns null', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token')); + + expect(await web.captureAi(eventName: r'$ai_generation'), isNull); + expect(captures, isEmpty); + }); } diff --git a/sdk_compliance_adapter/lib/adapter_server.dart b/sdk_compliance_adapter/lib/adapter_server.dart index f0682d96..43858465 100644 --- a/sdk_compliance_adapter/lib/adapter_server.dart +++ b/sdk_compliance_adapter/lib/adapter_server.dart @@ -287,6 +287,7 @@ class _CompliancePlatform extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, + Map? options, }) async { if (!isInitialized) { throw StateError('SDK not initialized'); From 790b10120e8169d620f0b59033d348b2d96cf6fa Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:02:58 -0400 Subject: [PATCH 2/4] feat: add captureAi queue and request settings to PostHogConfig Add captureAiCompression, captureAiMaxQueueSize, captureAiTimeout and captureAiMaxEventBytes. Set values are sent to native setup and applied to the posthog-ios and posthog-android config; unset values are omitted so the native defaults apply. They have no effect on Windows, Linux and web. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-capture-options-ai.md | 1 + api/posthog_flutter.api.json | 44 +++++++++++++++++ .../posthog/flutter/PosthogFlutterPlugin.kt | 15 ++++++ .../flutter/PosthogFlutterPluginTest.kt | 43 ++++++++++++++++ .../PosthogFlutterPlugin.swift | 19 +++++++ posthog_flutter/lib/src/posthog_config.dart | 49 +++++++++++++++++++ .../lib/src/utils/channel_serialization.dart | 14 ++++++ .../test/event_shape_snapshot_test.dart | 35 +++++++++++++ 8 files changed, 220 insertions(+) diff --git a/.changeset/v6-capture-options-ai.md b/.changeset/v6-capture-options-ai.md index 4eecfdbb..78b2de6b 100644 --- a/.changeset/v6-capture-options-ai.md +++ b/.changeset/v6-capture-options-ai.md @@ -4,4 +4,5 @@ - Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS and Android other keys are sent as-is; on Windows, Linux and web the known keys are sent as their legacy `$` properties and other keys are dropped - Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID. On Windows, Linux and web it sends nothing and returns `null` +- Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` for the `captureAi` queue and requests on iOS, macOS and Android. Unset, they keep the native SDK defaults. They have no effect on Windows, Linux and web - On Windows and Linux, a `$process_person_profile` property passed with an event now applies to that event instead of being replaced by the SDK's value diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index a21342e5..9a5c8448 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -2727,6 +2727,50 @@ "relativePath": "lib/src/posthog_config.dart", "typeName": "PostHogCompression" }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "captureAiCompression", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "PostHogCompression?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "captureAiMaxQueueSize", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "int?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "captureAiTimeout", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "Duration?" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": true, + "name": "captureAiMaxEventBytes", + "relativePath": "lib/src/posthog_config.dart", + "typeName": "int?" + }, { "entryPoints": [], "isDeprecated": false, diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index e68dd6ee..cc06a9de 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 @@ -613,6 +613,21 @@ class PosthogFlutterPlugin : "none" -> compression = PostHogCompression.NONE } } + posthogConfig.getIfNotNull("captureAiCompression") { + when (it) { + "gzip" -> captureAiCompression = PostHogCompression.GZIP + "none" -> captureAiCompression = PostHogCompression.NONE + } + } + posthogConfig.getIfNotNull("captureAiMaxQueueSize") { + captureAiMaxQueueSize = it + } + posthogConfig.getIfNotNull("captureAiTimeoutSeconds") { + captureAiTimeout = it + } + posthogConfig.getIfNotNull("captureAiMaxEventBytes") { + captureAiMaxEventBytes = it + } posthogConfig.getIfNotNull("sessionReplay") { sessionReplay = it } diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index fc333ff8..a82b7734 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.os.BadParcelableException import com.google.firebase.FirebaseApp import com.posthog.PostHogCompression +import com.posthog.PostHogConfig import com.posthog.PostHogEvent import com.posthog.android.replay.PostHogScreenshotColorMode import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -225,6 +226,48 @@ internal class PosthogFlutterPluginTest { assertEquals(PostHogCompression.NONE, assertNotNull(plugin.lastBuiltConfig).compression) } + @Test + fun setup_captureAiSettings_forwardToNativeConfig() { + val plugin = PosthogFlutterPlugin() + attach(plugin, Mockito.mock(BinaryMessenger::class.java)) + + val call = + MethodCall( + "setup", + mapOf( + "projectToken" to "test-token", + "captureAiCompression" to "none", + "captureAiMaxQueueSize" to 7, + "captureAiTimeoutSeconds" to 90, + "captureAiMaxEventBytes" to 1024, + ), + ) + plugin.onMethodCall(call, Mockito.mock(MethodChannel.Result::class.java)) + + val config = assertNotNull(plugin.lastBuiltConfig) + assertEquals(PostHogCompression.NONE, config.captureAiCompression) + assertEquals(7, config.captureAiMaxQueueSize) + assertEquals(90, config.captureAiTimeout) + assertEquals(1024, config.captureAiMaxEventBytes) + } + + @Test + fun setup_captureAiSettings_unsetKeepNativeDefaults() { + val plugin = PosthogFlutterPlugin() + attach(plugin, Mockito.mock(BinaryMessenger::class.java)) + + plugin.onMethodCall( + MethodCall("setup", mapOf("projectToken" to "test-token")), + Mockito.mock(MethodChannel.Result::class.java), + ) + + val config = assertNotNull(plugin.lastBuiltConfig) + assertEquals(PostHogCompression.GZIP, config.captureAiCompression) + assertEquals(PostHogConfig.DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE, config.captureAiMaxQueueSize) + assertEquals(PostHogConfig.DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS, config.captureAiTimeout) + assertEquals(PostHogConfig.DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES, config.captureAiMaxEventBytes) + } + @Test fun setup_flutterVersion_addedOnlyToInstallAndUpdateEvents() { val plugin = PosthogFlutterPlugin() diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 9b300eed..e2e2308e 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -243,6 +243,25 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { break } } + if let captureAiCompression = posthogConfig["captureAiCompression"] as? String { + switch captureAiCompression { + case "gzip": + config.captureAiCompression = .gzip + case "none": + config.captureAiCompression = .none + default: + break + } + } + if let captureAiMaxQueueSize = posthogConfig["captureAiMaxQueueSize"] as? Int { + config.captureAiMaxQueueSize = captureAiMaxQueueSize + } + if let captureAiTimeoutSeconds = posthogConfig["captureAiTimeoutSeconds"] as? Int { + config.captureAiTimeout = TimeInterval(captureAiTimeoutSeconds) + } + if let captureAiMaxEventBytes = posthogConfig["captureAiMaxEventBytes"] as? Int { + config.captureAiMaxEventBytes = captureAiMaxEventBytes + } if let dataMode = posthogConfig["dataMode"] as? String { switch dataMode { case "wifi": diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index b5225fd7..1df2624a 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -196,6 +196,55 @@ class PostHogConfig { /// Defaults to [PostHogCompression.gzip]. var compression = PostHogCompression.gzip; + /// How [Posthog.captureAi] request bodies are compressed, separately from + /// [compression]. + /// + /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, + /// where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the native SDK default (gzip). + PostHogCompression? captureAiCompression; + + /// Maximum number of [Posthog.captureAi] events kept in their on-disk queue + /// before the oldest are dropped. + /// + /// AI events can be megabytes, so the native default (100) is lower than + /// [maxQueueSize]. The native queue also keeps at most 50 MiB of AI events, + /// whatever this is set to. A value of `0` or less uses the native default. + /// + /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, + /// where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the native SDK default. + int? captureAiMaxQueueSize; + + /// Timeout of a [Posthog.captureAi] request, sent to the native SDK in whole + /// seconds (a positive value under one second is sent as one second). + /// + /// AI requests can carry several MB, so the native default (60 seconds) is + /// longer than for other requests. [Duration.zero] or less uses the native + /// default. + /// + /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, + /// where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the native SDK default. + Duration? captureAiTimeout; + + /// Maximum size, in bytes, of a [Posthog.captureAi] event. The native SDK + /// drops a larger event and [Posthog.captureAi] returns `null`. + /// + /// Use it to lower the limit. PostHog drops AI events whose properties are + /// over 8 MiB, so a larger value, or a value of `0` or less, uses the native + /// default (8 MiB on Android, 8 MiB plus 64 KiB for the rest of the event on + /// iOS and macOS). + /// + /// Applies on iOS, macOS and Android. Not applied on Windows, Linux and web, + /// where [Posthog.captureAi] sends nothing. + /// + /// Defaults to `null`, which keeps the native SDK default. + int? captureAiMaxEventBytes; + /// Whether the SDK is enabled on Windows and Linux. /// /// Windows and Linux support is experimental. Defaults to `true`. diff --git a/posthog_flutter/lib/src/utils/channel_serialization.dart b/posthog_flutter/lib/src/utils/channel_serialization.dart index c1bd9ebc..34e00c25 100644 --- a/posthog_flutter/lib/src/utils/channel_serialization.dart +++ b/posthog_flutter/lib/src/utils/channel_serialization.dart @@ -22,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(), @@ -37,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 { diff --git a/posthog_flutter/test/event_shape_snapshot_test.dart b/posthog_flutter/test/event_shape_snapshot_test.dart index d06a6711..0320d7e0 100644 --- a/posthog_flutter/test/event_shape_snapshot_test.dart +++ b/posthog_flutter/test/event_shape_snapshot_test.dart @@ -90,6 +90,41 @@ void main() { expect(arguments['compression'], 'none'); }); + test('captureAi settings are omitted when unset', () async { + await Posthog().setup(PostHogConfig('snapshot_project_token')); + + final arguments = calls.single.arguments as Map; + expect( + arguments.keys.where((key) => '$key'.startsWith('captureAi')), + isEmpty, + ); + }); + + test('captureAi settings are sent to native setup when set', () async { + final config = PostHogConfig('snapshot_project_token') + ..captureAiCompression = PostHogCompression.none + ..captureAiMaxQueueSize = 7 + ..captureAiTimeout = const Duration(seconds: 90) + ..captureAiMaxEventBytes = 1024; + + await Posthog().setup(config); + + final arguments = calls.single.arguments as Map; + expect(arguments['captureAiCompression'], 'none'); + expect(arguments['captureAiMaxQueueSize'], 7); + expect(arguments['captureAiTimeoutSeconds'], 90); + expect(arguments['captureAiMaxEventBytes'], 1024); + }); + + test('a sub-second captureAi timeout is sent as one second', () async { + final config = PostHogConfig('snapshot_project_token') + ..captureAiTimeout = const Duration(milliseconds: 500); + + await Posthog().setup(config); + + expect((calls.single.arguments as Map)['captureAiTimeoutSeconds'], 1); + }); + test('snapshots event and identity channel shapes', () async { final config = PostHogConfig( 'snapshot_project_token', From 29c2e30ea14fe4ca70d139f8ba513c1bc5b7b0d6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:02:58 -0400 Subject: [PATCH 3/4] docs: spell out when captureAi returns null null means the event wasn't admitted: dropped by beforeSend, oversized on iOS and macOS, SDK disabled or opted out, or an unsupported platform. On Windows, Linux and web captureAi is a no-op that sends nothing. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-capture-options-ai.md | 2 +- posthog_flutter/lib/src/posthog.dart | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.changeset/v6-capture-options-ai.md b/.changeset/v6-capture-options-ai.md index 78b2de6b..b172c360 100644 --- a/.changeset/v6-capture-options-ai.md +++ b/.changeset/v6-capture-options-ai.md @@ -3,6 +3,6 @@ --- - Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS and Android other keys are sent as-is; on Windows, Linux and web the known keys are sent as their legacy `$` properties and other keys are dropped -- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID. On Windows, Linux and web it sends nothing and returns `null` +- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID, or `null` when the event isn't admitted (dropped by `beforeSend`, oversized on iOS and macOS, SDK disabled or opted out). On Windows, Linux and web `captureAi` is a no-op: it sends nothing and returns `null` - Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` for the `captureAi` queue and requests on iOS, macOS and Android. Unset, they keep the native SDK defaults. They have no effect on Windows, Linux and web - On Windows and Linux, a `$process_person_profile` property passed with an event now applies to that event instead of being replaced by the SDK's value diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 268ae28b..747018ce 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -282,14 +282,18 @@ class Posthog { /// doesn't start with `$ai_`. [capture] never sends events here, even ones /// named `$ai_*`. /// - /// Returns the event's UUID, or `null` if the event wasn't queued: for - /// example, the SDK is opted out or a [PostHogConfig.beforeSend] callback - /// dropped it. An event over the AI size limit is dropped: iOS and macOS - /// then return `null`, while Android returns the UUID and logs the drop. - /// - /// **Note:** supported on iOS, macOS and Android. On Windows, Linux and web - /// the event is not sent, a debug message is logged, and `null` is - /// returned. + /// Returns the event's UUID once the SDK has admitted it to the AI queue. + /// `null` means the event was not admitted and will not be sent: + /// - a [PostHogConfig.beforeSend] callback dropped it, + /// - it is over [PostHogConfig.captureAiMaxEventBytes] (iOS and macOS only: + /// Android checks the size after returning, so it returns the UUID and + /// logs the drop), + /// - the SDK is disabled, not set up or opted out, or + /// - the platform is Windows, Linux or web (see below). + /// + /// **Platforms:** supported on iOS, macOS and Android. On Windows, Linux and + /// web, `captureAi` is a no-op: it sends nothing, logs a debug message and + /// returns `null`. /// /// **Example** /// ```dart From 6ceffca098756fd043fc4b2e8d1db9ef43dcca38 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:03:58 -0400 Subject: [PATCH 4/4] docs: captureAi returns null for an oversized event on Android too posthog-android now checks the size before queuing (f46a9711). Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/posthog.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 747018ce..ccd57100 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -285,9 +285,7 @@ class Posthog { /// Returns the event's UUID once the SDK has admitted it to the AI queue. /// `null` means the event was not admitted and will not be sent: /// - a [PostHogConfig.beforeSend] callback dropped it, - /// - it is over [PostHogConfig.captureAiMaxEventBytes] (iOS and macOS only: - /// Android checks the size after returning, so it returns the UUID and - /// logs the drop), + /// - it is over [PostHogConfig.captureAiMaxEventBytes], /// - the SDK is disabled, not set up or opted out, or /// - the platform is Windows, Linux or web (see below). ///