Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/v6-capture-options-ai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"posthog_flutter": minor
---

- Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks. The known keys `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On iOS, macOS and Android other keys are sent as-is; on Windows, Linux and web the known keys are sent as their legacy `$` properties and other keys are dropped
- Add `captureAi` for AI observability events such as `$ai_generation`, sent to PostHog's AI endpoint on iOS, macOS and Android, returning the event's UUID, or `null` when the event isn't admitted (dropped by `beforeSend`, oversized on iOS and macOS, SDK disabled or opted out). On Windows, Linux and web `captureAi` is a no-op: it sends nothing and returns `null`
- Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` for the `captureAi` queue and requests on iOS, macOS and Android. Unset, they keep the native SDK defaults. They have no effect on Windows, Linux and web
- On Windows and Linux, a `$process_person_profile` property passed with an event now applies to that event instead of being replaced by the SDK's value
427 changes: 427 additions & 0 deletions api/posthog_flutter.api.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ class PosthogFlutterPlugin :
capture(call, result)
}

"captureAi" -> {
captureAi(call, result)
}

"captureFeatureView" -> {
captureFeatureView(call, result)
}
Expand Down Expand Up @@ -609,6 +613,21 @@ class PosthogFlutterPlugin :
"none" -> compression = PostHogCompression.NONE
}
}
posthogConfig.getIfNotNull<String>("captureAiCompression") {
when (it) {
"gzip" -> captureAiCompression = PostHogCompression.GZIP
"none" -> captureAiCompression = PostHogCompression.NONE
}
}
posthogConfig.getIfNotNull<Int>("captureAiMaxQueueSize") {
captureAiMaxQueueSize = it
}
posthogConfig.getIfNotNull<Int>("captureAiTimeoutSeconds") {
captureAiTimeout = it
}
posthogConfig.getIfNotNull<Int>("captureAiMaxEventBytes") {
captureAiMaxEventBytes = it
}
posthogConfig.getIfNotNull<Boolean>("sessionReplay") {
sessionReplay = it
}
Expand Down Expand Up @@ -1731,7 +1750,8 @@ class PosthogFlutterPlugin :
val userId: String = call.argument("userId")!!
val userProperties: Map<String, Any>? = call.argument("userProperties")
val userPropertiesSetOnce: Map<String, Any>? = call.argument("userPropertiesSetOnce")
PostHog.identify(userId, userProperties, userPropertiesSetOnce)
val options: Map<String, Any?>? = call.argument("options")
PostHog.identify(userId, userProperties, userPropertiesSetOnce, options = options)
result.success(null)
} catch (e: Throwable) {
result.error("PosthogFlutterException", e.localizedMessage, null)
Expand All @@ -1745,7 +1765,8 @@ class PosthogFlutterPlugin :
try {
val userPropertiesToSet: Map<String, Any>? = call.argument("userPropertiesToSet")
val userPropertiesToSetOnce: Map<String, Any>? = call.argument("userPropertiesToSetOnce")
PostHog.setPersonProperties(userPropertiesToSet, userPropertiesToSetOnce)
val options: Map<String, Any?>? = call.argument("options")
PostHog.setPersonProperties(userPropertiesToSet, userPropertiesToSetOnce, options = options)
result.success(null)
} catch (e: Throwable) {
result.error("PosthogFlutterException", e.localizedMessage, null)
Expand All @@ -1761,26 +1782,53 @@ class PosthogFlutterPlugin :
val properties: Map<String, Any>? = call.argument("properties")
val userProperties: Map<String, Any>? = call.argument("userProperties")
val userPropertiesSetOnce: Map<String, Any>? = call.argument("userPropertiesSetOnce")
val options: Map<String, Any?>? = call.argument("options")
PostHog.capture(
eventName,
properties = properties,
userProperties = userProperties,
userPropertiesSetOnce = userPropertiesSetOnce,
options = options,
)
result.success(null)
} catch (e: Throwable) {
result.error("PosthogFlutterException", e.localizedMessage, null)
}
}

private fun captureAi(
call: MethodCall,
result: Result,
) {
try {
val eventName: String = call.argument("eventName")!!
val properties: Map<String, Any>? = call.argument("properties")
val userProperties: Map<String, Any>? = call.argument("userProperties")
val userPropertiesSetOnce: Map<String, Any>? = call.argument("userPropertiesSetOnce")
val options: Map<String, Any?>? = 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,
) {
try {
val screenName: String = call.argument("screenName")!!
val properties: Map<String, Any>? = call.argument("properties")
PostHog.screen(screenName, properties)
val options: Map<String, Any?>? = call.argument("options")
PostHog.screen(screenName, properties, options = options)
result.success(null)
} catch (e: Throwable) {
result.error("PosthogFlutterException", e.localizedMessage, null)
Expand Down Expand Up @@ -1821,7 +1869,8 @@ class PosthogFlutterPlugin :
) {
try {
val alias: String = call.argument("alias")!!
PostHog.alias(alias)
val options: Map<String, Any?>? = call.argument("options")
PostHog.alias(alias, options = options)
result.success(null)
} catch (e: Throwable) {
result.error("PosthogFlutterException", e.localizedMessage, null)
Expand Down Expand Up @@ -1999,7 +2048,8 @@ class PosthogFlutterPlugin :
val groupType: String = call.argument("groupType")!!
val groupKey: String = call.argument("groupKey")!!
val groupProperties: Map<String, Any>? = call.argument("groupProperties")
PostHog.group(groupType, groupKey, groupProperties)
val options: Map<String, Any?>? = call.argument("options")
PostHog.group(groupType, groupKey, groupProperties, options = options)
result.success(null)
} catch (e: Throwable) {
result.error("PosthogFlutterException", e.localizedMessage, null)
Expand Down Expand Up @@ -2131,14 +2181,17 @@ class PosthogFlutterPlugin :
val properties = arguments["properties"] as? Map<String, Any>
val timestampMs = arguments["timestamp"] as? Long

@Suppress("UNCHECKED_CAST")
val options = arguments["options"] as? Map<String, Any?>

// Extract timestamp from Flutter
val timestamp: Date? =
timestampMs?.let {
// timestampMs already in UTC milliseconds epoch
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PostHogEvent>()
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<Any>())),
"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<Map<String, Any?>?>(options, event.options, event.event)
}
} finally {
PostHog.close()
}
}

@Test
fun captureAiReturnsTheQueuedEventUuid() {
val events = mutableListOf<PostHogEvent>()
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<Map<String, Any?>?>(options, event.options)
} finally {
PostHog.close()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading