From 6c5c6067d829efb48f7e3b224893fe93622feb8f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:00:53 -0400 Subject: [PATCH 01/45] feat!: disable the SDK when the project token is a personal API key A `phx_` token is a personal API key; capture rejects every request made with it. Setup now logs once and stays disabled, as for an empty token. Co-Authored-By: Claude Fable 5.1 --- posthog/src/main/java/com/posthog/PostHog.kt | 8 ++++++++ .../src/main/java/com/posthog/PostHogStateless.kt | 8 ++++++++ .../test/java/com/posthog/PostHogStatelessTest.kt | 12 ++++++++++++ 3 files changed, 28 insertions(+) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index a616e878c..e8925eeb8 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -218,6 +218,14 @@ public class PostHog private constructor( return } + if (config.projectToken.startsWith("phx_")) { + config.logger.log( + "PostHog SDK is disabled because the project token is a personal API key (phx_...), " + + "use your project token (phc_...).", + ) + return + } + if (!projectTokens.add(config.projectToken)) { config.logger.log("Project token: ${config.projectToken} already has a PostHog instance.") } diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index bd6505876..a7605f232 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -65,6 +65,14 @@ public open class PostHogStateless protected constructor( return } + if (config.projectToken.startsWith("phx_")) { + config.logger.log( + "PostHog SDK is disabled because the project token is a personal API key (phx_...), " + + "use your project token (phc_...).", + ) + return + } + if (!projectTokens.add(config.projectToken)) { config.logger.log("Project token: ${config.projectToken} already has a PostHog instance.") } diff --git a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt index 00dddfffb..b2b0cd2fe 100644 --- a/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogStatelessTest.kt @@ -280,6 +280,18 @@ internal class PostHogStatelessTest { assertTrue(mockLogger.messages.any { it.contains("PostHog SDK is disabled because the project token is required") }) } + @Test + fun `setup no-ops for a personal API key`() { + sut = createStatelessInstance() + val mockLogger = TestLogger() + config = PostHogConfig("phx_personal", "https://api.posthog.com").apply { logger = mockLogger } + + sut.setup(config) + + assertFalse(sut.isEnabledPublic()) + assertTrue(mockLogger.messages.any { it.contains("PostHog SDK is disabled because the project token is a personal API key") }) + } + @Test fun `setup logs warning when called multiple times`() { sut = createStatelessInstance() From 5f21b66c8ddd54fbe1a0e9e3dd7e440d8da7e24a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:01:15 -0400 Subject: [PATCH 02/45] feat!: send analytics events through capture v1 Analytics events now go to `POST /i/v1/analytics/events` with the project token in an `Authorization: Bearer` header, `PostHog-Sdk-Info`, `PostHog-Attempt`, `PostHog-Request-Id` and `PostHog-Request-Timestamp`. The body is `{created_at, batch}`; each event drops `$lib`/`$lib_version`, lifts string `$session_id`/`$window_id` to the root, and hoists the legacy `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties into the new `PostHogEvent.options`. The queue reads the per-event results: `ok`, `warning` and `drop` leave the queue, only `retry` is kept and resent under the same request id and `created_at` with the next attempt number. A 2xx without readable results counts as delivered. 408 and 500/502/503/504 are retried; 429, 404 and other 4xx are terminal, as are redirects other than a same-origin 307/308 (followed at most 5 times with the same body and headers). Backoff doubles from 1 to 30 seconds with 25% jitter, and `Retry-After` is a floor capped at 30 seconds for every queue. Replay and logs keep their retry sets. Legacy cached events (`PostHogSendCachedEventsIntegration`) are sent through capture v1 too; the ones capture asks to retry are re-queued at the tail of the legacy file for the next launch. `PostHogConfig.maxRetries` is renamed `pushNotificationSubscriptionMaxRetries` since it only limits push-subscription retries. `PostHogApi.batch()` stays for posthog-server. Co-Authored-By: Claude Fable 5.1 --- .../PostHogAndroidEventSnapshotsTest.kt | 21 +- .../json/snapshots/batch-request.json | 102 ++-- posthog/api/posthog.api | 15 +- .../main/java/com/posthog/PostHogConfig.kt | 7 +- .../src/main/java/com/posthog/PostHogEvent.kt | 14 +- .../java/com/posthog/internal/EndpointSpec.kt | 53 +- .../java/com/posthog/internal/PostHogApi.kt | 288 +++++++++- .../PostHogPushSubscriptionManager.kt | 4 +- .../java/com/posthog/internal/PostHogQueue.kt | 119 +++- .../PostHogSendCachedEventsIntegration.kt | 51 +- .../java/com/posthog/PostHogBootstrapTest.kt | 2 +- .../com/posthog/PostHogPersonProfilesTest.kt | 30 +- .../src/test/java/com/posthog/PostHogTest.kt | 52 +- posthog/src/test/java/com/posthog/Utils.kt | 8 + .../com/posthog/internal/EndpointSpecTest.kt | 38 +- .../com/posthog/internal/PostHogApiTest.kt | 6 +- .../posthog/internal/PostHogCaptureV1Test.kt | 531 ++++++++++++++++++ .../PostHogPushSubscriptionManagerTest.kt | 2 +- .../internal/PostHogQueueDurabilityTest.kt | 17 +- .../com/posthog/internal/PostHogQueueTest.kt | 131 ++++- .../internal/PostHogReplayQueueTest.kt | 2 +- ...ogSendCachedLegacyEventsIntegrationTest.kt | 72 ++- 22 files changed, 1366 insertions(+), 199 deletions(-) create mode 100644 posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt index ae466718a..8942764cb 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt @@ -30,6 +30,7 @@ import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -94,7 +95,10 @@ internal class PostHogAndroidEventSnapshotsTest { sut.captureException(fixedThrowable(), mapOf("handled" to true, "component" to "checkout")) sut.flush() - val request = fixture.server.takeRequests(CONFIG_PATH, "/flags/?v=2", "/batch").single { it.path == "/batch" } + val request = + fixture.server.takeRequests(CONFIG_PATH, "/flags/?v=2", "/i/v1/analytics/events").single { + it.path == "/i/v1/analytics/events" + } val snapshot = requestSnapshot(request, fixture.config) normalizeBatchVolatileFields(snapshot) @@ -215,9 +219,11 @@ internal class PostHogAndroidEventSnapshotsTest { ): MutableMap { assertEquals("POST", request.method) assertEquals("gzip", request.getHeader("Content-Encoding")) - assertEquals("application/json; charset=utf-8", request.getHeader("Content-Type")) + assertTrue(assertNotNull(request.getHeader("Content-Type")).startsWith("application/json")) val userAgent = assertNotNull(request.getHeader("User-Agent")) assertEquals("posthog-android/${config.sdkVersion}", userAgent) + // capture v1 names the SDK in its own header too; other requests don't send it + request.getHeader("PostHog-Sdk-Info")?.let { assertEquals(userAgent, it) } assertTrue(config.sdkVersion.isNotEmpty()) assertEquals("android-events-v1", request.getHeader("X-PostHog-Snapshot")) @@ -230,18 +236,21 @@ internal class PostHogAndroidEventSnapshotsTest { "path" to request.path, "headers" to linkedMapOf( + "Authorization" to request.getHeader("Authorization"), "Content-Encoding" to request.getHeader("Content-Encoding"), "Content-Type" to request.getHeader("Content-Type"), + "PostHog-Attempt" to request.getHeader("PostHog-Attempt"), + "PostHog-Sdk-Info" to request.getHeader("PostHog-Sdk-Info")?.let { "posthog-android/" }, "User-Agent" to "posthog-android/", "X-PostHog-Snapshot" to request.getHeader("X-PostHog-Snapshot"), - ), + ).filterValues { it != null }, "body" to decodedBody, ) } private fun normalizeBatchVolatileFields(snapshot: MutableMap) { val body = snapshot.map("body") - assertEquals(FIXED_TIMESTAMP, body["sent_at"]) + assertEquals(FIXED_TIMESTAMP, body["created_at"]) val batch = body["batch"] assertIs>(batch) @@ -257,7 +266,9 @@ internal class PostHogAndroidEventSnapshotsTest { UUID.fromString(uuid) event["uuid"] = "" val properties = event.map("properties") - normalizeSdkVersion(properties) + // capture v1 reads the SDK name and version from the PostHog-Sdk-Info header + assertFalse(properties.containsKey("\$lib")) + assertFalse(properties.containsKey("\$lib_version")) // Depends on how many earlier captures the async queueExecutor has drained by the time // this event's properties are built on the calling thread -- not deterministic run to run. if (properties.containsKey("\$sdk_debug_pending_queue_size")) { diff --git a/posthog-android/src/test/resources/json/snapshots/batch-request.json b/posthog-android/src/test/resources/json/snapshots/batch-request.json index c9d3b3997..8f9fb1544 100644 --- a/posthog-android/src/test/resources/json/snapshots/batch-request.json +++ b/posthog-android/src/test/resources/json/snapshots/batch-request.json @@ -1,18 +1,27 @@ { "method": "POST", - "path": "/batch", + "path": "/i/v1/analytics/events", "headers": { + "Authorization": "Bearer _6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI", "Content-Encoding": "gzip", - "Content-Type": "application/json; charset=utf-8", + "Content-Type": "application/json", + "PostHog-Attempt": "1", + "PostHog-Sdk-Info": "posthog-android/", "User-Agent": "posthog-android/", "X-PostHog-Snapshot": "android-events-v1" }, "body": { - "api_key": "_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI", + "created_at": "2023-11-14T22:13:20.123Z", "batch": [ { "event": "checkout completed", + "uuid": "", "distinct_id": "anonymous-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", + "options": { + "process_person_profile": true + }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -41,9 +50,6 @@ "$groups": { "company": "posthog" }, - "$lib": "posthog-android", - "$lib_version": "", - "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "amount": 42.5, "currency": "USD", "items": [ @@ -53,19 +59,22 @@ } ], "$is_identified": false, - "$process_person_profile": true, - "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_internal_buffer_length": 0 - }, - "timestamp": "2023-11-14T22:13:20.123Z", - "uuid": "" + "$sdk_debug_replay_internal_buffer_length": 0, + "$sdk_debug_pending_queue_size": "" + } }, { "event": "$identify", + "uuid": "", "distinct_id": "identified-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", + "options": { + "process_person_profile": true + }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -94,26 +103,26 @@ "$groups": { "company": "posthog" }, - "$lib": "posthog-android", - "$lib_version": "", - "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "$anon_distinct_id": "anonymous-user", "$is_identified": true, - "$process_person_profile": true, - "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", "$sdk_debug_replay_capture_mode": "wireframe", "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_session_start": 1700000000123 - }, - "timestamp": "2023-11-14T22:13:20.123Z", - "uuid": "" + "$sdk_debug_session_start": 1700000000123, + "$sdk_debug_pending_queue_size": "" + } }, { "event": "$groupidentify", + "uuid": "", "distinct_id": "identified-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", + "options": { + "process_person_profile": true + }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -133,9 +142,6 @@ "$timezone": "UTC", "$network_type": "wifi", "$network_carrier": "Snapshot Telecom", - "$lib": "posthog-android", - "$lib_version": "", - "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "$group_type": "company", "$group_key": "posthog", "$group_set": { @@ -143,19 +149,22 @@ "employees": 100 }, "$is_identified": true, - "$process_person_profile": true, - "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_internal_buffer_length": 0 - }, - "timestamp": "2023-11-14T22:13:20.123Z", - "uuid": "" + "$sdk_debug_replay_internal_buffer_length": 0, + "$sdk_debug_pending_queue_size": "" + } }, { "event": "$feature_flag_called", + "uuid": "", "distinct_id": "identified-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", + "options": { + "process_person_profile": true + }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -178,13 +187,10 @@ "$groups": { "company": "posthog" }, - "$lib": "posthog-android", - "$lib_version": "", "$feature/snapshot-flag": "snapshot-variant", "$active_feature_flags": [ "snapshot-flag" ], - "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "$feature_flag": "snapshot-flag", "$feature_flag_response": "snapshot-variant", "$feature_flag_request_id": "flag-request-123", @@ -192,19 +198,22 @@ "$feature_flag_version": 7, "$feature_flag_reason": "", "$is_identified": true, - "$process_person_profile": true, - "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_internal_buffer_length": 0 - }, - "timestamp": "2023-11-14T22:13:20.123Z", - "uuid": "" + "$sdk_debug_replay_internal_buffer_length": 0, + "$sdk_debug_pending_queue_size": "" + } }, { "event": "$exception", + "uuid": "", "distinct_id": "identified-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", + "options": { + "process_person_profile": true + }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -227,13 +236,10 @@ "$groups": { "company": "posthog" }, - "$lib": "posthog-android", - "$lib_version": "", "$feature/snapshot-flag": "snapshot-variant", "$active_feature_flags": [ "snapshot-flag" ], - "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "$exception_level": "error", "$exception_list": [ { @@ -293,17 +299,13 @@ "handled": true, "component": "checkout", "$is_identified": true, - "$process_person_profile": true, - "$sdk_debug_pending_queue_size": "", "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_internal_buffer_length": 0 - }, - "timestamp": "2023-11-14T22:13:20.123Z", - "uuid": "" + "$sdk_debug_replay_internal_buffer_length": 0, + "$sdk_debug_pending_queue_size": "" + } } - ], - "sent_at": "2023-11-14T22:13:20.123Z" + ] } } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index d515cea20..aa272e6b6 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -184,7 +184,6 @@ public class com/posthog/PostHogConfig { public final fun getLogsStoragePrefix ()Ljava/lang/String; public final fun getMaxBatchSize ()I public final fun getMaxQueueSize ()I - public final fun getMaxRetries ()I public final fun getNetworkStatus ()Lcom/posthog/internal/PostHogNetworkStatus; public final fun getOnFeatureFlags ()Lcom/posthog/PostHogOnFeatureFlags; public final fun getOptOut ()Z @@ -194,6 +193,7 @@ public class com/posthog/PostHogConfig { public final fun getProjectToken ()Ljava/lang/String; public final fun getProxy ()Ljava/net/Proxy; public final fun getPushIdentityProvider ()Lkotlin/jvm/functions/Function3; + public final fun getPushNotificationSubscriptionMaxRetries ()I public final fun getQueueProvider ()Lkotlin/jvm/functions/Function5; public final fun getReleaseIdentifier ()Ljava/lang/String; public final fun getRemoteConfigHolder ()Lcom/posthog/internal/PostHogRemoteConfig; @@ -235,7 +235,6 @@ public class com/posthog/PostHogConfig { public final fun setLogsStoragePrefix (Ljava/lang/String;)V public final fun setMaxBatchSize (I)V public final fun setMaxQueueSize (I)V - public final fun setMaxRetries (I)V public final fun setNetworkStatus (Lcom/posthog/internal/PostHogNetworkStatus;)V public final fun setOnFeatureFlags (Lcom/posthog/PostHogOnFeatureFlags;)V public final fun setOptOut (Z)V @@ -244,6 +243,7 @@ public class com/posthog/PostHogConfig { public final fun setPreloadFeatureFlags (Z)V public final fun setProxy (Ljava/net/Proxy;)V public final fun setPushIdentityProvider (Lkotlin/jvm/functions/Function3;)V + public final fun setPushNotificationSubscriptionMaxRetries (I)V public final fun setReleaseIdentifier (Ljava/lang/String;)V public final fun setRemoteConfigHolder (Lcom/posthog/internal/PostHogRemoteConfig;)V public final fun setReplayStoragePrefix (Ljava/lang/String;)V @@ -289,9 +289,10 @@ public abstract interface class com/posthog/PostHogEncryption { } public final class com/posthog/PostHogEvent { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/UUID;Ljava/lang/String;Ljava/util/UUID;Ljava/util/Map;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/UUID;Ljava/lang/String;Ljava/util/UUID;Ljava/util/Map;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/UUID;Ljava/lang/String;Ljava/util/UUID;Ljava/util/Map;Ljava/lang/String;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/UUID;Ljava/lang/String;Ljava/util/UUID;Ljava/util/Map;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/util/Map; public final fun component4 ()Ljava/util/Date; @@ -300,13 +301,14 @@ public final class com/posthog/PostHogEvent { public final fun component7 ()Ljava/util/UUID; public final fun component8 ()Ljava/util/Map; public final fun component9 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/UUID;Ljava/lang/String;Ljava/util/UUID;Ljava/util/Map;Ljava/lang/String;)Lcom/posthog/PostHogEvent; - public static synthetic fun copy$default (Lcom/posthog/PostHogEvent;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/UUID;Ljava/lang/String;Ljava/util/UUID;Ljava/util/Map;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/PostHogEvent; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/UUID;Ljava/lang/String;Ljava/util/UUID;Ljava/util/Map;Ljava/lang/String;Ljava/util/Map;)Lcom/posthog/PostHogEvent; + public static synthetic fun copy$default (Lcom/posthog/PostHogEvent;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/UUID;Ljava/lang/String;Ljava/util/UUID;Ljava/util/Map;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/PostHogEvent; public fun equals (Ljava/lang/Object;)Z public final fun getApiKey ()Ljava/lang/String; public final fun getDistinctId ()Ljava/lang/String; public final fun getEvent ()Ljava/lang/String; public final fun getMessageId ()Ljava/util/UUID; + public final fun getOptions ()Ljava/util/Map; public final fun getProperties ()Ljava/util/Map; public final fun getSet ()Ljava/util/Map; public final fun getTimestamp ()Ljava/util/Date; @@ -316,6 +318,7 @@ public final class com/posthog/PostHogEvent { public final fun isExceptionEvent ()Z public final fun isFatalExceptionEvent ()Z public final fun setApiKey (Ljava/lang/String;)V + public final fun setOptions (Ljava/util/Map;)V public fun toString ()Ljava/lang/String; } diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index c72ffa1e4..da0268a16 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -133,7 +133,7 @@ public open class PostHogConfig( * * Defaults to 3 */ - public var maxRetries: Int = 3, + public var pushNotificationSubscriptionMaxRetries: Int = 3, /** * Maximum number of retries for feature flag requests after transient network errors. * Defaults to 1. Set to 0 to disable feature flag request retries. @@ -396,7 +396,10 @@ public open class PostHogConfig( /** * Custom headers to send with every request to the PostHog API. * - * Useful for reverse-proxy setups that require authentication, e.g. an `Authorization` header. + * Useful for reverse-proxy setups that require authentication. Capture requests + * (`/i/v1/analytics/events`) send the project token in an `Authorization: Bearer` header, which + * replaces an `Authorization` header set here, so authenticate your proxy with a different + * header, such as `X-Proxy-Token`. Other requests still send your `Authorization` header. * * Read once when the SDK is set up; changes after setup are ignored. * diff --git a/posthog/src/main/java/com/posthog/PostHogEvent.kt b/posthog/src/main/java/com/posthog/PostHogEvent.kt index 8f2ceee88..4e0a3cd0f 100644 --- a/posthog/src/main/java/com/posthog/PostHogEvent.kt +++ b/posthog/src/main/java/com/posthog/PostHogEvent.kt @@ -11,12 +11,12 @@ import java.util.UUID // if we'd ever allow users to inspect and mutate the event, it should be a public API /** - * The PostHog event data structure accepted by the batch API + * The PostHog event data structure sent to capture * @property event The event name * @property distinctId The distinct Id * @property properties All the event properties * @property timestamp The event timestamp. UTC is preferred; the equivalent instant is serialized in UTC - * @property uuid the UUID v4 is automatically generated and used for deduplication + * @property uuid the UUID v7 is automatically generated and used for deduplication */ public data class PostHogEvent( val event: String, @@ -37,6 +37,16 @@ public data class PostHogEvent( // Only used for Replay @SerializedName("api_key") var apiKey: String? = null, + /** + * Options that tell PostHog how to process this event, separate from its properties. + * + * Known keys are `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and + * `product_tour_id`; unknown keys are sent unchanged and ignored by PostHog. When the event is + * sent, the legacy `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and + * `$product_tour_id` properties are removed from [properties] and used for their option + * unless that option is already set here. + */ + var options: MutableMap? = null, ) { /** * Checks if the event is an exception event ($exception) diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index 4ed7bcf48..5ee8d7f99 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -9,9 +9,14 @@ import java.io.OutputStream /** * Per-endpoint specification consumed by [PostHogQueue]. Carries - * everything that differs between PostHog endpoints (`/batch`, `/snapshot`, - * and any future endpoint): the codec, the send call, the retry policy, - * and the per-endpoint runtime knobs. + * everything that differs between PostHog endpoints (`/i/v1/analytics/events`, + * `/snapshot`, and any future endpoint): the codec, the send call, the retry + * policy, and the per-endpoint runtime knobs. + * + * `send` returns a [SendResult] naming the [recordId]s the server asked to + * retry; an empty set means every record is done (delivered or dropped). + * Endpoints without per-record results return an empty result and leave + * [recordId] null. * * The queue itself stays record-type-agnostic; everything that varies * between endpoints lives here. @@ -27,10 +32,11 @@ public class EndpointSpec internal constructor( internal val encode: (Record, OutputStream) -> Unit, internal val decode: (InputStream) -> Record?, internal val describe: (Record) -> String, - internal val send: (List) -> Unit, + internal val send: (List) -> SendResult, internal val isRetriableStatusCode: (Int) -> Boolean, internal val isFatalRecord: (Record) -> Boolean = { false }, internal val canBatchTogether: ((Record, Record) -> Boolean)? = null, + internal val recordId: ((Record) -> String?)? = null, ) { public companion object { @JvmStatic @@ -53,9 +59,10 @@ public class EndpointSpec internal constructor( config.serializer.deserialize(stream.reader().buffered()) }, describe = { event -> "Event ${event.event}" }, - send = { events -> api.batch(events) }, - isRetriableStatusCode = ::isEventsRetriableStatusCode, + send = { events -> api.captureV1(events) }, + isRetriableStatusCode = ::isCaptureV1RetriableStatusCode, isFatalRecord = { it.isFatalExceptionEvent() }, + recordId = { event -> event.uuid?.toString() }, ) @JvmStatic @@ -83,8 +90,11 @@ public class EndpointSpec internal constructor( first.distinctId == next.distinctId && (first.properties?.get("\$session_id") as? String) == (next.properties?.get("\$session_id") as? String) }, - send = { events -> api.snapshot(events) }, - isRetriableStatusCode = ::isEventsRetriableStatusCode, + send = { events -> + api.snapshot(events) + SendResult() + }, + isRetriableStatusCode = ::isSnapshotRetriableStatusCode, isFatalRecord = { it.isFatalExceptionEvent() }, ) @@ -119,18 +129,37 @@ public class EndpointSpec internal constructor( map?.let { PostHogLogRecord.fromStorageMap(it) } }, describe = { _ -> "log" }, - send = { records -> api.sendLogs(records, resourceAttrs) }, + send = { records -> + api.sendLogs(records, resourceAttrs) + SendResult() + }, isRetriableStatusCode = ::isLogsRetriableStatusCode, ) } } } -internal val DEFAULT_EVENTS_RETRYABLE_STATUS_CODES: Set = setOf(408, 429, 500, 502, 503, 504) +/** + * What an endpoint's `send` reports back to the queue: the record ids the + * server asked to retry, empty when every record is done, and the + * `Retry-After` the server sent with that answer. + */ +internal class SendResult( + val retryIds: Set = emptySet(), + val retryAfterSeconds: Int? = null, +) + +/** + * Capture v1 retry policy: 408 and the listed 5xx. Everything else, including 429 and 3xx, is + * terminal; 413 is handled by the queue's batch halving. + */ +internal fun isCaptureV1RetriableStatusCode(code: Int): Boolean = code == 408 || code in setOf(500, 502, 503, 504) -internal fun isEventsRetriableStatusCode(code: Int): Boolean = code in DEFAULT_EVENTS_RETRYABLE_STATUS_CODES +/** `/snapshot` (replay) retry policy: 408, 429, the listed 5xx, plus 3xx redirects. */ +internal fun isSnapshotRetriableStatusCode(code: Int): Boolean = code in setOf(408, 429, 500, 502, 503, 504) || code in 300..399 -internal fun isLogsRetriableStatusCode(code: Int): Boolean = code == 408 || code == 429 || code in 500..599 +/** `/i/v1/logs` retry policy: 408, 429, every 5xx, plus 3xx redirects. */ +internal fun isLogsRetriableStatusCode(code: Int): Boolean = code == 408 || code == 429 || code in 300..399 || code in 500..599 /** * Captures the resource attributes for the logs endpoint at SDK setup. diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index c3cdbec6d..c95aac374 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -11,6 +11,9 @@ import com.posthog.PostHogEvent import com.posthog.PostHogInternal import com.posthog.internal.logs.PostHogLogsOTLP import com.posthog.logs.PostHogLogRecord +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -25,6 +28,7 @@ import java.io.OutputStream import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException +import java.util.UUID import javax.net.ssl.SSLException /** @@ -37,9 +41,12 @@ public class PostHogApi( ) { private companion object { private const val APP_JSON_UTF_8 = "application/json; charset=utf-8" + private const val APP_JSON = "application/json" private const val FLAGS_INITIAL_RETRY_DELAY_MS = 300L private const val FLAGS_MAX_RETRY_DELAY_MS = 30_000L private const val ERROR_BODY_PEEK_BYTES = 2048L + private const val CAPTURE_V1_ANALYTICS_PATH = "/i/v1/analytics/events" + private const val CAPTURE_V1_MAX_REDIRECTS = 5 } private val mediaType by lazy { @@ -51,6 +58,14 @@ public class PostHogApi( } } + private val captureMediaType by lazy { + try { + APP_JSON.toMediaType() + } catch (ignored: Throwable) { + null + } + } + private val client: OkHttpClient = config.httpClient ?: OkHttpClient.Builder() .proxy(config.proxy) @@ -65,6 +80,21 @@ public class PostHogApi( .build() } + // Capture v1 decides its own redirects (see captureV1): OkHttp would follow any 3xx, drop the + // Authorization header on a cross-host hop and turn a 302 into a body-less GET. + private val captureClient: OkHttpClient by lazy { + client.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + } + + private val captureV1Lock = Any() + + // The identity of the last capture v1 request per endpoint path whose events are still to be + // retried. In memory only: a new request id after an app restart is fine, the server only logs it. + private val captureV1PendingRetry = mutableMapOf() + private val theHost: String get() { return if (config.host.endsWith("/")) config.host.substring(0, config.host.length - 1) else config.host @@ -87,6 +117,168 @@ public class PostHogApi( executeNoBody(request) } + /** + * Sends analytics events to capture v1 (`POST /i/v1/analytics/events`). + * + * Returns the uuids of the events the 2xx response asked to retry, with the `Retry-After` it + * sent; every other event in [events] was delivered or dropped and must not be sent again. A 2xx + * whose per-event results can't be read, including a transfer that failed after the status + * arrived, counts as delivered: the server already stored its events and a resend would store + * them again. + * + * Only a 307 or 308 to the origin of [PostHogConfig.host] is followed, at most 5 times, with + * the same body and headers; any other redirect is a terminal [PostHogApiError]. + * + * Throws [PostHogApiError] for a non-2xx status and [IOException] for a transport failure. + */ + @Throws(PostHogApiError::class, IOException::class) + internal fun captureV1(events: List): SendResult { + val path = CAPTURE_V1_ANALYTICS_PATH + val uuids = events.mapNotNull { it.uuid?.toString() } + val identity = nextCaptureV1Identity(path, uuids.toSet()) + val body = + mapOf( + "created_at" to identity.createdAt, + "batch" to events.map { captureV1Json(it, config.logger) }, + ) + + val url = "$theHost$path" + var request = + makeRequest(url, contentType = captureMediaType) { + logRequest(body, url) + + config.serializer.serialize(body, it.bufferedWriter()) + }.newBuilder() + .header("Authorization", "Bearer ${config.projectToken}") + .header("PostHog-Sdk-Info", config.userAgent) + .header("PostHog-Attempt", identity.attempt.toString()) + .header("PostHog-Request-Id", identity.requestId) + .header("PostHog-Request-Timestamp", formatISO8601Date(config.dateProvider.currentDate())) + .build() + + var redirects = 0 + while (true) { + logRequestHeaders(request) + + captureClient.newCall(request).execute().use { + val response = logResponse(it) + + if (response.code != 307 && response.code != 308) { + return captureV1Result(response, path, uuids, identity) + } + + val target = response.header("Location")?.let { location -> request.url.resolve(location) } + if (redirects >= CAPTURE_V1_MAX_REDIRECTS || target == null || !isConfiguredOrigin(target)) { + config.logger.log("Capture did not follow a ${response.code} redirect.") + clearCaptureV1Retry(path) + throw PostHogApiError(response.code, response.message, response.body, parseRetryAfter(response)) + } + // Same origin, so the body and every header are safe to resend. + request = request.newBuilder().url(target).build() + redirects++ + } + } + } + + /** + * Reuses the request id and `created_at` with the next attempt number when [uuids] is the set + * of events the previous request to [path] left to retry; otherwise starts a new request at + * attempt 1. The identity is kept until the outcome is known, so a transport failure or a + * retriable status resends the whole batch under it. + */ + private fun nextCaptureV1Identity( + path: String, + uuids: Set, + ): CaptureV1RequestIdentity { + synchronized(captureV1Lock) { + val pending = captureV1PendingRetry[path] + val identity = + if (pending != null && pending.uuids == uuids) { + pending.copy(attempt = pending.attempt + 1) + } else { + CaptureV1RequestIdentity( + uuids = uuids, + requestId = UUID.randomUUID().toString(), + attempt = 1, + createdAt = formatISO8601Date(config.dateProvider.currentDate()), + ) + } + captureV1PendingRetry[path] = identity + return identity + } + } + + private fun clearCaptureV1Retry(path: String) { + synchronized(captureV1Lock) { + captureV1PendingRetry.remove(path) + } + } + + /** + * Reads the per-event results of a 2xx and records which events, if any, the next request + * retries under the same identity. A non-2xx is thrown; a terminal one also drops the identity. + */ + @Throws(PostHogApiError::class) + private fun captureV1Result( + response: Response, + path: String, + uuids: List, + identity: CaptureV1RequestIdentity, + ): SendResult { + if (!response.isSuccessful) { + if (!isCaptureV1RetriableStatusCode(response.code)) { + clearCaptureV1Retry(path) + } + throw PostHogApiError(response.code, response.message, response.body, parseRetryAfter(response)) + } + + val results = readCaptureV1Results(response) + if (results == null) { + config.logger.log( + "Capture returned ${response.code} without readable per-event results, treating ${uuids.size} events as delivered.", + ) + } + + val retryUuids = mutableSetOf() + var dropped = 0 + // Events missing from the results count as delivered. + for (uuid in uuids) { + val entry = results?.get(uuid) as? Map<*, *> ?: continue + when (entry["result"]) { + "retry" -> retryUuids.add(uuid) + "drop" -> dropped++ + } + } + + // Counts only: event uuids and server details can identify users. + if (dropped > 0 || retryUuids.isNotEmpty()) { + config.logger.log("Capture: $dropped dropped, ${retryUuids.size} to retry.") + } + + synchronized(captureV1Lock) { + if (retryUuids.isEmpty()) { + captureV1PendingRetry.remove(path) + } else { + captureV1PendingRetry[path] = identity.copy(uuids = retryUuids) + } + } + return SendResult(retryUuids, parseRetryAfter(response)) + } + + private fun readCaptureV1Results(response: Response): Map<*, *>? { + return try { + val body = response.body?.string() ?: return null + (config.serializer.deserializeString(body) as? Map<*, *>)?.get("results") as? Map<*, *> + } catch (e: Throwable) { + null + } + } + + private fun isConfiguredOrigin(url: HttpUrl): Boolean { + val host = config.host.toHttpUrlOrNull() ?: return false + return url.scheme == host.scheme && url.host.equals(host.host, ignoreCase = true) && url.port == host.port + } + @Throws(PostHogApiError::class, IOException::class) public fun snapshot(events: List) { events.forEach { @@ -224,11 +416,12 @@ public class PostHogApi( private fun makeRequest( url: String, method: String = "POST", + contentType: MediaType? = mediaType, serializer: (outputStream: OutputStream) -> Unit, ): Request { val requestBody = object : RequestBody() { - override fun contentType() = mediaType + override fun contentType() = contentType override fun writeTo(sink: BufferedSink) { sink.outputStream().use { @@ -521,3 +714,96 @@ public class PostHogApi( } } } + +/** + * Identity of a capture v1 request, kept so a retry of the same events reuses + * `PostHog-Request-Id` and `created_at` with an incremented `PostHog-Attempt`. + */ +internal class CaptureV1RequestIdentity( + val uuids: Set, + val requestId: String, + val attempt: Int, + val createdAt: String, +) { + fun copy( + uuids: Set = this.uuids, + attempt: Int = this.attempt, + ): CaptureV1RequestIdentity = CaptureV1RequestIdentity(uuids, requestId, attempt, createdAt) +} + +/** Legacy properties and the capture v1 option each one fills. */ +internal val LEGACY_OPTION_PROPERTIES: List> = + listOf( + "\$cookieless_mode" to "cookieless_mode", + "\$ignore_sent_at" to "disable_skew_correction", + "\$product_tour_id" to "product_tour_id", + "\$process_person_profile" to "process_person_profile", + ) + +/** + * Shapes a stored event for capture v1. `$session_id` and `$window_id` move to the event root, and + * `$lib`/`$lib_version` are dropped because the server reads them from `PostHog-Sdk-Info`. + * + * Legacy option properties are hoisted here, after `beforeSend`, so events queued by older versions + * are hoisted too. Each is always removed from properties and fills its option only when the option + * is missing or null. Values aren't coerced: the server validates them. + */ +internal fun captureV1Json( + event: PostHogEvent, + logger: PostHogLogger, +): Map { + val properties = LinkedHashMap(event.properties ?: emptyMap()) + properties.remove("\$lib") + properties.remove("\$lib_version") + + val options = LinkedHashMap(event.options ?: emptyMap()) + for ((property, option) in LEGACY_OPTION_PROPERTIES) { + if (!properties.containsKey(property)) continue + val legacy = properties.remove(property) + if (options[option] == null) { + options[option] = legacy + } + } + + val json = LinkedHashMap() + json["event"] = event.event + json["uuid"] = event.uuid?.toString() + json["distinct_id"] = event.distinctId + json["timestamp"] = formatISO8601Date(event.timestamp) + liftStringProperty("\$session_id", properties, logger)?.let { json["session_id"] = it } + liftStringProperty("\$window_id", properties, logger)?.let { json["window_id"] = it } + if (options.isNotEmpty()) { + json["options"] = options + } + json["properties"] = properties + return json +} + +/** + * Removes [key] from [properties] and returns its value when it's a string, including an empty one, + * which is sent unchanged. Capture rejects the whole request when a root `session_id` or `window_id` + * isn't a string, so any other value is dropped with a warning that names its JSON type, never the + * value. A null value counts as unset and is dropped without a warning. + */ +internal fun liftStringProperty( + key: String, + properties: MutableMap, + logger: PostHogLogger, +): String? { + if (!properties.containsKey(key)) return null + val value = properties.remove(key) ?: return null + if (value is String) return value + logger.log("dropping $key: a ${jsonTypeName(value)} value is not a string") + return null +} + +private fun jsonTypeName(value: Any): String { + return when (value) { + is Boolean -> "bool" + is Number -> "number" + is String -> "string" + is Map<*, *> -> "object" + is Iterable<*>, is Array<*> -> "array" + else -> value.javaClass.simpleName + } +} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt index 367257ff8..e6892323f 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogPushSubscriptionManager.kt @@ -243,7 +243,7 @@ internal class PostHogPushSubscriptionManager( return@executeSafely } // retryCount deliberately not reset: the exponential ladder persists across triggers, so - // repeated failures still exhaust maxRetries and halt for the session. + // repeated failures still exhaust pushNotificationSubscriptionMaxRetries and halt for the session. attempt(resetStateOnFold = false) } } @@ -629,7 +629,7 @@ internal class PostHogPushSubscriptionManager( } retryCount++ - if (retryCount > config.maxRetries) { + if (retryCount > config.pushNotificationSubscriptionMaxRetries) { config.logger.log( "Push subscription retries exhausted after $retryCount attempts; " + "will retry on next SDK startup.", diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 8b07352c2..147c97d50 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -10,10 +10,13 @@ import java.util.Timer import java.util.TimerTask import java.util.UUID import java.util.concurrent.ExecutorService +import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.atomic.AtomicBoolean import kotlin.concurrent.schedule +import kotlin.math.max import kotlin.math.min import kotlin.math.pow +import kotlin.math.roundToInt /** * Generic file-backed queue. One implementation, multiple instances — @@ -31,11 +34,24 @@ public class PostHogQueue( private val timerLock = Any() private var pausedUntil: Date? = null private var retryCount = 0 + + // The Retry-After of the last 2xx that kept some records for retry; read by executeWithRetry. + private var partialRetryAfterSeconds: Int? = null private val batchLimits = BatchLimits(cap = spec.initialCap(config).coerceAtLeast(1), flushAt = spec.initialFlushAt(config).coerceAtLeast(1)) private val initialRetryDelaySeconds = 1 private val maxRetryDelaySeconds = 30 + // Largest fraction a retry wait moves either way, so devices refused together don't all come back + // at the same moment. + private val retryJitter = 0.25 + + /** + * Source of the backoff jitter: a uniform value in `[0, 1)`. Tests replace it for fixed delays. + */ + @Volatile + internal var jitterUnit: () -> Double = { ThreadLocalRandom.current().nextDouble() } + @Volatile private var timer: Timer? = null @@ -198,13 +214,23 @@ public class PostHogQueue( executeBatch() } - private fun executeWithRetry(block: () -> Unit) { + /** + * Runs [block], which reports whether the server kept some records for retry, and applies the + * retry state: a failure or a partial retry counts as one more consecutive failure and pauses + * the queue with backoff; a full success resets both. + */ + private fun executeWithRetry(block: () -> Boolean) { var retry = false var retryAfterSeconds: Int? = null try { - block() - retryCount = 0 - pausedUntil = null + retry = block() + if (retry) { + retryCount = (retryCount + 1).coerceAtMost(maxRetryDelaySeconds) + retryAfterSeconds = partialRetryAfterSeconds + } else { + retryCount = 0 + pausedUntil = null + } } catch (e: Throwable) { config.logger.log("Flushing failed: $e.") @@ -215,6 +241,7 @@ public class PostHogQueue( retryAfterSeconds = e.retryAfterSeconds } } finally { + partialRetryAfterSeconds = null calculateDelay(retry, retryAfterSeconds) isFlushing.set(false) @@ -243,8 +270,12 @@ public class PostHogQueue( config.logger.log("File: ${file.name} failed to parse: $throwable.") } + /** + * Sends one FIFO window of the queue. Returns true when a group was partially retried, so the + * caller stops this flush and the kept records wait for the backoff. + */ @Throws(PostHogApiError::class, IOException::class) - private fun batchRecords() { + private fun batchRecords(): Boolean { val files = takeFiles() val records = mutableListOf>() @@ -271,23 +302,49 @@ public class PostHogQueue( while (end < records.size && spec.canBatchTogether?.invoke(records[start].second, records[end].second) != false) { end++ } - sendRecords(records.subList(start, end)) + if (sendRecords(records.subList(start, end))) { + return true + } start = end } + return false } + /** + * Sends [batch] and removes the delivered records. Returns true when the server asked to retry + * some of them: those files stay queued and the rest are deleted. + */ @Throws(PostHogApiError::class, IOException::class) - private fun sendRecords(batch: List>) { + private fun sendRecords(batch: List>): Boolean { val files = batch.map { it.first } val records = batch.map { it.second } var deleteFiles = true + var retained = emptyList() try { if (records.isNotEmpty()) { config.logger.log("Flushing ${records.size} ${spec.recordsLabel}.") - spec.send(records) - - config.logger.log("Flushed ${records.size} ${spec.recordsLabel} successfully.") + val result = spec.send(records) + + val recordId = spec.recordId + if (result.retryIds.isNotEmpty() && recordId != null) { + retained = + batch.mapNotNull { (file, record) -> + val id = recordId(record) + if (id != null && id in result.retryIds) file else null + } + if (retained.isNotEmpty()) { + partialRetryAfterSeconds = result.retryAfterSeconds + } + } + if (retained.isEmpty()) { + config.logger.log("Flushed ${records.size} ${spec.recordsLabel} successfully.") + } else { + config.logger.log( + "Flushed ${records.size - retained.size} of ${records.size} ${spec.recordsLabel}, " + + "keeping ${retained.size} for retry.", + ) + } } } catch (e: PostHogApiError) { deleteFiles = deleteFilesIfAPIError(e, batchLimits, records.size, config.logger, spec.isRetriableStatusCode) @@ -303,15 +360,17 @@ public class PostHogQueue( throw e } finally { if (deleteFiles) { + val delivered = files - retained.toSet() synchronized(dequeLock) { - deque.removeAll(files) + deque.removeAll(delivered) } - files.forEach { + delivered.forEach { it.deleteSafely(config) } } } + return retained.isNotEmpty() } override fun flush() { @@ -342,9 +401,11 @@ public class PostHogQueue( } executeWithRetry { - while (deque.isNotEmpty()) { - batchRecords() + var retryPending = false + while (deque.isNotEmpty() && !retryPending) { + retryPending = batchRecords() } + retryPending } } } @@ -368,18 +429,25 @@ public class PostHogQueue( } } + /** + * Pauses the queue after the [retryCount]-th consecutive failure. The delay doubles from + * [initialRetryDelaySeconds] to [maxRetryDelaySeconds], scaled by a jitter factor in + * `[1 - retryJitter, 1 + retryJitter)` and capped again, in whole seconds. A server `Retry-After`, + * capped at [maxRetryDelaySeconds], is a floor applied after jitter, so jitter never retries + * earlier than the server asked. + */ private fun calculateDelay( retry: Boolean, retryAfterSeconds: Int? = null, ) { if (retry) { - val delay = - if (retryAfterSeconds != null && retryAfterSeconds > 0) { - retryAfterSeconds - } else { - min(initialRetryDelaySeconds * 2.0.pow((retryCount - 1).toDouble()).toInt(), maxRetryDelaySeconds) - } - pausedUntil = config.dateProvider.addSecondsToCurrentDate(delay) + val maxDelay = maxRetryDelaySeconds.toDouble() + val computed = min(initialRetryDelaySeconds * 2.0.pow((retryCount - 1).toDouble()), maxDelay) + val unit = jitterUnit() + val factor = if (unit >= 0.0 && unit < 1.0) 1 - retryJitter + 2 * retryJitter * unit else 1.0 + val jittered = min(computed * factor, maxDelay).roundToInt() + val retryAfterFloor = (retryAfterSeconds ?: 0).coerceIn(0, maxRetryDelaySeconds) + pausedUntil = config.dateProvider.addSecondsToCurrentDate(max(jittered, retryAfterFloor)) } } @@ -540,13 +608,8 @@ internal fun deleteFilesIfAPIError( batchLimits: BatchLimits, actualBatchSize: Int, logger: PostHogLogger, - isRetriableStatusCode: (Int) -> Boolean = ::isEventsRetriableStatusCode, + isRetriableStatusCode: (Int) -> Boolean = ::isCaptureV1RetriableStatusCode, ): Boolean { - if (e.statusCode < 400) { - logger.log("Flushing failed with ${e.statusCode}, let's try again soon.") - - return false - } // workaround due to png images exceed our max. limit in kafka if (e.statusCode == 413 && actualBatchSize > 1 && batchLimits.cap > 1) { // try to reduce multi-event batches and flushAt until the cap is 1. @@ -557,7 +620,7 @@ internal fun deleteFilesIfAPIError( return false } - // Transient server errors and rate limiting, retry + // Transient server errors, and whatever else the endpoint's policy retries if (isRetriableStatusCode(e.statusCode)) { logger.log("Flushing failed with ${e.statusCode}, let's try again soon.") diff --git a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt index 4924b1f52..0457cc456 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt @@ -5,13 +5,15 @@ import com.posthog.PostHogEvent import com.posthog.PostHogIntegration import com.posthog.PostHogInterface import com.posthog.PostHogVisibleForTesting +import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.io.File import java.io.IOException import java.util.concurrent.ExecutorService import java.util.concurrent.atomic.AtomicBoolean /** - * The integration that sends all the cached legacy events, triggered once the SDK is setup + * The integration that sends all the cached legacy events through capture v1, triggered once the + * SDK is setup * @property config the Config * @property api the API class * @property executor the Executor @@ -70,6 +72,8 @@ internal class PostHogSendCachedEventsIntegration( while (!legacy.isEmpty) { val events = mutableListOf() + // The stored bytes of each event in `events`, to re-queue the ones capture asks to retry. + val eventsBytes = mutableListOf() val iterator = legacy.iterator() var eventsCount = 0 @@ -81,7 +85,8 @@ internal class PostHogSendCachedEventsIntegration( inputStream.use { theInputStream -> val event = config.serializer.deserialize(theInputStream.reader().buffered()) event?.let { - events.add(event) + events.add(normalizeLegacyEvent(event)) + eventsBytes.add(eventBytes) eventsCount++ } ?: run { removeFileSafely(iterator) @@ -98,10 +103,14 @@ internal class PostHogSendCachedEventsIntegration( if (events.isNotEmpty()) { var deleteFiles = true + var retryBytes = emptyList() try { - api.batch(events) + val retryUuids = api.captureV1(events).retryIds + if (retryUuids.isNotEmpty()) { + retryBytes = eventsBytes.filterIndexed { index, _ -> events[index].uuid?.toString() in retryUuids } + } } catch (e: PostHogApiError) { - if (e.statusCode < 400) { + if (isCaptureV1RetriableStatusCode(e.statusCode)) { deleteFiles = false } throw e @@ -112,6 +121,17 @@ internal class PostHogSendCachedEventsIntegration( } throw e } finally { + if (deleteFiles && retryBytes.isNotEmpty()) { + // The legacy file is FIFO: re-queue the events capture asked to retry + // at its tail first, so they stay durable while the delivered ones are + // removed from the head below. They're sent on the next launch. + try { + retryBytes.forEach { legacy.add(it) } + } catch (e: Throwable) { + config.logger.log("Error re-queuing legacy events: $e.") + deleteFiles = false + } + } if (deleteFiles && eventsCount > 0) { for (i in 1..eventsCount) { try { @@ -127,6 +147,10 @@ internal class PostHogSendCachedEventsIntegration( } } } + if (retryBytes.isNotEmpty()) { + config.logger.log("Kept ${retryBytes.size} legacy events for retry.") + return + } } } } catch (e: Throwable) { @@ -140,6 +164,25 @@ internal class PostHogSendCachedEventsIntegration( } } + /** + * Events cached by 2.x carry their uuid as `message_id` and person properties as a root `$set`, + * which capture v1 doesn't read: a missing uuid fails the whole request, and the properties + * would be lost. Moves both where capture v1 expects them. + */ + @Suppress("DEPRECATION") + private fun normalizeLegacyEvent(event: PostHogEvent): PostHogEvent { + val uuid = event.uuid ?: event.messageId ?: TimeBasedEpochGenerator.generate() + val set = event.set + if (uuid == event.uuid && set == null) { + return event + } + val properties = event.properties ?: mutableMapOf() + if (set != null && !properties.containsKey("\$set")) { + properties["\$set"] = set + } + return event.copy(uuid = uuid, properties = properties, messageId = null, set = null) + } + private fun removeFileSafely( iterator: MutableIterator, throwable: Throwable? = null, diff --git a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt index 214256e76..f4e588c87 100644 --- a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt @@ -244,7 +244,7 @@ internal class PostHogBootstrapTest { // No $identify (nothing to merge); a single person-processed $set marks the transition. val event = firstEvent(http, "\$set") assertEquals("user-123", event.distinctId) - assertEquals(true, event.properties?.get("\$process_person_profile")) + assertEquals(true, event.options?.get("process_person_profile")) assertEquals(true, prefs.getValue(IS_IDENTIFIED)) sut.close() diff --git a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt index 0f76373d9..f0b964878 100644 --- a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt @@ -81,7 +81,7 @@ internal class PostHogPersonProfilesTest { val theEvent = batch.batch.first() assertEquals("\$identify", theEvent.event) - assertEquals(true, theEvent.properties?.get("\$process_person_profile")) + assertEquals(true, theEvent.options?.get("process_person_profile")) assertEquals(DISTINCT_ID, theEvent.distinctId) assertNotNull(theEvent.properties!!["\$anon_distinct_id"]) assertEquals(userProps, theEvent.properties!!["\$set"]) @@ -108,7 +108,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(false, event.options!!["process_person_profile"] as Boolean) sut.close() } @@ -133,7 +133,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(false, event.options!!["process_person_profile"] as Boolean) assertEquals(false, event.properties!!["\$is_identified"] as Boolean) sut.close() @@ -160,7 +160,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(true, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(true, event.options!!["process_person_profile"] as Boolean) sut.close() } @@ -185,7 +185,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(true, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(true, event.options!!["process_person_profile"] as Boolean) sut.close() } @@ -210,7 +210,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(true, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(true, event.options!!["process_person_profile"] as Boolean) sut.close() } @@ -234,7 +234,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.last() - assertEquals(true, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(true, event.options!!["process_person_profile"] as Boolean) sut.close() } @@ -261,7 +261,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.last() - assertEquals(true, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(true, event.options!!["process_person_profile"] as Boolean) assertEquals(true, event.properties!!["\$is_identified"] as Boolean) sut.close() @@ -287,7 +287,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.last() - assertEquals(true, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(true, event.options!!["process_person_profile"] as Boolean) sut.close() } @@ -311,7 +311,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.last() - assertEquals(true, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(true, event.options!!["process_person_profile"] as Boolean) sut.reset() sut.close() @@ -334,7 +334,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(true, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(true, event.options!!["process_person_profile"] as Boolean) sut.reset() sut.close() @@ -359,7 +359,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(false, event.options!!["process_person_profile"] as Boolean) sut.reset() sut.close() @@ -384,7 +384,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(false, event.options!!["process_person_profile"] as Boolean) sut.reset() sut.close() @@ -409,7 +409,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(false, event.options!!["process_person_profile"] as Boolean) sut.reset() sut.close() @@ -432,7 +432,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.properties!!["\$process_person_profile"] as Boolean) + assertEquals(false, event.options!!["process_person_profile"] as Boolean) sut.close() http.shutdown() diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 22176defb..325b1c201 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -281,6 +281,27 @@ internal class PostHogTest { sut.close() } + @Test + fun `setup no-ops for a personal API key`() { + val config = PostHogConfig("phx_personal", "https://api.posthog.com") + val logger = TestLogger() + config.logger = logger + val sut = + PostHog.withInternal( + config, + queueExecutor, + replayQueueExecutor, + remoteConfigExecutor, + cachedEventsExecutor, + reloadFeatureFlags = true, + ) + + assertTrue(sut.isOptOut()) + assertTrue(logger.messages.any { it.contains("PostHog SDK is disabled because the project token is a personal API key") }) + + sut.close() + } + @Test fun `setup adds integration by default`() { val http = mockHttp() @@ -681,10 +702,6 @@ internal class PostHogTest { "\$feature_flag_reason", "\$feature_flag_request_id", "\$groups", - "\$process_person_profile", - "\$session_id", - "\$lib", - "\$lib_version", ) assertEquals(expectedKeys, theEvent.properties!!.keys) assertFalse(theEvent.properties!!.containsKey("\$recording_status")) @@ -1766,8 +1783,7 @@ internal class PostHogTest { val content = request.body.unGzip() val batch = serializer.deserialize(content.reader()) - assertEquals(PROJECT_TOKEN, batch.projectToken) - assertNotNull(batch.sentAt) + assertEquals("Bearer $PROJECT_TOKEN", request.headers["Authorization"]) val theEvent = batch.batch.first() assertEquals(EVENT, theEvent.event) @@ -1805,10 +1821,9 @@ internal class PostHogTest { val request = http.takeRequest() val content = request.body.unGzip() - val batch = serializer.deserialize(content.reader()) - val theEvent = batch.batch.first() + val theEvent = captureV1Events(content).first() - assertEquals(callerSessionId, theEvent.properties!!["\$session_id"]) + assertEquals(callerSessionId, theEvent["session_id"]) sut.close() } @@ -2098,8 +2113,7 @@ internal class PostHogTest { val content = request.body.unGzip() val batch = serializer.deserialize(content.reader()) - assertEquals(PROJECT_TOKEN, batch.projectToken) - assertNotNull(batch.sentAt) + assertEquals("Bearer $PROJECT_TOKEN", request.headers["Authorization"]) val theEvent = batch.batch.first() assertNotNull(theEvent.timestamp) @@ -2635,7 +2649,7 @@ internal class PostHogTest { val theEvent = batch.batch.first() assertEquals("\$groupidentify", theEvent.event) - assertNotNull(theEvent.properties!!["\$session_id"]) + assertNotNull(captureV1Events(content).first()["session_id"]) assertFalse(theEvent.properties!!["\$is_identified"] as Boolean) assertFalse(theEvent.properties!!.containsKey("\$groups")) @@ -2796,8 +2810,7 @@ internal class PostHogTest { val content = request.body.unGzip() val batch = serializer.deserialize(content.reader()) - assertEquals(PROJECT_TOKEN, batch.projectToken) - assertNotNull(batch.sentAt) + assertEquals("Bearer $PROJECT_TOKEN", request.headers["Authorization"]) val theEvent = batch.batch.first() assertEquals(EVENT, theEvent.event) @@ -3109,7 +3122,7 @@ internal class PostHogTest { var batch = serializer.deserialize(content.reader()) var theEvent = batch.batch.first() - val currentSessionId = theEvent.properties!!["\$session_id"] + val currentSessionId = captureV1Events(content).first()["session_id"] assertNotNull(currentSessionId) sut.reset() @@ -3132,7 +3145,7 @@ internal class PostHogTest { batch = serializer.deserialize(content.reader()) theEvent = batch.batch.first() - val newSessionId = theEvent.properties!!["\$session_id"] + val newSessionId = captureV1Events(content).first()["session_id"] assertNotNull(newSessionId) assertTrue(currentSessionId != newSessionId) @@ -5344,7 +5357,7 @@ internal class PostHogTest { val request = http.takeRequest() val content = request.body.unGzip() val batch = serializer.deserialize(content.reader()) - assertEquals(true, batch.batch[0].properties?.get("\$process_person_profile")) + assertEquals(true, batch.batch[0].options?.get("process_person_profile")) sut.close() } @@ -6323,8 +6336,9 @@ internal class PostHogTest { sut.capture(SDK_EVENT, DISTINCT_ID, properties = mapOf("\$session_id" to callerSessionId.toString())) queueExecutor.shutdownAndAwaitTermination() - val props = serializer.deserialize(http.takeRequest().body.unGzip().reader()).batch.first().properties!! - assertEquals(callerSessionId.toString(), props["\$session_id"]) + val theEvent = captureV1Events(http.takeRequest().body.unGzip()).first() + val props = theEvent["properties"] as Map<*, *> + assertEquals(callerSessionId.toString(), theEvent["session_id"]) assertEquals(managerStart - oneHourMs, (props["\$sdk_debug_session_start"] as Number).toLong()) sut.close() diff --git a/posthog/src/test/java/com/posthog/Utils.kt b/posthog/src/test/java/com/posthog/Utils.kt index 25e5e65f6..b3288259f 100644 --- a/posthog/src/test/java/com/posthog/Utils.kt +++ b/posthog/src/test/java/com/posthog/Utils.kt @@ -1,6 +1,7 @@ package com.posthog import com.posthog.internal.PostHogContext +import com.posthog.internal.PostHogSerializer import com.posthog.internal.parseISO8601Date import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -88,6 +89,13 @@ public fun Buffer.unGzip(): String { } } +/** The events of a capture v1 request [body], as sent on the wire. */ +@Suppress("UNCHECKED_CAST") +public fun captureV1Events(body: String): List> { + val parsed = PostHogSerializer(PostHogConfig(PROJECT_TOKEN)).deserialize>(body.reader()) + return parsed["batch"] as List> +} + public class TestPostHogContext : PostHogContext { override fun getStaticContext(): Map = mapOf( diff --git a/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt index b3ac02c26..73a68f956 100644 --- a/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt +++ b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt @@ -81,7 +81,10 @@ internal class EndpointSpecTest { stream.reader().buffered().use { SyntheticRecord(it.readText()) } }, describe = { "synthetic '${it.value}'" }, - send = { records -> sender.send(records) }, + send = { records -> + sender.send(records) + SendResult() + }, isRetriableStatusCode = isRetriableStatusCode, ) @@ -257,14 +260,13 @@ internal class EndpointSpecTest { } @Test - fun `logs spec retry policy retries 408 429 and 5xx`() { + fun `logs spec retry policy retries 408 429 3xx and 5xx`() { assertTrue(isLogsRetriableStatusCode(408)) assertTrue(isLogsRetriableStatusCode(429)) assertTrue(isLogsRetriableStatusCode(500)) assertTrue(isLogsRetriableStatusCode(599)) - // 3xx redirects are not retriable — a redirect from /i/v1/logs means - // misconfigured host, retrying would loop. - assertEquals(false, isLogsRetriableStatusCode(301)) + // A 3xx that OkHttp did not follow keeps the records, as it always did for logs. + assertTrue(isLogsRetriableStatusCode(301)) assertEquals(false, isLogsRetriableStatusCode(400)) assertEquals(false, isLogsRetriableStatusCode(404)) assertEquals(false, isLogsRetriableStatusCode(600)) @@ -272,14 +274,16 @@ internal class EndpointSpecTest { @Test fun `events spec does not retry non-retriable status codes`() { - // Events keeps a curated retry set (408, 429, 500, 502, 503, 504); it - // must not retry arbitrary 4xx, nor the extra 5xx codes the broad logs + // Events keeps a curated retry set (408, 500, 502, 503, 504); it must not + // retry 429, 3xx, arbitrary 4xx, nor the extra 5xx codes the broad logs // policy (500..599) covers. Guards against widening the set by mistake. - assertEquals(false, isEventsRetriableStatusCode(400)) - assertEquals(false, isEventsRetriableStatusCode(413)) - assertEquals(false, isEventsRetriableStatusCode(501)) - assertEquals(false, isEventsRetriableStatusCode(505)) - assertEquals(false, isEventsRetriableStatusCode(599)) + assertEquals(false, isCaptureV1RetriableStatusCode(307)) + assertEquals(false, isCaptureV1RetriableStatusCode(400)) + assertEquals(false, isCaptureV1RetriableStatusCode(413)) + assertEquals(false, isCaptureV1RetriableStatusCode(429)) + assertEquals(false, isCaptureV1RetriableStatusCode(501)) + assertEquals(false, isCaptureV1RetriableStatusCode(505)) + assertEquals(false, isCaptureV1RetriableStatusCode(599)) } @Test @@ -359,11 +363,11 @@ internal class EndpointSpecTest { assertEquals(1, http.requestCount) val request = assertNotNull(http.takeRequest(5, TimeUnit.SECONDS)) - assertTrue(request.path!!.startsWith("/batch")) - val batch = PostHogSerializer(config).deserialize(request.body.unGzip().reader()) - assertEquals("event-via-spec", batch.batch.single().event) - assertEquals(com.posthog.DISTINCT_ID, batch.batch.single().distinctId) - assertEquals(com.posthog.props, batch.batch.single().properties?.toMap()) + assertEquals("/i/v1/analytics/events", request.path) + val sent = com.posthog.captureV1Events(request.body.unGzip()).single() + assertEquals("event-via-spec", sent["event"]) + assertEquals(com.posthog.DISTINCT_ID, sent["distinct_id"]) + assertEquals(com.posthog.props, sent["properties"]) http.shutdown() } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt index 2c0b10816..e1e2d22e1 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogApiTest.kt @@ -60,7 +60,7 @@ internal class PostHogApiTest { debug: Boolean = false, logger: PostHogLogger? = null, httpClient: OkHttpClient? = null, - maxRetries: Int? = null, + pushNotificationSubscriptionMaxRetries: Int? = null, featureFlagRequestMaxRetries: Int? = null, requestHeaders: Map? = null, compression: PostHogCompression = PostHogCompression.GZIP, @@ -78,8 +78,8 @@ internal class PostHogApiTest { if (httpClient != null) { config.httpClient = httpClient } - if (maxRetries != null) { - config.maxRetries = maxRetries + if (pushNotificationSubscriptionMaxRetries != null) { + config.pushNotificationSubscriptionMaxRetries = pushNotificationSubscriptionMaxRetries } if (featureFlagRequestMaxRetries != null) { config.featureFlagRequestMaxRetries = featureFlagRequestMaxRetries diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt new file mode 100644 index 000000000..598e9e4e9 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -0,0 +1,531 @@ +package com.posthog.internal + +import com.posthog.BuildConfig +import com.posthog.PROJECT_TOKEN +import com.posthog.PostHogConfig +import com.posthog.PostHogEvent +import com.posthog.TestHttpServers +import com.posthog.TestLogger +import com.posthog.awaitExecution +import com.posthog.date +import com.posthog.shutdownAndAwaitTermination +import com.posthog.unGzip +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.SocketPolicy +import org.junit.Assert.assertThrows +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.IOException +import java.util.UUID +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PostHogCaptureV1Test { + @get:Rule + val httpServers = TestHttpServers() + + @get:Rule + val tmpDir = TemporaryFolder() + + private val executor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("CaptureV1Test")) + private val queues = mutableListOf>() + private val logger = TestLogger() + private val clock = FakePostHogDateProvider() + private val serializer = PostHogSerializer(PostHogConfig(PROJECT_TOKEN)) + + @AfterTest + fun cleanup() { + queues.forEach { + it.stop() + it.clear() + } + executor.shutdownAndAwaitTermination() + } + + private fun config(host: String): PostHogConfig = + PostHogConfig(PROJECT_TOKEN, host).apply { + logger = this@PostHogCaptureV1Test.logger + dateProvider = clock + } + + private fun api(host: String): PostHogApi = PostHogApi(config(host)) + + private fun queue( + http: MockWebServer, + flushAt: Int = 1, + ): PostHogQueue { + val config = + config(http.url("/").toString()).apply { + this.flushAt = flushAt + storagePrefix = tmpDir.newFolder().absolutePath + } + // pause time pinned to the past so a backoff never blocks the next flush + clock.setAddSecondsToCurrentDate(parseISO8601Date("1970-01-01T00:00:00.000Z")!!) + val spec = EndpointSpec.batch(config, PostHogApi(config), config.storagePrefix) + return PostHogQueue(config, spec, executor).also { queues.add(it) } + } + + private fun resultsResponse(vararg results: Pair): MockResponse { + val entries = results.joinToString(",") { (uuid, result) -> "\"$uuid\":{\"result\":\"$result\"}" } + return MockResponse().setBody("{\"results\":{$entries}}") + } + + private fun event( + name: String = "event", + properties: Map = mapOf("prop" to "value"), + options: Map? = null, + ): PostHogEvent = + PostHogEvent( + name, + distinctId = "distinctId", + properties = properties.toMutableMap(), + timestamp = date, + uuid = UUID.randomUUID(), + options = options?.toMutableMap(), + ) + + @Suppress("UNCHECKED_CAST") + private fun body(request: RecordedRequest): Map = + serializer.deserialize>(request.body.unGzip().reader()) + + @Suppress("UNCHECKED_CAST") + private fun batch(request: RecordedRequest): List> = body(request)["batch"] as List> + + private fun wire(event: PostHogEvent): Map = captureV1Json(event, logger) + + @Suppress("UNCHECKED_CAST") + private fun Map.properties(): Map = this["properties"] as Map + + @Suppress("UNCHECKED_CAST") + private fun Map.options(): Map? = this["options"] as Map? + + @Test + fun `posts to the capture v1 endpoint with the required headers`() { + val http = httpServers.mockHttp() + clock.setCurrentDate(parseISO8601Date("2024-05-01T10:20:30.456Z")!!) + + api(http.url("/").toString()).captureV1(listOf(event())) + + val request = http.takeRequest() + assertEquals("POST", request.method) + assertEquals("/i/v1/analytics/events", request.path) + assertEquals("Bearer $PROJECT_TOKEN", request.headers["Authorization"]) + assertEquals("posthog-java/${BuildConfig.VERSION_NAME}", request.headers["PostHog-Sdk-Info"]) + assertEquals("posthog-java/${BuildConfig.VERSION_NAME}", request.headers["User-Agent"]) + assertEquals("1", request.headers["PostHog-Attempt"]) + UUID.fromString(assertNotNull(request.headers["PostHog-Request-Id"])) + assertEquals("2024-05-01T10:20:30.456Z", request.headers["PostHog-Request-Timestamp"]) + assertEquals("application/json", request.headers["Content-Type"]) + assertEquals("gzip", request.headers["Content-Encoding"]) + } + + @Test + fun `the capture Bearer token replaces a custom Authorization header`() { + val http = httpServers.mockHttp() + val config = + config(http.url("/").toString()).apply { + requestHeaders = mapOf("Authorization" to "Bearer proxy", "X-Proxy" to "yes") + } + + PostHogApi(config).captureV1(listOf(event())) + + val request = http.takeRequest() + assertEquals("Bearer $PROJECT_TOKEN", request.headers["Authorization"]) + assertEquals("yes", request.headers["X-Proxy"]) + } + + @Test + fun `body carries created_at and the batch without api_key or sent_at`() { + val http = httpServers.mockHttp() + clock.setCurrentDate(parseISO8601Date("2024-05-01T10:20:30.456Z")!!) + val event = event() + + api(http.url("/").toString()).captureV1(listOf(event)) + + val body = body(http.takeRequest()) + assertEquals(setOf("created_at", "batch"), body.keys) + assertEquals("2024-05-01T10:20:30.456Z", body["created_at"]) + @Suppress("UNCHECKED_CAST") + assertEquals(listOf(event.uuid.toString()), (body["batch"] as List>).map { it["uuid"] }) + } + + @Test + fun `an event is sent with its root fields and an RFC 3339 UTC timestamp`() { + val event = event(properties = mapOf("prop" to "value", "\$lib" to "posthog-android", "\$lib_version" to "1.2.3")) + + val sent = wire(event) + + assertEquals(listOf("event", "uuid", "distinct_id", "timestamp", "properties"), sent.keys.toList()) + assertEquals("event", sent["event"]) + assertEquals(event.uuid.toString(), sent["uuid"]) + assertEquals("distinctId", sent["distinct_id"]) + assertEquals("2023-09-20T11:58:49.000Z", sent["timestamp"]) + // $lib and $lib_version come from the PostHog-Sdk-Info header + assertEquals(mapOf("prop" to "value"), sent.properties()) + assertNull(sent.options()) + } + + @Test + fun `lifts string session and window ids to the event root`() { + val sent = wire(event(properties = mapOf("\$session_id" to "session", "\$window_id" to ""))) + + assertEquals("session", sent["session_id"]) + assertEquals("", sent["window_id"]) + assertFalse(sent.properties().containsKey("\$session_id")) + assertFalse(sent.properties().containsKey("\$window_id")) + assertTrue(logger.messages.isEmpty()) + } + + @Test + fun `drops a non-string session id with a warning naming the key and JSON type`() { + val sent = wire(event(properties = mapOf("\$session_id" to 42, "\$window_id" to listOf("a")))) + + assertFalse(sent.containsKey("session_id")) + assertFalse(sent.containsKey("window_id")) + assertFalse(sent.properties().containsKey("\$session_id")) + assertFalse(sent.properties().containsKey("\$window_id")) + assertEquals( + listOf( + "dropping \$session_id: a number value is not a string", + "dropping \$window_id: a array value is not a string", + ), + logger.messages, + ) + } + + @Test + fun `drops a null session id silently`() { + val event = event() + @Suppress("UNCHECKED_CAST") + (event.properties as MutableMap)["\$session_id"] = null + + val sent = wire(event) + + assertFalse(sent.containsKey("session_id")) + assertFalse(sent.properties().containsKey("\$session_id")) + assertTrue(logger.messages.isEmpty()) + } + + @Test + fun `hoists legacy option properties into options and always removes them`() { + val event = + event( + properties = + mapOf( + "\$process_person_profile" to false, + "\$cookieless_mode" to "yes", + "\$ignore_sent_at" to true, + "\$product_tour_id" to 5, + ), + ) + + val sent = wire(event) + + // values are sent as given, the server reads them + assertEquals( + mapOf("cookieless_mode" to "yes", "disable_skew_correction" to true, "product_tour_id" to 5, "process_person_profile" to false), + sent.options(), + ) + assertEquals(mapOf(), sent.properties()) + } + + @Test + fun `an option set on the event wins over its legacy property`() { + val event = + event( + properties = mapOf("\$process_person_profile" to false, "\$cookieless_mode" to false), + options = mapOf("process_person_profile" to true, "cookieless_mode" to null, "custom" to "kept"), + ) + + val sent = wire(event) + + // a null option counts as unset, so the legacy property fills it; unknown keys pass through + assertEquals(mapOf("process_person_profile" to true, "cookieless_mode" to false, "custom" to "kept"), sent.options()) + assertFalse(sent.properties().containsKey("\$process_person_profile")) + assertFalse(sent.properties().containsKey("\$cookieless_mode")) + } + + @Test + fun `returns the uuids to retry and drops the rest from the batch`() { + val ok = event("ok") + val dropped = event("dropped") + val retry = event("retry") + val missing = event("missing") + val http = + httpServers.mockHttp( + response = + resultsResponse( + ok.uuid!! to "ok", + dropped.uuid!! to "drop", + retry.uuid!! to "retry", + ).setHeader("Retry-After", "7"), + ) + + val result = api(http.url("/").toString()).captureV1(listOf(ok, dropped, retry, missing)) + + assertEquals(setOf(retry.uuid.toString()), result.retryIds) + assertEquals(7, result.retryAfterSeconds) + assertTrue(logger.messages.contains("Capture: 1 dropped, 1 to retry.")) + assertTrue(logger.messages.none { it.contains(retry.uuid.toString()) }) + } + + @Test + fun `a 2xx without readable results counts as delivered`() { + val http = httpServers.mockHttp(response = MockResponse().setBody("not json")) + + val result = api(http.url("/").toString()).captureV1(listOf(event())) + + assertTrue(result.retryIds.isEmpty()) + assertTrue(logger.messages.any { it.contains("without readable per-event results, treating 1 events as delivered") }) + } + + @Test + fun `a 2xx whose transfer fails after the status counts as delivered`() { + val http = + httpServers.mockHttp( + response = + MockResponse() + .setBody("{\"results\":{\"some-uuid\":{\"result\":\"retry\"}}}") + .setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY), + ) + + val result = api(http.url("/").toString()).captureV1(listOf(event())) + + assertTrue(result.retryIds.isEmpty()) + } + + @Test + fun `a retry reuses the request id and created_at and increments the attempt`() { + val event = event() + val http = httpServers.mockHttp(response = resultsResponse(event.uuid!! to "retry")) + http.enqueue(resultsResponse(event.uuid!! to "ok")) + clock.setCurrentDate(parseISO8601Date("2024-05-01T10:20:30.000Z")!!) + val sut = api(http.url("/").toString()) + + sut.captureV1(listOf(event)) + clock.setCurrentDate(parseISO8601Date("2024-05-01T10:20:31.000Z")!!) + sut.captureV1(listOf(event)) + + val first = http.takeRequest() + val second = http.takeRequest() + assertEquals("1", first.headers["PostHog-Attempt"]) + assertEquals("2", second.headers["PostHog-Attempt"]) + assertEquals(first.headers["PostHog-Request-Id"], second.headers["PostHog-Request-Id"]) + assertEquals("2024-05-01T10:20:30.000Z", first.headers["PostHog-Request-Timestamp"]) + assertEquals("2024-05-01T10:20:31.000Z", second.headers["PostHog-Request-Timestamp"]) + assertEquals("2024-05-01T10:20:30.000Z", body(first)["created_at"]) + assertEquals("2024-05-01T10:20:30.000Z", body(second)["created_at"]) + } + + @Test + fun `a retriable status and a network error keep the request identity`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503)) + http.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + http.enqueue(MockResponse().setBody("")) + val events = listOf(event()) + // a short read timeout turns the unanswered request into an IOException OkHttp doesn't retry itself + val config = + config(http.url("/").toString()).apply { + httpClient = OkHttpClient.Builder().readTimeout(500, TimeUnit.MILLISECONDS).build() + } + val sut = PostHogApi(config) + + assertThrows(PostHogApiError::class.java) { sut.captureV1(events) } + assertThrows(IOException::class.java) { sut.captureV1(events) } + sut.captureV1(events) + + val requestIds = List(3) { http.takeRequest() }.map { it.headers["PostHog-Request-Id"] to it.headers["PostHog-Attempt"] } + assertEquals(1, requestIds.map { it.first }.toSet().size) + assertEquals(listOf("1", "2", "3"), requestIds.map { it.second }) + } + + @Test + fun `a different set of events starts a new request identity`() { + val event = event() + val other = event("other") + val http = httpServers.mockHttp(response = resultsResponse(event.uuid!! to "retry")) + http.enqueue(resultsResponse(event.uuid!! to "ok", other.uuid!! to "ok")) + val sut = api(http.url("/").toString()) + + sut.captureV1(listOf(event)) + sut.captureV1(listOf(event, other)) + + val first = http.takeRequest() + val second = http.takeRequest() + assertEquals("1", second.headers["PostHog-Attempt"]) + assertNotEquals(first.headers["PostHog-Request-Id"], second.headers["PostHog-Request-Id"]) + } + + @Test + fun `a terminal status starts a new request identity on the next send`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(400)) + http.enqueue(MockResponse().setBody("")) + val events = listOf(event()) + val sut = api(http.url("/").toString()) + + assertThrows(PostHogApiError::class.java) { sut.captureV1(events) } + sut.captureV1(events) + + val first = http.takeRequest() + val second = http.takeRequest() + assertEquals("1", second.headers["PostHog-Attempt"]) + assertNotEquals(first.headers["PostHog-Request-Id"], second.headers["PostHog-Request-Id"]) + } + + @Test + fun `a non-2xx throws with its status and Retry-After`() { + for (status in listOf(408, 500, 502, 503, 504, 429, 404, 400, 413)) { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(status).setHeader("Retry-After", "7")) + + val error = assertThrows(PostHogApiError::class.java) { api(http.url("/").toString()).captureV1(listOf(event())) } + + assertEquals(status, error.statusCode) + assertEquals(7, error.retryAfterSeconds) + } + } + + @Test + fun `follows a same-origin 307 with the same body and headers`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(307).setHeader("Location", "/proxied/events")) + http.enqueue(MockResponse().setResponseCode(308).setHeader("Location", "/final/events")) + http.enqueue(MockResponse().setBody("")) + + api(http.url("/").toString()).captureV1(listOf(event())) + + val requests = List(3) { http.takeRequest() } + assertEquals(listOf("/i/v1/analytics/events", "/proxied/events", "/final/events"), requests.map { it.path }) + requests.forEach { + assertEquals("POST", it.method) + assertEquals("Bearer $PROJECT_TOKEN", it.headers["Authorization"]) + assertEquals(requests[0].headers["PostHog-Request-Id"], it.headers["PostHog-Request-Id"]) + assertEquals(requests[0].headers["PostHog-Request-Timestamp"], it.headers["PostHog-Request-Timestamp"]) + } + assertEquals(1, requests.map { body(it) }.toSet().size) + } + + @Test + fun `does not follow a 302`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(302).setHeader("Location", "/elsewhere")) + http.enqueue(MockResponse().setBody("")) + + val error = assertThrows(PostHogApiError::class.java) { api(http.url("/").toString()).captureV1(listOf(event())) } + + assertEquals(302, error.statusCode) + assertEquals(1, http.requestCount) + } + + @Test + fun `does not follow a 307 to another origin`() { + val other = httpServers.mockHttp() + val http = + httpServers.mockHttp( + response = MockResponse().setResponseCode(307).setHeader("Location", other.url("/events").toString()), + ) + + val error = assertThrows(PostHogApiError::class.java) { api(http.url("/").toString()).captureV1(listOf(event())) } + + assertEquals(307, error.statusCode) + assertEquals(1, http.requestCount) + assertEquals(0, other.requestCount) + assertTrue(logger.messages.contains("Capture did not follow a 307 redirect.")) + } + + @Test + fun `stops after five redirects`() { + val http = httpServers.mockHttp(total = 6, response = MockResponse().setResponseCode(307).setHeader("Location", "/again")) + http.enqueue(MockResponse().setBody("")) + + val error = assertThrows(PostHogApiError::class.java) { api(http.url("/").toString()).captureV1(listOf(event())) } + + assertEquals(307, error.statusCode) + assertEquals(6, http.requestCount) + } + + @Test + fun `queue keeps only the events capture asked to retry and resends them under the same request id`() { + val ok = event("ok") + val dropped = event("dropped") + val retry = event("retry") + val http = httpServers.mockHttp(response = resultsResponse(ok.uuid!! to "ok", dropped.uuid!! to "drop", retry.uuid!! to "retry")) + http.enqueue(resultsResponse(retry.uuid!! to "ok")) + val sut = queue(http, flushAt = 3) + + sut.add(ok) + sut.add(dropped) + sut.add(retry) + executor.awaitExecution() + + assertEquals(1, http.requestCount) + assertEquals(1, sut.dequeList.size) + assertEquals(1, sut.currentRetryCountForTesting) + assertEquals( + listOf(retry.uuid.toString()), + sut.dequeList.map { file -> file.reader().use { serializer.deserialize(it).uuid.toString() } }, + ) + + sut.flush() + executor.awaitExecution() + + assertEquals(2, http.requestCount) + assertTrue(sut.dequeList.isEmpty()) + assertEquals(0, sut.currentRetryCountForTesting) + val first = http.takeRequest() + val second = http.takeRequest() + assertEquals(listOf(retry.uuid.toString()), batch(second).map { it["uuid"] }) + assertEquals("2", second.headers["PostHog-Attempt"]) + assertEquals(first.headers["PostHog-Request-Id"], second.headers["PostHog-Request-Id"]) + } + + @Test + fun `queue drops the batch on 429 and keeps it on 503`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(429)) + http.enqueue(MockResponse().setResponseCode(503)) + val sut = queue(http) + + sut.add(event()) + executor.awaitExecution() + assertTrue(sut.dequeList.isEmpty()) + + sut.add(event()) + executor.awaitExecution() + assertEquals(1, sut.dequeList.size) + } + + @Test + fun `queued events round-trip their options through disk`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503)) + val sut = queue(http) + + sut.add(event(options = mapOf("process_person_profile" to false, "product_tour_id" to 7))) + executor.awaitExecution() + + val stored = sut.dequeList.single().reader().use { serializer.deserialize(it) } + val options = mapOf("process_person_profile" to false, "product_tour_id" to 7) + assertEquals?>(options, stored.options) + assertEquals(options, batch(http.takeRequest()).single().options()) + } + + @Test + fun `queued events without options still decode`() { + val event = event() + val json = + "{\"uuid\":\"${event.uuid}\",\"event\":\"legacy\",\"distinct_id\":\"d\"," + + "\"properties\":{\"a\":1},\"timestamp\":\"2023-09-19T07:23:00.165Z\"}" + + val decoded = serializer.deserialize(json.reader()) + + assertEquals("legacy", decoded.event) + assertNull(decoded.options) + } +} diff --git a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt index 2c45e6302..7bc798775 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogPushSubscriptionManagerTest.kt @@ -94,7 +94,7 @@ internal class PostHogPushSubscriptionManagerTest { PostHogConfig(PROJECT_TOKEN, host = http.url("/").toString()).apply { this.storagePrefix = storagePrefix this.networkStatus = networkStatus - this.maxRetries = maxRetries + this.pushNotificationSubscriptionMaxRetries = maxRetries this.encryption = encryption this.cachePreferences = preferences this.dateProvider = clock diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueDurabilityTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueDurabilityTest.kt index 7b2822900..399c09557 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueDurabilityTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueDurabilityTest.kt @@ -47,7 +47,7 @@ internal class PostHogQueueDurabilityTest { maxQueueSize = capacity maxBatchSize = batchSize flushAt = 100 - maxRetries = 2 + pushNotificationSubscriptionMaxRetries = 2 dateProvider = clock this.networkStatus = networkStatus } @@ -66,10 +66,17 @@ internal class PostHogQueueDurabilityTest { } }, describe = { it }, - send = send, - isRetriableStatusCode = ::isEventsRetriableStatusCode, + send = { + send(it) + SendResult() + }, + isRetriableStatusCode = ::isCaptureV1RetriableStatusCode, ) - return PostHogQueue(config, spec, executor).also { queues.add(it) } + return PostHogQueue(config, spec, executor).also { + // the midpoint of the jitter range keeps the unjittered delays + it.jitterUnit = { 0.5 } + queues.add(it) + } } @Test @@ -111,7 +118,7 @@ internal class PostHogQueueDurabilityTest { @Test fun `retryable failures retain exact entries with capped backoff then reset after success`() { - val failures = listOf(408, 429, 500, 502, 503, 504) + val failures = listOf(408, 500, 502, 503, 504) var status = failures.first() var attempts = 0 val sut = diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt index 35b447160..21a92cfce 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt @@ -69,7 +69,7 @@ internal class PostHogQueueTest { dateProvider: PostHogDateProvider = PostHogDeviceDateProvider(), maxBatchSize: Int = 50, networkStatus: PostHogNetworkStatus? = null, - maxRetries: Int = 3, + pushNotificationSubscriptionMaxRetries: Int = 3, httpClient: OkHttpClient? = null, ): PostHogQueue { val config = @@ -80,7 +80,7 @@ internal class PostHogQueueTest { this.networkStatus = networkStatus this.maxBatchSize = maxBatchSize this.dateProvider = dateProvider - this.maxRetries = maxRetries + this.pushNotificationSubscriptionMaxRetries = pushNotificationSubscriptionMaxRetries this.httpClient = httpClient } val api = PostHogApi(config) @@ -302,7 +302,7 @@ internal class PostHogQueueTest { host = http.url("/").toString(), storagePrefix = path, flushAt = 1, - maxRetries = 0, + pushNotificationSubscriptionMaxRetries = 0, networkStatus = object : PostHogNetworkStatus { override fun isConnected() = connected @@ -358,7 +358,7 @@ internal class PostHogQueueTest { storagePrefix = path, flushAt = 100, maxQueueSize = 2, - maxRetries = 0, + pushNotificationSubscriptionMaxRetries = 0, dateProvider = fakeCurrentTime, ) @@ -398,8 +398,8 @@ internal class PostHogQueueTest { } @Test - fun `retry after remains authoritative beyond the exponential backoff cap`() { - val http = mockHttp(response = MockResponse().setResponseCode(429).setHeader("Retry-After", "120").setBody("error")) + fun `retry after is a floor capped at the exponential backoff cap`() { + val http = mockHttp(response = MockResponse().setResponseCode(503).setHeader("Retry-After", "120").setBody("error")) var scheduledDelay = 0 val dateProvider = object : PostHogDateProvider { @@ -427,7 +427,52 @@ internal class PostHogQueueTest { sut.add(generateEvent()) executor.awaitExecution() - assertEquals(120, scheduledDelay) + assertEquals(30, scheduledDelay) + assertEquals(1, sut.dequeList.size) + } finally { + sut.clear() + awaitQueue() + http.shutdown() + } + } + + @Test + fun `a partial retry honors the Retry-After of the 2xx`() { + val event = generateEvent(givenUuuid = UUID.randomUUID()) + val http = + mockHttp( + response = + MockResponse() + .setHeader("Retry-After", "10") + .setBody("{\"results\":{\"${event.uuid}\":{\"result\":\"retry\"}}}"), + ) + var scheduledDelay = 0 + val dateProvider = + object : PostHogDateProvider { + override fun currentDate() = Date() + + override fun addSecondsToCurrentDate(seconds: Int): Date { + scheduledDelay = seconds + return Date(System.currentTimeMillis() + seconds * 1000L) + } + + override fun currentTimeMillis() = System.currentTimeMillis() + + override fun nanoTime() = System.nanoTime() + } + val sut = + getSut( + host = http.url("/").toString(), + storagePrefix = tmpDir.newFolder().absolutePath, + flushAt = 1, + dateProvider = dateProvider, + ) + + try { + sut.add(event) + executor.awaitExecution() + + assertEquals(10, scheduledDelay) assertEquals(1, sut.dequeList.size) } finally { sut.clear() @@ -458,7 +503,7 @@ internal class PostHogQueueTest { host = http.url("/").toString(), storagePrefix = path, flushAt = 100, - maxRetries = 0, + pushNotificationSubscriptionMaxRetries = 0, dateProvider = fakeCurrentTime, httpClient = httpClient, ) @@ -569,8 +614,9 @@ internal class PostHogQueueTest { } else { throw IOException("retain replacements after their later send attempt") } + SendResult() }, - isRetriableStatusCode = ::isEventsRetriableStatusCode, + isRetriableStatusCode = ::isCaptureV1RetriableStatusCode, ) val sut = PostHogQueue(config, spec, queueExecutor) @@ -612,7 +658,7 @@ internal class PostHogQueueTest { } @Test - fun `does not delete file if API is 3xx`() { + fun `deletes file if API is 3xx`() { val http = mockHttp(response = MockResponse().setResponseCode(300).setBody("error")) val url = http.url("/") @@ -623,8 +669,48 @@ internal class PostHogQueueTest { awaitQueue() - assertEquals(1, sut.dequeList.size) - assertEquals(1, File(path, PROJECT_TOKEN).listFiles()!!.size) + assertEquals(0, sut.dequeList.size) + assertEquals(0, File(path, PROJECT_TOKEN).listFiles()!!.size) + } + + @Test + fun `backoff doubles from one to thirty seconds with 25 percent jitter rounded to whole seconds`() { + val http = mockHttp(total = 20, response = MockResponse().setResponseCode(503).setBody("error")) + val delays = mutableListOf() + val dateProvider = + object : PostHogDateProvider { + override fun currentDate() = Date() + + override fun addSecondsToCurrentDate(seconds: Int): Date { + delays.add(seconds) + // keep the pause in the past so every flush sends + return Date(0) + } + + override fun currentTimeMillis() = System.currentTimeMillis() + + override fun nanoTime() = System.nanoTime() + } + val sut = getSut(host = http.url("/").toString(), flushAt = 1, dateProvider = dateProvider) + sut.add(generateEvent()) + executor.awaitExecution() + + var jitter = 0.0 + sut.jitterUnit = { jitter } + repeat(6) { + sut.flush() + executor.awaitExecution() + } + jitter = 0.999 + repeat(6) { + sut.flush() + executor.awaitExecution() + } + // -25%: 2→1.5 rounds to 2, 4→3, 8→6, 16→12, then 30→22.5 rounds to 23; +25% is capped at 30 + assertEquals(listOf(1, 2, 3, 6, 12, 23, 23, 30, 30, 30, 30, 30, 30), delays) + + sut.clear() + awaitQueue() } @Test @@ -662,7 +748,7 @@ internal class PostHogQueueTest { @Test fun `clear deletes all files and clean the queue`() { - val http = mockHttp(response = MockResponse().setResponseCode(300).setBody("error")) + val http = mockHttp(response = MockResponse().setResponseCode(503).setBody("error")) val url = http.url("/") val path = tmpDir.newFolder().absolutePath @@ -685,7 +771,7 @@ internal class PostHogQueueTest { @Test fun `flush flushes the queue`() { - val http = mockHttp(response = MockResponse().setResponseCode(300).setBody("error")) + val http = mockHttp(response = MockResponse().setResponseCode(503).setBody("error")) val url = http.url("/") val fakeCurrentTime = FakePostHogDateProvider() @@ -715,7 +801,7 @@ internal class PostHogQueueTest { @Test fun `flush flushes the queue but respect maxBatchSize`() { - val http = mockHttp(response = MockResponse().setResponseCode(300).setBody("error")) + val http = mockHttp(response = MockResponse().setResponseCode(503).setBody("error")) val url = http.url("/") val fakeCurrentTime = FakePostHogDateProvider() @@ -733,7 +819,7 @@ internal class PostHogQueueTest { assertEquals(1, sut.dequeList.size) assertEquals(1, File(path, PROJECT_TOKEN).listFiles()!!.size) - http.enqueue(MockResponse().setResponseCode(300).setBody("error")) + http.enqueue(MockResponse().setResponseCode(503).setBody("error")) sut.add(generateEvent(givenUuuid = UUID.randomUUID())) @@ -904,12 +990,21 @@ internal class PostHogQueueTest { } @Test - fun `retries on 429`() { + fun `does not retry on 429`() { val e = PostHogApiError(429, "", null) val config = PostHogConfig(PROJECT_TOKEN) val limits = initialBatchLimits(config) - assertFalse(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) + assertTrue(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) + } + + @Test + fun `does not retry on 3xx`() { + val e = PostHogApiError(307, "", null) + val config = PostHogConfig(PROJECT_TOKEN) + val limits = initialBatchLimits(config) + + assertTrue(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) } @Test diff --git a/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt index 50820f459..574513184 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt @@ -271,7 +271,7 @@ internal class PostHogReplayQueueTest { assertEquals(1, http.requestCount) val request = assertNotNull(http.takeRequest(5, TimeUnit.SECONDS)) - assertEquals("/batch", request.path) + assertEquals("/i/v1/analytics/events", request.path) val payload = PostHogSerializer(PostHogConfig(PROJECT_TOKEN)).deserialize>(request.body.unGzip().reader()) assertEquals(2, (payload["batch"] as List<*>).size) assertEquals(0, sut.size) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedLegacyEventsIntegrationTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedLegacyEventsIntegrationTest.kt index ab38558b1..a6f527d6e 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSendCachedLegacyEventsIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSendCachedLegacyEventsIntegrationTest.kt @@ -6,6 +6,7 @@ import com.posthog.TestHttpServers import com.posthog.shutdownAndAwaitTermination import com.posthog.unGzip import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest import okhttp3.mockwebserver.SocketPolicy import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -104,12 +105,59 @@ internal class PostHogSendCachedLegacyEventsIntegrationTest { assertEquals(emptyList(), retained(prefix)) assertEquals(1, http.requestCount) val request = assertNotNull(http.takeRequest(5, TimeUnit.SECONDS)) + assertEquals("/i/v1/analytics/events", request.path) + val sent = sentEvents(request).single() + assertEquals("testEvent", sent["event"]) + assertEquals("9740f814-0b10-42e2-b3d4-8f35af1b11f6", sent["distinct_id"]) + assertEquals(mapOf("testProperty" to "testValue"), sent["properties"]) + } + + @Suppress("UNCHECKED_CAST") + private fun sentEvents(request: RecordedRequest): List> { val serializer = PostHogSerializer(PostHogConfig(PROJECT_TOKEN)) - val batch = serializer.deserialize(request.body.unGzip().reader()) - val sent = batch.batch.single() - assertEquals("testEvent", sent.event) - assertEquals("9740f814-0b10-42e2-b3d4-8f35af1b11f6", sent.distinctId) - assertEquals(mapOf("testProperty" to "testValue"), sent.properties?.toMap()) + val body = serializer.deserialize>(request.body.unGzip().reader()) + return body["batch"] as List> + } + + @Test + fun `sends a 2x cached event with its message_id as the uuid and the root set as a property`() { + val legacyEvent = + """ + { + "message_id": "8837f4d8-01e3-4bb8-b3f3-16b8fa8173e5", + "event": "testEvent", + "distinct_id": "9740f814-0b10-42e2-b3d4-8f35af1b11f6", + "properties": {"testProperty": "testValue"}, + "${'$'}set": {"name": "Max"}, + "timestamp": "2023-09-19T07:23:00.165Z" + } + """.trimIndent() + val prefix = writeLegacyFile(listOf(legacyEvent)) + val http = servers.mockHttp() + getSut(prefix, http.url("/").toString()).install(mock()) + executor.shutdownAndAwaitTermination() + assertEquals(emptyList(), retained(prefix)) + val sent = sentEvents(assertNotNull(http.takeRequest(5, TimeUnit.SECONDS))).single() + assertEquals("8837f4d8-01e3-4bb8-b3f3-16b8fa8173e5", sent["uuid"]) + assertEquals(mapOf("testProperty" to "testValue", "${'$'}set" to mapOf("name" to "Max")), sent["properties"]) + } + + @Test + fun `re-queues the events capture asked to retry for the next launch`() { + val retried = event.replace("8837f4d8-01e3-4bb8-b3f3-16b8fa8173e5", "11111111-0b10-42e2-b3d4-8f35af1b11f6") + val prefix = writeLegacyFile(listOf(retried, event, event)) + val http = + servers.mockHttp( + response = + MockResponse().setBody( + "{\"results\":{\"11111111-0b10-42e2-b3d4-8f35af1b11f6\":{\"result\":\"retry\"}," + + "\"8837f4d8-01e3-4bb8-b3f3-16b8fa8173e5\":{\"result\":\"ok\"}}}", + ), + ) + getSut(prefix, http.url("/").toString()).install(mock()) + executor.shutdownAndAwaitTermination() + assertEquals(1, http.requestCount) + assertEquals(listOf(retried), retained(prefix)) } @Test @@ -143,15 +191,25 @@ internal class PostHogSendCachedLegacyEventsIntegrationTest { } @Test - fun `keeps the events if returns 3xx`() { + fun `keeps the events if returns 503`() { val prefix = writeLegacyFile(listOf(event, event)) - val http = servers.mockHttp(response = MockResponse().setResponseCode(300)) + val http = servers.mockHttp(response = MockResponse().setResponseCode(503)) getSut(prefix, http.url("/").toString()).install(mock()) executor.shutdownAndAwaitTermination() assertEquals(listOf(event, event), retained(prefix)) assertEquals(1, http.requestCount) } + @Test + fun `discards the events if returns 3xx`() { + val prefix = writeLegacyFile(listOf(event, event)) + val http = servers.mockHttp(response = MockResponse().setResponseCode(300)) + getSut(prefix, http.url("/").toString()).install(mock()) + executor.shutdownAndAwaitTermination() + assertEquals(emptyList(), retained(prefix)) + assertEquals(1, http.requestCount) + } + @Test fun `keeps the events if no connection`() { val prefix = writeLegacyFile(listOf(event, event)) From bd4a8be3787d55d91cd9b7ed8b5daf2a05a9ade0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:01:15 -0400 Subject: [PATCH 03/45] chore(compliance): run the capture v1 suite against the adapter The adapter advertises `capture_v1` and `encoding_gzip`, tracks `/i/v1/analytics/events` requests with `retry_attempt` derived from `PostHog-Attempt`, maps `max_retries` to the renamed config field and runs with `personProfiles = ALWAYS`, since the harness never identifies. The workflow pins harness 1.20.0 and also runs for PRs that target the `chore/v4-*` and `feat/v4-*` branches. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/sdk-compliance-tests.yml | 4 +-- .../IMPLEMENTATION_NOTES.md | 13 ++++----- sdk_compliance_adapter/README.md | 10 +++---- .../posthog/compliance/ComplianceAdapter.kt | 27 ++++++++++++------- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/.github/workflows/sdk-compliance-tests.yml b/.github/workflows/sdk-compliance-tests.yml index a2a2854cb..2f4dc4ee1 100644 --- a/.github/workflows/sdk-compliance-tests.yml +++ b/.github/workflows/sdk-compliance-tests.yml @@ -8,7 +8,7 @@ on: - 'sdk_compliance_adapter/**' - '.github/workflows/sdk-compliance-tests.yml' pull_request: - branches: [ main ] + branches: [ main, 'chore/v4-*', 'feat/v4-*' ] paths: - 'posthog/**' - 'sdk_compliance_adapter/**' @@ -26,5 +26,5 @@ jobs: with: adapter-dockerfile: sdk_compliance_adapter/Dockerfile adapter-context: . - test-harness-version: "0.10.0" + test-harness-version: "1.20.0" report-name: android-sdk-compliance-report diff --git a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md index 8e45c9564..a678f02e4 100644 --- a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md +++ b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md @@ -18,9 +18,9 @@ This directory contains the compliance test adapter for the PostHog Android SDK. #### HTTP Request Tracking The adapter uses a custom OkHttpClient with a `TrackingInterceptor` that: -- Intercepts all HTTP requests to the `/batch/` endpoint +- Intercepts all HTTP requests to the `/i/v1/analytics/events` endpoint - Parses request bodies to extract event UUIDs using regex -- Tracks request count, status codes, retry attempts, and event counts +- Tracks request count, status codes, retry attempts (`PostHog-Attempt` minus one), and event counts - Updates adapter state with request metadata #### Event UUID Tracking @@ -36,6 +36,7 @@ This dual approach ensures UUIDs are available immediately when events are captu The adapter configures the PostHog SDK for optimal testing: - `flushAt = 1` - Send events immediately (or as configured) - `flushIntervalSeconds` - Fast flush intervals for tests +- `personProfiles = ALWAYS` - The harness never identifies and expects person processing on unless an event opts out - `debug = true` - Enable logging - `httpClient` - Custom OkHttpClient with tracking interceptor @@ -75,11 +76,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and compliance test comma ## SDK Type -The Android SDK uses **server SDK format**: -- Endpoint: `/batch/` -- Format: `{api_key: "...", batch: [{event}, {event}], sent_at: "..."}` +The Android SDK advertises `capture_v1` and `encoding_gzip`: +- Endpoint: `POST /i/v1/analytics/events` +- Format: `{created_at: "...", batch: [{event}, {event}]}` with the project token in an `Authorization: Bearer` header -Tests run with `--sdk-type server` flag. +Tests run with `--sdk-type server` flag; the harness selects the `capture_v1` suite from the capabilities. ## API Endpoints diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index acfce3905..c39b7e409 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -29,7 +29,7 @@ This allows the test adapter to inject a custom OkHttpClient with tracking inter ### HTTP Request Tracking The adapter injects a custom OkHttpClient that: -- Intercepts all `/batch/` requests +- Intercepts all `/i/v1/analytics/events` requests - Extracts event UUIDs from request bodies - Tracks status codes, retry attempts, and event counts @@ -41,11 +41,11 @@ Events are tracked via: ### SDK Type -The Android SDK uses **server SDK format**: -- Endpoint: `/batch/` -- Format: `{api_key, batch, sent_at}` +The Android SDK advertises `capture_v1` and `encoding_gzip`: +- Endpoint: `POST /i/v1/analytics/events` +- Format: `{created_at, batch}` with the project token in an `Authorization: Bearer` header -Tests run with `--sdk-type server`. +Tests run with `--sdk-type server`; the harness selects the `capture_v1` suite from the capabilities. ## Files Created diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt index faa5c9962..5c1760340 100644 --- a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt @@ -1,5 +1,6 @@ package com.posthog.compliance +import com.posthog.PersonProfiles import com.posthog.PostHog import com.posthog.PostHogCompression import com.posthog.PostHogConfig @@ -33,6 +34,9 @@ private const val QUEUE_STORAGE_PREFIX = "/tmp/posthog-queue" // Upper bound on the wait for a flush()'d batch to reach the mock (generous for Docker). private const val FLUSH_SETTLE_MS = 2000L +// The capture endpoint the SDK posts analytics events to. +private const val CAPTURE_PATH = "/i/v1/analytics/events" + /** * PostHog Android SDK Compliance Adapter * @@ -138,7 +142,7 @@ object AdapterContext { val state = AdapterState() val lock = ReentrantLock() - // Signalled by the tracking interceptor when a /batch response lands (see awaitBatch). + // Signalled by the tracking interceptor when a capture response lands (see awaitBatch). val batchObserved: Condition = lock.newCondition() var postHog: com.posthog.PostHogInterface? = null var api: PostHogApi? = null @@ -157,14 +161,14 @@ class TrackingInterceptor : Interceptor { val uuidList = mutableListOf() // Parse request body before sending - if (url.contains("/batch") && !url.contains("/flags") && requestBody != null) { + if (url.contains(CAPTURE_PATH) && requestBody != null) { try { val buffer = okio.Buffer() requestBody.writeTo(buffer) val bodyString = buffer.readUtf8() // Parse JSON to extract UUIDs - // The body format is: {"api_key": "...", "batch": [{event}, {event}], "sent_at": "..."} + // The body format is: {"created_at": "...", "batch": [{event}, {event}]} val uuidRegex = """"uuid"\s*:\s*"([^"]+)"""".toRegex() uuidRegex.findAll(bodyString).forEach { match -> uuidList.add(match.groupValues[1]) @@ -178,10 +182,10 @@ class TrackingInterceptor : Interceptor { val response = chain.proceed(request) // Track the response - if (url.contains("/batch") && !url.contains("/flags")) { + if (url.contains(CAPTURE_PATH)) { try { - // Extract retry count from URL if present - val retryCount = request.url.queryParameter("retry_count")?.toIntOrNull() ?: 0 + // PostHog-Attempt starts at 1; the harness counts retries from 0. + val retryCount = (request.header("PostHog-Attempt")?.toIntOrNull() ?: 1) - 1 AdapterContext.lock.withLock { val record = @@ -214,7 +218,7 @@ class TrackingInterceptor : Interceptor { } } -// Block until [predicate] holds (signalled as each /batch response lands) or [timeoutMs] elapses. +// Block until [predicate] holds (signalled as each capture response lands) or [timeoutMs] elapses. private fun awaitBatch( timeoutMs: Long, predicate: () -> Boolean, @@ -242,8 +246,8 @@ fun main() { sdk_name = "posthog-android", sdk_version = PostHogConfig(projectToken = "").sdkVersion, adapter_version = "1.0.0", - // Opt into the capture suites (android posts /batch with gzip). - capabilities = listOf("capture_v0", "encoding_gzip"), + // Opt into the capture suites (android posts /i/v1/analytics/events with gzip). + capabilities = listOf("capture_v1", "encoding_gzip"), ), ) } @@ -281,6 +285,9 @@ fun main() { debug = true, preloadFeatureFlags = false, ) + // The harness models a server SDK: it never identifies, and expects person + // processing on unless an event opts out. + config.personProfiles = PersonProfiles.ALWAYS // Create OkHttpClient with tracking and gzip interceptors val httpClient = @@ -291,7 +298,7 @@ fun main() { config.httpClient = httpClient - req.max_retries?.let { config.maxRetries = it } + req.max_retries?.let { config.pushNotificationSubscriptionMaxRetries = it } req.enable_compression?.let { config.compression = if (it) PostHogCompression.GZIP else PostHogCompression.NONE } From 96a38b25fc92dbbbe09a9175eb99f33a7de27bec Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:01:16 -0400 Subject: [PATCH 04/45] docs: changeset bullets and migration guide for capture v1 Co-Authored-By: Claude Fable 5.1 --- .changeset/android-v4-major.md | 9 ++ .changeset/server-personal-api-key-token.md | 5 + docs/migration-3.x-to-4.0.md | 114 ++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 .changeset/server-personal-api-key-token.md create mode 100644 docs/migration-3.x-to-4.0.md diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index e89b373a7..3d02eadbb 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -11,3 +11,12 @@ - **Breaking:** return `null` instead of the raw string for a malformed, empty or whitespace-only feature flag payload from `getFeatureFlagResult(key)?.payload`; payloads must be valid JSON (a JSON `null` payload now also reads as `null` instead of the string `"null"`) - **Breaking:** make SDK-internal helpers in the `com.posthog.internal` and `com.posthog.android.replay.internal` packages non-public - **Breaking:** remove the deprecated `remoteConfig` config option; the SDK now always loads remote config at setup +- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because a 404 or other non-retried response drops the events, and authenticate your proxy with a header other than `Authorization`, which capture requests replace +- **Breaking:** stop retrying 429 and 3xx responses for events, and cap a `Retry-After` pause at 30 seconds for events, session replay and logs +- **Breaking:** add up to 25% random jitter to the wait after a failed send, which still doubles from 1 second to 30 seconds, and treat a `Retry-After` as a minimum wait instead of replacing the backoff, for events, session replay and logs; queued records are still kept until they're delivered +- **Breaking:** follow only a 307 or 308 redirect of a capture request to the same origin as `host`, at most 5 times; any other redirect drops the events, so point `host` at the final host +- **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options (`PostHogEvent.options`, a new trailing constructor parameter that Java callers of the full constructor and `copy` must pass), where PostHog drops an event whose option value it can't read +- **Breaking:** rename `PostHogConfig.maxRetries` to `pushNotificationSubscriptionMaxRetries`, because it only limits push-notification subscription retries +- **Breaking:** set `$lib` and `$lib_version` from the `PostHog-Sdk-Info` request header; a value set in properties, super properties or `beforeSend` is removed before sending +- **Breaking:** send `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields; a non-string value is dropped with a warning +- **Breaking:** disable the SDK at setup when `projectToken` is a personal API key (`phx_...`), as for an empty token diff --git a/.changeset/server-personal-api-key-token.md b/.changeset/server-personal-api-key-token.md new file mode 100644 index 000000000..35c725b1b --- /dev/null +++ b/.changeset/server-personal-api-key-token.md @@ -0,0 +1,5 @@ +--- +'posthog-server': patch +--- + +Disable the client at setup when `projectToken` is a personal API key (`phx_...`), as for an empty token diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md new file mode 100644 index 000000000..8759748b8 --- /dev/null +++ b/docs/migration-3.x-to-4.0.md @@ -0,0 +1,114 @@ +# Migrating from PostHog Android 3.x to 4.0 + +4.0 sends analytics events to capture v1 (`/i/v1/analytics/events`) instead of `/batch`. +Most apps upgrade without code changes. +Read the checklist first, then the sections that apply to you. + + + +For the full list of 4.0 changes, see the [changelog](../posthog-android/CHANGELOG.md). + +## Checklist + +You need to change something if your app does any of these: + +- sends events to a self-hosted PostHog or a reverse proxy that doesn't serve `/i/v1/analytics/events` +- authenticates its proxy with an `Authorization` header in `requestHeaders` +- sends events through a proxy that redirects capture requests, other than with a 307 or 308 to the same origin as `host` +- captures events with an empty event name, or with an event name or distinct ID over 200 bytes +- sets an empty distinct ID in `beforeSend` +- sets `projectToken` to a personal API key (`phx_...`) +- sets `$lib` or `$lib_version` and expects PostHog to store that value +- reads `$session_id` or `$window_id` from the raw request body, for example in a proxy +- sets `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` +- relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds +- sets `maxRetries` on `PostHogConfig` or `PostHogAndroidConfig` + +## Endpoints + +| Data | 3.x | 4.0 | +| --- | --- | --- | +| Analytics events | `/batch` | `/i/v1/analytics/events` | +| Session replay | `/s/` | `/s/`, unchanged | +| Logs | `/i/v1/logs` | `/i/v1/logs`, unchanged | +| Feature flags | `/flags` | `/flags`, unchanged | + +Capture v1 requests send the project token in an `Authorization: Bearer ` header instead of an `api_key` body field. +This header replaces any `Authorization` header you set in `PostHogConfig.requestHeaders`, so authenticate your proxy with a different header, such as `X-Proxy-Token`. +Other requests still send your `Authorization` header. + +A personal API key (`phx_...`) in `projectToken` now disables the SDK at setup, as an empty token does, and logs why when `debug` is on. +In 3.x, the SDK started and PostHog rejected every event with a 401. Use your project token (`phc_...`). + +### Hosts without capture v1 + +If you use a reverse proxy or self-host PostHog, make sure `/i/v1/analytics/events` is served before you upgrade. +PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves it. + +The SDK doesn't fall back to `/batch`. A 404, or any other error status that isn't retried, such as 403 or 405, drops the events. + +### Redirects + +The SDK follows a 307 or 308 redirect only to the origin of `host` (same scheme, host and port), at most 5 times, and resends the same body and headers. +Any other redirect is not followed and the events are dropped. +In 3.x, OkHttp followed redirects to any host. +If a proxy redirects capture requests to another host, point `host` at the final host. + +## Event validation + +Capture v1 checks each event on its own and drops only the invalid ones, so the rest of the batch is delivered. +The SDK doesn't check these before sending. PostHog drops the event, and the SDK removes it from the queue. +With `debug` on, the SDK logs how many events were dropped. + +- An event with an empty event name is dropped, for example `capture("")`. +- An event with an empty or whitespace-only distinct ID is dropped, for example one `beforeSend` sets to `""`. +- An event whose name is over 200 UTF-8 bytes is dropped. Legacy capture had no limit on event name length. +- An event whose distinct ID is over 200 UTF-8 bytes is dropped. Legacy capture shortened the ID to 200 characters and kept the event. + +In 3.x, an empty event name or distinct ID made legacy capture reject the whole batch it was in. + +The distinct ID can come from `identify()`, the `distinctId` argument of `capture` or `beforeSend`. +If you identify users with a long ID, such as a JWT or a long email address, shorten or hash it before you upgrade. Otherwise every event for that user is dropped. + +## SDK identity + +PostHog sets `$lib` and `$lib_version` from the `PostHog-Sdk-Info` request header, for example `posthog-android/4.0.0`. +The SDK removes `$lib` and `$lib_version` from the properties it sends to capture v1. +A value you set in properties, in a super property or in `beforeSend` doesn't reach PostHog. +The SDK removes them when it sends the event, so `beforeSend` still sees them. + +## Session and window IDs + +Capture v1 sends `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields, not as properties. +The SDK moves them out of `properties` for you. +A string value, including an empty string, is sent unchanged. +Any other value is removed and not sent, and the SDK logs a warning that names the key and the value's JSON type, never the value. +A null value counts as unset and is removed without a warning. +In PostHog, the events still have `$session_id` and `$window_id`, so insights, filters and session replay work as before. + +## Event options + +Capture v1 reads processing settings from an `options` object sent next to `properties`, which `beforeSend` can read and change through `PostHogEvent.options`. +The legacy option properties still work. +When the event is sent, the SDK removes each one from `properties` and uses it for its option, unless that option is already set: + +| Legacy property | Option | +| --- | --- | +| `$process_person_profile` | `process_person_profile` | +| `$cookieless_mode` | `cookieless_mode` | +| `$ignore_sent_at` | `disable_skew_correction` | +| `$product_tour_id` | `product_tour_id` | + +- The SDK sends option values as given. PostHog reads common forms of a value, such as `"false"` or `0` for a boolean, and drops an event whose option value it can't read. +- Other option keys are sent unchanged, and PostHog ignores keys it doesn't know. +- Events still queued on disk by 3.x are sent to capture v1 after the update, with their legacy properties moved to options. + +## Retries + +- The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. +- When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. +- A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x there was no cap. +- After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter, rounded to whole seconds. In 3.x it doubled without jitter and a `Retry-After` replaced it. This applies to events, session replay and logs. +- Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). +- `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, with no deprecated alias. It only limits push-notification subscription retries. +- A 413 still splits the batch into smaller requests. From 7827bd02770ccb314110188a42ae8dcf0ab8634a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 14:37:42 -0400 Subject: [PATCH 05/45] fix: resend a failed capture request as is, without client retries or logged bodies The queue now resends exactly the files of a request that failed or came back with events to retry, so the API keeps its request ID and `created_at` and bumps `PostHog-Attempt`. Before, the next request took the head of the queue, and any event queued since gave it a new ID at attempt 1. A 413 still halves the batch as a new request, and a file the queue drops in the meantime starts over. The capture client turns off OkHttp's `retryOnConnectionFailure`, which resent a request on a reused connection with a stale attempt number and timestamp. Debug logs for capture requests name the event count and the response status instead of printing the request and response bodies, which hold event content. Co-Authored-By: Claude Opus 5.5 --- .changeset/android-v4-major.md | 1 + docs/migration-3.x-to-4.0.md | 1 + .../java/com/posthog/internal/PostHogApi.kt | 12 ++- .../java/com/posthog/internal/PostHogQueue.kt | 26 ++++++- .../posthog/internal/PostHogCaptureV1Test.kt | 78 +++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 3d02eadbb..a3eddafb8 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -20,3 +20,4 @@ - **Breaking:** set `$lib` and `$lib_version` from the `PostHog-Sdk-Info` request header; a value set in properties, super properties or `beforeSend` is removed before sending - **Breaking:** send `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields; a non-string value is dropped with a warning - **Breaking:** disable the SDK at setup when `projectToken` is a personal API key (`phx_...`), as for an empty token +- debug logs for capture requests name the event count and the response status instead of printing the request and response bodies diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 8759748b8..95c3e0c48 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -107,6 +107,7 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. +- A failed request is resent with the same events, request ID and `created_at`, and the next attempt number, before newer events are sent. OkHttp doesn't retry capture requests on its own. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x there was no cap. - After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter, rounded to whole seconds. In 3.x it doubled without jitter and a `Retry-After` replaced it. This applies to events, session replay and logs. - Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index c95aac374..d4fc18d90 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -81,11 +81,13 @@ public class PostHogApi( } // Capture v1 decides its own redirects (see captureV1): OkHttp would follow any 3xx, drop the - // Authorization header on a cross-host hop and turn a 302 into a body-less GET. + // Authorization header on a cross-host hop and turn a 302 into a body-less GET. The queue owns + // retries too: an OkHttp resend would repeat PostHog-Attempt and PostHog-Request-Timestamp. private val captureClient: OkHttpClient by lazy { client.newBuilder() .followRedirects(false) .followSslRedirects(false) + .retryOnConnectionFailure(false) .build() } @@ -145,7 +147,8 @@ public class PostHogApi( val url = "$theHost$path" var request = makeRequest(url, contentType = captureMediaType) { - logRequest(body, url) + // Events hold user data, so the debug log names only the count. + config.logger.log("Request $url: ${events.size} events") config.serializer.serialize(body, it.bufferedWriter()) }.newBuilder() @@ -160,8 +163,9 @@ public class PostHogApi( while (true) { logRequestHeaders(request) - captureClient.newCall(request).execute().use { - val response = logResponse(it) + captureClient.newCall(request).execute().use { response -> + // The status only: the results name event uuids. + config.logger.log("Response ${response.request.url}: ${response.code}") if (response.code != 307 && response.code != 308) { return captureV1Result(response, path, uuids, identity) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 147c97d50..007237de4 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -31,6 +31,10 @@ public class PostHogQueue( ) : PostHogQueueInterface { private val deque: ArrayDeque = ArrayDeque() private val dequeLock = Any() + + // The files of the last request that failed or came back with records to retry. The next flush + // resends exactly these, so the request keeps its id and counts the attempt. Guarded by dequeLock. + private var retryFiles: List = emptyList() private val timerLock = Any() private var pausedUntil: Date? = null private var retryCount = 0 @@ -193,11 +197,15 @@ public class PostHogQueue( } private fun takeFiles(): List { - val events: List synchronized(dequeLock) { - events = deque.take(batchLimits.cap) + val retry = retryFiles + retryFiles = emptyList() + // A file dropped since (queue full, clear) changes the request, so it starts over. + if (retry.isNotEmpty() && deque.containsAll(retry)) { + return retry + } + return deque.take(batchLimits.cap) } - return events } private fun flushBatch(isFatal: Boolean) { @@ -335,6 +343,7 @@ public class PostHogQueue( } if (retained.isNotEmpty()) { partialRetryAfterSeconds = result.retryAfterSeconds + keepForRetry(retained) } } if (retained.isEmpty()) { @@ -352,10 +361,15 @@ public class PostHogQueue( // only re-throw if retriable (files kept), so executeWithRetry // can track retryCount and apply backoff if (!deleteFiles) { + // A 413 halves the batch instead, so the next request carries fewer records. + if (spec.isRetriableStatusCode(e.statusCode)) { + keepForRetry(files) + } throw e } } catch (e: IOException) { deleteFiles = false + keepForRetry(files) config.logger.log("Flushing failed because of a network error, let's try again soon.") throw e } finally { @@ -373,6 +387,12 @@ public class PostHogQueue( return retained.isNotEmpty() } + private fun keepForRetry(files: List) { + synchronized(dequeLock) { + retryFiles = files + } + } + override fun flush() { if (isFlushing.getAndSet(true)) { config.logger.log("Queue is flushing.") diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 598e9e4e9..96cdcc397 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -350,6 +350,32 @@ internal class PostHogCaptureV1Test { assertEquals(listOf("1", "2", "3"), requestIds.map { it.second }) } + @Test + fun `a dropped connection is not resent by the HTTP client`() { + val http = httpServers.mockHttp(response = MockResponse().setBody("")) + // the second request reuses the pooled connection, which OkHttp would otherwise retry on a new one + http.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + http.enqueue(MockResponse().setBody("")) + val sut = api(http.url("/").toString()) + + sut.captureV1(listOf(event())) + assertThrows(IOException::class.java) { sut.captureV1(listOf(event())) } + + assertEquals(2, http.requestCount) + } + + @Test + fun `debug logs leave out event content`() { + val sent = event(properties = mapOf("email" to "user@example.com")) + val http = httpServers.mockHttp(response = resultsResponse(sent.uuid!! to "ok")) + val config = config(http.url("/").toString()).apply { debug = true } + + PostHogApi(config).captureV1(listOf(sent)) + + assertTrue(logger.messages.any { it.endsWith("/i/v1/analytics/events: 1 events") }) + assertTrue(logger.messages.none { it.contains("user@example.com") || it.contains(sent.uuid.toString()) }) + } + @Test fun `a different set of events starts a new request identity`() { val event = event() @@ -487,6 +513,58 @@ internal class PostHogCaptureV1Test { assertEquals(first.headers["PostHog-Request-Id"], second.headers["PostHog-Request-Id"]) } + @Test + fun `queue resends a failed batch alone under the same request id`() { + val failed = event("failed") + val later = event("later") + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503)) + http.enqueue(resultsResponse(failed.uuid!! to "ok")) + http.enqueue(resultsResponse(later.uuid!! to "ok")) + val sut = queue(http) + + sut.add(failed) + executor.awaitExecution() + sut.add(later) + executor.awaitExecution() + sut.flush() + executor.awaitExecution() + + assertEquals(3, http.requestCount) + val requests = List(3) { http.takeRequest() } + assertEquals( + listOf(listOf(failed.uuid.toString()), listOf(failed.uuid.toString()), listOf(later.uuid.toString())), + requests.map { request -> batch(request).map { it["uuid"] } }, + ) + assertEquals(listOf("1", "2", "1"), requests.map { it.headers["PostHog-Attempt"] }) + assertEquals(requests[0].headers["PostHog-Request-Id"], requests[1].headers["PostHog-Request-Id"]) + assertNotEquals(requests[1].headers["PostHog-Request-Id"], requests[2].headers["PostHog-Request-Id"]) + assertTrue(sut.dequeList.isEmpty()) + } + + @Test + fun `queue resends only the retried events while newer ones wait`() { + val ok = event("ok") + val retry = event("retry") + val later = event("later") + val http = httpServers.mockHttp(response = resultsResponse(ok.uuid!! to "ok", retry.uuid!! to "retry")) + http.enqueue(resultsResponse(retry.uuid!! to "ok")) + val sut = queue(http, flushAt = 2) + + sut.add(ok) + sut.add(retry) + executor.awaitExecution() + sut.add(later) + executor.awaitExecution() + + assertEquals(2, http.requestCount) + val first = http.takeRequest() + val second = http.takeRequest() + assertEquals(listOf(retry.uuid.toString()), batch(second).map { it["uuid"] }) + assertEquals("2", second.headers["PostHog-Attempt"]) + assertEquals(first.headers["PostHog-Request-Id"], second.headers["PostHog-Request-Id"]) + assertEquals(1, sut.dequeList.size) + } + @Test fun `queue drops the batch on 429 and keeps it on 503`() { val http = httpServers.mockHttp(response = MockResponse().setResponseCode(429)) From eb6b07dcc4d29c3c0a7cae739f2cd124d70f1989 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 09:45:58 -0400 Subject: [PATCH 06/45] fix: stop OkHttp resending a capture 503 with Retry-After 0 OkHttp's follow-up layer resends a 503 carrying `Retry-After: 0` even with retryOnConnectionFailure off, repeating PostHog-Attempt and PostHog-Request-Timestamp outside the queue's backoff. A network interceptor on the capture client drops that header before the retry layer sees it; the queue waits the same with or without it. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/posthog/internal/PostHogApi.kt | 15 +++++++++++++++ .../com/posthog/internal/PostHogCaptureV1Test.kt | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index d4fc18d90..77931f373 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -13,6 +13,7 @@ import com.posthog.internal.logs.PostHogLogsOTLP import com.posthog.logs.PostHogLogRecord import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -83,11 +84,25 @@ public class PostHogApi( // Capture v1 decides its own redirects (see captureV1): OkHttp would follow any 3xx, drop the // Authorization header on a cross-host hop and turn a 302 into a body-less GET. The queue owns // retries too: an OkHttp resend would repeat PostHog-Attempt and PostHog-Request-Timestamp. + // OkHttp also resends a 503 with `Retry-After: 0` whatever retryOnConnectionFailure says, so + // that header is dropped below its retry layer; the queue waits the same with or without it. private val captureClient: OkHttpClient by lazy { client.newBuilder() .followRedirects(false) .followSslRedirects(false) .retryOnConnectionFailure(false) + .addNetworkInterceptor( + object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + return if (response.code == 503 && response.header("Retry-After")?.toIntOrNull() == 0) { + response.newBuilder().removeHeader("Retry-After").build() + } else { + response + } + } + }, + ) .build() } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 96cdcc397..8694ead78 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -305,6 +305,16 @@ internal class PostHogCaptureV1Test { assertTrue(result.retryIds.isEmpty()) } + @Test + fun `a 503 with Retry-After 0 is not resent by the HTTP client`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503).setHeader("Retry-After", "0")) + http.enqueue(MockResponse().setBody("")) + + assertThrows(PostHogApiError::class.java) { api(http.url("/").toString()).captureV1(listOf(event())) } + + assertEquals(1, http.requestCount) + } + @Test fun `a retry reuses the request id and created_at and increments the attempt`() { val event = event() From c042057c276431e1fe101800f0e61acf2aa92b88 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 09:45:58 -0400 Subject: [PATCH 07/45] fix: cap the capture v1 response read at 1 MiB A successful capture response was read whole into memory. Read at most 1 MiB and treat a larger or endless body as unreadable, which counts the batch as delivered like any other 2xx without readable results. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/posthog/internal/PostHogApi.kt | 6 +++++- .../com/posthog/internal/PostHogCaptureV1Test.kt | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 77931f373..2ed8369a7 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -48,6 +48,7 @@ public class PostHogApi( private const val ERROR_BODY_PEEK_BYTES = 2048L private const val CAPTURE_V1_ANALYTICS_PATH = "/i/v1/analytics/events" private const val CAPTURE_V1_MAX_REDIRECTS = 5 + private const val CAPTURE_V1_MAX_RESULTS_BYTES = 1024L * 1024L } private val mediaType by lazy { @@ -286,7 +287,10 @@ public class PostHogApi( private fun readCaptureV1Results(response: Response): Map<*, *>? { return try { - val body = response.body?.string() ?: return null + // Bounded: an oversized or endless body counts as unreadable instead of filling the heap. + val source = response.body?.source() ?: return null + if (source.request(CAPTURE_V1_MAX_RESULTS_BYTES + 1)) return null + val body = source.buffer.readUtf8() (config.serializer.deserializeString(body) as? Map<*, *>)?.get("results") as? Map<*, *> } catch (e: Throwable) { null diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 8694ead78..2644d759c 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -305,6 +305,20 @@ internal class PostHogCaptureV1Test { assertTrue(result.retryIds.isEmpty()) } + @Test + fun `a 2xx with results over 1 MiB counts as delivered`() { + val event = event() + val padding = "x".repeat(1024 * 1024) + val http = + httpServers.mockHttp( + response = MockResponse().setBody("{\"results\":{\"${event.uuid}\":{\"result\":\"retry\"}},\"pad\":\"$padding\"}"), + ) + + val result = api(http.url("/").toString()).captureV1(listOf(event)) + + assertTrue(result.retryIds.isEmpty()) + } + @Test fun `a 503 with Retry-After 0 is not resent by the HTTP client`() { val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503).setHeader("Retry-After", "0")) From 464dedaa8b4df1971e278c7eeafed5e3709aa777 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:37:06 -0400 Subject: [PATCH 08/45] fix: keep the queue's retry jitter in milliseconds Rounding the jittered backoff to whole seconds removed the jitter from the 1 s and 2 s waits, so devices refused together retried together. The delay is now kept in milliseconds. Co-Authored-By: Claude Opus 5.5 --- .../java/com/posthog/internal/PostHogQueue.kt | 19 +++++++++----- .../com/posthog/internal/PostHogQueueTest.kt | 26 +++++++++---------- .../internal/PostHogReplayQueueTest.kt | 7 +++-- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 007237de4..830638b6c 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -16,7 +16,7 @@ import kotlin.concurrent.schedule import kotlin.math.max import kotlin.math.min import kotlin.math.pow -import kotlin.math.roundToInt +import kotlin.math.roundToLong /** * Generic file-backed queue. One implementation, multiple instances — @@ -36,7 +36,10 @@ public class PostHogQueue( // resends exactly these, so the request keeps its id and counts the attempt. Guarded by dequeLock. private var retryFiles: List = emptyList() private val timerLock = Any() - private var pausedUntil: Date? = null + + @PostHogVisibleForTesting + internal var pausedUntil: Date? = null + private set private var retryCount = 0 // The Retry-After of the last 2xx that kept some records for retry; read by executeWithRetry. @@ -452,7 +455,7 @@ public class PostHogQueue( /** * Pauses the queue after the [retryCount]-th consecutive failure. The delay doubles from * [initialRetryDelaySeconds] to [maxRetryDelaySeconds], scaled by a jitter factor in - * `[1 - retryJitter, 1 + retryJitter)` and capped again, in whole seconds. A server `Retry-After`, + * `[1 - retryJitter, 1 + retryJitter)` and capped again, in milliseconds. A server `Retry-After`, * capped at [maxRetryDelaySeconds], is a floor applied after jitter, so jitter never retries * earlier than the server asked. */ @@ -465,9 +468,13 @@ public class PostHogQueue( val computed = min(initialRetryDelaySeconds * 2.0.pow((retryCount - 1).toDouble()), maxDelay) val unit = jitterUnit() val factor = if (unit >= 0.0 && unit < 1.0) 1 - retryJitter + 2 * retryJitter * unit else 1.0 - val jittered = min(computed * factor, maxDelay).roundToInt() - val retryAfterFloor = (retryAfterSeconds ?: 0).coerceIn(0, maxRetryDelaySeconds) - pausedUntil = config.dateProvider.addSecondsToCurrentDate(max(jittered, retryAfterFloor)) + val jitteredMillis = (min(computed * factor, maxDelay) * 1000).roundToLong() + val retryAfterFloorMillis = (retryAfterSeconds ?: 0).coerceIn(0, maxRetryDelaySeconds) * 1000L + val delayMillis = max(jitteredMillis, retryAfterFloorMillis) + // Whole seconds through the date provider, the rest in milliseconds, so the short early + // waits keep their jitter instead of rounding to the same second on every device. + val wholeSeconds = config.dateProvider.addSecondsToCurrentDate((delayMillis / 1000).toInt()) + pausedUntil = Date(wholeSeconds.time + delayMillis % 1000) } } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt index 21a92cfce..9ad84b2e7 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt @@ -674,40 +674,40 @@ internal class PostHogQueueTest { } @Test - fun `backoff doubles from one to thirty seconds with 25 percent jitter rounded to whole seconds`() { + fun `backoff doubles from one to thirty seconds with 25 percent jitter kept in milliseconds`() { val http = mockHttp(total = 20, response = MockResponse().setResponseCode(503).setBody("error")) - val delays = mutableListOf() val dateProvider = object : PostHogDateProvider { override fun currentDate() = Date() - override fun addSecondsToCurrentDate(seconds: Int): Date { - delays.add(seconds) - // keep the pause in the past so every flush sends - return Date(0) - } + // the epoch plus the delay, so the pause stays in the past and every flush sends + override fun addSecondsToCurrentDate(seconds: Int) = Date(seconds * 1000L) override fun currentTimeMillis() = System.currentTimeMillis() override fun nanoTime() = System.nanoTime() } val sut = getSut(host = http.url("/").toString(), flushAt = 1, dateProvider = dateProvider) + var jitter = 0.0 + sut.jitterUnit = { jitter } + val delays = mutableListOf() sut.add(generateEvent()) executor.awaitExecution() + delays.add(sut.pausedUntil!!.time) - var jitter = 0.0 - sut.jitterUnit = { jitter } - repeat(6) { + repeat(5) { sut.flush() executor.awaitExecution() + delays.add(sut.pausedUntil!!.time) } jitter = 0.999 - repeat(6) { + repeat(3) { sut.flush() executor.awaitExecution() + delays.add(sut.pausedUntil!!.time) } - // -25%: 2→1.5 rounds to 2, 4→3, 8→6, 16→12, then 30→22.5 rounds to 23; +25% is capped at 30 - assertEquals(listOf(1, 2, 3, 6, 12, 23, 23, 30, 30, 30, 30, 30, 30), delays) + // -25% of 1, 2, 4, 8, 16 and 30 s; +25% of 64 s is capped at 30 s + assertEquals(listOf(750L, 1500L, 3000L, 6000L, 12000L, 22500L, 30000L, 30000L, 30000L), delays) sut.clear() awaitQueue() diff --git a/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt index 574513184..1fd4cfc1d 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt @@ -35,6 +35,9 @@ internal class PostHogReplayQueueTest { setAddSecondsToCurrentDate(Date(1000)) } + // past the pause: the whole seconds pinned above plus a jittered remainder under a second + private val afterPause = Date(2000) + @AfterTest fun cleanup() { queues.forEach { @@ -201,7 +204,7 @@ internal class PostHogReplayQueueTest { http.enqueue(MockResponse()) http.enqueue(MockResponse()) - clock.setCurrentDate(Date(1000)) + clock.setCurrentDate(afterPause) sut.flush() awaitQueue() assertBatches(http, listOf(List(2) { "failed" to "user" }, listOf("later" to "user"))) @@ -237,7 +240,7 @@ internal class PostHogReplayQueueTest { assertEquals(files, sut.dequeList) assertEquals(1, sut.currentBatchCapForTesting) repeat(3) { http.enqueue(MockResponse()) } - clock.setCurrentDate(Date(1000)) + clock.setCurrentDate(afterPause) sut.flush() awaitQueue() assertBatches(http, listOf(listOf("oversized" to "user"), listOf("oversized" to "user"), listOf("later" to "user"))) From 8c230bf544fc84141df454c305042daf7d990b75 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:37:06 -0400 Subject: [PATCH 09/45] test: send a 3.x event queued on disk through capture v1 Co-Authored-By: Claude Opus 5.5 --- .../posthog/internal/PostHogCaptureV1Test.kt | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 2644d759c..f85ebd0a7 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -18,6 +18,7 @@ import okhttp3.mockwebserver.SocketPolicy import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.rules.TemporaryFolder +import java.io.File import java.io.IOException import java.util.UUID import java.util.concurrent.Executors @@ -64,11 +65,12 @@ internal class PostHogCaptureV1Test { private fun queue( http: MockWebServer, flushAt: Int = 1, + storagePrefix: String = tmpDir.newFolder().absolutePath, ): PostHogQueue { val config = config(http.url("/").toString()).apply { this.flushAt = flushAt - storagePrefix = tmpDir.newFolder().absolutePath + this.storagePrefix = storagePrefix } // pause time pinned to the past so a backoff never blocks the next flush clock.setAddSecondsToCurrentDate(parseISO8601Date("1970-01-01T00:00:00.000Z")!!) @@ -618,6 +620,29 @@ internal class PostHogCaptureV1Test { assertEquals(options, batch(http.takeRequest()).single().options()) } + @Test + fun `a 3x event queued on disk is sent in the capture v1 shape`() { + val uuid = UUID.randomUUID() + val storagePrefix = tmpDir.newFolder().absolutePath + File(storagePrefix, PROJECT_TOKEN).apply { mkdirs() }.resolve("$uuid.event").writeText( + "{\"uuid\":\"$uuid\",\"event\":\"legacy\",\"distinct_id\":\"d\",\"timestamp\":\"2023-09-19T07:23:00.165Z\"," + + "\"properties\":{\"a\":\"b\",\"\$process_person_profile\":false,\"\$lib\":\"posthog-android\"," + + "\"\$lib_version\":\"3.20.0\",\"\$session_id\":\"session\"}}", + ) + val http = httpServers.mockHttp(response = resultsResponse(uuid to "ok")) + val sut = queue(http, storagePrefix = storagePrefix) + + sut.flush() + executor.awaitExecution() + + val sent = batch(http.takeRequest()).single() + assertEquals(uuid.toString(), sent["uuid"]) + assertEquals("session", sent["session_id"]) + assertEquals(mapOf("process_person_profile" to false), sent.options()) + assertEquals(mapOf("a" to "b"), sent.properties()) + assertTrue(sut.dequeList.isEmpty()) + } + @Test fun `queued events without options still decode`() { val event = event() From 74946c9b871ed04d1f3878d53244093bd8ec4c5d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:37:06 -0400 Subject: [PATCH 10/45] docs: list what a reverse proxy needs for capture v1 Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 95c3e0c48..67dd19d73 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -42,9 +42,17 @@ In 3.x, the SDK started and PostHog rejected every event with a 401. Use your pr ### Hosts without capture v1 -If you use a reverse proxy or self-host PostHog, make sure `/i/v1/analytics/events` is served before you upgrade. +If you self-host PostHog, make sure `/i/v1/analytics/events` is served before you upgrade. PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves it. +If you send events through your own reverse proxy, update it before you upgrade: + +- Forward `/i/v1/analytics/events` to PostHog. +- Pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged. PostHog answers `401` without `Authorization` and `400` without the others, and the events are dropped. +- Accept request bodies up to 20 MB. nginx and ingress-nginx default to 1 MB. A `413` from the proxy makes the SDK halve the batch until it fits, and drops an event that doesn't fit on its own. + +PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). + The SDK doesn't fall back to `/batch`. A 404, or any other error status that isn't retried, such as 403 or 405, drops the events. ### Redirects From 6bd51e5846f37538fcf284f488b0cb8772c8a5b7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:17:30 -0400 Subject: [PATCH 11/45] fix: cap the time spent reading capture v1 results A 2xx body that trickles bytes within the read timeout held the queue thread until it ended. Reading the results now has a 10 second deadline, after which the body counts as unreadable and the events as delivered, like an oversized body. Co-Authored-By: Claude Opus 5.5 --- .../java/com/posthog/internal/PostHogApi.kt | 6 ++++++ .../posthog/internal/PostHogCaptureV1Test.kt | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 2ed8369a7..752dab9ee 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -30,6 +30,7 @@ import java.net.SocketException import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.UUID +import java.util.concurrent.TimeUnit import javax.net.ssl.SSLException /** @@ -68,6 +69,10 @@ public class PostHogApi( } } + // Caps the whole results read, since a body that trickles bytes never hits the read timeout + // and would hold the queue thread. Internal so tests can shorten it. + internal var captureV1ResultsReadDeadlineMs: Long = 10_000L + private val client: OkHttpClient = config.httpClient ?: OkHttpClient.Builder() .proxy(config.proxy) @@ -289,6 +294,7 @@ public class PostHogApi( return try { // Bounded: an oversized or endless body counts as unreadable instead of filling the heap. val source = response.body?.source() ?: return null + source.timeout().deadline(captureV1ResultsReadDeadlineMs, TimeUnit.MILLISECONDS) if (source.request(CAPTURE_V1_MAX_RESULTS_BYTES + 1)) return null val body = source.buffer.readUtf8() (config.serializer.deserializeString(body) as? Map<*, *>)?.get("results") as? Map<*, *> diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index f85ebd0a7..492cec17b 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -321,6 +321,25 @@ internal class PostHogCaptureV1Test { assertTrue(result.retryIds.isEmpty()) } + @Test + fun `a 2xx whose results trickle past the read deadline counts as delivered`() { + val event = event() + val padding = "x".repeat(16 * 1024) + // throttles the request read too, so allow a whole small request per period + val http = + httpServers.mockHttp( + response = + MockResponse() + .setBody("{\"results\":{\"${event.uuid}\":{\"result\":\"retry\"}},\"pad\":\"$padding\"}") + .throttleBody(1024, 200, TimeUnit.MILLISECONDS), + ) + val sut = api(http.url("/").toString()).apply { captureV1ResultsReadDeadlineMs = 300 } + + val result = sut.captureV1(listOf(event)) + + assertTrue(result.retryIds.isEmpty()) + } + @Test fun `a 503 with Retry-After 0 is not resent by the HTTP client`() { val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503).setHeader("Retry-After", "0")) From 6a1a5cfa48055c9e8386fe72781378c55dbd791b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:09:57 -0400 Subject: [PATCH 12/45] feat!: add per-event capture options and one property precedence `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take an `options` map that is copied onto the new `PostHogEvent.options`, so PostHog can be told how to process one event apart from its properties and `beforeSend` can edit it. Properties are merged in one order: SDK-set values, registered super properties, the caller's properties (with `$set`, `$set_once` and `$groups` as per-call values), then `beforeSend`. `$is_identified`, `$process_person_profile` and the replay debug properties are no longer re-applied after the caller's values. The SDK sets the `process_person_profile` option to `false` only when person processing is off, and only when neither a caller option nor a caller or registered `$process_person_profile` property already says so. It no longer writes a `$process_person_profile` property. Also: a blank `distinctId` argument on `capture` uses the current distinct id; a `$set` sent with `process_person_profile: false` neither stores nor matches the person-properties hash; an event returned by `beforeSend` keeps the uuid of the client capture it came from; the minimal `$feature_flag_called` allowlist keeps `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id`; the compliance adapter forwards `options` and advertises `event_options`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L --- .../PostHogNativeCrashIntegrationTest.kt | 13 +- .../replay/PostHogReplayIntegrationTest.kt | 3 +- .../json/snapshots/batch-request.json | 75 +++----- posthog/api/posthog.api | 74 ++++---- posthog/src/main/java/com/posthog/PostHog.kt | 179 +++++++++++++----- .../java/com/posthog/PostHogCoreInterface.kt | 13 -- .../main/java/com/posthog/PostHogInterface.kt | 54 +++++- .../main/java/com/posthog/PostHogStateless.kt | 15 ++ .../com/posthog/PostHogStatelessInterface.kt | 13 ++ .../java/com/posthog/PostHogBootstrapTest.kt | 3 +- .../com/posthog/PostHogPersonProfilesTest.kt | 49 +++-- .../src/test/java/com/posthog/PostHogTest.kt | 52 ++++- ...ErrorTrackingAutoCaptureIntegrationTest.kt | 12 +- .../java/com/posthog/PostHogFake.kt | 11 +- .../posthog/compliance/ComplianceAdapter.kt | 4 +- 15 files changed, 390 insertions(+), 180 deletions(-) diff --git a/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt index 57c896485..5c59a2131 100644 --- a/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/errortracking/PostHogNativeCrashIntegrationTest.kt @@ -176,6 +176,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), eq(Date(200)), + anyOrNull(), ) assertEquals(200, watermark()) } @@ -195,6 +196,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), eq(Date(700_000)), + anyOrNull(), ) assertEquals(900_000, watermark()) } @@ -215,6 +217,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), eq(Date(850_000)), + anyOrNull(), ) } @@ -236,6 +239,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), eq(Date(900_000)), + anyOrNull(), ) } @@ -288,6 +292,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), ) assertEquals(200, watermark()) } @@ -306,6 +311,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), ) assertEquals(300, watermark()) } @@ -317,7 +323,7 @@ internal class PostHogNativeCrashIntegrationTest { doNothing() .doThrow(RuntimeException("queue rejected")) .whenever(postHog) - .capture(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + .capture(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) install() @@ -332,6 +338,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), ) assertEquals(0, watermark()) } @@ -389,6 +396,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), ) assertEquals(500, watermark()) } @@ -442,6 +450,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), ) // oversized is deterministic, so the record must not wedge the scanner // by holding the watermark below it forever @@ -467,6 +476,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), ) // a deterministic parse failure must not hold the watermark below the // record forever, or every newer crash behind it would be blocked @@ -506,6 +516,7 @@ internal class PostHogNativeCrashIntegrationTest { anyOrNull(), anyOrNull(), anyOrNull(), + anyOrNull(), ) assertEquals(800, watermark()) } diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index a4571077a..cf79bb8a0 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -2574,10 +2574,11 @@ internal class PostHogReplayIntegrationTest { userPropertiesSetOnce: Map?, groups: Map?, timestamp: Date?, + options: Map?, ) { PostHogSessionManager.setSessionId(UUID.randomUUID()) fx.sut.onSessionIdChanged() - core.capture(event, distinctId, properties, userProperties, userPropertiesSetOnce, groups, timestamp) + core.capture(event, distinctId, properties, userProperties, userPropertiesSetOnce, groups, timestamp, options) } } fx.sut.install(client) diff --git a/posthog-android/src/test/resources/json/snapshots/batch-request.json b/posthog-android/src/test/resources/json/snapshots/batch-request.json index 8f9fb1544..57fb49cab 100644 --- a/posthog-android/src/test/resources/json/snapshots/batch-request.json +++ b/posthog-android/src/test/resources/json/snapshots/batch-request.json @@ -19,9 +19,6 @@ "distinct_id": "anonymous-user", "timestamp": "2023-11-14T22:13:20.123Z", "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", - "options": { - "process_person_profile": true - }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -50,6 +47,12 @@ "$groups": { "company": "posthog" }, + "$is_identified": false, + "$recording_status": "disabled", + "$sdk_debug_replay_event_trigger_status": "trigger_disabled", + "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", + "$sdk_debug_replay_internal_buffer_length": 0, + "$sdk_debug_pending_queue_size": "", "amount": 42.5, "currency": "USD", "items": [ @@ -57,13 +60,7 @@ "sku": "sku-1", "quantity": 2 } - ], - "$is_identified": false, - "$recording_status": "disabled", - "$sdk_debug_replay_event_trigger_status": "trigger_disabled", - "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_pending_queue_size": "" + ] } }, { @@ -72,9 +69,6 @@ "distinct_id": "identified-user", "timestamp": "2023-11-14T22:13:20.123Z", "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", - "options": { - "process_person_profile": true - }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -103,7 +97,6 @@ "$groups": { "company": "posthog" }, - "$anon_distinct_id": "anonymous-user", "$is_identified": true, "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", @@ -111,7 +104,8 @@ "$sdk_debug_replay_capture_mode": "wireframe", "$sdk_debug_replay_internal_buffer_length": 0, "$sdk_debug_session_start": 1700000000123, - "$sdk_debug_pending_queue_size": "" + "$sdk_debug_pending_queue_size": "", + "$anon_distinct_id": "anonymous-user" } }, { @@ -120,9 +114,6 @@ "distinct_id": "identified-user", "timestamp": "2023-11-14T22:13:20.123Z", "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", - "options": { - "process_person_profile": true - }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -142,18 +133,18 @@ "$timezone": "UTC", "$network_type": "wifi", "$network_carrier": "Snapshot Telecom", - "$group_type": "company", - "$group_key": "posthog", - "$group_set": { - "industry": "analytics", - "employees": 100 - }, "$is_identified": true, "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_pending_queue_size": "" + "$sdk_debug_pending_queue_size": "", + "$group_type": "company", + "$group_key": "posthog", + "$group_set": { + "industry": "analytics", + "employees": 100 + } } }, { @@ -162,9 +153,6 @@ "distinct_id": "identified-user", "timestamp": "2023-11-14T22:13:20.123Z", "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", - "options": { - "process_person_profile": true - }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -191,18 +179,18 @@ "$active_feature_flags": [ "snapshot-flag" ], - "$feature_flag": "snapshot-flag", - "$feature_flag_response": "snapshot-variant", - "$feature_flag_request_id": "flag-request-123", - "$feature_flag_id": 123, - "$feature_flag_version": 7, - "$feature_flag_reason": "", "$is_identified": true, "$recording_status": "disabled", "$sdk_debug_replay_event_trigger_status": "trigger_disabled", "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_pending_queue_size": "" + "$sdk_debug_pending_queue_size": "", + "$feature_flag": "snapshot-flag", + "$feature_flag_response": "snapshot-variant", + "$feature_flag_request_id": "flag-request-123", + "$feature_flag_id": 123, + "$feature_flag_version": 7, + "$feature_flag_reason": "" } }, { @@ -211,9 +199,6 @@ "distinct_id": "identified-user", "timestamp": "2023-11-14T22:13:20.123Z", "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", - "options": { - "process_person_profile": true - }, "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -240,6 +225,12 @@ "$active_feature_flags": [ "snapshot-flag" ], + "$is_identified": true, + "$recording_status": "disabled", + "$sdk_debug_replay_event_trigger_status": "trigger_disabled", + "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", + "$sdk_debug_replay_internal_buffer_length": 0, + "$sdk_debug_pending_queue_size": "", "$exception_level": "error", "$exception_list": [ { @@ -297,13 +288,7 @@ } ], "handled": true, - "component": "checkout", - "$is_identified": true, - "$recording_status": "disabled", - "$sdk_debug_replay_event_trigger_status": "trigger_disabled", - "$sdk_debug_replay_linked_flag_trigger_status": "trigger_disabled", - "$sdk_debug_replay_internal_buffer_length": 0, - "$sdk_debug_pending_queue_size": "" + "component": "checkout" } } ] diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index aa272e6b6..f99193f55 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -23,9 +23,9 @@ public final class com/posthog/PersonProfiles : java/lang/Enum { public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posthog/PostHogInterface { public static final field Companion Lcom/posthog/PostHog$Companion; public fun addExceptionStep (Ljava/lang/String;Ljava/util/Map;)V - public fun alias (Ljava/lang/String;)V - public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V - public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V + public fun alias (Ljava/lang/String;Ljava/util/Map;)V + public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V + public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V public fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V public fun captureLog (Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V @@ -42,8 +42,9 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/Boolean;)Lcom/posthog/FeatureFlagResult; public fun getLogger ()Lcom/posthog/logs/PostHogLogger; public fun getSessionId ()Ljava/util/UUID; - public fun group (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V + public fun group (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V public fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V public fun isFeatureEnabled (Ljava/lang/String;ZLjava/lang/Boolean;)Z public fun isOptOut ()Z public fun isSessionActive ()Z @@ -56,9 +57,9 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun reset ()V public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public fun resetPersonPropertiesForFlags (Z)V - public fun screen (Ljava/lang/String;Ljava/util/Map;)V + public fun screen (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V public fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V - public fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V + public fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V public fun setPersonPropertiesForFlags (Ljava/util/Map;Z)V public fun setup (Lcom/posthog/PostHogConfig;)V public fun startSession ()V @@ -70,9 +71,9 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface { public fun addExceptionStep (Ljava/lang/String;Ljava/util/Map;)V - public fun alias (Ljava/lang/String;)V - public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V - public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V + public fun alias (Ljava/lang/String;Ljava/util/Map;)V + public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V + public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V public fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V public fun captureLog (Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V @@ -90,8 +91,8 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/Boolean;)Lcom/posthog/FeatureFlagResult; public fun getLogger ()Lcom/posthog/logs/PostHogLogger; public fun getSessionId ()Ljava/util/UUID; - public fun group (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V - public fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public fun group (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V public fun isFeatureEnabled (Ljava/lang/String;ZLjava/lang/Boolean;)Z public fun isOptOut ()Z public fun isSessionActive ()Z @@ -106,9 +107,9 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun resetGroupPropertiesForFlags (Ljava/lang/String;Z)V public fun resetPersonPropertiesForFlags (Z)V public final fun resetSharedInstance ()V - public fun screen (Ljava/lang/String;Ljava/util/Map;)V + public fun screen (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V public fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V - public fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V + public fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V public fun setPersonPropertiesForFlags (Ljava/util/Map;Z)V public fun setup (Lcom/posthog/PostHogConfig;)V public fun startSession ()V @@ -270,8 +271,6 @@ public abstract interface class com/posthog/PostHogCoreInterface { public abstract fun debug (Z)V public static synthetic fun debug$default (Lcom/posthog/PostHogCoreInterface;ZILjava/lang/Object;)V public abstract fun flush ()V - public abstract fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V - public static synthetic fun identify$default (Lcom/posthog/PostHogCoreInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun isOptOut ()Z public abstract fun optIn ()V public abstract fun optOut ()V @@ -280,7 +279,6 @@ public abstract interface class com/posthog/PostHogCoreInterface { public final class com/posthog/PostHogCoreInterface$DefaultImpls { public static synthetic fun debug$default (Lcom/posthog/PostHogCoreInterface;ZILjava/lang/Object;)V - public static synthetic fun identify$default (Lcom/posthog/PostHogCoreInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V } public abstract interface class com/posthog/PostHogEncryption { @@ -366,11 +364,12 @@ public final class com/posthog/PostHogIntegration$DefaultImpls { public abstract interface class com/posthog/PostHogInterface : com/posthog/PostHogCoreInterface { public abstract fun addExceptionStep (Ljava/lang/String;Ljava/util/Map;)V public static synthetic fun addExceptionStep$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V - public abstract fun alias (Ljava/lang/String;)V - public abstract fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V - public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ILjava/lang/Object;)V - public abstract fun captureException (Ljava/lang/Throwable;Ljava/util/Map;)V - public static synthetic fun captureException$default (Lcom/posthog/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun alias (Ljava/lang/String;Ljava/util/Map;)V + public static synthetic fun alias$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V + public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V + public static synthetic fun captureException$default (Lcom/posthog/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public static synthetic fun captureFeatureInteraction$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public abstract fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V @@ -391,8 +390,10 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; public abstract fun getLogger ()Lcom/posthog/logs/PostHogLogger; public abstract fun getSessionId ()Ljava/util/UUID; - public abstract fun group (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V - public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun group (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V + public static synthetic fun identify$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun isFeatureEnabled (Ljava/lang/String;ZLjava/lang/Boolean;)Z public static synthetic fun isFeatureEnabled$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZLjava/lang/Boolean;ILjava/lang/Object;)Z public abstract fun isSessionActive ()Z @@ -406,12 +407,12 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V public abstract fun resetPersonPropertiesForFlags (Z)V public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V - public abstract fun screen (Ljava/lang/String;Ljava/util/Map;)V - public static synthetic fun screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun screen (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public static synthetic fun screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;Z)V public static synthetic fun setGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ZILjava/lang/Object;)V - public abstract fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;)V - public static synthetic fun setPersonProperties$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun setPersonProperties (Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)V + public static synthetic fun setPersonProperties$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun setPersonPropertiesForFlags (Ljava/util/Map;Z)V public static synthetic fun setPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;ZILjava/lang/Object;)V public abstract fun startSession ()V @@ -424,22 +425,24 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun addExceptionStep$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V - public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;ILjava/lang/Object;)V - public static synthetic fun captureException$default (Lcom/posthog/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun alias$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun captureException$default (Lcom/posthog/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun captureFeatureInteraction$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun captureFeatureView$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun captureLog$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Lcom/posthog/logs/PostHogLogSeverity;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;ILjava/lang/Object;)V public static synthetic fun capturePushNotificationOpened$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun getFeatureFlag$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; - public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun group$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun identify$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun isFeatureEnabled$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZLjava/lang/Boolean;ILjava/lang/Object;)Z public static synthetic fun reloadFeatureFlags$default (Lcom/posthog/PostHogInterface;Lcom/posthog/PostHogOnFeatureFlags;ILjava/lang/Object;)V public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;ZILjava/lang/Object;)V public static synthetic fun resetPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V - public static synthetic fun screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun screen$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun setGroupPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ZILjava/lang/Object;)V - public static synthetic fun setPersonProperties$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun setPersonProperties$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun setPersonPropertiesForFlags$default (Lcom/posthog/PostHogInterface;Ljava/util/Map;ZILjava/lang/Object;)V public static synthetic fun startSessionReplay$default (Lcom/posthog/PostHogInterface;ZILjava/lang/Object;)V } @@ -471,8 +474,8 @@ public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterfac protected fun (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;)V public synthetic fun (Ljava/util/concurrent/ExecutorService;Ljava/util/concurrent/ExecutorService;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun aliasStateless (Ljava/lang/String;Ljava/lang/String;)V - protected final fun buildEvent (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;)Lcom/posthog/PostHogEvent; - public static synthetic fun buildEvent$default (Lcom/posthog/PostHogStateless;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;ILjava/lang/Object;)Lcom/posthog/PostHogEvent; + protected final fun buildEvent (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;Ljava/util/UUID;)Lcom/posthog/PostHogEvent; + public static synthetic fun buildEvent$default (Lcom/posthog/PostHogStateless;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;Ljava/util/UUID;ILjava/lang/Object;)Lcom/posthog/PostHogEvent; protected final fun captureExceptionEvent (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Lkotlin/jvm/functions/Function0;)V public fun captureExceptionStateless (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;)V protected final fun captureFeatureFlagCalledEvent (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;)V @@ -547,6 +550,8 @@ public abstract interface class com/posthog/PostHogStatelessInterface : com/post public static synthetic fun getFeatureFlagStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun groupStateless (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V public static synthetic fun groupStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun identify (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public static synthetic fun identify$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun isFeatureEnabledStateless (Ljava/lang/String;Ljava/lang/String;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;)Z public static synthetic fun isFeatureEnabledStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Z } @@ -558,6 +563,7 @@ public final class com/posthog/PostHogStatelessInterface$DefaultImpls { public static synthetic fun getFeatureFlagResultStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; public static synthetic fun getFeatureFlagStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun groupStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun identify$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun isFeatureEnabledStateless$default (Lcom/posthog/PostHogStatelessInterface;Ljava/lang/String;Ljava/lang/String;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Z } diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index e8925eeb8..c7f9596eb 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -743,12 +743,9 @@ public class PostHog private constructor( ): MutableMap { val props = mutableMapOf() + // Values the SDK sets itself. Registered super properties and then the caller's properties + // override them below, and beforeSend has the final say. if (appendSharedProps) { - val registeredPrefs = getPreferences().getAll() - if (registeredPrefs.isNotEmpty()) { - props.putAll(registeredPrefs) - } - config?.context?.getStaticContext()?.let { props.putAll(it) } @@ -776,22 +773,12 @@ public class PostHog private constructor( } } - userProperties?.let { - props["\$set"] = it - } - - userPropertiesSetOnce?.let { - props["\$set_once"] = it - } - - if (appendGroups) { - // merge groups - mergeGroups(groups)?.let { - props["\$groups"] = it - } - } - stampCachedScreenName(props) + + props["\$is_identified"] = isIdentified + // No `$process_person_profile`: person processing is sent as the `process_person_profile` + // option, only when it's off (see personProcessingOptions). A registered or caller + // `$process_person_profile` wins over it when the send hoists the property. } // Session replay should have the SDK info as well @@ -818,22 +805,37 @@ public class PostHog private constructor( } } - properties?.let { - props.putAll(it) - } - - // After the caller merge so the SDK's person-processing state can't be overridden per event - // (e.g. a caller `true` bypassing PersonProfiles.NEVER), matching posthog-js and posthog-ios. if (appendSharedProps) { - props["\$is_identified"] = isIdentified - props["\$process_person_profile"] = hasPersonProcessing() + // After session resolution so the debug snapshot never precedes a rotation triggered by + // getActiveSessionId() above. + props.putAll(sdkDebugProperties(sessionIdString, timestamp, claimReplayDebugBundle)) + + // Registered super properties win over everything the SDK set above. + val registeredPrefs = getPreferences().getAll() + if (registeredPrefs.isNotEmpty()) { + props.putAll(registeredPrefs) + } + + // Per-call values win over registered ones. + userProperties?.let { + props["\$set"] = it + } + + userPropertiesSetOnce?.let { + props["\$set_once"] = it + } + + if (appendGroups) { + // merge groups + mergeGroups(groups)?.let { + props["\$groups"] = it + } + } } - // After the caller merge so SDK-computed debug values win, matching posthog-js's - // extend(properties, sdkDebugProperties). After session resolution so the debug snapshot - // never precedes a rotation triggered by getActiveSessionId() above. - if (appendSharedProps) { - props.putAll(sdkDebugProperties(sessionIdString, timestamp, claimReplayDebugBundle)) + // Caller-supplied properties win over registered super properties and SDK-set values. + properties?.let { + props.putAll(it) } // only Session replay needs distinct_id also in the props @@ -939,6 +941,40 @@ public class PostHog private constructor( return uuid.mostSignificantBits ushr 16 } + /** + * Adds `process_person_profile: false` when the SDK doesn't process person profiles for this + * event, unless the caller set that option or [properties] carry the legacy + * `$process_person_profile` property (per call or registered). Nothing is added otherwise: + * PostHog processes person profiles by default. Returns a copy, so `beforeSend` edits never + * reach the caller's map. + */ + private fun personProcessingOptions( + options: Map?, + properties: Map, + ): MutableMap? { + val eventOptions = options?.toMutableMap() + if (hasPersonProcessing() || properties.containsKey("\$process_person_profile")) { + return eventOptions + } + val result = eventOptions ?: mutableMapOf() + if (result["process_person_profile"] == null) { + result["process_person_profile"] = false + } + return result + } + + /** + * Whether a `$set` sent with [options] updates the person. A personless one doesn't, so it + * neither stores nor matches the person-properties hash. + */ + private fun isPersonProcessed(options: Map?): Boolean { + val option = options?.get("process_person_profile") + if (option != null) { + return option != false + } + return getPreferences().getValue("\$process_person_profile") != false + } + /** * Stamps `$screen_name = lastScreenName` into [props]. Called inside * the `appendSharedProps` block in [buildProperties] BEFORE @@ -961,6 +997,7 @@ public class PostHog private constructor( userPropertiesSetOnce: Map?, groups: Map?, timestamp: Date?, + options: Map?, ) { // Tracked on this call rather than derived from the event, so it survives beforeSend renaming // or replacing the event. @@ -974,7 +1011,8 @@ public class PostHog private constructor( return } - val newDistinctId = distinctId ?: this.distinctId + // A blank distinct ID falls back to the current one, like null, instead of PostHog dropping the event. + val newDistinctId = distinctId?.takeIf { it.isNotBlank() } ?: this.distinctId // if the user isn't identified but passed userProperties, userPropertiesSetOnce or groups, // we should still enable person processing since this is intentional @@ -1041,7 +1079,13 @@ public class PostHog private constructor( val eventProperties = minimizeIfFeatureFlagCalled(event, mergedProperties) - val postHogEvent = buildEvent(event, newDistinctId, eventProperties, timestamp) + // Snapshots don't carry the SDK's person-processing option. + val eventOptions = + if (isSnapshotEvent) options?.toMutableMap() else personProcessingOptions(options, mergedProperties) + + // This capture's identity, kept through beforeSend. + val uuid = TimeBasedEpochGenerator.generate() + val postHogEvent = buildEvent(event, newDistinctId, eventProperties, timestamp, eventOptions, uuid) if (postHogEvent == null) { val originalMessage = "PostHog event $event was dropped" val message = @@ -1091,6 +1135,7 @@ public class PostHog private constructor( override fun captureException( throwable: Throwable, properties: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1115,7 +1160,7 @@ public class PostHog private constructor( // $exception_steps are attached in capture() for all $exception events, // so externally-built exceptions (e.g. the Flutter/RN bridge) get them too. - capture(PostHogEventName.EXCEPTION.event, properties = exceptionProperties) + capture(PostHogEventName.EXCEPTION.event, properties = exceptionProperties, options = options) } catch (e: Throwable) { // we swallow all exceptions that the SDK has thrown by trying to convert // a captured exception to a PostHog exception event @@ -1443,6 +1488,7 @@ public class PostHog private constructor( public override fun screen( screenTitle: String, properties: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1467,10 +1513,13 @@ public class PostHog private constructor( // The title wins over a caller-supplied $screen_name, matching the cached value. props["\$screen_name"] = trimmedTitle - capture(PostHogEventName.SCREEN.event, properties = props) + capture(PostHogEventName.SCREEN.event, properties = props, options = options) } - public override fun alias(alias: String) { + public override fun alias( + alias: String, + options: Map?, + ) { if (!isEnabled()) { return } @@ -1495,7 +1544,7 @@ public class PostHog private constructor( // read the event properties can resolve both sides of the alias linkage. props["distinct_id"] = sourceDistinctId - capture(PostHogEventName.CREATE_ALIAS.event, distinctId = sourceDistinctId, properties = props) + capture(PostHogEventName.CREATE_ALIAS.event, distinctId = sourceDistinctId, properties = props, options = options) } public override fun captureFeatureView( @@ -1587,6 +1636,15 @@ public class PostHog private constructor( distinctId: String, userProperties: Map?, userPropertiesSetOnce: Map?, + ) { + identify(distinctId, userProperties, userPropertiesSetOnce, options = null) + } + + public override fun identify( + distinctId: String, + userProperties: Map?, + userPropertiesSetOnce: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1640,6 +1698,7 @@ public class PostHog private constructor( properties = props, userProperties = userProperties, userPropertiesSetOnce = userPropertiesSetOnce, + options = options, ) if (config?.reuseAnonymousId != true) { @@ -1679,12 +1738,15 @@ public class PostHog private constructor( distinctId = distinctId, userProperties = userProperties ?: emptyMap(), userPropertiesSetOnce = userPropertiesSetOnce ?: emptyMap(), + options = options, ) // The transition event must fire even when an identical property call was cached // earlier; cache only after capture so deduplication cannot suppress it. - synchronized(cachedPersonPropertiesLock) { - cachedPersonPropertiesHash = getPersonPropertiesHash(distinctId, userProperties, userPropertiesSetOnce) + if (isPersonProcessed(options)) { + synchronized(cachedPersonPropertiesLock) { + cachedPersonPropertiesHash = getPersonPropertiesHash(distinctId, userProperties, userPropertiesSetOnce) + } } // The identified state itself is not part of the flags request; reload only when the @@ -1693,7 +1755,8 @@ public class PostHog private constructor( reloadFeatureFlags(config?.onFeatureFlags) } } else if (!hasDifferentDistinctId && (userProperties?.isNotEmpty() == true || userPropertiesSetOnce?.isNotEmpty() == true)) { - if (shouldCapturePersonPropertiesEvent( + if (!isPersonProcessed(options) || + shouldCapturePersonPropertiesEvent( distinctId, userProperties, userPropertiesSetOnce, @@ -1705,6 +1768,7 @@ public class PostHog private constructor( distinctId = distinctId, userProperties = userProperties, userPropertiesSetOnce = userPropertiesSetOnce, + options = options, ) } // Note we don't reload flags on property changes as these get processed async @@ -1716,6 +1780,7 @@ public class PostHog private constructor( public override fun setPersonProperties( userPropertiesToSet: Map?, userPropertiesToSetOnce: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1731,7 +1796,8 @@ public class PostHog private constructor( val currentDistinctId = this.distinctId - if (!shouldCapturePersonPropertiesEvent( + if (isPersonProcessed(options) && + !shouldCapturePersonPropertiesEvent( currentDistinctId, userPropertiesToSet, userPropertiesToSetOnce, @@ -1755,6 +1821,7 @@ public class PostHog private constructor( distinctId = currentDistinctId, userProperties = userPropertiesToSet, userPropertiesSetOnce = userPropertiesToSetOnce, + options = options, ) } @@ -1866,6 +1933,7 @@ public class PostHog private constructor( type: String, key: String, groupProperties: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1911,7 +1979,7 @@ public class PostHog private constructor( preferences.setValue(GROUPS, newGroups) } - capture(PostHogEventName.GROUP_IDENTIFY.event, distinctId = this.distinctId, properties = props) + capture(PostHogEventName.GROUP_IDENTIFY.event, distinctId = this.distinctId, properties = props, options = options) // Automatically set group properties for feature flags setGroupPropertiesForFlagsIfNeeded(type, groupProperties) @@ -2675,6 +2743,7 @@ public class PostHog private constructor( userPropertiesSetOnce: Map?, groups: Map?, timestamp: Date?, + options: Map?, ) { shared.capture( event, @@ -2684,14 +2753,16 @@ public class PostHog private constructor( userPropertiesSetOnce = userPropertiesSetOnce, groups = groups, timestamp = timestamp, + options = options, ) } public override fun captureException( throwable: Throwable, properties: Map?, + options: Map?, ) { - shared.captureException(throwable, properties) + shared.captureException(throwable, properties, options) } public override fun addExceptionStep( @@ -2705,11 +2776,13 @@ public class PostHog private constructor( distinctId: String, userProperties: Map?, userPropertiesSetOnce: Map?, + options: Map?, ) { shared.identify( distinctId, userProperties = userProperties, userPropertiesSetOnce = userPropertiesSetOnce, + options = options, ) } @@ -2750,8 +2823,9 @@ public class PostHog private constructor( public override fun setPersonProperties( userPropertiesToSet: Map?, userPropertiesToSetOnce: Map?, + options: Map?, ) { - shared.setPersonProperties(userPropertiesToSet, userPropertiesToSetOnce) + shared.setPersonProperties(userPropertiesToSet, userPropertiesToSetOnce, options) } public override fun setPersonPropertiesForFlags( @@ -2796,19 +2870,24 @@ public class PostHog private constructor( type: String, key: String, groupProperties: Map?, + options: Map?, ) { - shared.group(type, key, groupProperties = groupProperties) + shared.group(type, key, groupProperties = groupProperties, options = options) } public override fun screen( screenTitle: String, properties: Map?, + options: Map?, ) { - shared.screen(screenTitle, properties = properties) + shared.screen(screenTitle, properties = properties, options = options) } - public override fun alias(alias: String) { - shared.alias(alias) + public override fun alias( + alias: String, + options: Map?, + ) { + shared.alias(alias, options) } public override fun captureFeatureView( diff --git a/posthog/src/main/java/com/posthog/PostHogCoreInterface.kt b/posthog/src/main/java/com/posthog/PostHogCoreInterface.kt index dc77ef3a3..ee6760ec4 100644 --- a/posthog/src/main/java/com/posthog/PostHogCoreInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogCoreInterface.kt @@ -12,19 +12,6 @@ public interface PostHogCoreInterface { */ public fun close() - /** - * Identifies the user - * Docs https://posthog.com/docs/product-analytics/identify - * @param distinctId the distinctId - * @param userProperties the user properties, set as a "$set" property, Docs https://posthog.com/docs/product-analytics/user-properties - * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property, Docs https://posthog.com/docs/product-analytics/user-properties - */ - public fun identify( - distinctId: String, - userProperties: Map? = null, - userPropertiesSetOnce: Map? = null, - ) - /** * Flushes all the events in the Queue right away */ diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 50e76b49b..15476fc54 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -68,13 +68,29 @@ public interface PostHogInterface : PostHogCoreInterface { /** * Captures events + * + * The event's properties are merged in this order, and later values win: properties the SDK + * sets itself (such as `$os`, `$app_version`, `$screen_name`, `$is_identified` and the session + * replay debug properties), registered super properties ([register]), [properties], then + * `beforeSend`. + * + * Example: + * ``` + * PostHog.capture("signed_up", options = mapOf("cookieless_mode" to true)) + * ``` * @param event the event name - * @param distinctId the distinctId, the generated [distinctId] is used if not given + * @param distinctId the distinctId, the generated [distinctId] is used if not given or blank * @param properties the custom properties * @param userProperties the user properties, set as a "$set" property, Docs https://posthog.com/docs/product-analytics/user-properties * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property, Docs https://posthog.com/docs/product-analytics/user-properties * @param groups the groups, set as a "$groups" property, Docs https://posthog.com/docs/product-analytics/group-analytics * @param timestamp the event timestamp override. UTC is preferred; the equivalent instant is serialized in UTC. If omitted, the current time is used + * @param options settings that tell PostHog how to process this event, sent apart from its + * properties. Known keys are `process_person_profile`, `cookieless_mode`, + * `disable_skew_correction` and `product_tour_id`; unknown keys are sent unchanged and + * ignored by PostHog. An option wins over its legacy property (`$process_person_profile`, + * `$cookieless_mode`, `$ignore_sent_at`, `$product_tour_id`) when the event is sent. The map + * is copied, and `beforeSend` can change it through [PostHogEvent.options] */ public fun capture( event: String, @@ -84,16 +100,20 @@ public interface PostHogInterface : PostHogCoreInterface { userPropertiesSetOnce: Map? = null, groups: Map? = null, timestamp: Date? = null, + options: Map? = null, ) /** * Captures exceptions * @param throwable the Throwable error * @param properties the custom properties + * @param options settings that tell PostHog how to process the `$exception` event, see + * `options` in [capture] */ public fun captureException( throwable: Throwable, properties: Map? = null, + options: Map? = null, ) /** @@ -190,29 +210,40 @@ public interface PostHogInterface : PostHogCoreInterface { * @param type the Group type * @param key the Group key * @param groupProperties the Group properties, set as a "$group_set" property, Docs https://posthog.com/docs/product-analytics/group-analytics + * @param options settings that tell PostHog how to process the `$groupidentify` event, see + * `options` in [capture] */ public fun group( type: String, key: String, groupProperties: Map? = null, + options: Map? = null, ) /** * Captures a screen view event * @param screenTitle the screen title. It takes precedence over a `$screen_name` key in [properties]. * @param properties the custom properties + * @param options settings that tell PostHog how to process the `$screen` event, see + * `options` in [capture] */ public fun screen( screenTitle: String, properties: Map? = null, + options: Map? = null, ) /** * Creates an alias for the user * Docs https://posthog.com/docs/product-analytics/identify#alias-assigning-multiple-distinct-ids-to-the-same-user * @param alias the alias + * @param options settings that tell PostHog how to process the `$create_alias` event, see + * `options` in [capture] */ - public fun alias(alias: String) + public fun alias( + alias: String, + options: Map? = null, + ) /** * Register a property to always be sent with all the following events until you call @@ -314,6 +345,22 @@ public interface PostHogInterface : PostHogCoreInterface { */ public fun getSessionId(): UUID? + /** + * Identifies the user + * Docs https://posthog.com/docs/product-analytics/identify + * @param distinctId the distinctId + * @param userProperties the user properties, set as a "$set" property, Docs https://posthog.com/docs/product-analytics/user-properties + * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property, Docs https://posthog.com/docs/product-analytics/user-properties + * @param options settings that tell PostHog how to process the `$identify` or `$set` event + * this call sends, see `options` in [capture] + */ + public fun identify( + distinctId: String, + userProperties: Map? = null, + userPropertiesSetOnce: Map? = null, + options: Map? = null, + ) + /** * Sets properties on the person profile associated with the current distinct_id. * Learn more about [identifying users](https://posthog.com/docs/product-analytics/identify) @@ -327,10 +374,13 @@ public interface PostHogInterface : PostHogCoreInterface { * @param userPropertiesToSetOnce Optional: Properties to store about the user only if not previously set. * Note: For feature flag evaluations, if the same key is present in both parameters, * the value from userPropertiesToSet will take precedence. + * @param options settings that tell PostHog how to process the `$set` event, see `options` + * in [capture] */ public fun setPersonProperties( userPropertiesToSet: Map? = null, userPropertiesToSetOnce: Map? = null, + options: Map? = null, ) /** diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index a7605f232..104829ce5 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -14,6 +14,7 @@ import com.posthog.internal.PostHogQueueInterface import com.posthog.internal.PostHogThreadFactory import com.posthog.internal.errortracking.PostHogThrowable import com.posthog.internal.errortracking.ThrowableCoercer +import com.posthog.vendor.uuid.TimeBasedEpochGenerator import java.util.Date import java.util.UUID import java.util.concurrent.ExecutorService @@ -305,6 +306,8 @@ public open class PostHogStateless protected constructor( distinctId: String, properties: MutableMap, timestamp: Date? = null, + options: MutableMap? = null, + uuid: UUID? = null, ): PostHogEvent? { val postHogEvent = PostHogEvent( @@ -312,6 +315,8 @@ public open class PostHogStateless protected constructor( distinctId, properties = properties, timestamp = timestamp ?: config?.dateProvider?.currentDate() ?: Date(), + uuid = uuid ?: TimeBasedEpochGenerator.generate(), + options = options, ) var eventChecked: PostHogEvent = postHogEvent @@ -331,6 +336,12 @@ public open class PostHogStateless protected constructor( } } + // A given uuid is this capture's identity: beforeSend can return an event kept from an + // earlier call, and a batch must never repeat a uuid. + if (uuid != null && eventChecked.uuid != uuid) { + eventChecked = eventChecked.copy(uuid = uuid) + } + return eventChecked } @@ -795,6 +806,10 @@ public open class PostHogStateless protected constructor( "locally_evaluated", "\$groups", "\$process_person_profile", + // Legacy option properties, e.g. registered as super properties, so they still become options. + "\$cookieless_mode", + "\$ignore_sent_at", + "\$product_tour_id", "\$session_id", "\$window_id", "\$lib", diff --git a/posthog/src/main/java/com/posthog/PostHogStatelessInterface.kt b/posthog/src/main/java/com/posthog/PostHogStatelessInterface.kt index 98769e366..eb409b9ce 100644 --- a/posthog/src/main/java/com/posthog/PostHogStatelessInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogStatelessInterface.kt @@ -6,6 +6,19 @@ import java.util.Date * The Stateless PostHog SDK entry point */ public interface PostHogStatelessInterface : PostHogCoreInterface { + /** + * Identifies the user + * Docs https://posthog.com/docs/product-analytics/identify + * @param distinctId the distinctId + * @param userProperties the user properties, set as a "$set" property, Docs https://posthog.com/docs/product-analytics/user-properties + * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property, Docs https://posthog.com/docs/product-analytics/user-properties + */ + public fun identify( + distinctId: String, + userProperties: Map? = null, + userPropertiesSetOnce: Map? = null, + ) + /** * Captures events * @param event the event name diff --git a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt index f4e588c87..de6aa7fcb 100644 --- a/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt @@ -17,6 +17,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals +import kotlin.test.assertNull import kotlin.test.assertTrue internal class PostHogBootstrapTest { @@ -244,7 +245,7 @@ internal class PostHogBootstrapTest { // No $identify (nothing to merge); a single person-processed $set marks the transition. val event = firstEvent(http, "\$set") assertEquals("user-123", event.distinctId) - assertEquals(true, event.options?.get("process_person_profile")) + assertNull(event.options?.get("process_person_profile")) assertEquals(true, prefs.getValue(IS_IDENTIFIED)) sut.close() diff --git a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt index f0b964878..193ee7860 100644 --- a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt @@ -10,7 +10,9 @@ import java.util.concurrent.Executors import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull internal class PostHogPersonProfilesTest { @get:Rule @@ -57,6 +59,9 @@ internal class PostHogPersonProfilesTest { tmpDir.root.deleteRecursively() } + private val PostHogEvent.processPersonProfile: Any? + get() = options?.get("process_person_profile") + @Test fun `captures an identify event where personProfile is identified_only`() { val http = mockHttp() @@ -81,7 +86,7 @@ internal class PostHogPersonProfilesTest { val theEvent = batch.batch.first() assertEquals("\$identify", theEvent.event) - assertEquals(true, theEvent.options?.get("process_person_profile")) + assertNull(theEvent.processPersonProfile) assertEquals(DISTINCT_ID, theEvent.distinctId) assertNotNull(theEvent.properties!!["\$anon_distinct_id"]) assertEquals(userProps, theEvent.properties!!["\$set"]) @@ -108,13 +113,13 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.options!!["process_person_profile"] as Boolean) + assertEquals(false, event.processPersonProfile) sut.close() } @Test - fun `capture ignores caller person processing properties if identified only and not identified`() { + fun `caller person processing properties win if identified only and not identified`() { val http = mockHttp() val url = http.url("/") @@ -133,8 +138,10 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.options!!["process_person_profile"] as Boolean) - assertEquals(false, event.properties!!["\$is_identified"] as Boolean) + // the caller's legacy property is hoisted into the option when the event is sent + assertEquals(true, event.processPersonProfile) + assertFalse(event.properties!!.containsKey("\$process_person_profile")) + assertEquals(true, event.properties!!["\$is_identified"] as Boolean) sut.close() http.shutdown() @@ -160,7 +167,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(true, event.options!!["process_person_profile"] as Boolean) + assertNull(event.processPersonProfile) sut.close() } @@ -185,7 +192,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(true, event.options!!["process_person_profile"] as Boolean) + assertNull(event.processPersonProfile) sut.close() } @@ -210,7 +217,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(true, event.options!!["process_person_profile"] as Boolean) + assertNull(event.processPersonProfile) sut.close() } @@ -234,13 +241,13 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.last() - assertEquals(true, event.options!!["process_person_profile"] as Boolean) + assertNull(event.processPersonProfile) sut.close() } @Test - fun `capture ignores caller person processing properties if identified only and identified`() { + fun `caller person processing properties win if identified only and identified`() { val http = mockHttp() val url = http.url("/") @@ -261,8 +268,9 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.last() - assertEquals(true, event.options!!["process_person_profile"] as Boolean) - assertEquals(true, event.properties!!["\$is_identified"] as Boolean) + assertEquals(false, event.processPersonProfile) + assertFalse(event.properties!!.containsKey("\$process_person_profile")) + assertEquals(false, event.properties!!["\$is_identified"] as Boolean) sut.close() http.shutdown() @@ -287,7 +295,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.last() - assertEquals(true, event.options!!["process_person_profile"] as Boolean) + assertNull(event.processPersonProfile) sut.close() } @@ -311,7 +319,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.last() - assertEquals(true, event.options!!["process_person_profile"] as Boolean) + assertNull(event.processPersonProfile) sut.reset() sut.close() @@ -334,7 +342,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(true, event.options!!["process_person_profile"] as Boolean) + assertNull(event.processPersonProfile) sut.reset() sut.close() @@ -359,7 +367,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.options!!["process_person_profile"] as Boolean) + assertEquals(false, event.processPersonProfile) sut.reset() sut.close() @@ -384,7 +392,7 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.options!!["process_person_profile"] as Boolean) + assertEquals(false, event.processPersonProfile) sut.reset() sut.close() @@ -409,14 +417,14 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.options!!["process_person_profile"] as Boolean) + assertEquals(false, event.processPersonProfile) sut.reset() sut.close() } @Test - fun `capture ignores caller process person override if never`() { + fun `caller process person override wins if never`() { val http = mockHttp() val url = http.url("/") @@ -432,7 +440,8 @@ internal class PostHogPersonProfilesTest { val event = batch.batch.first() - assertEquals(false, event.options!!["process_person_profile"] as Boolean) + assertEquals(true, event.processPersonProfile) + assertFalse(event.properties!!.containsKey("\$process_person_profile")) sut.close() http.shutdown() diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 325b1c201..15fed2010 100644 --- a/posthog/src/test/java/com/posthog/PostHogTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogTest.kt @@ -705,6 +705,7 @@ internal class PostHogTest { ) assertEquals(expectedKeys, theEvent.properties!!.keys) assertFalse(theEvent.properties!!.containsKey("\$recording_status")) + assertEquals(false, theEvent.options?.get("process_person_profile")) assertEquals("IAmInactive", theEvent.properties!!["\$feature_flag"]) assertEquals(false, theEvent.properties!!["\$feature_flag_response"]) @@ -717,6 +718,44 @@ internal class PostHogTest { sut.close() } + @Test + fun `minimal feature flag called event keeps the registered legacy option properties`() { + val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json") + val responseFlagsApi = file.readText() + + val http = mockHttp(response = MockResponse().setBody(responseFlagsApi)) + http.enqueue(MockResponse().setBody("")) + val url = http.url("/") + + val sut = getSut(url.toString(), preloadFeatureFlags = false, context = TestPostHogContext()) + + // registered as super properties, so the send can still hoist them into options + sut.register("\$cookieless_mode", true) + sut.register("\$ignore_sent_at", true) + sut.register("\$product_tour_id", "tour") + sut.register("myCustomProp", "value") + + sut.reloadFeatureFlags() + remoteConfigExecutor.shutdownAndAwaitTermination() + http.takeRequest() + + assertFalse(sut.getFeatureFlag("IAmInactive") as Boolean) + queueExecutor.shutdownAndAwaitTermination() + + val batch = serializer.deserialize(http.takeRequest().body.unGzip().reader()) + val event = batch.batch.first() + // the send hoists the kept legacy properties into their options + val options = event.options!! + assertEquals(true, options["cookieless_mode"]) + assertEquals(true, options["disable_skew_correction"]) + assertEquals("tour", options["product_tour_id"]) + val props = event.properties!! + assertFalse(props.containsKey("\$cookieless_mode")) + assertFalse(props.containsKey("myCustomProp")) + + sut.close() + } + @Test fun `feature flag called event keeps the full shape when gated and the flag has an experiment`() { val file = File("src/test/resources/json/basic-flags-minimal-flag-called-events.json") @@ -3464,7 +3503,7 @@ internal class PostHogTest { } @Test - fun `debug properties win over caller and registered properties of the same name`() { + fun `caller and registered properties win over debug properties of the same name`() { val http = mockHttp() val url = http.url("/") val integration = @@ -3473,6 +3512,7 @@ internal class PostHogTest { } val sut = getSut(url.toString(), preloadFeatureFlags = false, reloadFeatureFlags = false, integration = integration) sut.register("\$recording_status", "registered") + sut.register("\$sdk_debug_replay_event_trigger_status", "registered") sut.capture(EVENT, properties = mapOf("\$recording_status" to "caller", "\$sdk_debug_pending_queue_size" to -1)) @@ -3483,8 +3523,9 @@ internal class PostHogTest { val batch = serializer.deserialize(content.reader()) val theEvent = batch.batch.first() - assertEquals("active", theEvent.properties!!["\$recording_status"]) - assertNotEquals(-1, (theEvent.properties!!["\$sdk_debug_pending_queue_size"] as Number).toInt()) + assertEquals("caller", theEvent.properties!!["\$recording_status"]) + assertEquals("registered", theEvent.properties!!["\$sdk_debug_replay_event_trigger_status"]) + assertEquals(-1, (theEvent.properties!!["\$sdk_debug_pending_queue_size"] as Number).toInt()) sut.close() } @@ -5353,11 +5394,12 @@ internal class PostHogTest { queueExecutor.shutdownAndAwaitTermination() - http.takeRequest() + val lockedBatch = serializer.deserialize(http.takeRequest().body.unGzip().reader()) + assertEquals(false, lockedBatch.batch[0].options?.get("process_person_profile")) val request = http.takeRequest() val content = request.body.unGzip() val batch = serializer.deserialize(content.reader()) - assertEquals(true, batch.batch[0].options?.get("process_person_profile")) + assertNull(batch.batch[0].options?.get("process_person_profile")) sut.close() } diff --git a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt index b2c0f062d..647e62868 100644 --- a/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt +++ b/posthog/src/test/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegrationTest.kt @@ -164,7 +164,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { integration.uncaughtException(thread, throwable) - verify(mockPostHog).captureException(any(), anyOrNull()) + verify(mockPostHog).captureException(any(), anyOrNull(), anyOrNull()) integration.uninstall() } @@ -193,7 +193,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { whenever(mockConfig.remoteConfigHolder).thenReturn(mockRemoteConfig) whenever(mockRemoteConfig.isAutocaptureExceptionsEnabled()).thenReturn(true) currentHandler = mockExceptionHandler - whenever(mockPostHog.captureException(any(), anyOrNull())).thenThrow(IllegalStateException("telemetry broke")) + whenever(mockPostHog.captureException(any(), anyOrNull(), anyOrNull())).thenThrow(IllegalStateException("telemetry broke")) val thread = Thread.currentThread() val throwable = RuntimeException("Test exception") @@ -494,7 +494,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { val throwable = RuntimeException("crash while dormant") integration.uncaughtException(thread, throwable) - verify(mockPostHog, never()).captureException(any(), anyOrNull()) + verify(mockPostHog, never()).captureException(any(), anyOrNull(), anyOrNull()) verify(mockExceptionHandler).uncaughtException(thread, throwable) // Clear the process-wide install flag so it doesn't leak into the next test. @@ -520,7 +520,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { val throwable = RuntimeException("crash after re-enable") integration.uncaughtException(thread, throwable) - verify(mockPostHog).captureException(any(), anyOrNull()) + verify(mockPostHog).captureException(any(), anyOrNull(), anyOrNull()) verify(mockExceptionHandler).uncaughtException(thread, throwable) // Clear the process-wide install flag so it doesn't leak into the next test. @@ -540,7 +540,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { val throwable = RuntimeException("crash while remote-disabled") integration.uncaughtException(thread, throwable) - verify(mockPostHog, never()).captureException(any(), anyOrNull()) + verify(mockPostHog, never()).captureException(any(), anyOrNull(), anyOrNull()) verify(mockExceptionHandler).uncaughtException(thread, throwable) // Clear the process-wide install flag so it doesn't leak into the next test. @@ -577,7 +577,7 @@ internal class PostHogErrorTrackingAutoCaptureIntegrationTest { val throwable = RuntimeException("crash after re-setup") second.uncaughtException(thread, throwable) - verify(mockPostHog).captureException(any(), anyOrNull()) + verify(mockPostHog).captureException(any(), anyOrNull(), anyOrNull()) verify(overlay).uncaughtException(thread, throwable) currentHandler = second diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index b76f57c4e..e7db1d47f 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -44,6 +44,7 @@ public class PostHogFake : PostHogInterface { userPropertiesSetOnce: Map?, groups: Map?, timestamp: Date?, + options: Map?, ) { this.event = event this.properties = properties @@ -63,6 +64,7 @@ public class PostHogFake : PostHogInterface { override fun captureException( throwable: Throwable, properties: Map?, + options: Map?, ) { } @@ -76,6 +78,7 @@ public class PostHogFake : PostHogInterface { distinctId: String, userProperties: Map?, userPropertiesSetOnce: Map?, + options: Map?, ) { } @@ -116,6 +119,7 @@ public class PostHogFake : PostHogInterface { override fun setPersonProperties( userPropertiesToSet: Map?, userPropertiesToSetOnce: Map?, + options: Map?, ) { } @@ -170,17 +174,22 @@ public class PostHogFake : PostHogInterface { type: String, key: String, groupProperties: Map?, + options: Map?, ) { } override fun screen( screenTitle: String, properties: Map?, + options: Map?, ) { this.screenTitle = screenTitle } - override fun alias(alias: String) { + override fun alias( + alias: String, + options: Map?, + ) { } override fun isOptOut(): Boolean { diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt index 5c1760340..46e217163 100644 --- a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt @@ -82,6 +82,7 @@ data class CaptureRequest( val event: String, val properties: Map? = null, val timestamp: String? = null, + val options: Map? = null, ) data class CaptureResponse( @@ -247,7 +248,7 @@ fun main() { sdk_version = PostHogConfig(projectToken = "").sdkVersion, adapter_version = "1.0.0", // Opt into the capture suites (android posts /i/v1/analytics/events with gzip). - capabilities = listOf("capture_v1", "encoding_gzip"), + capabilities = listOf("capture_v1", "encoding_gzip", "event_options"), ), ) } @@ -349,6 +350,7 @@ fun main() { event = req.event, distinctId = req.distinct_id, properties = properties, + options = req.options, ) // Get the UUID that was just captured (via beforeSend hook) From 0f573d450ddf04a6b7702e2317ea7316b59da74e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:09:57 -0400 Subject: [PATCH 13/45] test: cover capture options, property precedence and the personless option Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L --- .../com/posthog/PostHogCaptureOptionsTest.kt | 334 ++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt new file mode 100644 index 000000000..4e85eaea9 --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt @@ -0,0 +1,334 @@ +package com.posthog + +import com.posthog.internal.PostHogBatchEvent +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogSerializer +import com.posthog.internal.PostHogThreadFactory +import okhttp3.mockwebserver.MockWebServer +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.Executors +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PostHogCaptureOptionsTest { + @get:Rule + val tmpDir = TemporaryFolder() + + @get:Rule + val httpServers = TestHttpServers() + + private val queueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestQueue")) + private val replayQueueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestReplayQueue")) + private val remoteConfigExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestRemoteConfig")) + private val cachedEventsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestCachedEvents")) + private val serializer = PostHogSerializer(PostHogConfig(PROJECT_TOKEN)) + private lateinit var config: PostHogConfig + + // Events as beforeSend sees them. The hook drops them, so each capture is recorded once and + // nothing is sent. + private val captured = mutableListOf() + + private fun getSut( + host: String = "http://localhost", + personProfiles: PersonProfiles = PersonProfiles.IDENTIFIED_ONLY, + beforeSend: PostHogBeforeSend = + PostHogBeforeSend { event -> + captured.add(event) + null + }, + ): PostHogInterface { + val storage = tmpDir.newFolder().absolutePath + config = + PostHogConfig(PROJECT_TOKEN, host).apply { + flushAt = 1 + storagePrefix = File(storage, "events").absolutePath + replayStoragePrefix = File(storage, "snapshots").absolutePath + preloadFeatureFlags = false + sendFeatureFlagEvent = false + cachePreferences = PostHogMemoryPreferences() + context = TestPostHogContext() + this.personProfiles = personProfiles + addBeforeSend(beforeSend) + } + return PostHog.withInternal( + config, + queueExecutor, + replayQueueExecutor, + remoteConfigExecutor, + cachedEventsExecutor, + false, + ) + } + + @AfterTest + fun `set down`() { + tmpDir.root.deleteRecursively() + } + + private fun sentEvents(http: MockWebServer): List = + List(http.requestCount) { + serializer.deserialize(http.takeRequest().body.unGzip().reader()).batch + }.flatten() + + private fun PostHogEvent.processPersonProfile(): Any? = options?.get("process_person_profile") + + @Test + fun `options reach the event of every event-sending method`() { + val options = mapOf("cookieless_mode" to true) + val cases = + listOf Unit>>( + "test_event" to { it.capture("test_event", options = options) }, + PostHogEventName.SCREEN.event to { it.screen("screen", options = options) }, + PostHogEventName.IDENTIFY.event to { it.identify("user_id", options = options) }, + PostHogEventName.SET.event to { it.setPersonProperties(userProps, options = options) }, + PostHogEventName.CREATE_ALIAS.event to { it.alias("alias", options = options) }, + PostHogEventName.GROUP_IDENTIFY.event to { it.group("type", "key", options = options) }, + PostHogEventName.EXCEPTION.event to { it.captureException(RuntimeException("boom"), options = options) }, + ) + for ((eventName, trigger) in cases) { + captured.clear() + // always processed, so the SDK adds no option of its own + val sut = getSut(personProfiles = PersonProfiles.ALWAYS) + + trigger(sut) + + val event = captured.single { it.event == eventName } + assertEquals?>(mapOf("cookieless_mode" to true), event.options, eventName) + sut.close() + } + } + + @Test + fun `identify with the same id sends its options on the set event`() { + val sut = getSut() + sut.identify("user_id") + + sut.identify("user_id", userProperties = userProps, options = mapOf("cookieless_mode" to true)) + + val event = captured.single { it.event == PostHogEventName.SET.event } + assertEquals?>(mapOf("cookieless_mode" to true), event.options) + sut.close() + } + + @Test + fun `the event gets a copy of the caller's options`() { + val callerOptions = mutableMapOf("cookieless_mode" to true) + val sut = getSut() + + sut.capture("test_event", options = callerOptions) + + val event = captured.single() + assertNotSame(callerOptions, event.options) + event.options!!["product_tour_id"] = "tour" + assertEquals?>(mapOf("cookieless_mode" to true), callerOptions) + sut.close() + } + + @Test + fun `beforeSend can edit the options`() { + val http = httpServers.mockHttp() + val sut = + getSut( + http.url("/").toString(), + beforeSend = + PostHogBeforeSend { event -> + event.options = (event.options ?: mutableMapOf()).apply { put("cookieless_mode", true) } + event + }, + ) + + sut.capture("test_event", options = mapOf("product_tour_id" to "tour")) + queueExecutor.shutdownAndAwaitTermination() + + val event = sentEvents(http).single() + assertEquals("tour", event.options?.get("product_tour_id")) + assertEquals(true, event.options?.get("cookieless_mode")) + sut.close() + } + + @Test + fun `beforeSend can remove the personless option and the send does not add it back`() { + val http = httpServers.mockHttp() + val sut = + getSut( + http.url("/").toString(), + personProfiles = PersonProfiles.NEVER, + beforeSend = + PostHogBeforeSend { event -> + event.options = null + event + }, + ) + + sut.capture("test_event") + queueExecutor.shutdownAndAwaitTermination() + + val event = sentEvents(http).single() + assertNull(event.options) + assertFalse(event.properties!!.containsKey("\$process_person_profile")) + sut.close() + } + + @Test + fun `the personless option is set only when person processing is off`() { + val never = getSut(personProfiles = PersonProfiles.NEVER) + never.capture("test_event") + assertEquals(false, captured.single().processPersonProfile()) + assertFalse(captured.single().properties!!.containsKey("\$process_person_profile")) + never.close() + + captured.clear() + val identifiedOnly = getSut(personProfiles = PersonProfiles.IDENTIFIED_ONLY) + identifiedOnly.capture("anonymous_event") + identifiedOnly.identify("user_id") + identifiedOnly.capture("identified_event") + assertEquals(false, captured.single { it.event == "anonymous_event" }.processPersonProfile()) + assertNull(captured.single { it.event == PostHogEventName.IDENTIFY.event }.options) + assertNull(captured.single { it.event == "identified_event" }.options) + identifiedOnly.close() + + captured.clear() + val always = getSut(personProfiles = PersonProfiles.ALWAYS) + always.capture("test_event") + assertNull(captured.single().options) + always.close() + } + + @Test + fun `a caller option wins over the personless default`() { + val sut = getSut(personProfiles = PersonProfiles.NEVER) + + sut.capture("explicit", options = mapOf("process_person_profile" to true)) + sut.capture("unset", options = mapOf("process_person_profile" to null, "cookieless_mode" to true)) + + assertEquals?>(mapOf("process_person_profile" to true), captured.single { it.event == "explicit" }.options) + assertEquals?>( + mapOf("process_person_profile" to false, "cookieless_mode" to true), + captured.single { it.event == "unset" }.options, + ) + sut.close() + } + + @Test + fun `a registered legacy person processing property suppresses the personless option`() { + val sut = getSut(personProfiles = PersonProfiles.NEVER) + sut.register("\$process_person_profile", true) + + sut.capture("test_event") + + val event = captured.single() + assertNull(event.options) + assertEquals(true, event.properties!!["\$process_person_profile"]) + sut.close() + } + + @Test + fun `registered properties win over SDK properties, caller properties win over registered ones`() { + val sut = getSut() + sut.register("\$os_name", "registered") + sut.register("\$is_identified", true) + sut.register("custom", "registered") + + sut.capture("test_event", properties = mapOf("custom" to "caller")) + + val props = captured.single().properties!! + assertEquals("registered", props["\$os_name"]) + assertEquals(true, props["\$is_identified"]) + assertEquals("caller", props["custom"]) + sut.close() + } + + @Test + fun `beforeSend wins over caller properties`() { + val http = httpServers.mockHttp() + val sut = + getSut( + http.url("/").toString(), + beforeSend = + PostHogBeforeSend { event -> + event.properties?.set("custom", "hook") + event + }, + ) + + sut.capture("test_event", properties = mapOf("custom" to "caller")) + queueExecutor.shutdownAndAwaitTermination() + + assertEquals("hook", sentEvents(http).single().properties!!["custom"]) + sut.close() + } + + @Test + fun `typed call values win over registered set and groups properties`() { + val sut = getSut() + sut.register("\$set", mapOf("plan" to "registered")) + sut.register("\$groups", mapOf("company" to "registered")) + + sut.capture("test_event", userProperties = mapOf("plan" to "call"), groups = mapOf("company" to "call")) + + val props = captured.single().properties!! + assertEquals(mapOf("plan" to "call"), props["\$set"] as Map<*, *>) + assertEquals(mapOf("company" to "call"), props["\$groups"] as Map<*, *>) + sut.close() + } + + @Test + fun `a blank distinct id falls back to the current one`() { + val sut = getSut() + + sut.capture("test_event", distinctId = " ") + sut.identify(" ") + + assertEquals(sut.distinctId(), captured.single().distinctId) + assertTrue(captured.none { it.event == PostHogEventName.IDENTIFY.event }) + sut.close() + } + + @Test + fun `a personless set neither stores nor matches the person properties hash`() { + val personless = mapOf("process_person_profile" to false) + val sut = getSut() + sut.identify("user_id") + captured.clear() + + sut.setPersonProperties(userProps, options = personless) + sut.setPersonProperties(userProps) + sut.setPersonProperties(userProps, options = personless) + sut.setPersonProperties(userProps) + + // only the second processed call is a duplicate + assertEquals(3, captured.count { it.event == PostHogEventName.SET.event }) + sut.close() + } + + @Test + fun `an event returned by beforeSend keeps the uuid of its own capture`() { + val http = httpServers.mockHttp(total = 2) + var cached: PostHogEvent? = null + val sut = + getSut( + http.url("/").toString(), + beforeSend = + PostHogBeforeSend { event -> + cached ?: event.also { cached = it } + }, + ) + + sut.capture("first") + sut.capture("second") + queueExecutor.shutdownAndAwaitTermination() + + val uuids = sentEvents(http).map { it.uuid } + assertEquals(2, uuids.size) + assertNotEquals(uuids[0], uuids[1]) + sut.close() + } +} From fa03eb71a521e549078f9ff29b169d60e309848d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:09:57 -0400 Subject: [PATCH 14/45] docs: describe capture options and property precedence for 4.0 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L --- .changeset/android-v4-major.md | 7 ++++++ docs/migration-3.x-to-4.0.md | 44 ++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index a3eddafb8..981f34461 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -20,4 +20,11 @@ - **Breaking:** set `$lib` and `$lib_version` from the `PostHog-Sdk-Info` request header; a value set in properties, super properties or `beforeSend` is removed before sending - **Breaking:** send `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields; a non-string value is dropped with a warning - **Breaking:** disable the SDK at setup when `projectToken` is a personal API key (`phx_...`), as for an empty token +- **Breaking:** add an `options` argument to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` for per-event capture options such as `cookieless_mode`, sent apart from the properties and editable in `beforeSend` through `PostHogEvent.options`; Java callers pass the trailing argument or `null`, and classes that implement `PostHogInterface` add the parameter +- **Breaking:** move `identify(distinctId, userProperties, userPropertiesSetOnce)` from `PostHogCoreInterface` to `PostHogStatelessInterface`; `PostHogInterface` declares `identify` with the trailing `options` parameter +- **Breaking:** registered super properties now override the properties the SDK sets itself, such as `$os_name`; super and event properties also override `$is_identified`, `$process_person_profile` and the session replay debug properties, which the SDK no longer re-applies after the caller's values +- **Breaking:** the SDK sets the `process_person_profile` option to `false` only when person processing is off, and no longer adds a `$process_person_profile` property to every event +- **Breaking:** keep the uuid of its own capture on the event `beforeSend` returns, so a uuid set in `beforeSend`, for example with `copy(uuid = …)`, is replaced +- use the current distinct ID when the `distinctId` argument to `capture` is empty or whitespace-only, instead of dropping the event +- stop storing or matching the person-properties deduplication hash for a `$set` sent with `process_person_profile: false` - debug logs for capture requests name the event count and the response status instead of printing the request and response bodies diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 67dd19d73..bca189c6c 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -21,6 +21,9 @@ You need to change something if your app does any of these: - sets `$lib` or `$lib_version` and expects PostHog to store that value - reads `$session_id` or `$window_id` from the raw request body, for example in a proxy - sets `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` +- registers a super property, or passes an event property, with the same key as one the SDK sets, such as `$os_name` or `$is_identified`, and expects the SDK's value to win +- calls `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` or `captureException` from Java, implements `PostHogInterface`, or calls `identify` through a `PostHogCoreInterface` reference +- sets the event `uuid` in `beforeSend` - relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds - sets `maxRetries` on `PostHogConfig` or `PostHogAndroidConfig` @@ -85,6 +88,20 @@ The SDK removes `$lib` and `$lib_version` from the properties it sends to captur A value you set in properties, in a super property or in `beforeSend` doesn't reach PostHog. The SDK removes them when it sends the event, so `beforeSend` still sees them. +## Property precedence + +An event's properties are merged in this order, and later values win: + +1. properties the SDK sets itself, such as `$os_name`, `$app_version`, `$screen_name`, `$is_identified`, `$session_id` and the session replay debug properties (`$recording_status`, `$sdk_debug_*`) +2. registered super properties (`register`) +3. the properties you pass to `capture` or another event-sending method; `userProperties`, `userPropertiesSetOnce` and `groups` are placed as `$set`, `$set_once` and `$groups` here +4. `beforeSend` + +In 3.x, the SDK's own values won over super properties, and `$is_identified`, `$process_person_profile` and the session replay debug properties also won over the properties you pass. +In 4.0 a value you register or pass wins, for example `register("$os_name", "custom")` sends every event with that value. +A registered `$session_id` now overrides the SDK's session ID, which can detach events from their session replay. +A caller `$screen_name` or `$session_id` still replaces the SDK's value even when it's blank, as in 3.x, so a blank `$session_id` is sent as an empty `session_id`. + ## Session and window IDs Capture v1 sends `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields, not as properties. @@ -96,9 +113,26 @@ In PostHog, the events still have `$session_id` and `$window_id`, so insights, f ## Event options -Capture v1 reads processing settings from an `options` object sent next to `properties`, which `beforeSend` can read and change through `PostHogEvent.options`. +Capture v1 reads processing settings from an `options` object sent next to `properties`. +`capture` takes a new `options` argument, and `beforeSend` can read and change `PostHogEvent.options`. +`screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take the same argument, as their last parameter. +For `identify`, the options apply to the `$identify` or `$set` event the call sends. + +```kotlin +// 3.x +PostHog.capture("signed_up", properties = mapOf("$cookieless_mode" to true)) + +// 4.0 +PostHog.capture("signed_up", options = mapOf("cookieless_mode" to true)) +``` + +Kotlin callers that use named or positional arguments for the existing parameters keep compiling. +Java callers pass the new trailing `options` argument, or `null`. +Classes that implement `PostHogInterface` add the parameter. +`identify(distinctId, userProperties, userPropertiesSetOnce)` moved from `PostHogCoreInterface` to `PostHogStatelessInterface`; call it through `PostHogInterface` or `PostHogStatelessInterface`. + The legacy option properties still work. -When the event is sent, the SDK removes each one from `properties` and uses it for its option, unless that option is already set: +When the event is sent, the SDK removes each one from `properties` and uses it for its option: | Legacy property | Option | | --- | --- | @@ -107,8 +141,14 @@ When the event is sent, the SDK removes each one from `properties` and uses it f | `$ignore_sent_at` | `disable_skew_correction` | | `$product_tour_id` | `product_tour_id` | +- An option you pass to any of these methods or set in `beforeSend` wins over its legacy property. An option set to `null` counts as unset, so the legacy property fills it. +- To change person processing in `beforeSend`, edit `event.options`. A `$process_person_profile` property the hook adds only fills an option that is still unset, so it doesn't override the `false` the SDK set for an anonymous event. +- When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. In 3.x, every event carried a `$process_person_profile` property. To change person processing for one event, pass the `process_person_profile` option or the `$process_person_profile` property. A registered `$process_person_profile` changes it for every event. +- A `$set` sent with `process_person_profile: false` doesn't update the person, so it no longer suppresses a later `setPersonProperties` or `identify` call with the same properties as a duplicate. - The SDK sends option values as given. PostHog reads common forms of a value, such as `"false"` or `0` for a boolean, and drops an event whose option value it can't read. - Other option keys are sent unchanged, and PostHog ignores keys it doesn't know. +- An empty or whitespace-only `distinctId` argument to `capture` now uses the current distinct ID, like `null`. In 3.x, the event was dropped. +- The event `beforeSend` returns keeps the uuid of the capture it came from. A uuid you set in `beforeSend`, for example with `copy(uuid = …)`, is replaced, so a hook that returns an event kept from an earlier call still sends one uuid per capture. - Events still queued on disk by 3.x are sent to capture v1 after the update, with their legacy properties moved to options. ## Retries From ceba968ad0075db307a8a1051a181e677a9bfd5e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:33:11 -0400 Subject: [PATCH 15/45] fix: skip PostHog-reserved deep link query parameters A link opened with ?$process_person_profile=true copied that parameter into the Deep Link Opened properties, where capture v1 turns it into the process_person_profile option and enables person processing despite PersonProfiles.NEVER. Query parameters whose name starts with $ are now skipped. Co-Authored-By: Claude Opus 5.5 --- .changeset/android-v4-major.md | 1 + .../PostHogActivityLifecycleCallbackIntegration.kt | 3 +++ .../PostHogActivityLifecycleCallbackIntegrationTest.kt | 9 +++++++++ 3 files changed, 13 insertions(+) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 981f34461..946bd027d 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -26,5 +26,6 @@ - **Breaking:** the SDK sets the `process_person_profile` option to `false` only when person processing is off, and no longer adds a `$process_person_profile` property to every event - **Breaking:** keep the uuid of its own capture on the event `beforeSend` returns, so a uuid set in `beforeSend`, for example with `copy(uuid = …)`, is replaced - use the current distinct ID when the `distinctId` argument to `capture` is empty or whitespace-only, instead of dropping the event +- **Breaking:** skip deep link query parameters whose name starts with `$` in the `Deep Link Opened` event, so a link can't set a PostHog property such as `$process_person_profile` - stop storing or matching the person-properties deduplication hash for a `$set` sent with `process_person_profile: false` - debug logs for capture requests name the event count and the response status instead of printing the request and response bodies diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt index e08701196..32363cfbe 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegration.kt @@ -168,6 +168,9 @@ internal class PostHogActivityLifecycleCallbackIntegration( try { data?.let { for (item in it.queryParameterNames) { + // `$` names are PostHog's own, such as `$process_person_profile`, which would + // become a capture option; a link must not set them + if (item.startsWith("$")) continue val param = it.getQueryParameter(item) if (!param.isNullOrEmpty()) { props[item] = param diff --git a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt index 793241981..edb167c40 100644 --- a/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/internal/PostHogActivityLifecycleCallbackIntegrationTest.kt @@ -29,6 +29,7 @@ import java.util.concurrent.atomic.AtomicReference import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -209,6 +210,14 @@ internal class PostHogActivityLifecycleCallbackIntegrationTest { assertEquals("fasf", fake.properties?.get("Reference")) } + @Test + fun `onActivityCreated drops deep link query params reserved for PostHog`() { + val fake = executeDeepLinkTest("test://print?barcode=ABCDEFG&%24process_person_profile=true") + + assertEquals("ABCDEFG", fake.properties?.get("barcode")) + assertFalse(fake.properties!!.containsKey("\$process_person_profile")) + } + @Test fun `onActivityCreated captures deep link even if hierarchical url`() { val url = "mailto:nobody@google.com" From 7e2a367bbb8492a86eeaf59042c90e815e60367e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 18:13:19 -0400 Subject: [PATCH 16/45] docs: say the retry jitter keeps millisecond precision Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 67dd19d73..0395ebb70 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -117,7 +117,7 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. - A failed request is resent with the same events, request ID and `created_at`, and the next attempt number, before newer events are sent. OkHttp doesn't retry capture requests on its own. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x there was no cap. -- After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter, rounded to whole seconds. In 3.x it doubled without jitter and a `Retry-After` replaced it. This applies to events, session replay and logs. +- After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter at millisecond precision. In 3.x it doubled without jitter and a `Retry-After` replaced it. This applies to events, session replay and logs. - Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). - `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, with no deprecated alias. It only limits push-notification subscription retries. - A 413 still splits the batch into smaller requests. From 77498eecda046debad72a0f70b3ec4a40f23b913 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 18:43:37 -0400 Subject: [PATCH 17/45] fix: keep the personless option when the legacy property is null A Java caller can pass a null `$process_person_profile`. It no longer counts as an override, so the SDK still sends `process_person_profile: false` when person processing is off. Co-Authored-By: Claude Opus 5.5 --- posthog/src/main/java/com/posthog/PostHog.kt | 4 +-- .../com/posthog/PostHogCaptureOptionsTest.kt | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index c7f9596eb..a36d2b480 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -943,7 +943,7 @@ public class PostHog private constructor( /** * Adds `process_person_profile: false` when the SDK doesn't process person profiles for this - * event, unless the caller set that option or [properties] carry the legacy + * event, unless the caller set that option or [properties] carry a non-null legacy * `$process_person_profile` property (per call or registered). Nothing is added otherwise: * PostHog processes person profiles by default. Returns a copy, so `beforeSend` edits never * reach the caller's map. @@ -953,7 +953,7 @@ public class PostHog private constructor( properties: Map, ): MutableMap? { val eventOptions = options?.toMutableMap() - if (hasPersonProcessing() || properties.containsKey("\$process_person_profile")) { + if (hasPersonProcessing() || properties["\$process_person_profile"] != null) { return eventOptions } val result = eventOptions ?: mutableMapOf() diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt index 4e85eaea9..be24342cc 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt @@ -230,6 +230,32 @@ internal class PostHogCaptureOptionsTest { sut.close() } + @Test + fun `a null legacy person processing property keeps the personless option when profiles are never processed`() { + assertNullLegacyPropertyKeepsPersonlessOption(PersonProfiles.NEVER) + } + + @Test + fun `a null legacy person processing property keeps the personless option for an anonymous user`() { + assertNullLegacyPropertyKeepsPersonlessOption(PersonProfiles.IDENTIFIED_ONLY) + } + + private fun assertNullLegacyPropertyKeepsPersonlessOption(personProfiles: PersonProfiles) { + // Java callers can put a null value in the properties map. + @Suppress("UNCHECKED_CAST") + val nullLegacy = hashMapOf("\$process_person_profile" to null) as Map + val http = httpServers.mockHttp() + val sut = getSut(http.url("/").toString(), personProfiles = personProfiles, beforeSend = { it }) + + sut.capture("null_legacy", properties = nullLegacy) + queueExecutor.shutdownAndAwaitTermination() + + val event = sentEvents(http).single() + assertEquals(false, event.processPersonProfile()) + assertFalse(event.properties!!.containsKey("\$process_person_profile")) + sut.close() + } + @Test fun `registered properties win over SDK properties, caller properties win over registered ones`() { val sut = getSut() From 3ce2ae336a03dd715068c6fb2ddef9c1323a3e31 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 18:43:37 -0400 Subject: [PATCH 18/45] docs: escape dollar-prefixed keys in the Kotlin migration examples Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index bca189c6c..4550a7ee7 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -98,7 +98,7 @@ An event's properties are merged in this order, and later values win: 4. `beforeSend` In 3.x, the SDK's own values won over super properties, and `$is_identified`, `$process_person_profile` and the session replay debug properties also won over the properties you pass. -In 4.0 a value you register or pass wins, for example `register("$os_name", "custom")` sends every event with that value. +In 4.0 a value you register or pass wins, for example `register("\$os_name", "custom")` sends every event with that value. A registered `$session_id` now overrides the SDK's session ID, which can detach events from their session replay. A caller `$screen_name` or `$session_id` still replaces the SDK's value even when it's blank, as in 3.x, so a blank `$session_id` is sent as an empty `session_id`. @@ -120,7 +120,7 @@ For `identify`, the options apply to the `$identify` or `$set` event the call se ```kotlin // 3.x -PostHog.capture("signed_up", properties = mapOf("$cookieless_mode" to true)) +PostHog.capture("signed_up", properties = mapOf("\$cookieless_mode" to true)) // 4.0 PostHog.capture("signed_up", options = mapOf("cookieless_mode" to true)) From 9003ae05bf6c58b548b874b330f9062a83ec0684 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:57:09 -0400 Subject: [PATCH 19/45] feat: add captureAi on the capture v1 AI endpoint `captureAi` takes the same arguments as `capture`, goes through the same enrichment, precedence, personless default and `beforeSend`, and queues the event on its own disk queue, executor and retry identity for `POST /i/v1/ai/events`. It returns the event's uuid once it's handed to the queue, or null when the SDK is disabled or opted out or `beforeSend` dropped it. A name not starting with `$ai_` logs one debug line and is sent unchanged; `capture` never reroutes `$ai_*` events. The AI request client uses `captureAiTimeout` for its call, read and write timeouts and honors `captureAiCompression` through its own gzip interceptor instance. `PostHogQueue` becomes byte-aware through `EndpointSpec`: `maxBatchBytes` bounds a request window (check before append, an oversize record goes alone) and flushes as soon as that much is queued; `maxQueueBytes` is a FIFO disk cap on top of `maxQueueSize`, enforced on add and on load; `maxRecordBytes` drops a record over the limit when it's queued, logging only its name and size. The analytics spec gets a 5 MiB request cap; the AI spec gets 5 MiB requests, 50 MiB on disk and `captureAiMaxEventBytes` (at most 8 MiB) plus a 64 KiB envelope per event. New `PostHogConfig` knobs: `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes`; non-positive values use the defaults. `PostHogAndroid` stores the AI queue under `posthog-disk-ai-queue`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L --- .../com/posthog/android/PostHogAndroid.kt | 1 + posthog/api/posthog.api | 22 +++ posthog/src/main/java/com/posthog/PostHog.kt | 147 +++++++++++++++--- .../main/java/com/posthog/PostHogConfig.kt | 55 +++++++ .../main/java/com/posthog/PostHogInterface.kt | 46 ++++++ .../java/com/posthog/internal/EndpointSpec.kt | 78 ++++++++++ .../internal/GzipRequestInterceptor.kt | 115 +++++++------- .../java/com/posthog/internal/PostHogApi.kt | 41 ++++- .../java/com/posthog/internal/PostHogQueue.kt | 104 +++++++++++-- .../java/com/posthog/PostHogFake.kt | 16 ++ 10 files changed, 536 insertions(+), 89 deletions(-) diff --git a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt index c6e845b6e..d4b1bf6b4 100644 --- a/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt +++ b/posthog-android/src/main/java/com/posthog/android/PostHogAndroid.kt @@ -195,6 +195,7 @@ public class PostHogAndroid private constructor() { config.storagePrefix = config.storagePrefix ?: File(cacheDir, "posthog-disk-queue").absolutePath config.replayStoragePrefix = config.replayStoragePrefix ?: File(cacheDir, "posthog-disk-replay-queue").absolutePath config.logsStoragePrefix = config.logsStoragePrefix ?: File(cacheDir, "posthog-disk-logs-queue").absolutePath + config.aiStoragePrefix = config.aiStoragePrefix ?: File(cacheDir, "posthog-disk-ai-queue").absolutePath val preferences = config.cachePreferences ?: PostHogSharedPreferences(context, config) config.cachePreferences = preferences // Defaults to PostHogDeviceDateProvider when api < 33 diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index f99193f55..bdad93b91 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -25,6 +25,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun addExceptionStep (Ljava/lang/String;Ljava/util/Map;)V public fun alias (Ljava/lang/String;Ljava/util/Map;)V public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V + public fun captureAi (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)Ljava/util/UUID; public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V public fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V @@ -73,6 +74,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun addExceptionStep (Ljava/lang/String;Ljava/util/Map;)V public fun alias (Ljava/lang/String;Ljava/util/Map;)V public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V + public fun captureAi (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)Ljava/util/UUID; public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V public fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V @@ -147,6 +149,9 @@ public final class com/posthog/PostHogCompression : java/lang/Enum { public class com/posthog/PostHogConfig { public static final field Companion Lcom/posthog/PostHogConfig$Companion; + public static final field DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES I + public static final field DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE I + public static final field DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS I public static final field DEFAULT_EU_ASSETS_HOST Ljava/lang/String; public static final field DEFAULT_EU_HOST Ljava/lang/String; public static final field DEFAULT_FEATURE_FLAG_CALLED_CACHE_SIZE I @@ -161,9 +166,14 @@ public class com/posthog/PostHogConfig { public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V + public final fun getAiStoragePrefix ()Ljava/lang/String; public final fun getBeforeSendList ()Ljava/util/List; public final fun getBootstrap ()Lcom/posthog/PostHogBootstrapConfig; public final fun getCachePreferences ()Lcom/posthog/internal/PostHogPreferences; + public final fun getCaptureAiCompression ()Lcom/posthog/PostHogCompression; + public final fun getCaptureAiMaxEventBytes ()I + public final fun getCaptureAiMaxQueueSize ()I + public final fun getCaptureAiTimeout ()I public final fun getCompression ()Lcom/posthog/PostHogCompression; public final fun getContext ()Lcom/posthog/internal/PostHogContext; public final fun getDateProvider ()Lcom/posthog/internal/PostHogDateProvider; @@ -216,8 +226,13 @@ public class com/posthog/PostHogConfig { public final synthetic fun getTracingHeaders ()Ljava/util/List; public final fun removeBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun removeIntegration (Lcom/posthog/PostHogIntegration;)V + public final fun setAiStoragePrefix (Ljava/lang/String;)V public final fun setBootstrap (Lcom/posthog/PostHogBootstrapConfig;)V public final fun setCachePreferences (Lcom/posthog/internal/PostHogPreferences;)V + public final fun setCaptureAiCompression (Lcom/posthog/PostHogCompression;)V + public final fun setCaptureAiMaxEventBytes (I)V + public final fun setCaptureAiMaxQueueSize (I)V + public final fun setCaptureAiTimeout (I)V public final fun setCompression (Lcom/posthog/PostHogCompression;)V public final fun setContext (Lcom/posthog/internal/PostHogContext;)V public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V @@ -368,6 +383,8 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public static synthetic fun alias$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun captureAi (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)Ljava/util/UUID; + public static synthetic fun captureAi$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/UUID; public abstract fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V public static synthetic fun captureException$default (Lcom/posthog/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V @@ -427,6 +444,7 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun addExceptionStep$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun alias$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun captureAi$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/UUID; public static synthetic fun captureException$default (Lcom/posthog/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun captureFeatureInteraction$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun captureFeatureView$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V @@ -615,12 +633,14 @@ public final class com/posthog/errortracking/PostHogExceptionStepsConfig { public final class com/posthog/internal/EndpointSpec { public static final field Companion Lcom/posthog/internal/EndpointSpec$Companion; + public static final fun ai (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public static final fun batch (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public static final fun logs (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public static final fun snapshot (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; } public final class com/posthog/internal/EndpointSpec$Companion { + public final fun ai (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public final fun batch (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public final fun logs (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public final fun snapshot (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; @@ -735,6 +755,8 @@ public final class com/posthog/internal/FlagProperty { public final class com/posthog/internal/GzipRequestInterceptor : okhttp3/Interceptor { public fun (Lcom/posthog/PostHogConfig;)V + public fun (Lcom/posthog/PostHogConfig;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lcom/posthog/PostHogConfig;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; } diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index a36d2b480..0cb5db421 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -94,6 +94,10 @@ public class PostHog private constructor( Executors.newSingleThreadScheduledExecutor( PostHogThreadFactory("PostHogLogsQueueThread"), ), + private val aiExecutor: ExecutorService = + Executors.newSingleThreadScheduledExecutor( + PostHogThreadFactory("PostHogAiQueueThread"), + ), private val remoteConfigExecutor: ExecutorService = Executors.newSingleThreadScheduledExecutor( PostHogThreadFactory("PostHogRemoteConfigThread"), @@ -126,6 +130,10 @@ public class PostHog private constructor( private var logsQueue: PostHogQueueInterface? = null + // The AI lane never shares a queue, executor or retry state with analytics events. + @Volatile + private var aiQueue: PostHogQueueInterface? = null + private var pushSubscriptionManager: PostHogPushSubscriptionManager? = null /** @@ -260,6 +268,12 @@ public class PostHog private constructor( EndpointSpec.logs(config, api, config.logsStoragePrefix), logsExecutor, ) + val aiQueue = + PostHogQueue( + config, + EndpointSpec.ai(config, api, config.aiStoragePrefix), + aiExecutor, + ) val onRemoteConfigLoaded = PostHogOnRemoteConfigLoaded { // The callback fires whether the attempt succeeded or terminally failed; @@ -329,6 +343,7 @@ public class PostHog private constructor( this.queue = queue this.replayQueue = replayQueue this.logsQueue = logsQueue + this.aiQueue = aiQueue this.pushSubscriptionManager = PostHogPushSubscriptionManager( config, @@ -372,6 +387,7 @@ public class PostHog private constructor( queue.start() logsQueue.start() + aiQueue.start() // Force the persisted OPT_OUT to hydrate before this passive push trigger: the // manager reads config.optOut directly, and it is otherwise resolved lazily on @@ -631,6 +647,7 @@ public class PostHog private constructor( queue?.stop() replayQueue?.stop() logsQueue?.stop() + aiQueue?.stop() pushSubscriptionManager?.close() pushSubscriptionManager = null @@ -999,16 +1016,70 @@ public class PostHog private constructor( timestamp: Date?, options: Map?, ) { + captureInternal( + event, + distinctId, + properties, + userProperties, + userPropertiesSetOnce, + groups, + timestamp, + options, + aiLane = false, + ) + } + + public override fun captureAi( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + options: Map?, + ): UUID? { + if (!event.startsWith("\$ai_")) { + config?.logger?.log("captureAi event $event doesn't start with \$ai_, so PostHog will drop it. Use capture for non-AI events.") + } + return captureInternal( + event, + distinctId, + properties, + userProperties, + userPropertiesSetOnce, + groups, + timestamp, + options, + aiLane = true, + ) + } + + /** + * Enriches and queues one event. Returns its uuid once it's handed to the queue, or null when + * it isn't. [aiLane] sends it through the AI queue instead of the analytics (or replay) queue. + */ + private fun captureInternal( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + options: Map?, + aiLane: Boolean, + ): UUID? { // Tracked on this call rather than derived from the event, so it survives beforeSend renaming // or replacing the event. var claimedReplayDebugBundle = false try { if (!isEnabled()) { - return + return null } if (isOptedOut()) { config?.logger?.log("PostHog is in OptOut state.") - return + return null } // A blank distinct ID falls back to the current one, like null, instead of PostHog dropping the event. @@ -1025,10 +1096,11 @@ public class PostHog private constructor( if (newDistinctId.isBlank()) { config?.logger?.log("capture call not allowed, distinctId is invalid: $newDistinctId.") - return + return null } - var isSnapshotEvent = event == PostHogEventName.SNAPSHOT.event + // The AI lane always uses its own queue. + var isSnapshotEvent = !aiLane && event == PostHogEventName.SNAPSHOT.event var groupIdentify = false if (event == PostHogEventName.GROUP_IDENTIFY.event) { groupIdentify = true @@ -1044,7 +1116,7 @@ public class PostHog private constructor( config?.logger?.log( "Skipping \$exception: an entry in \$exception_list matches ignoredExceptionTypes", ) - return + return null } // Attach the buffered exception steps to any $exception event (unless the caller @@ -1095,36 +1167,40 @@ public class PostHog private constructor( originalMessage } config?.logger?.log(message) - return + return null } // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook - isSnapshotEvent = postHogEvent.event == PostHogEventName.SNAPSHOT.event + isSnapshotEvent = !aiLane && postHogEvent.event == PostHogEventName.SNAPSHOT.event // if this is a $snapshot event and $session_id is missing, don't process then event if (isSnapshotEvent && postHogEvent.properties?.get("\$session_id") == null) { config?.logger?.log("${postHogEvent.event} event dropped, because the \$session_id property is missing") - return + return null } // Replay has its own queue if (isSnapshotEvent) { replayQueue?.add(postHogEvent) - } else { - val queue = queue - // beforeSend may have opted out - if (isOptedOut()) { - config?.logger?.log("PostHog is in OptOut state.") - } else if (queue != null) { - queue.add(postHogEvent) - if (claimedReplayDebugBundle) { - commitReplayDebugPropertiesWindow() - } + return uuid + } + val targetQueue = if (aiLane) aiQueue ?: return null else queue + var queued = false + // beforeSend may have opted out + if (isOptedOut()) { + config?.logger?.log("PostHog is in OptOut state.") + } else if (targetQueue != null) { + targetQueue.add(postHogEvent) + queued = true + if (claimedReplayDebugBundle) { + commitReplayDebugPropertiesWindow() } - // Notify surveys integration about the event - surveysHandler?.onEvent(event, mergedProperties) - // Notify session replay handler about the event for event triggers - sessionReplayHandler?.onEvent(event, mergedProperties) } + // Notify surveys integration about the event + surveysHandler?.onEvent(event, mergedProperties) + // Notify session replay handler about the event for event triggers + sessionReplayHandler?.onEvent(event, mergedProperties) + return if (queued) uuid else null } catch (e: Throwable) { config?.logger?.log("Capture failed: $e.") + return null } finally { if (claimedReplayDebugBundle) { releaseReplayDebugPropertiesClaim() @@ -2191,6 +2267,7 @@ public class PostHog private constructor( super.flush() replayQueue?.flush() logsQueue?.flush() + aiQueue?.flush() // See the setup() call site: hydrate opt-out before the manager reads the raw config field. isOptedOut() pushSubscriptionManager?.retryPending() @@ -2713,12 +2790,17 @@ public class PostHog private constructor( Executors.newSingleThreadScheduledExecutor( PostHogThreadFactory("PostHogLogsQueueThread"), ), + aiExecutor: ExecutorService = + Executors.newSingleThreadScheduledExecutor( + PostHogThreadFactory("PostHogAiQueueThread"), + ), ): PostHogInterface { val instance = PostHog( queueExecutor = queueExecutor, replayExecutor = replayExecutor, logsExecutor = logsExecutor, + aiExecutor = aiExecutor, remoteConfigExecutor = featureFlagsExecutor, cachedEventsExecutor = cachedEventsExecutor, reloadFeatureFlags = reloadFeatureFlags, @@ -2757,6 +2839,27 @@ public class PostHog private constructor( ) } + public override fun captureAi( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + options: Map?, + ): UUID? = + shared.captureAi( + event, + distinctId = distinctId, + properties = properties, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + groups = groups, + timestamp = timestamp, + options = options, + ) + public override fun captureException( throwable: Throwable, properties: Map?, diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index da0268a16..9550b5c89 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -421,6 +421,49 @@ public open class PostHogConfig( */ public var compression: PostHogCompression = PostHogCompression.GZIP + /** + * How the SDK compresses [PostHog.captureAi] request bodies, separately from [compression]. + * + * When [httpClient] is set, it applies only if that client installs a [com.posthog.internal.GzipRequestInterceptor], + * which the SDK then reads this setting through for AI requests. + * + * Default: [PostHogCompression.GZIP]. + */ + public var captureAiCompression: PostHogCompression = PostHogCompression.GZIP + + /** + * Maximum number of [PostHog.captureAi] events kept in their on-disk queue before the oldest + * are dropped. + * + * AI events can be megabytes, so this is lower than [maxQueueSize]. The queue also keeps at most + * 50 MiB of AI events, whatever this is set to. A value of 0 or less uses the default. + * + * Default: 100. + */ + public var captureAiMaxQueueSize: Int = DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE + + /** + * Timeout in seconds of a [PostHog.captureAi] request: its HTTP client's call, read and write + * timeouts. + * + * AI requests can carry several MB, so this is longer than for other requests, which keep + * OkHttp's 10 second defaults. A value of 0 or less uses the default. + * + * Default: 60. + */ + public var captureAiTimeout: Int = DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS + + /** + * Maximum size, in bytes, of a [PostHog.captureAi] event. The SDK drops a larger event when it + * queues it, logging only the event name and size. + * + * Use it to lower the limit. PostHog drops an AI event whose properties are over 8 MiB, so a + * larger value, or a value of 0 or less, uses 8 MiB. + * + * Default: 8388608 (8 MiB). + */ + public var captureAiMaxEventBytes: Int = DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES + /** * Your PostHog project token, trimmed of leading and trailing whitespace. * @@ -475,6 +518,9 @@ public open class PostHogConfig( @PostHogInternal public var logsStoragePrefix: String? = null + @PostHogInternal + public var aiStoragePrefix: String? = null + @PostHogInternal public var cachePreferences: PostHogPreferences? = null @@ -589,6 +635,15 @@ public open class PostHogConfig( /** Shared default for [flushIntervalSeconds] and [PostHogLogsConfig.flushIntervalSeconds]. */ public const val DEFAULT_FLUSH_INTERVAL_SECONDS: Int = 30 + /** Default for [captureAiMaxQueueSize]. */ + public const val DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE: Int = 100 + + /** Default for [captureAiTimeout]. */ + public const val DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS: Int = 60 + + /** Default for [captureAiMaxEventBytes]: PostHog's 8 MiB limit on an AI event's properties. */ + public const val DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES: Int = 8 * 1024 * 1024 + public const val DEFAULT_US_HOST: String = "https://us.i.posthog.com" public const val DEFAULT_US_ASSETS_HOST: String = "https://us-assets.i.posthog.com" diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 15476fc54..58e57dcef 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -103,6 +103,52 @@ public interface PostHogInterface : PostHogCoreInterface { options: Map? = null, ) + /** + * Captures an AI observability event, such as `$ai_generation`, `$ai_span`, `$ai_trace` or + * `$ai_embedding`, and sends it to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts much + * larger events than [capture]. + * + * Takes the same arguments as [capture], merges properties in the same order and goes through + * the same `beforeSend` hooks. The event is queued on disk 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_`, and the SDK logs a debug message when it's + * given one. [capture] never sends events here, even ones named `$ai_*`. + * + * AI events use [PostHogConfig.flushAt], [PostHogConfig.maxBatchSize] and + * [PostHogConfig.flushIntervalSeconds], like other events, and + * [PostHogConfig.captureAiCompression], [PostHogConfig.captureAiMaxQueueSize] and + * [PostHogConfig.captureAiTimeout] instead of `compression`, `maxQueueSize` and the default + * timeouts. An event over [PostHogConfig.captureAiMaxEventBytes] (8 MiB by default) is dropped + * when it's queued, with a log line naming the event and its size. Requests carry up to about + * 5 MiB of events, and the SDK sends as soon as that much is queued. Up to 50 MiB of AI events + * are kept on disk; beyond that, the oldest are dropped. + * + * Example: + * ``` + * PostHog.captureAi("$ai_generation", properties = mapOf("$ai_model" to "gpt-5", "$ai_trace_id" to traceId)) + * ``` + * @param event the AI event name, such as `$ai_generation` + * @param distinctId the distinctId, the generated [distinctId] is used if not given or blank + * @param properties the custom properties + * @param userProperties the user properties, set as a "$set" property + * @param userPropertiesSetOnce the user properties to set only once, set as a "$set_once" property + * @param groups the groups, set as a "$groups" property + * @param timestamp the event timestamp override. UTC is preferred; the equivalent instant is serialized in UTC. If omitted, the current time is used + * @param options settings that tell PostHog how to process this event, see `options` in [capture] + * @return the event's uuid once it's handed to the queue, or null when it isn't: the SDK is + * disabled or opted out, or `beforeSend` dropped it + */ + public fun captureAi( + event: String, + distinctId: String? = null, + properties: Map? = null, + userProperties: Map? = null, + userPropertiesSetOnce: Map? = null, + groups: Map? = null, + timestamp: Date? = null, + options: Map? = null, + ): UUID? + /** * Captures exceptions * @param throwable the Throwable error diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index 5ee8d7f99..f9cbda7b0 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -18,6 +18,14 @@ import java.io.OutputStream * Endpoints without per-record results return an empty result and leave * [recordId] null. * + * The byte limits are measured on the stored record files. [maxBatchBytes] is + * a soft limit on one request: a record that doesn't fit starts the next + * request, a record larger than the limit goes alone, and the queue flushes as + * soon as that many bytes are queued. [maxQueueBytes] is a FIFO limit on the + * queued files on top of [maxQueueSize]; the newest record is always kept. + * [maxRecordBytes] drops a larger record when it is queued. Null means no + * limit. + * * The queue itself stays record-type-agnostic; everything that varies * between endpoints lives here. */ @@ -37,6 +45,9 @@ public class EndpointSpec internal constructor( internal val isFatalRecord: (Record) -> Boolean = { false }, internal val canBatchTogether: ((Record, Record) -> Boolean)? = null, internal val recordId: ((Record) -> String?)? = null, + internal val maxBatchBytes: Long? = null, + internal val maxQueueBytes: Long? = null, + internal val maxRecordBytes: Long? = null, ) { public companion object { @JvmStatic @@ -63,6 +74,42 @@ public class EndpointSpec internal constructor( isRetriableStatusCode = ::isCaptureV1RetriableStatusCode, isFatalRecord = { it.isFatalExceptionEvent() }, recordId = { event -> event.uuid?.toString() }, + maxBatchBytes = CAPTURE_V1_MAX_BATCH_BYTES, + ) + + /** + * Returns the [EndpointSpec] for the capture v1 AI endpoint (`/i/v1/ai/events`), used by + * `captureAi`. Same codec, results and retry policy as [batch], on its own queue: AI events + * can be megabytes, so the queue is capped by bytes as well as count, and an event over + * [PostHogConfig.captureAiMaxEventBytes] (plus the stored envelope) is dropped when queued. + */ + @JvmStatic + public fun ai( + config: PostHogConfig, + api: PostHogApi, + storagePrefix: String?, + ): EndpointSpec = + EndpointSpec( + recordsLabel = "AI events", + storagePrefix = storagePrefix, + initialCap = { it.maxBatchSize }, + initialFlushAt = { it.flushAt }, + maxQueueSize = { it.effectiveCaptureAiMaxQueueSize }, + flushIntervalSeconds = { it.flushIntervalSeconds }, + encode = { event, stream -> + config.serializer.serialize(event, stream.writer().buffered()) + }, + decode = { stream -> + config.serializer.deserialize(stream.reader().buffered()) + }, + // Name only: AI properties can hold prompts and media. + describe = { event -> "AI event ${event.event}" }, + send = { events -> api.captureAi(events) }, + isRetriableStatusCode = ::isCaptureV1RetriableStatusCode, + recordId = { event -> event.uuid?.toString() }, + maxBatchBytes = CAPTURE_V1_MAX_BATCH_BYTES, + maxQueueBytes = AI_MAX_QUEUE_BYTES, + maxRecordBytes = config.captureAiMaxStoredEventBytes, ) @JvmStatic @@ -155,6 +202,37 @@ internal class SendResult( */ internal fun isCaptureV1RetriableStatusCode(code: Int): Boolean = code == 408 || code in setOf(500, 502, 503, 504) +/** Soft request size for the capture v1 analytics and AI endpoints, well under their body limits. */ +internal const val CAPTURE_V1_MAX_BATCH_BYTES: Long = 5L * 1024 * 1024 + +/** Disk budget for queued AI events; the oldest are dropped first. */ +internal const val AI_MAX_QUEUE_BYTES: Long = 50L * 1024 * 1024 + +/** + * The stored AI event also holds its envelope (name, ids, timestamp), so this much is allowed on + * top of the properties limit, which PostHog measures on the properties alone. + */ +internal const val AI_EVENT_ENVELOPE_BYTES: Long = 64L * 1024 + +/** [PostHogConfig.captureAiMaxQueueSize], or its default when it isn't positive. */ +internal val PostHogConfig.effectiveCaptureAiMaxQueueSize: Int + get() = if (captureAiMaxQueueSize > 0) captureAiMaxQueueSize else PostHogConfig.DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE + +/** [PostHogConfig.captureAiTimeout], or its default when it isn't positive. */ +internal val PostHogConfig.effectiveCaptureAiTimeoutSeconds: Int + get() = if (captureAiTimeout > 0) captureAiTimeout else PostHogConfig.DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS + +/** + * Stored-size limit for an AI event: [PostHogConfig.captureAiMaxEventBytes], capped at PostHog's + * 8 MiB limit, plus the envelope allowance. + */ +internal val PostHogConfig.captureAiMaxStoredEventBytes: Long + get() { + val limit = PostHogConfig.DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES + val bytes = if (captureAiMaxEventBytes > 0) minOf(captureAiMaxEventBytes, limit) else limit + return bytes + AI_EVENT_ENVELOPE_BYTES + } + /** `/snapshot` (replay) retry policy: 408, 429, the listed 5xx, plus 3xx redirects. */ internal fun isSnapshotRetriableStatusCode(code: Int): Boolean = code in setOf(408, 429, 500, 502, 503, 504) || code in 300..399 diff --git a/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt b/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt index a84cdff8d..4c47a0022 100644 --- a/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt +++ b/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt @@ -37,74 +37,81 @@ import java.io.IOException /** * This interceptor compresses the HTTP request body. Many webservers can't handle this! * @property config The Config + * @property compression The setting that decides whether to compress, [PostHogConfig.compression] + * by default; the AI capture client reads [PostHogConfig.captureAiCompression] instead */ @PostHogInternal -public class GzipRequestInterceptor(private val config: PostHogConfig) : Interceptor { - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest = chain.request() - val body = originalRequest.body +public class GzipRequestInterceptor + @JvmOverloads + constructor( + private val config: PostHogConfig, + private val compression: () -> PostHogCompression = { config.compression }, + ) : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + val body = originalRequest.body - return if (config.compression != PostHogCompression.GZIP || - body == null || - originalRequest.header("Content-Encoding") != null || - body is MultipartBody - ) { - chain.proceed(originalRequest) - } else { - val compressedRequest = - try { - originalRequest.newBuilder() - .header("Content-Encoding", "gzip") - .method(originalRequest.method, forceContentLength(gzip(body))) - .build() - } catch (e: Throwable) { - config.logger.log("Failed to gzip the request body: $e.") + return if (compression() != PostHogCompression.GZIP || + body == null || + originalRequest.header("Content-Encoding") != null || + body is MultipartBody + ) { + chain.proceed(originalRequest) + } else { + val compressedRequest = + try { + originalRequest.newBuilder() + .header("Content-Encoding", "gzip") + .method(originalRequest.method, forceContentLength(gzip(body))) + .build() + } catch (e: Throwable) { + config.logger.log("Failed to gzip the request body: $e.") - originalRequest - } - chain.proceed(compressedRequest) + originalRequest + } + chain.proceed(compressedRequest) + } } - } - private fun gzip(body: RequestBody): RequestBody { - return object : RequestBody() { - override fun contentType(): MediaType? { - return body.contentType() - } + private fun gzip(body: RequestBody): RequestBody { + return object : RequestBody() { + override fun contentType(): MediaType? { + return body.contentType() + } - override fun contentLength(): Long { - return -1 // We don't know the compressed length in advance! - } + override fun contentLength(): Long { + return -1 // We don't know the compressed length in advance! + } - @Throws(IOException::class) - override fun writeTo(sink: BufferedSink) { - val gzipSink = GzipSink(sink).buffer() - body.writeTo(gzipSink) - gzipSink.close() + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + val gzipSink = GzipSink(sink).buffer() + body.writeTo(gzipSink) + gzipSink.close() + } } } - } - // https://github.com/square/okhttp/issues/350 - @Throws(IOException::class) - private fun forceContentLength(body: RequestBody): RequestBody { - val buffer = Buffer() - body.writeTo(buffer) + // https://github.com/square/okhttp/issues/350 + @Throws(IOException::class) + private fun forceContentLength(body: RequestBody): RequestBody { + val buffer = Buffer() + body.writeTo(buffer) - return object : RequestBody() { - override fun contentType(): MediaType? { - return body.contentType() - } + return object : RequestBody() { + override fun contentType(): MediaType? { + return body.contentType() + } - override fun contentLength(): Long { - return buffer.size - } + override fun contentLength(): Long { + return buffer.size + } - @Throws(IOException::class) - override fun writeTo(sink: BufferedSink) { - sink.write(buffer.snapshot()) + @Throws(IOException::class) + override fun writeTo(sink: BufferedSink) { + sink.write(buffer.snapshot()) + } } } } -} diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 752dab9ee..4f52da0c2 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -48,6 +48,7 @@ public class PostHogApi( private const val FLAGS_MAX_RETRY_DELAY_MS = 30_000L private const val ERROR_BODY_PEEK_BYTES = 2048L private const val CAPTURE_V1_ANALYTICS_PATH = "/i/v1/analytics/events" + private const val CAPTURE_V1_AI_PATH = "/i/v1/ai/events" private const val CAPTURE_V1_MAX_REDIRECTS = 5 private const val CAPTURE_V1_MAX_RESULTS_BYTES = 1024L * 1024L } @@ -112,6 +113,24 @@ public class PostHogApi( .build() } + // AI batches can be several MB, so they get their own timeout and compression setting. + private val aiCaptureClient: OkHttpClient by lazy { + val timeoutSeconds = config.effectiveCaptureAiTimeoutSeconds.toLong() + val builder = + // Idle timeouts only: a multi-MB upload that is still making progress on a slow link + // must not be cut off by a whole-call deadline. + captureClient.newBuilder() + .readTimeout(timeoutSeconds, TimeUnit.SECONDS) + .writeTimeout(timeoutSeconds, TimeUnit.SECONDS) + val interceptors = builder.interceptors() + for (index in interceptors.indices) { + if (interceptors[index] is GzipRequestInterceptor) { + interceptors[index] = GzipRequestInterceptor(config) { config.captureAiCompression } + } + } + builder.build() + } + private val captureV1Lock = Any() // The identity of the last capture v1 request per endpoint path whose events are still to be @@ -155,8 +174,22 @@ public class PostHogApi( * Throws [PostHogApiError] for a non-2xx status and [IOException] for a transport failure. */ @Throws(PostHogApiError::class, IOException::class) - internal fun captureV1(events: List): SendResult { - val path = CAPTURE_V1_ANALYTICS_PATH + internal fun captureV1(events: List): SendResult = postCaptureV1(CAPTURE_V1_ANALYTICS_PATH, events, captureClient) + + /** + * Sends AI events to the capture v1 AI endpoint (`POST /i/v1/ai/events`). Same wire format, + * per-event results, redirect rule and retry identity (kept per path) as [captureV1], with + * [PostHogConfig.captureAiTimeout] and [PostHogConfig.captureAiCompression]. + */ + @Throws(PostHogApiError::class, IOException::class) + internal fun captureAi(events: List): SendResult = postCaptureV1(CAPTURE_V1_AI_PATH, events, aiCaptureClient) + + @Throws(PostHogApiError::class, IOException::class) + private fun postCaptureV1( + path: String, + events: List, + client: OkHttpClient, + ): SendResult { val uuids = events.mapNotNull { it.uuid?.toString() } val identity = nextCaptureV1Identity(path, uuids.toSet()) val body = @@ -168,7 +201,7 @@ public class PostHogApi( val url = "$theHost$path" var request = makeRequest(url, contentType = captureMediaType) { - // Events hold user data, so the debug log names only the count. + // Events hold user data, and AI events prompts and media, so the debug log names only the count. config.logger.log("Request $url: ${events.size} events") config.serializer.serialize(body, it.bufferedWriter()) @@ -184,7 +217,7 @@ public class PostHogApi( while (true) { logRequestHeaders(request) - captureClient.newCall(request).execute().use { response -> + client.newCall(request).execute().use { response -> // The status only: the results name event uuids. config.logger.log("Response ${response.request.url}: ${response.code}") diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 830638b6c..8608c7330 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -35,6 +35,10 @@ public class PostHogQueue( // The files of the last request that failed or came back with records to retry. The next flush // resends exactly these, so the request keeps its id and counts the attempt. Guarded by dequeLock. private var retryFiles: List = emptyList() + + // Total size of the files in the deque, kept only for byte-limited endpoints. Guarded by dequeLock. + private val trackBytes = spec.maxBatchBytes != null || spec.maxQueueBytes != null + private var queuedBytes = 0L private val timerLock = Any() @PostHogVisibleForTesting @@ -99,6 +103,23 @@ public class PostHogQueue( os.use { theOutputStream -> spec.encode(record, theOutputStream) } + val size = file.length() + val maxRecordBytes = spec.maxRecordBytes + if (maxRecordBytes != null && size > maxRecordBytes) { + // Name and size only: the record can hold prompts and media. + config.logger.log("Dropping ${spec.describe(record)} of $size bytes, over the $maxRecordBytes byte limit.") + // Not counted in queuedBytes yet, so only the deque entry goes. + synchronized(dequeLock) { + deque.remove(file) + } + file.deleteSafely(config) + return false + } + if (trackBytes) { + synchronized(dequeLock) { + queuedBytes += size + } + } config.logger.log("Queued ${spec.describe(record)}: ${file.name}.") return true @@ -119,18 +140,45 @@ public class PostHogQueue( return true } + /** Removes [files] from the deque, subtracting their size while they still exist on disk. */ + private fun removeFromDeque(files: Collection) { + synchronized(dequeLock) { + deque.removeAll(files) + if (trackBytes) { + queuedBytes -= files.sumOf { it.length() } + } + } + } + private fun removeRecordSync() { if (deque.size > spec.maxQueueSize(config).coerceAtLeast(1)) { try { val first: File synchronized(dequeLock) { first = deque.removeFirst() + if (trackBytes) { + queuedBytes -= first.length() + } } first.deleteSafely(config) config.logger.log("Queue is full, the oldest ${spec.recordsLabel} ${first.name} is dropped.") } catch (ignored: NoSuchElementException) { } } + val maxQueueBytes = spec.maxQueueBytes ?: return + val evicted = mutableListOf() + synchronized(dequeLock) { + // The newest record is always kept, even when it's over the limit on its own. + while (queuedBytes > maxQueueBytes && deque.size > 1) { + val first = deque.removeFirst() + queuedBytes -= first.length() + evicted.add(first) + } + } + if (evicted.isNotEmpty()) { + evicted.forEach { it.deleteSafely(config) } + config.logger.log("Queue is over $maxQueueBytes bytes, the oldest ${evicted.size} ${spec.recordsLabel} are dropped.") + } } /** @@ -181,7 +229,7 @@ public class PostHogQueue( } private fun isAboveThreshold(flushAt: Int): Boolean { - if (deque.size >= flushAt) { + if (deque.size >= flushAt || isAboveByteThreshold()) { return true } else if (deque.size > 0) { // only log if there are records in the queue @@ -199,6 +247,18 @@ public class PostHogQueue( return true } + /** A full request's worth of queued bytes flushes like [BatchLimits.flushAt] does. */ + private fun isAboveByteThreshold(): Boolean { + val maxBatchBytes = spec.maxBatchBytes ?: return false + return synchronized(dequeLock) { queuedBytes } >= maxBatchBytes + } + + /** + * The next FIFO window: at most [BatchLimits.cap] files and, for a byte-limited endpoint, about + * [EndpointSpec.maxBatchBytes]. The check runs before each file is added, so a file that doesn't + * fit starts the next window and a file over the limit goes alone. The files of a request to + * retry go again on their own first. + */ private fun takeFiles(): List { synchronized(dequeLock) { val retry = retryFiles @@ -207,7 +267,17 @@ public class PostHogQueue( if (retry.isNotEmpty() && deque.containsAll(retry)) { return retry } - return deque.take(batchLimits.cap) + val maxBatchBytes = spec.maxBatchBytes ?: return deque.take(batchLimits.cap) + val files = mutableListOf() + var bytes = 0L + for (file in deque) { + if (files.size >= batchLimits.cap) break + val size = file.length() + if (files.isNotEmpty() && bytes + size > maxBatchBytes) break + files.add(file) + bytes += size + } + return files } } @@ -274,9 +344,7 @@ public class PostHogQueue( file: File, throwable: Throwable? = null, ) { - synchronized(dequeLock) { - deque.remove(file) - } + removeFromDeque(listOf(file)) file.deleteSafely(config) config.logger.log("File: ${file.name} failed to parse: $throwable.") } @@ -378,9 +446,7 @@ public class PostHogQueue( } finally { if (deleteFiles) { val delivered = files - retained.toSet() - synchronized(dequeLock) { - deque.removeAll(delivered) - } + removeFromDeque(delivered) delivered.forEach { it.deleteSafely(config) @@ -446,6 +512,7 @@ public class PostHogQueue( synchronized(dequeLock) { tempFiles = deque.toList() deque.clear() + queuedBytes = 0 } tempFiles.forEach { it.deleteSafely(config) @@ -517,6 +584,9 @@ public class PostHogQueue( deque.clear() deque.addAll(files) deque.addAll(existingFiles) + if (trackBytes) { + queuedBytes += files.sumOf { it.length() } + } } config.logger.log("Loaded ${files.size} cached ${spec.recordsLabel} from disk.") } @@ -537,7 +607,16 @@ public class PostHogQueue( files.sortBy { file -> file.lastModified() } val maxQueueSize = spec.maxQueueSize(config).coerceAtLeast(1) - val overflow = (files.size - maxQueueSize).coerceAtLeast(0) + var overflow = (files.size - maxQueueSize).coerceAtLeast(0) + val maxQueueBytes = spec.maxQueueBytes + if (maxQueueBytes != null) { + // Oldest first, keeping at least the newest file, as when a record is added. + var bytes = files.drop(overflow).sumOf { it.length() } + while (bytes > maxQueueBytes && overflow < files.size - 1) { + bytes -= files[overflow].length() + overflow++ + } + } if (overflow > 0) { files.take(overflow).forEach { it.deleteSafely(config) } config.logger.log("Dropped $overflow oldest cached ${spec.recordsLabel} to enforce queue capacity.") @@ -550,6 +629,9 @@ public class PostHogQueue( synchronized(dequeLock) { deque.clear() deque.addAll(files) + if (trackBytes) { + queuedBytes = files.sumOf { it.length() } + } } cachedRecordsLoaded = true } @@ -604,6 +686,10 @@ public class PostHogQueue( internal val currentRetryCountForTesting: Int @PostHogVisibleForTesting get() = retryCount + + internal val queuedBytesForTesting: Long + @PostHogVisibleForTesting + get() = synchronized(dequeLock) { queuedBytes } } internal class BatchLimits( diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index e7db1d47f..957123a20 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -51,6 +51,22 @@ public class PostHogFake : PostHogInterface { captures++ } + override fun captureAi( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + options: Map?, + ): UUID? { + this.event = event + this.properties = properties + captures++ + return UUID.randomUUID() + } + override fun captureLog( message: String, severity: PostHogLogSeverity, From 5c104bd97af13a13230b13044673b2e63b78492a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:57:09 -0400 Subject: [PATCH 20/45] test: cover captureAi, the AI lane and byte-aware batching SDK-level: routing by method, beforeSend and options, null returns, a terminal 404, the oversize drop (name and size only in the log), the non-`$ai_` debug line, flush and close draining the AI queue, compression per lane and the AI timeout. API-level: the AI endpoint's headers and body, separate retry identities per lane, and an AI queue keeping only the events asked to retry. Queue-level, on a synthetic record: byte-limited request windows, an oversize record sent alone, the flush at a request's worth of bytes, the per-record drop, the disk cap on add and on load, and the spec limits and config fallbacks. The Android setup test covers the AI storage prefix. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L --- .../android/PostHogAndroidSetupLookupsTest.kt | 20 +- .../java/com/posthog/PostHogCaptureAiTest.kt | 292 ++++++++++++++++++ .../posthog/internal/PostHogCaptureV1Test.kt | 74 ++++- .../internal/PostHogQueueByteLimitsTest.kt | 229 ++++++++++++++ 4 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt create mode 100644 posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidSetupLookupsTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidSetupLookupsTest.kt index adf83051d..65c9f9c12 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidSetupLookupsTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidSetupLookupsTest.kt @@ -110,6 +110,7 @@ internal class PostHogAndroidSetupLookupsTest { assertEquals(File(cacheDir, "posthog-disk-queue").absolutePath, config.storagePrefix) assertEquals(File(cacheDir, "posthog-disk-replay-queue").absolutePath, config.replayStoragePrefix) assertEquals(File(cacheDir, "posthog-disk-logs-queue").absolutePath, config.logsStoragePrefix) + assertEquals(File(cacheDir, "posthog-disk-ai-queue").absolutePath, config.aiStoragePrefix) } @Test @@ -120,15 +121,29 @@ internal class PostHogAndroidSetupLookupsTest { storagePrefix = tmpDir.newFolder().absolutePath replayStoragePrefix = tmpDir.newFolder().absolutePath logsStoragePrefix = tmpDir.newFolder().absolutePath + aiStoragePrefix = tmpDir.newFolder().absolutePath } - val expected = listOf(config.legacyStoragePrefix, config.storagePrefix, config.replayStoragePrefix, config.logsStoragePrefix) + val expected = + listOf( + config.legacyStoragePrefix, + config.storagePrefix, + config.replayStoragePrefix, + config.logsStoragePrefix, + config.aiStoragePrefix, + ) setup(config) verify(app, never()).cacheDir verify(app, never()).getDir(any(), any()) assertEquals( expected, - listOf(config.legacyStoragePrefix, config.storagePrefix, config.replayStoragePrefix, config.logsStoragePrefix), + listOf( + config.legacyStoragePrefix, + config.storagePrefix, + config.replayStoragePrefix, + config.logsStoragePrefix, + config.aiStoragePrefix, + ), ) } @@ -142,6 +157,7 @@ internal class PostHogAndroidSetupLookupsTest { assertEquals(customPath, config.storagePrefix) assertEquals(File(cacheDir, "posthog-disk-replay-queue").absolutePath, config.replayStoragePrefix) assertEquals(File(cacheDir, "posthog-disk-logs-queue").absolutePath, config.logsStoragePrefix) + assertEquals(File(cacheDir, "posthog-disk-ai-queue").absolutePath, config.aiStoragePrefix) } @Test diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt new file mode 100644 index 000000000..476fdb62d --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -0,0 +1,292 @@ +package com.posthog + +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogThreadFactory +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class PostHogCaptureAiTest { + @get:Rule + val tmpDir = TemporaryFolder() + + @get:Rule + val httpServers = TestHttpServers() + + private val queueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestQueue")) + private val replayQueueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestReplayQueue")) + private val logsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestLogsQueue")) + private val aiExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestAiQueue")) + private val remoteConfigExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestRemoteConfig")) + private val cachedEventsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestCachedEvents")) + private val logger = TestLogger() + private val clients = mutableListOf() + private lateinit var config: PostHogConfig + + private fun getSut( + http: MockWebServer, + configure: PostHogConfig.() -> Unit = {}, + ): PostHogInterface { + val storage = tmpDir.newFolder().absolutePath + config = + PostHogConfig(PROJECT_TOKEN, http.url("/").toString()).apply { + storagePrefix = File(storage, "events").absolutePath + replayStoragePrefix = File(storage, "snapshots").absolutePath + logsStoragePrefix = File(storage, "logs").absolutePath + aiStoragePrefix = File(storage, "ai").absolutePath + flushAt = 1 + preloadFeatureFlags = false + sendFeatureFlagEvent = false + cachePreferences = PostHogMemoryPreferences() + context = TestPostHogContext() + logger = this@PostHogCaptureAiTest.logger + configure() + } + return PostHog.withInternal( + config, + queueExecutor, + replayQueueExecutor, + remoteConfigExecutor, + cachedEventsExecutor, + reloadFeatureFlags = false, + logsExecutor = logsExecutor, + aiExecutor = aiExecutor, + ).also { clients.add(it) } + } + + @AfterTest + fun cleanup() { + clients.forEach { it.close() } + listOf(queueExecutor, replayQueueExecutor, logsExecutor, aiExecutor, remoteConfigExecutor, cachedEventsExecutor).forEach { + it.shutdownAndAwaitTermination() + } + tmpDir.root.deleteRecursively() + } + + private fun aiQueueFiles(): List = File(config.aiStoragePrefix!!, PROJECT_TOKEN).listFiles()?.toList() ?: emptyList() + + private fun takeRequest(http: MockWebServer): RecordedRequest = assertNotNull(http.takeRequest(5, TimeUnit.SECONDS)) + + private fun events(request: RecordedRequest): List> = captureV1Events(request.body.unGzip()) + + @Suppress("UNCHECKED_CAST") + private fun Map.properties(): Map = this["properties"] as Map + + @Test + fun `captureAi posts to the AI endpoint and returns the uuid on the wire`() { + val http = httpServers.mockHttp() + val sut = getSut(http) + + val uuid = sut.captureAi("\$ai_generation", properties = mapOf("\$ai_model" to "gpt-5")) + + aiExecutor.awaitExecution() + val request = takeRequest(http) + assertEquals("/i/v1/ai/events", request.path) + assertEquals("Bearer $PROJECT_TOKEN", request.headers["Authorization"]) + assertEquals("1", request.headers["PostHog-Attempt"]) + val event = events(request).single() + assertEquals("\$ai_generation", event["event"]) + assertEquals(assertNotNull(uuid).toString(), event["uuid"]) + assertEquals(sut.distinctId(), event["distinct_id"]) + // same enrichment as capture: SDK context and the caller's properties + assertEquals("gpt-5", event.properties()["\$ai_model"]) + assertEquals("Android", event.properties()["\$os_name"]) + queueExecutor.awaitExecution() + assertEquals(1, http.requestCount) + } + + @Test + fun `capture keeps an ai-named event on the analytics endpoint`() { + val http = httpServers.mockHttp() + val sut = getSut(http) + + sut.capture("\$ai_generation") + + queueExecutor.awaitExecution() + aiExecutor.awaitExecution() + assertEquals("/i/v1/analytics/events", takeRequest(http).path) + assertEquals(1, http.requestCount) + assertTrue(aiQueueFiles().isEmpty()) + } + + @Test + fun `beforeSend, options and the personless default apply to AI events`() { + val http = httpServers.mockHttp() + val sut = + getSut(http) { + addBeforeSend { event -> + event.properties?.remove("\$ai_input") + event.properties?.put("redacted", true) + event + } + } + + sut.captureAi( + "\$ai_generation", + properties = mapOf("\$ai_input" to "secret prompt"), + options = mapOf("cookieless_mode" to true), + ) + + aiExecutor.awaitExecution() + val event = events(takeRequest(http)).single() + assertEquals(true, event.properties()["redacted"]) + assertTrue("\$ai_input" !in event.properties()) + // identified-only without an identify: the SDK adds the personless option + assertEquals(mapOf("cookieless_mode" to true, "process_person_profile" to false), event["options"]) + } + + @Test + fun `captureAi returns null when disabled, opted out or rejected by beforeSend`() { + val http = httpServers.mockHttp() + val sut = getSut(http) { addBeforeSend { event -> if (event.event == "\$ai_rejected") null else event } } + + assertNull(sut.captureAi("\$ai_rejected")) + sut.optOut() + assertNull(sut.captureAi("\$ai_generation")) + sut.optIn() + sut.close() + assertNull(sut.captureAi("\$ai_generation")) + + aiExecutor.awaitExecution() + assertEquals(0, http.requestCount) + assertTrue(aiQueueFiles().isEmpty()) + } + + @Test + fun `a 404 from the AI endpoint is terminal`() { + val http = httpServers.mockHttp(total = 2, response = MockResponse().setResponseCode(404)) + val sut = getSut(http) + + sut.captureAi("\$ai_generation") + aiExecutor.awaitExecution() + sut.flush() + aiExecutor.awaitExecution() + + assertEquals(1, http.requestCount) + assertEquals("/i/v1/ai/events", takeRequest(http).path) + assertTrue(aiQueueFiles().isEmpty()) + } + + @Test + fun `an AI event over captureAiMaxEventBytes is dropped when queued and logs only its name and size`() { + val http = httpServers.mockHttp() + // the stored limit is the configured bytes plus a 64 KiB envelope + val sut = getSut(http) { captureAiMaxEventBytes = 1 } + val prompt = "p".repeat(70_000) + + val uuid = sut.captureAi("\$ai_generation", properties = mapOf("\$ai_input" to prompt)) + + // handed to the queue, which drops it on its own thread + assertNotNull(uuid) + aiExecutor.awaitExecution() + sut.flush() + aiExecutor.awaitExecution() + assertEquals(0, http.requestCount) + assertTrue(aiQueueFiles().isEmpty()) + val dropped = logger.messages.single { it.startsWith("Dropping AI event") } + assertTrue( + dropped.matches(Regex("Dropping AI event \\\$ai_generation of \\d+ bytes, over the ${1 + 64 * 1024} byte limit\\.")), + dropped, + ) + assertTrue(logger.messages.none { it.contains("pppp") }) + } + + @Test + fun `a name that doesn't start with ai is logged and sent unchanged`() { + val http = httpServers.mockHttp() + val sut = getSut(http) + + sut.captureAi("not_ai") + + aiExecutor.awaitExecution() + val request = takeRequest(http) + assertEquals("/i/v1/ai/events", request.path) + assertEquals("not_ai", events(request).single()["event"]) + assertTrue(logger.messages.any { it.startsWith("captureAi event not_ai doesn't start with \$ai_") }) + } + + @Test + fun `flush and close drain the AI queue`() { + val http = httpServers.mockHttp(total = 2) + val sut = getSut(http) { flushAt = 10 } + + sut.captureAi("\$ai_generation") + aiExecutor.awaitExecution() + assertEquals(0, http.requestCount) + + sut.flush() + aiExecutor.awaitExecution() + assertEquals(1, http.requestCount) + + sut.captureAi("\$ai_span") + aiExecutor.awaitExecution() + sut.close() + aiExecutor.awaitExecution() + assertEquals(2, http.requestCount) + assertEquals(listOf("/i/v1/ai/events", "/i/v1/ai/events"), List(2) { takeRequest(http).path }) + } + + @Test + fun `compression is set per lane`() { + val http = httpServers.mockHttp(total = 2) + val sut = + getSut(http) { + compression = PostHogCompression.NONE + captureAiCompression = PostHogCompression.GZIP + } + + sut.capture("event") + sut.captureAi("\$ai_generation") + + queueExecutor.awaitExecution() + aiExecutor.awaitExecution() + val requests = List(2) { takeRequest(http) }.associateBy { it.path } + val analytics = assertNotNull(requests["/i/v1/analytics/events"]) + val ai = assertNotNull(requests["/i/v1/ai/events"]) + assertNull(analytics.headers["Content-Encoding"]) + assertTrue(analytics.body.readUtf8().contains("\"event\":\"event\"")) + assertEquals("gzip", ai.headers["Content-Encoding"]) + assertEquals("\$ai_generation", events(ai).single()["event"]) + } + + @Test + fun `captureAiCompression off sends the AI request uncompressed while analytics stay gzipped`() { + val http = httpServers.mockHttp(total = 2) + val sut = getSut(http) { captureAiCompression = PostHogCompression.NONE } + + sut.capture("event") + sut.captureAi("\$ai_generation") + + queueExecutor.awaitExecution() + aiExecutor.awaitExecution() + val requests = List(2) { takeRequest(http) }.associateBy { it.path } + assertEquals("gzip", assertNotNull(requests["/i/v1/analytics/events"]).headers["Content-Encoding"]) + val ai = assertNotNull(requests["/i/v1/ai/events"]) + assertNull(ai.headers["Content-Encoding"]) + assertEquals("\$ai_generation", captureV1Events(ai.body.readUtf8()).single()["event"]) + } + + @Test + fun `AI requests time out after captureAiTimeout and keep the event for retry`() { + val http = httpServers.mockHttp(response = MockResponse().setHeadersDelay(3, TimeUnit.SECONDS)) + val sut = getSut(http) { captureAiTimeout = 1 } + + sut.captureAi("\$ai_generation") + aiExecutor.awaitExecution() + + assertEquals(1, http.requestCount) + assertEquals(1, aiQueueFiles().size) + assertTrue(logger.messages.any { it.contains("network error") }) + } +} diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 492cec17b..2916d9eb6 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -66,6 +66,7 @@ internal class PostHogCaptureV1Test { http: MockWebServer, flushAt: Int = 1, storagePrefix: String = tmpDir.newFolder().absolutePath, + ai: Boolean = false, ): PostHogQueue { val config = config(http.url("/").toString()).apply { @@ -74,7 +75,8 @@ internal class PostHogCaptureV1Test { } // pause time pinned to the past so a backoff never blocks the next flush clock.setAddSecondsToCurrentDate(parseISO8601Date("1970-01-01T00:00:00.000Z")!!) - val spec = EndpointSpec.batch(config, PostHogApi(config), config.storagePrefix) + val api = PostHogApi(config) + val spec = if (ai) EndpointSpec.ai(config, api, config.storagePrefix) else EndpointSpec.batch(config, api, config.storagePrefix) return PostHogQueue(config, spec, executor).also { queues.add(it) } } @@ -132,6 +134,57 @@ internal class PostHogCaptureV1Test { assertEquals("gzip", request.headers["Content-Encoding"]) } + @Test + fun `captureAi posts to the AI endpoint with the same headers and body shape`() { + val http = httpServers.mockHttp() + clock.setCurrentDate(parseISO8601Date("2024-05-01T10:20:30.456Z")!!) + val event = event("\$ai_generation") + + api(http.url("/").toString()).captureAi(listOf(event)) + + val request = http.takeRequest() + assertEquals("POST", request.method) + assertEquals("/i/v1/ai/events", request.path) + assertEquals("Bearer $PROJECT_TOKEN", request.headers["Authorization"]) + assertEquals("posthog-java/${BuildConfig.VERSION_NAME}", request.headers["PostHog-Sdk-Info"]) + assertEquals("1", request.headers["PostHog-Attempt"]) + UUID.fromString(assertNotNull(request.headers["PostHog-Request-Id"])) + assertEquals("2024-05-01T10:20:30.456Z", request.headers["PostHog-Request-Timestamp"]) + assertEquals("application/json", request.headers["Content-Type"]) + assertEquals("gzip", request.headers["Content-Encoding"]) + val body = body(request) + assertEquals(setOf("created_at", "batch"), body.keys) + @Suppress("UNCHECKED_CAST") + assertEquals(listOf(event.uuid.toString()), (body["batch"] as List>).map { it["uuid"] }) + } + + @Test + fun `analytics and AI retries keep separate request identities`() { + val analytics = event() + val ai = event("\$ai_generation") + val http = httpServers.mockHttp(response = resultsResponse(analytics.uuid!! to "retry")) + http.enqueue(resultsResponse(ai.uuid!! to "retry")) + http.enqueue(resultsResponse(analytics.uuid!! to "ok")) + http.enqueue(resultsResponse(ai.uuid!! to "ok")) + val sut = api(http.url("/").toString()) + + sut.captureV1(listOf(analytics)) + sut.captureAi(listOf(ai)) + sut.captureV1(listOf(analytics)) + sut.captureAi(listOf(ai)) + + val requests = List(4) { http.takeRequest() } + assertEquals( + listOf("/i/v1/analytics/events", "/i/v1/ai/events", "/i/v1/analytics/events", "/i/v1/ai/events"), + requests.map { it.path }, + ) + assertEquals(listOf("1", "1", "2", "2"), requests.map { it.headers["PostHog-Attempt"] }) + val ids = requests.map { it.headers["PostHog-Request-Id"] } + assertNotEquals(ids[0], ids[1]) + assertEquals(ids[0], ids[2]) + assertEquals(ids[1], ids[3]) + } + @Test fun `the capture Bearer token replaces a custom Authorization header`() { val http = httpServers.mockHttp() @@ -610,6 +663,25 @@ internal class PostHogCaptureV1Test { assertEquals(1, sut.dequeList.size) } + @Test + fun `AI queue keeps only the events the AI endpoint asked to retry`() { + val ok = event("\$ai_generation") + val retry = event("\$ai_span") + val http = httpServers.mockHttp(response = resultsResponse(ok.uuid!! to "ok", retry.uuid!! to "retry")) + val sut = queue(http, flushAt = 2, ai = true) + + sut.add(ok) + sut.add(retry) + executor.awaitExecution() + + assertEquals("/i/v1/ai/events", http.takeRequest().path) + assertEquals(1, sut.currentRetryCountForTesting) + assertEquals( + listOf(retry.uuid.toString()), + sut.dequeList.map { file -> file.reader().use { serializer.deserialize(it).uuid.toString() } }, + ) + } + @Test fun `queue drops the batch on 429 and keeps it on 503`() { val http = httpServers.mockHttp(response = MockResponse().setResponseCode(429)) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt new file mode 100644 index 000000000..34c5dc58e --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt @@ -0,0 +1,229 @@ +package com.posthog.internal + +import com.posthog.PROJECT_TOKEN +import com.posthog.PostHogConfig +import com.posthog.TestLogger +import com.posthog.awaitExecution +import com.posthog.shutdownAndAwaitTermination +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.UUID +import java.util.concurrent.Executors +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The byte limits of [EndpointSpec] as [PostHogQueue] applies them, on a synthetic record whose + * stored size is its payload length. + */ +internal class PostHogQueueByteLimitsTest { + @get:Rule + val tmpDir = TemporaryFolder() + + private val executor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("Test")) + private val logger = TestLogger() + private val sent = mutableListOf>() + + @Volatile + private var connected = true + + private lateinit var config: PostHogConfig + private lateinit var storagePrefix: String + + @AfterTest + fun cleanup() { + executor.shutdownAndAwaitTermination() + } + + private fun record( + id: String, + bytes: Int, + ): String = id.padEnd(bytes, 'x') + + private fun queue( + maxBatchBytes: Long? = null, + maxQueueBytes: Long? = null, + maxRecordBytes: Long? = null, + flushAt: Int = 50, + maxQueueSize: Int = 1000, + ): PostHogQueue { + storagePrefix = tmpDir.newFolder().absolutePath + config = + PostHogConfig(PROJECT_TOKEN).apply { + logger = this@PostHogQueueByteLimitsTest.logger + networkStatus = + object : PostHogNetworkStatus { + override fun isConnected(): Boolean = connected + } + } + val spec = + EndpointSpec( + recordsLabel = "blobs", + storagePrefix = storagePrefix, + initialCap = { 50 }, + initialFlushAt = { flushAt }, + maxQueueSize = { maxQueueSize }, + flushIntervalSeconds = { 30 }, + encode = { record, stream -> stream.writer().use { it.write(record) } }, + decode = { stream -> stream.reader().use { it.readText() } }, + describe = { record -> "blob ${record.trimEnd('x')}" }, + send = { records -> + sent.add(records.map { it.trimEnd('x') }) + SendResult() + }, + isRetriableStatusCode = { false }, + maxBatchBytes = maxBatchBytes, + maxQueueBytes = maxQueueBytes, + maxRecordBytes = maxRecordBytes, + ) + return PostHogQueue(config, spec, executor) + } + + private fun queuedIds(sut: PostHogQueue): List = sut.dequeList.map { it.readText().trimEnd('x') } + + @Test + fun `a flush splits the queue into requests at the byte limit`() { + val sut = queue(maxBatchBytes = 100) + connected = false + sut.add(record("a", 40)) + sut.add(record("b", 40)) + sut.add(record("c", 40)) + executor.awaitExecution() + connected = true + + sut.flush() + executor.awaitExecution() + + assertEquals(listOf(listOf("a", "b"), listOf("c")), sent) + assertTrue(sut.dequeList.isEmpty()) + assertEquals(0L, sut.queuedBytesForTesting) + } + + @Test + fun `a record over the byte limit is sent alone and keeps its place`() { + val sut = queue(maxBatchBytes = 100) + connected = false + sut.add(record("a", 40)) + sut.add(record("big", 150)) + sut.add(record("c", 40)) + executor.awaitExecution() + connected = true + + sut.flush() + executor.awaitExecution() + + assertEquals(listOf(listOf("a"), listOf("big"), listOf("c")), sent) + } + + @Test + fun `flushes once a request's worth of bytes is queued, before flushAt`() { + val sut = queue(maxBatchBytes = 100, flushAt = 50) + + sut.add(record("a", 60)) + executor.awaitExecution() + assertTrue(sent.isEmpty()) + + sut.add(record("b", 60)) + executor.awaitExecution() + + // one request's worth: a alone, since b doesn't fit next to it + assertEquals(listOf(listOf("a")), sent) + assertEquals(listOf("b"), queuedIds(sut)) + } + + @Test + fun `drops a record over maxRecordBytes, logging its description and size`() { + val sut = queue(maxRecordBytes = 50, maxQueueBytes = 1000, flushAt = 50) + + sut.add(record("big", 80)) + sut.add(record("small", 20)) + executor.awaitExecution() + + assertEquals(listOf("small"), queuedIds(sut)) + assertEquals(1, File(storagePrefix, PROJECT_TOKEN).listFiles()!!.size) + // the dropped record was never counted, so it must not be subtracted either + assertEquals(sut.dequeList.single().length(), sut.queuedBytesForTesting) + assertEquals(listOf("Dropping blob big of 80 bytes, over the 50 byte limit."), logger.messages.filter { it.startsWith("Dropping") }) + } + + @Test + fun `evicts the oldest records over maxQueueBytes when a record is added`() { + val sut = queue(maxQueueBytes = 100, flushAt = 50) + + sut.add(record("a", 40)) + sut.add(record("b", 40)) + sut.add(record("c", 40)) + executor.awaitExecution() + assertEquals(listOf("b", "c"), queuedIds(sut)) + + // the newest record is kept even when it's over the limit on its own + sut.add(record("big", 150)) + executor.awaitExecution() + assertEquals(listOf("big"), queuedIds(sut)) + assertEquals(sut.dequeList.single().length(), sut.queuedBytesForTesting) + assertEquals(1, File(storagePrefix, PROJECT_TOKEN).listFiles()!!.size) + assertTrue(logger.messages.any { it == "Queue is over 100 bytes, the oldest 1 blobs are dropped." }) + } + + @Test + fun `evicts the oldest cached records over maxQueueBytes on load`() { + val sut = queue(maxQueueBytes = 100, flushAt = 50) + val dir = File(storagePrefix, PROJECT_TOKEN).apply { mkdirs() } + val cached = + listOf("a", "b", "c").mapIndexed { index, id -> + File(dir, "${UUID.randomUUID()}.event").apply { + writeText(record(id, 40)) + setLastModified(System.currentTimeMillis() - (3 - index) * 1000L) + } + } + + sut.reloadFromDisk() + + assertEquals(cached.drop(1), sut.dequeList) + assertEquals(listOf("b", "c"), queuedIds(sut)) + assertEquals(2, dir.listFiles()!!.size) + } + + @Test + fun `the analytics spec caps a request at 5 MiB and the AI spec adds the disk and event caps`() { + val config = PostHogConfig(PROJECT_TOKEN) + val api = PostHogApi(config) + + val batch = EndpointSpec.batch(config, api, null) + assertEquals(5L * 1024 * 1024, batch.maxBatchBytes) + assertEquals(null, batch.maxQueueBytes) + assertEquals(null, batch.maxRecordBytes) + + val ai = EndpointSpec.ai(config, api, null) + assertEquals(5L * 1024 * 1024, ai.maxBatchBytes) + assertEquals(50L * 1024 * 1024, ai.maxQueueBytes) + assertEquals(8L * 1024 * 1024 + 64 * 1024, ai.maxRecordBytes) + assertEquals(100, ai.maxQueueSize(config)) + } + + @Test + fun `the AI event limit can only be lowered and non-positive settings use the defaults`() { + val config = PostHogConfig(PROJECT_TOKEN) + val api = PostHogApi(config) + + config.captureAiMaxEventBytes = 1024 + assertEquals(1024L + 64 * 1024, EndpointSpec.ai(config, api, null).maxRecordBytes) + config.captureAiMaxEventBytes = 16 * 1024 * 1024 + assertEquals(8L * 1024 * 1024 + 64 * 1024, EndpointSpec.ai(config, api, null).maxRecordBytes) + config.captureAiMaxEventBytes = 0 + assertEquals(8L * 1024 * 1024 + 64 * 1024, EndpointSpec.ai(config, api, null).maxRecordBytes) + + config.captureAiMaxQueueSize = 0 + assertEquals(100, EndpointSpec.ai(config, api, null).maxQueueSize(config)) + config.captureAiMaxQueueSize = 7 + assertEquals(7, EndpointSpec.ai(config, api, null).maxQueueSize(config)) + + config.captureAiTimeout = -1 + assertEquals(60, config.effectiveCaptureAiTimeoutSeconds) + config.captureAiTimeout = 5 + assertEquals(5, config.effectiveCaptureAiTimeoutSeconds) + } +} From 51070c1a0b37e94f035ab97a8dd8d61c17fea206 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:57:09 -0400 Subject: [PATCH 21/45] chore(compliance): run the capture v1 AI suite against the adapter Adds `POST /capture_ai`, which routes through `captureAi` and returns the uuid it sent, tracks `/i/v1/ai/events` requests next to the analytics ones and advertises `capture_ai_v1`. Also restores the capture v1 adapter changes (tracking by capture path, `PostHog-Attempt` as the retry count, `personProfiles = ALWAYS`, the renamed push retries field) that the options commit reverted. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L --- .../IMPLEMENTATION_NOTES.md | 7 +- sdk_compliance_adapter/README.md | 6 +- .../posthog/compliance/ComplianceAdapter.kt | 69 +++++++++++++++++-- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md index a678f02e4..723403d76 100644 --- a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md +++ b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md @@ -76,11 +76,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and compliance test comma ## SDK Type -The Android SDK advertises `capture_v1` and `encoding_gzip`: -- Endpoint: `POST /i/v1/analytics/events` +The Android SDK advertises `capture_v1`, `capture_ai_v1`, `encoding_gzip` and `event_options`: +- Endpoints: `POST /i/v1/analytics/events` (`/capture`) and `POST /i/v1/ai/events` (`/capture_ai`) - Format: `{created_at: "...", batch: [{event}, {event}]}` with the project token in an `Authorization: Bearer` header -Tests run with `--sdk-type server` flag; the harness selects the `capture_v1` suite from the capabilities. +Tests run with `--sdk-type server` flag; the harness selects the `capture_v1` and `capture_ai_v1` suites from the capabilities. ## API Endpoints @@ -89,6 +89,7 @@ The adapter implements the standard compliance adapter API: - `GET /health` - Health check with SDK version info - `POST /init` - Initialize SDK with config - `POST /capture` - Capture a single event +- `POST /capture_ai` - Capture a single AI event through `captureAi` - `POST /flush` - Force flush all pending events - `GET /state` - Get adapter state for assertions - `POST /reset` - Reset SDK and adapter state diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index c39b7e409..ca843b536 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -41,11 +41,11 @@ Events are tracked via: ### SDK Type -The Android SDK advertises `capture_v1` and `encoding_gzip`: -- Endpoint: `POST /i/v1/analytics/events` +The Android SDK advertises `capture_v1`, `capture_ai_v1`, `encoding_gzip` and `event_options`: +- Endpoints: `POST /i/v1/analytics/events` (`/capture`) and `POST /i/v1/ai/events` (`/capture_ai`) - Format: `{created_at, batch}` with the project token in an `Authorization: Bearer` header -Tests run with `--sdk-type server`; the harness selects the `capture_v1` suite from the capabilities. +Tests run with `--sdk-type server`; the harness selects the `capture_v1` and `capture_ai_v1` suites from the capabilities. ## Files Created diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt index 46e217163..4e151ef6a 100644 --- a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt @@ -22,6 +22,9 @@ import io.ktor.server.routing.routing import okhttp3.Interceptor import okhttp3.Response import java.io.File +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException +import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit import java.util.concurrent.locks.Condition @@ -34,8 +37,11 @@ private const val QUEUE_STORAGE_PREFIX = "/tmp/posthog-queue" // Upper bound on the wait for a flush()'d batch to reach the mock (generous for Docker). private const val FLUSH_SETTLE_MS = 2000L -// The capture endpoint the SDK posts analytics events to. +// The capture v1 endpoints the SDK posts analytics and AI events to. private const val CAPTURE_PATH = "/i/v1/analytics/events" +private const val CAPTURE_AI_PATH = "/i/v1/ai/events" + +private fun isCaptureUrl(url: String): Boolean = url.contains(CAPTURE_PATH) || url.contains(CAPTURE_AI_PATH) /** * PostHog Android SDK Compliance Adapter @@ -162,7 +168,7 @@ class TrackingInterceptor : Interceptor { val uuidList = mutableListOf() // Parse request body before sending - if (url.contains(CAPTURE_PATH) && requestBody != null) { + if (isCaptureUrl(url) && requestBody != null) { try { val buffer = okio.Buffer() requestBody.writeTo(buffer) @@ -183,7 +189,7 @@ class TrackingInterceptor : Interceptor { val response = chain.proceed(request) // Track the response - if (url.contains(CAPTURE_PATH)) { + if (isCaptureUrl(url)) { try { // PostHog-Attempt starts at 1; the harness counts retries from 0. val retryCount = (request.header("PostHog-Attempt")?.toIntOrNull() ?: 1) - 1 @@ -219,6 +225,16 @@ class TrackingInterceptor : Interceptor { } } +// The harness sends RFC 3339 timestamps, with any offset; the SDK serializes the instant in UTC. +private fun parseHarnessTimestamp(timestamp: String?): Date? = + timestamp?.let { + try { + Date.from(OffsetDateTime.parse(it).toInstant()) + } catch (_: DateTimeParseException) { + null + } + } + // Block until [predicate] holds (signalled as each capture response lands) or [timeoutMs] elapses. private fun awaitBatch( timeoutMs: Long, @@ -247,8 +263,9 @@ fun main() { sdk_name = "posthog-android", sdk_version = PostHogConfig(projectToken = "").sdkVersion, adapter_version = "1.0.0", - // Opt into the capture suites (android posts /i/v1/analytics/events with gzip). - capabilities = listOf("capture_v1", "encoding_gzip", "event_options"), + // Opt into the capture suites (android posts /i/v1/analytics/events and + // /i/v1/ai/events with gzip). + capabilities = listOf("capture_v1", "capture_ai_v1", "encoding_gzip", "event_options"), ), ) } @@ -272,6 +289,7 @@ fun main() { // close() leaves queue files on disk; wipe so they don't replay into this test. File(QUEUE_STORAGE_PREFIX).deleteRecursively() + File("$QUEUE_STORAGE_PREFIX-ai").deleteRecursively() // Create new config val flushIntervalMs = req.flush_interval_ms ?: 100 @@ -301,11 +319,14 @@ fun main() { req.max_retries?.let { config.pushNotificationSubscriptionMaxRetries = it } req.enable_compression?.let { - config.compression = if (it) PostHogCompression.GZIP else PostHogCompression.NONE + val compression = if (it) PostHogCompression.GZIP else PostHogCompression.NONE + config.compression = compression + config.captureAiCompression = compression } - // Set storage prefix for file-backed queue + // Set storage prefixes for the file-backed queues (both wiped on /init and /reset) config.storagePrefix = QUEUE_STORAGE_PREFIX + config.aiStoragePrefix = "$QUEUE_STORAGE_PREFIX-ai" // Set minimal context to provide $lib and $lib_version config.context = TestPostHogContext("posthog-android", config.sdkVersion) @@ -350,6 +371,7 @@ fun main() { event = req.event, distinctId = req.distinct_id, properties = properties, + timestamp = parseHarnessTimestamp(req.timestamp), options = req.options, ) @@ -371,6 +393,38 @@ fun main() { call.respond(CaptureResponse(success = true, uuid = uuid)) } + // Same request as /capture, sent with captureAi to /i/v1/ai/events. Ktor's Netty engine + // streams request bodies without a size limit, so an 8 MiB AI event is received whole. + post("/capture_ai") { + val req = call.receive() + + val ph = + AdapterContext.lock.withLock { + AdapterContext.postHog + } + + if (ph == null) { + call.respond(HttpStatusCode.BadRequest, mapOf("error" to "SDK not initialized")) + return@post + } + + val uuid = + ph.captureAi( + event = req.event, + distinctId = req.distinct_id, + properties = req.properties, + timestamp = parseHarnessTimestamp(req.timestamp), + options = req.options, + ) + + AdapterContext.lock.withLock { + AdapterContext.state.totalEventsCaptured++ + AdapterContext.state.pendingEvents++ + } + + call.respond(CaptureResponse(success = true, uuid = (uuid ?: UUID.randomUUID()).toString())) + } + post("/get_feature_flag") { val req = call.receive() @@ -474,6 +528,7 @@ fun main() { // close() leaves queue files on disk; wipe so they don't replay next test (matches iOS). File(QUEUE_STORAGE_PREFIX).deleteRecursively() + File("$QUEUE_STORAGE_PREFIX-ai").deleteRecursively() AdapterContext.state.pendingEvents = 0 AdapterContext.state.totalEventsCaptured = 0 From 423bb809cbea667b40686de17fdfb6ce8d1e707a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 24 Oct 2026 13:57:09 -0400 Subject: [PATCH 22/45] docs: changeset bullets and migration guide for captureAi Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L --- .changeset/android-v4-major.md | 3 +++ docs/migration-3.x-to-4.0.md | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 946bd027d..15d127571 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -25,7 +25,10 @@ - **Breaking:** registered super properties now override the properties the SDK sets itself, such as `$os_name`; super and event properties also override `$is_identified`, `$process_person_profile` and the session replay debug properties, which the SDK no longer re-applies after the caller's values - **Breaking:** the SDK sets the `process_person_profile` option to `false` only when person processing is off, and no longer adds a `$process_person_profile` property to every event - **Breaking:** keep the uuid of its own capture on the event `beforeSend` returns, so a uuid set in `beforeSend`, for example with `copy(uuid = …)`, is replaced +- **Breaking:** add `captureAi` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB, on its own disk queue; it returns the event uuid, and classes that implement `PostHogInterface` add the method - use the current distinct ID when the `distinctId` argument to `capture` is empty or whitespace-only, instead of dropping the event - **Breaking:** skip deep link query parameters whose name starts with `$` in the `Deep Link Opened` event, so a link can't set a PostHog property such as `$process_person_profile` - stop storing or matching the person-properties deduplication hash for a `$set` sent with `process_person_profile: false` - debug logs for capture requests name the event count and the response status instead of printing the request and response bodies +- add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events; the defaults are gzip, 100 queued events (and at most 50 MiB), 60 seconds and 8 MiB +- cap a request at about 5 MiB of events, for analytics and AI events, and send as soon as that much is queued; an event larger than that is sent on its own diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 4550a7ee7..f632641e4 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -151,6 +151,24 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - The event `beforeSend` returns keeps the uuid of the capture it came from. A uuid you set in `beforeSend`, for example with `copy(uuid = …)`, is replaced, so a hook that returns an event kept from an earlier call still sends one uuid per capture. - Events still queued on disk by 3.x are sent to capture v1 after the update, with their legacy properties moved to options. +## AI events + +4.0 adds `captureAi`, which sends AI events, such as `$ai_generation`, to PostHog's AI endpoint (`/i/v1/ai/events`) instead of the analytics endpoint. +It takes the same arguments as `capture`, and the events go through the same `beforeSend` hooks. + +```kotlin +PostHog.captureAi("$ai_generation", properties = mapOf("$ai_model" to "gpt-5", "$ai_trace_id" to traceId)) +``` + +- `captureAi` returns the event's uuid, or `null` when the event isn't queued: the SDK is disabled or opted out, or `beforeSend` dropped it. +- An AI event can be up to 8 MiB. The SDK drops a larger event when it writes it to the queue and logs the event name and size, never its content. Set `captureAiMaxEventBytes` to lower the limit. +- PostHog drops an event sent with `captureAi` whose name doesn't start with `$ai_`. `capture` never sends events to the AI endpoint, even ones named `$ai_*`. +- AI requests time out after 60 seconds, or `captureAiTimeout`. Other requests keep OkHttp's 10-second defaults. +- AI events are queued on disk separately from other events (`posthog-disk-ai-queue` in the app's cache directory). Up to 100 events (`captureAiMaxQueueSize`) and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. +- AI requests are gzipped unless you set `captureAiCompression` to `PostHogCompression.NONE`. `compression` doesn't apply. +- AI events use `flushAt`, `maxBatchSize` and `flushIntervalSeconds`, like other events. A request carries up to about 5 MiB, as for analytics events (see [Retries](#retries)). +- The SDK sends AI events unchanged. It doesn't redact or truncate prompts, outputs or media, so use `beforeSend` to remove anything you don't want to send. + ## Retries - The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. @@ -160,4 +178,5 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter, rounded to whole seconds. In 3.x it doubled without jitter and a `Retry-After` replaced it. This applies to events, session replay and logs. - Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). - `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, with no deprecated alias. It only limits push-notification subscription retries. +- A request carries at most `maxBatchSize` events and about 5 MiB, and the SDK sends as soon as about 5 MiB of events is queued, even before `flushAt` is reached. In 3.x, batches were limited by event count only. - A 413 still splits the batch into smaller requests. From 0fa7a6f46a978854019a8dbc6e75f5fe774ea90f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 14:38:32 -0400 Subject: [PATCH 23/45] docs: list the AI endpoint in the migration guide's endpoint table Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index f632641e4..90a926796 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -33,6 +33,7 @@ You need to change something if your app does any of these: | --- | --- | --- | | Analytics events | `/batch` | `/i/v1/analytics/events` | | Session replay | `/s/` | `/s/`, unchanged | +| AI events (`captureAi`) | – | `/i/v1/ai/events` (see [AI events](#ai-events)) | | Logs | `/i/v1/logs` | `/i/v1/logs`, unchanged | | Feature flags | `/flags` | `/flags`, unchanged | From 0a5fa6c8a57931d7fda98d4cdae105579cc6f40a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:52:52 -0400 Subject: [PATCH 24/45] fix: check the AI event size before queuing and return null when over The queue measured an AI event after `encryption`, so an encryption that grows the data dropped events PostHog would accept, and captureAi had already returned the uuid. captureAi now measures the serialized event before encryption and returns null when it's over the limit. The queue's per-record limit had no other user and is removed. Co-Authored-By: Claude Opus 5.5 --- posthog/src/main/java/com/posthog/PostHog.kt | 19 +++++++++++ .../java/com/posthog/internal/EndpointSpec.kt | 31 ++++++++++++++--- .../java/com/posthog/internal/PostHogQueue.kt | 13 +------ .../java/com/posthog/PostHogCaptureAiTest.kt | 34 ++++++++++++++++--- .../internal/PostHogQueueByteLimitsTest.kt | 27 +++------------ 5 files changed, 80 insertions(+), 44 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 0cb5db421..1ab3d2320 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -33,10 +33,12 @@ import com.posthog.internal.PostHogSendCachedEventsIntegration import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory +import com.posthog.internal.captureAiMaxStoredEventBytes import com.posthog.internal.errortracking.PostHogExceptionStepsBuffer import com.posthog.internal.logIntegrationFailure import com.posthog.internal.personPropertiesContext import com.posthog.internal.replay.PostHogSessionReplayHandler +import com.posthog.internal.serializedSize import com.posthog.internal.sortMapRecursively import com.posthog.internal.surveys.PostHogSurveysHandler import com.posthog.logs.PostHogLogRecord @@ -1055,6 +1057,21 @@ public class PostHog private constructor( ) } + /** + * Whether [event], serialized and before [PostHogConfig.encryption], is over the AI event limit. + * Logs the name and size only: AI properties can hold prompts and media. + */ + private fun isOverAiEventLimit(event: PostHogEvent): Boolean { + val config = config ?: return false + val size = config.serializedSize(event) + val limit = config.captureAiMaxStoredEventBytes + if (size <= limit) { + return false + } + config.logger.log("Dropping AI event ${event.event} of $size bytes, over the $limit byte limit.") + return true + } + /** * Enriches and queues one event. Returns its uuid once it's handed to the queue, or null when * it isn't. [aiLane] sends it through the AI queue instead of the analytics (or replay) queue. @@ -1186,6 +1203,8 @@ public class PostHog private constructor( // beforeSend may have opted out if (isOptedOut()) { config?.logger?.log("PostHog is in OptOut state.") + } else if (aiLane && isOverAiEventLimit(postHogEvent)) { + // dropped here rather than in the queue, so captureAi returns null } else if (targetQueue != null) { targetQueue.add(postHogEvent) queued = true diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index f9cbda7b0..fbc0e816d 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -23,8 +23,7 @@ import java.io.OutputStream * request, a record larger than the limit goes alone, and the queue flushes as * soon as that many bytes are queued. [maxQueueBytes] is a FIFO limit on the * queued files on top of [maxQueueSize]; the newest record is always kept. - * [maxRecordBytes] drops a larger record when it is queued. Null means no - * limit. + * Null means no limit. * * The queue itself stays record-type-agnostic; everything that varies * between endpoints lives here. @@ -47,7 +46,6 @@ public class EndpointSpec internal constructor( internal val recordId: ((Record) -> String?)? = null, internal val maxBatchBytes: Long? = null, internal val maxQueueBytes: Long? = null, - internal val maxRecordBytes: Long? = null, ) { public companion object { @JvmStatic @@ -109,7 +107,6 @@ public class EndpointSpec internal constructor( recordId = { event -> event.uuid?.toString() }, maxBatchBytes = CAPTURE_V1_MAX_BATCH_BYTES, maxQueueBytes = AI_MAX_QUEUE_BYTES, - maxRecordBytes = config.captureAiMaxStoredEventBytes, ) @JvmStatic @@ -223,7 +220,31 @@ internal val PostHogConfig.effectiveCaptureAiTimeoutSeconds: Int get() = if (captureAiTimeout > 0) captureAiTimeout else PostHogConfig.DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS /** - * Stored-size limit for an AI event: [PostHogConfig.captureAiMaxEventBytes], capped at PostHog's + * Bytes [event] takes serialized as the queue stores it, before any [PostHogConfig.encryption]. + * Counted as it's written, so the event isn't held in memory a second time. + */ +internal fun PostHogConfig.serializedSize(event: PostHogEvent): Long { + var count = 0L + val counter = + object : OutputStream() { + override fun write(b: Int) { + count++ + } + + override fun write( + b: ByteArray, + off: Int, + len: Int, + ) { + count += len + } + } + serializer.serialize(event, counter.writer().buffered()) + return count +} + +/** + * Size limit for a serialized AI event, before any [PostHogConfig.encryption]: [PostHogConfig.captureAiMaxEventBytes], capped at PostHog's * 8 MiB limit, plus the envelope allowance. */ internal val PostHogConfig.captureAiMaxStoredEventBytes: Long diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 8608c7330..455b65b09 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -103,19 +103,8 @@ public class PostHogQueue( os.use { theOutputStream -> spec.encode(record, theOutputStream) } - val size = file.length() - val maxRecordBytes = spec.maxRecordBytes - if (maxRecordBytes != null && size > maxRecordBytes) { - // Name and size only: the record can hold prompts and media. - config.logger.log("Dropping ${spec.describe(record)} of $size bytes, over the $maxRecordBytes byte limit.") - // Not counted in queuedBytes yet, so only the deque entry goes. - synchronized(dequeLock) { - deque.remove(file) - } - file.deleteSafely(config) - return false - } if (trackBytes) { + val size = file.length() synchronized(dequeLock) { queuedBytes += size } diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt index 476fdb62d..076404b35 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -8,6 +8,9 @@ import okhttp3.mockwebserver.RecordedRequest import org.junit.Rule import org.junit.rules.TemporaryFolder import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.Base64 import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -179,16 +182,15 @@ internal class PostHogCaptureAiTest { } @Test - fun `an AI event over captureAiMaxEventBytes is dropped when queued and logs only its name and size`() { + fun `an AI event over captureAiMaxEventBytes returns null and logs only its name and size`() { val http = httpServers.mockHttp() - // the stored limit is the configured bytes plus a 64 KiB envelope + // the limit is the configured bytes plus a 64 KiB envelope val sut = getSut(http) { captureAiMaxEventBytes = 1 } val prompt = "p".repeat(70_000) val uuid = sut.captureAi("\$ai_generation", properties = mapOf("\$ai_input" to prompt)) - // handed to the queue, which drops it on its own thread - assertNotNull(uuid) + assertNull(uuid) aiExecutor.awaitExecution() sut.flush() aiExecutor.awaitExecution() @@ -202,6 +204,30 @@ internal class PostHogCaptureAiTest { assertTrue(logger.messages.none { it.contains("pppp") }) } + @Test + fun `the AI event limit is measured before encryption`() { + val http = httpServers.mockHttp() + val sut = + getSut(http) { + captureAiMaxEventBytes = 1 + // Base64 stores a third more bytes than it's given + encryption = + object : PostHogEncryption { + override fun encrypt(outputStream: OutputStream): OutputStream = Base64.getEncoder().wrap(outputStream) + + override fun decrypt(inputStream: InputStream): InputStream = Base64.getDecoder().wrap(inputStream) + } + } + // under the 64 KiB + 1 byte limit as JSON, over it once encrypted + val prompt = "p".repeat(60_000) + + val uuid = sut.captureAi("\$ai_generation", properties = mapOf("\$ai_input" to prompt)) + + assertNotNull(uuid) + aiExecutor.awaitExecution() + assertEquals(uuid.toString(), events(takeRequest(http)).single()["uuid"]) + } + @Test fun `a name that doesn't start with ai is logged and sent unchanged`() { val http = httpServers.mockHttp() diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt index 34c5dc58e..d89344426 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt @@ -46,7 +46,6 @@ internal class PostHogQueueByteLimitsTest { private fun queue( maxBatchBytes: Long? = null, maxQueueBytes: Long? = null, - maxRecordBytes: Long? = null, flushAt: Int = 50, maxQueueSize: Int = 1000, ): PostHogQueue { @@ -77,7 +76,6 @@ internal class PostHogQueueByteLimitsTest { isRetriableStatusCode = { false }, maxBatchBytes = maxBatchBytes, maxQueueBytes = maxQueueBytes, - maxRecordBytes = maxRecordBytes, ) return PostHogQueue(config, spec, executor) } @@ -134,21 +132,6 @@ internal class PostHogQueueByteLimitsTest { assertEquals(listOf("b"), queuedIds(sut)) } - @Test - fun `drops a record over maxRecordBytes, logging its description and size`() { - val sut = queue(maxRecordBytes = 50, maxQueueBytes = 1000, flushAt = 50) - - sut.add(record("big", 80)) - sut.add(record("small", 20)) - executor.awaitExecution() - - assertEquals(listOf("small"), queuedIds(sut)) - assertEquals(1, File(storagePrefix, PROJECT_TOKEN).listFiles()!!.size) - // the dropped record was never counted, so it must not be subtracted either - assertEquals(sut.dequeList.single().length(), sut.queuedBytesForTesting) - assertEquals(listOf("Dropping blob big of 80 bytes, over the 50 byte limit."), logger.messages.filter { it.startsWith("Dropping") }) - } - @Test fun `evicts the oldest records over maxQueueBytes when a record is added`() { val sut = queue(maxQueueBytes = 100, flushAt = 50) @@ -188,19 +171,17 @@ internal class PostHogQueueByteLimitsTest { } @Test - fun `the analytics spec caps a request at 5 MiB and the AI spec adds the disk and event caps`() { + fun `the analytics spec caps a request at 5 MiB and the AI spec adds the disk cap`() { val config = PostHogConfig(PROJECT_TOKEN) val api = PostHogApi(config) val batch = EndpointSpec.batch(config, api, null) assertEquals(5L * 1024 * 1024, batch.maxBatchBytes) assertEquals(null, batch.maxQueueBytes) - assertEquals(null, batch.maxRecordBytes) val ai = EndpointSpec.ai(config, api, null) assertEquals(5L * 1024 * 1024, ai.maxBatchBytes) assertEquals(50L * 1024 * 1024, ai.maxQueueBytes) - assertEquals(8L * 1024 * 1024 + 64 * 1024, ai.maxRecordBytes) assertEquals(100, ai.maxQueueSize(config)) } @@ -210,11 +191,11 @@ internal class PostHogQueueByteLimitsTest { val api = PostHogApi(config) config.captureAiMaxEventBytes = 1024 - assertEquals(1024L + 64 * 1024, EndpointSpec.ai(config, api, null).maxRecordBytes) + assertEquals(1024L + 64 * 1024, config.captureAiMaxStoredEventBytes) config.captureAiMaxEventBytes = 16 * 1024 * 1024 - assertEquals(8L * 1024 * 1024 + 64 * 1024, EndpointSpec.ai(config, api, null).maxRecordBytes) + assertEquals(8L * 1024 * 1024 + 64 * 1024, config.captureAiMaxStoredEventBytes) config.captureAiMaxEventBytes = 0 - assertEquals(8L * 1024 * 1024 + 64 * 1024, EndpointSpec.ai(config, api, null).maxRecordBytes) + assertEquals(8L * 1024 * 1024 + 64 * 1024, config.captureAiMaxStoredEventBytes) config.captureAiMaxQueueSize = 0 assertEquals(100, EndpointSpec.ai(config, api, null).maxQueueSize(config)) From 10842594169a665c11ab5ff94d29e558137286b3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:52:52 -0400 Subject: [PATCH 25/45] docs: describe the AI size limit and timeout as they behave The limit covers an AI event's properties up to 8 MiB, with 64 KiB for the rest of the serialized event, measured before encryption. captureAiTimeout is a timeout without network activity, not a call timeout. Co-Authored-By: Claude Opus 5.5 --- .changeset/android-v4-major.md | 2 +- docs/migration-3.x-to-4.0.md | 10 +++++----- posthog/src/main/java/com/posthog/PostHogConfig.kt | 9 +++++---- posthog/src/main/java/com/posthog/PostHogInterface.kt | 6 +++--- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 15d127571..78758d058 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -25,7 +25,7 @@ - **Breaking:** registered super properties now override the properties the SDK sets itself, such as `$os_name`; super and event properties also override `$is_identified`, `$process_person_profile` and the session replay debug properties, which the SDK no longer re-applies after the caller's values - **Breaking:** the SDK sets the `process_person_profile` option to `false` only when person processing is off, and no longer adds a `$process_person_profile` property to every event - **Breaking:** keep the uuid of its own capture on the event `beforeSend` returns, so a uuid set in `beforeSend`, for example with `copy(uuid = …)`, is replaced -- **Breaking:** add `captureAi` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB, on its own disk queue; it returns the event uuid, and classes that implement `PostHogInterface` add the method +- **Breaking:** add `captureAi` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events whose properties are up to 8 MiB, on its own disk queue; it returns the event uuid, or `null` when the event isn't queued, and classes that implement `PostHogInterface` add the method - use the current distinct ID when the `distinctId` argument to `capture` is empty or whitespace-only, instead of dropping the event - **Breaking:** skip deep link query parameters whose name starts with `$` in the `Deep Link Opened` event, so a link can't set a PostHog property such as `$process_person_profile` - stop storing or matching the person-properties deduplication hash for a `$set` sent with `process_person_profile: false` diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 90a926796..ffb9ac6fb 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -51,9 +51,9 @@ PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves it. If you send events through your own reverse proxy, update it before you upgrade: -- Forward `/i/v1/analytics/events` to PostHog. +- Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. - Pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged. PostHog answers `401` without `Authorization` and `400` without the others, and the events are dropped. -- Accept request bodies up to 20 MB. nginx and ingress-nginx default to 1 MB. A `413` from the proxy makes the SDK halve the batch until it fits, and drops an event that doesn't fit on its own. +- Accept request bodies up to 20 MB. AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. A `413` from the proxy makes the SDK halve the batch until it fits, and drops an event that doesn't fit on its own. PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). @@ -161,10 +161,10 @@ It takes the same arguments as `capture`, and the events go through the same `be PostHog.captureAi("$ai_generation", properties = mapOf("$ai_model" to "gpt-5", "$ai_trace_id" to traceId)) ``` -- `captureAi` returns the event's uuid, or `null` when the event isn't queued: the SDK is disabled or opted out, or `beforeSend` dropped it. -- An AI event can be up to 8 MiB. The SDK drops a larger event when it writes it to the queue and logs the event name and size, never its content. Set `captureAiMaxEventBytes` to lower the limit. +- `captureAi` returns the event's uuid, or `null` when the event isn't queued: the SDK is disabled or opted out, `beforeSend` dropped it, or it's over the size limit. +- An AI event's properties can be up to 8 MiB. The SDK drops a serialized event over 8 MiB + 64 KiB, measured before `encryption`, and logs the event name and size, never its content. Set `captureAiMaxEventBytes` to lower the properties limit; the SDK adds 64 KiB to it for the rest of the event. - PostHog drops an event sent with `captureAi` whose name doesn't start with `$ai_`. `capture` never sends events to the AI endpoint, even ones named `$ai_*`. -- AI requests time out after 60 seconds, or `captureAiTimeout`. Other requests keep OkHttp's 10-second defaults. +- AI requests time out after 60 seconds without network activity, or `captureAiTimeout`. Other requests keep OkHttp's 10-second defaults. - AI events are queued on disk separately from other events (`posthog-disk-ai-queue` in the app's cache directory). Up to 100 events (`captureAiMaxQueueSize`) and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. - AI requests are gzipped unless you set `captureAiCompression` to `PostHogCompression.NONE`. `compression` doesn't apply. - AI events use `flushAt`, `maxBatchSize` and `flushIntervalSeconds`, like other events. A request carries up to about 5 MiB, as for analytics events (see [Retries](#retries)). diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index 9550b5c89..188f56283 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -443,8 +443,8 @@ public open class PostHogConfig( public var captureAiMaxQueueSize: Int = DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE /** - * Timeout in seconds of a [PostHog.captureAi] request: its HTTP client's call, read and write - * timeouts. + * How long, in seconds, a [PostHog.captureAi] request can go without network activity before it + * times out: its HTTP client's read and write timeouts. * * AI requests can carry several MB, so this is longer than for other requests, which keep * OkHttp's 10 second defaults. A value of 0 or less uses the default. @@ -454,8 +454,9 @@ public open class PostHogConfig( public var captureAiTimeout: Int = DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS /** - * Maximum size, in bytes, of a [PostHog.captureAi] event. The SDK drops a larger event when it - * queues it, logging only the event name and size. + * Maximum size, in bytes, of a [PostHog.captureAi] event's properties. The SDK drops a serialized + * event over this plus 64 KiB for the rest of the event, measured before [encryption]; then + * `captureAi` returns null and logs only the event name and size. * * Use it to lower the limit. PostHog drops an AI event whose properties are over 8 MiB, so a * larger value, or a value of 0 or less, uses 8 MiB. diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 58e57dcef..9cc064879 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -118,8 +118,8 @@ public interface PostHogInterface : PostHogCoreInterface { * [PostHogConfig.flushIntervalSeconds], like other events, and * [PostHogConfig.captureAiCompression], [PostHogConfig.captureAiMaxQueueSize] and * [PostHogConfig.captureAiTimeout] instead of `compression`, `maxQueueSize` and the default - * timeouts. An event over [PostHogConfig.captureAiMaxEventBytes] (8 MiB by default) is dropped - * when it's queued, with a log line naming the event and its size. Requests carry up to about + * timeouts. A serialized event over [PostHogConfig.captureAiMaxEventBytes] plus 64 KiB (8 MiB + + * 64 KiB by default) is dropped, with a log line naming the event and its size. Requests carry up to about * 5 MiB of events, and the SDK sends as soon as that much is queued. Up to 50 MiB of AI events * are kept on disk; beyond that, the oldest are dropped. * @@ -136,7 +136,7 @@ public interface PostHogInterface : PostHogCoreInterface { * @param timestamp the event timestamp override. UTC is preferred; the equivalent instant is serialized in UTC. If omitted, the current time is used * @param options settings that tell PostHog how to process this event, see `options` in [capture] * @return the event's uuid once it's handed to the queue, or null when it isn't: the SDK is - * disabled or opted out, or `beforeSend` dropped it + * disabled or opted out, `beforeSend` dropped it, or it's over the size limit */ public fun captureAi( event: String, From f153814cb772709019d0a0c5eb7379f7d392ba36 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:55:17 -0400 Subject: [PATCH 26/45] chore(compliance): report no uuid when captureAi doesn't queue the event Co-Authored-By: Claude Opus 5.5 --- .../com/posthog/compliance/ComplianceAdapter.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt index 4e151ef6a..1bd55d60e 100644 --- a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt @@ -93,7 +93,8 @@ data class CaptureRequest( data class CaptureResponse( val success: Boolean, - val uuid: String, + // Null (omitted from the JSON) when the SDK didn't queue the event. + val uuid: String? = null, ) data class FlagRequest( @@ -318,6 +319,8 @@ fun main() { config.httpClient = httpClient req.max_retries?.let { config.pushNotificationSubscriptionMaxRetries = it } + // The harness sends one enable_compression flag and no AI-lane setting, so it sets + // both lanes. With it unset, the AI lane keeps its documented default (gzip). req.enable_compression?.let { val compression = if (it) PostHogCompression.GZIP else PostHogCompression.NONE config.compression = compression @@ -417,12 +420,16 @@ fun main() { options = req.options, ) - AdapterContext.lock.withLock { - AdapterContext.state.totalEventsCaptured++ - AdapterContext.state.pendingEvents++ + // A null uuid means the SDK didn't queue the event (for example it's over the size + // limit), so no request will carry it and no uuid is reported. + if (uuid != null) { + AdapterContext.lock.withLock { + AdapterContext.state.totalEventsCaptured++ + AdapterContext.state.pendingEvents++ + } } - call.respond(CaptureResponse(success = true, uuid = (uuid ?: UUID.randomUUID()).toString())) + call.respond(CaptureResponse(success = true, uuid = uuid?.toString())) } post("/get_feature_flag") { From 941cd6ce8cd76e3ed33655a95f0cb366aea9fcbc Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:00:52 -0400 Subject: [PATCH 27/45] fix: log each event capture v1 drops with the reason it gives Debug logs named only the drop count, so a misrouted_event or ai_event_too_big drop was invisible. Each dropped event is now logged with its name, uuid and the server's details, never its properties. Adds a test that an AI event with exactly 8 MiB of properties is sent. Co-Authored-By: Claude Opus 5.5 --- .changeset/android-v4-major.md | 2 +- docs/migration-3.x-to-4.0.md | 2 +- .../java/com/posthog/internal/PostHogApi.kt | 17 +++++++++----- .../java/com/posthog/PostHogCaptureAiTest.kt | 22 ++++++++++++++++++ .../posthog/internal/PostHogCaptureV1Test.kt | 23 +++++++++++++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 78758d058..60ec1b526 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -29,6 +29,6 @@ - use the current distinct ID when the `distinctId` argument to `capture` is empty or whitespace-only, instead of dropping the event - **Breaking:** skip deep link query parameters whose name starts with `$` in the `Deep Link Opened` event, so a link can't set a PostHog property such as `$process_person_profile` - stop storing or matching the person-properties deduplication hash for a `$set` sent with `process_person_profile: false` -- debug logs for capture requests name the event count and the response status instead of printing the request and response bodies +- debug logs for capture requests name the event count and the response status instead of printing the request and response bodies, and name each event PostHog drops with the reason it gives - add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events; the defaults are gzip, 100 queued events (and at most 50 MiB), 60 seconds and 8 MiB - cap a request at about 5 MiB of events, for analytics and AI events, and send as soon as that much is queued; an event larger than that is sent on its own diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index ffb9ac6fb..267ef2a93 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -70,7 +70,7 @@ If a proxy redirects capture requests to another host, point `host` at the final Capture v1 checks each event on its own and drops only the invalid ones, so the rest of the batch is delivered. The SDK doesn't check these before sending. PostHog drops the event, and the SDK removes it from the queue. -With `debug` on, the SDK logs how many events were dropped. +With `debug` on, the SDK logs the name and uuid of each dropped event and the reason PostHog gives, such as `misrouted_event`, never its properties. - An event with an empty event name is dropped, for example `capture("")`. - An event with an empty or whitespace-only distinct ID is dropped, for example one `beforeSend` sets to `""`. diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 4f52da0c2..2fde08f2b 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -222,7 +222,7 @@ public class PostHogApi( config.logger.log("Response ${response.request.url}: ${response.code}") if (response.code != 307 && response.code != 308) { - return captureV1Result(response, path, uuids, identity) + return captureV1Result(response, path, events, identity) } val target = response.header("Location")?.let { location -> request.url.resolve(location) } @@ -280,7 +280,7 @@ public class PostHogApi( private fun captureV1Result( response: Response, path: String, - uuids: List, + events: List, identity: CaptureV1RequestIdentity, ): SendResult { if (!response.isSuccessful) { @@ -293,22 +293,27 @@ public class PostHogApi( val results = readCaptureV1Results(response) if (results == null) { config.logger.log( - "Capture returned ${response.code} without readable per-event results, treating ${uuids.size} events as delivered.", + "Capture returned ${response.code} without readable per-event results, treating ${events.size} events as delivered.", ) } val retryUuids = mutableSetOf() var dropped = 0 // Events missing from the results count as delivered. - for (uuid in uuids) { + for (event in events) { + val uuid = event.uuid?.toString() ?: continue val entry = results?.get(uuid) as? Map<*, *> ?: continue when (entry["result"]) { "retry" -> retryUuids.add(uuid) - "drop" -> dropped++ + "drop" -> { + dropped++ + // The event name, uuid and the server's reason (such as misrouted_event or + // ai_event_too_big), never the properties. + config.logger.log("Capture dropped event ${event.event} ($uuid): ${entry["details"] ?: "no details"}.") + } } } - // Counts only: event uuids and server details can identify users. if (dropped > 0 || retryUuids.isNotEmpty()) { config.logger.log("Capture: $dropped dropped, ${retryUuids.size} to retry.") } diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt index 076404b35..77546bc9e 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -204,6 +204,28 @@ internal class PostHogCaptureAiTest { assertTrue(logger.messages.none { it.contains("pppp") }) } + @Test + fun `an AI event whose properties are exactly 8 MiB is sent`() { + val ceiling = 8 * 1024 * 1024 + val http = httpServers.mockHttp() + val sut = + getSut(http) { + captureAiCompression = PostHogCompression.NONE + // pin the properties after the SDK's enrichment; {"$ai_input":"…"} is 16 bytes around the value + addBeforeSend { event -> event.copy(properties = mutableMapOf("\$ai_input" to "a".repeat(ceiling - 16))) } + } + + assertNotNull(sut.captureAi("\$ai_generation")) + + aiExecutor.awaitExecution() + val body = takeRequest(http).body.readUtf8() + // the properties object is the event's last field, closed by the event, batch and body + val properties = body.substring(body.indexOf("\"properties\":") + "\"properties\":".length, body.length - "}]}".length) + assertEquals(ceiling, properties.toByteArray(Charsets.UTF_8).size) + assertEquals("\$ai_generation", captureV1Events(body).single()["event"]) + assertTrue(logger.messages.none { it.startsWith("Dropping AI event") }) + } + @Test fun `the AI event limit is measured before encryption`() { val http = httpServers.mockHttp() diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 2916d9eb6..f2b4ea503 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -332,9 +332,32 @@ internal class PostHogCaptureV1Test { assertEquals(setOf(retry.uuid.toString()), result.retryIds) assertEquals(7, result.retryAfterSeconds) assertTrue(logger.messages.contains("Capture: 1 dropped, 1 to retry.")) + assertTrue(logger.messages.contains("Capture dropped event dropped (${dropped.uuid}): no details.")) assertTrue(logger.messages.none { it.contains(retry.uuid.toString()) }) } + @Test + fun `logs each dropped event with the details capture gives, without its properties`() { + val misrouted = event("button_clicked", properties = mapOf("prompt" to "secret prompt")) + val tooBig = event("\$ai_generation", properties = mapOf("prompt" to "secret prompt")) + val http = + httpServers.mockHttp( + response = + MockResponse().setBody( + "{\"results\":{" + + "\"${misrouted.uuid}\":{\"result\":\"drop\",\"details\":\"misrouted_event\"}," + + "\"${tooBig.uuid}\":{\"result\":\"drop\",\"details\":\"ai_event_too_big\"}}}", + ), + ) + + api(http.url("/").toString()).captureAi(listOf(misrouted, tooBig)) + + assertTrue(logger.messages.contains("Capture dropped event button_clicked (${misrouted.uuid}): misrouted_event.")) + assertTrue(logger.messages.contains("Capture dropped event \$ai_generation (${tooBig.uuid}): ai_event_too_big.")) + assertTrue(logger.messages.contains("Capture: 2 dropped, 0 to retry.")) + assertTrue(logger.messages.none { it.contains("secret prompt") }) + } + @Test fun `a 2xx without readable results counts as delivered`() { val http = httpServers.mockHttp(response = MockResponse().setBody("not json")) From 117bc048b90c2156a7be33a7bdfac023b409a67f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:10:52 -0400 Subject: [PATCH 28/45] fix: stop measuring an AI event once it passes the size limit captureAi checks the size on the caller's thread, so a huge prompt or base64 payload no longer costs a full serialization just to be dropped. Co-Authored-By: Claude Opus 5.5 --- posthog/src/main/java/com/posthog/PostHog.kt | 8 +++---- .../java/com/posthog/internal/EndpointSpec.kt | 21 ++++++++++++++++--- .../java/com/posthog/PostHogCaptureAiTest.kt | 2 +- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 1ab3d2320..614304cac 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1059,16 +1059,16 @@ public class PostHog private constructor( /** * Whether [event], serialized and before [PostHogConfig.encryption], is over the AI event limit. - * Logs the name and size only: AI properties can hold prompts and media. + * Counting stops at the limit, so rejecting a large payload on the caller's thread costs no more + * than the limit. Logs the name and size only: AI properties can hold prompts and media. */ private fun isOverAiEventLimit(event: PostHogEvent): Boolean { val config = config ?: return false - val size = config.serializedSize(event) val limit = config.captureAiMaxStoredEventBytes - if (size <= limit) { + if (config.serializedSize(event, stopAfter = limit) <= limit) { return false } - config.logger.log("Dropping AI event ${event.event} of $size bytes, over the $limit byte limit.") + config.logger.log("Dropping AI event ${event.event} of more than $limit bytes, over the limit.") return true } diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index fbc0e816d..b3500616c 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -219,16 +219,26 @@ internal val PostHogConfig.effectiveCaptureAiMaxQueueSize: Int internal val PostHogConfig.effectiveCaptureAiTimeoutSeconds: Int get() = if (captureAiTimeout > 0) captureAiTimeout else PostHogConfig.DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS +/** Thrown by [serializedSize]'s counter to stop serializing once the count passes its bound. */ +private object SerializedSizeExceeded : RuntimeException() { + override fun fillInStackTrace(): Throwable = this +} + /** * Bytes [event] takes serialized as the queue stores it, before any [PostHogConfig.encryption]. - * Counted as it's written, so the event isn't held in memory a second time. + * Counted as it's written, so the event isn't held in memory a second time. Stops at + * [stopAfter] + 1, so an oversized event costs no more than the bound to reject. */ -internal fun PostHogConfig.serializedSize(event: PostHogEvent): Long { +internal fun PostHogConfig.serializedSize( + event: PostHogEvent, + stopAfter: Long = Long.MAX_VALUE, +): Long { var count = 0L val counter = object : OutputStream() { override fun write(b: Int) { count++ + if (count > stopAfter) throw SerializedSizeExceeded } override fun write( @@ -237,9 +247,14 @@ internal fun PostHogConfig.serializedSize(event: PostHogEvent): Long { len: Int, ) { count += len + if (count > stopAfter) throw SerializedSizeExceeded } } - serializer.serialize(event, counter.writer().buffered()) + try { + serializer.serialize(event, counter.writer().buffered()) + } catch (e: SerializedSizeExceeded) { + return stopAfter + 1 + } return count } diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt index 77546bc9e..768b2a3ff 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -198,7 +198,7 @@ internal class PostHogCaptureAiTest { assertTrue(aiQueueFiles().isEmpty()) val dropped = logger.messages.single { it.startsWith("Dropping AI event") } assertTrue( - dropped.matches(Regex("Dropping AI event \\\$ai_generation of \\d+ bytes, over the ${1 + 64 * 1024} byte limit\\.")), + dropped.matches(Regex("Dropping AI event \\\$ai_generation of more than ${1 + 64 * 1024} bytes, over the limit\\.")), dropped, ) assertTrue(logger.messages.none { it.contains("pppp") }) From f219836ca971f1f487ea3298313dbf9b059abe72 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:10:52 -0400 Subject: [PATCH 29/45] fix: log an unserializable property by key and type, not by value Properties can hold prompts and other user content. Co-Authored-By: Claude Opus 5.5 --- .../posthog/internal/GsonSafeMapSerializer.kt | 5 ++-- .../posthog/internal/PostHogSerializerTest.kt | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/GsonSafeMapSerializer.kt b/posthog/src/main/java/com/posthog/internal/GsonSafeMapSerializer.kt index 103f6d222..f8ef422b9 100644 --- a/posthog/src/main/java/com/posthog/internal/GsonSafeMapSerializer.kt +++ b/posthog/src/main/java/com/posthog/internal/GsonSafeMapSerializer.kt @@ -59,9 +59,10 @@ internal class GsonSafeMapSerializer(private val config: PostHogConfig) : JsonSe try { config.serializer.gson.toJsonTree(targetValue) } catch (e: Throwable) { + // the value's type only: properties can hold prompts and other user content config.logger.log( - "Property '$key' with value '$targetValue' cannot be serialized to JSON: $e. " + - "This property will be ignored.", + "Property '$key' of type ${targetValue.javaClass.name} cannot be serialized to JSON " + + "(${e.javaClass.simpleName}). This property will be ignored.", ) null } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt index cd7a5da72..19daaf390 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt @@ -3,6 +3,7 @@ package com.posthog.internal import com.posthog.PROJECT_TOKEN import com.posthog.PostHogConfig import com.posthog.PostHogEvent +import com.posthog.TestLogger import com.posthog.date import com.posthog.generateEvent import org.junit.Rule @@ -313,4 +314,28 @@ internal class PostHogSerializerTest { assertEquals(expectedJson, actualJson) } + + private data class Turn(val prompt: String, val score: Double) + + @Test + fun `an unserializable property is logged by key and type, never by value`() { + val logger = TestLogger() + val config = PostHogConfig(PROJECT_TOKEN).apply { this.logger = logger } + val event = generateEvent().copy(properties = mutableMapOf("turn" to Turn("SECRET-PROMPT", Double.NaN))) + + config.serializer.serialize(event, StringWriter()) + + val message = logger.messages.single { it.contains("cannot be serialized") } + assertTrue(message.contains("'turn'"), message) + assertTrue(logger.messages.none { it.contains("SECRET-PROMPT") }) + } + + @Test + fun `serializedSize stops counting past its bound`() { + val config = PostHogConfig(PROJECT_TOKEN) + val event = generateEvent().copy(properties = mutableMapOf("big" to "a".repeat(1_000_000))) + + assertEquals(1_001L, config.serializedSize(event, stopAfter = 1_000)) + assertTrue(config.serializedSize(event) > 1_000_000) + } } From d44bc39a714987f3822b7709aeac484d448a70d7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 14:38:57 -0400 Subject: [PATCH 30/45] docs: say the AI size check builds the full JSON tree before it stops counting --- posthog/src/main/java/com/posthog/PostHog.kt | 5 +++-- posthog/src/main/java/com/posthog/internal/EndpointSpec.kt | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 614304cac..97eff37e3 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1059,8 +1059,9 @@ public class PostHog private constructor( /** * Whether [event], serialized and before [PostHogConfig.encryption], is over the AI event limit. - * Counting stops at the limit, so rejecting a large payload on the caller's thread costs no more - * than the limit. Logs the name and size only: AI properties can hold prompts and media. + * Runs on the caller's thread so captureAi can return null; writing stops at the limit, but the + * properties JSON tree is built in full first. Logs the name and size only: AI properties can + * hold prompts and media. */ private fun isOverAiEventLimit(event: PostHogEvent): Boolean { val config = config ?: return false diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index b3500616c..81fe9cf9e 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -226,8 +226,8 @@ private object SerializedSizeExceeded : RuntimeException() { /** * Bytes [event] takes serialized as the queue stores it, before any [PostHogConfig.encryption]. - * Counted as it's written, so the event isn't held in memory a second time. Stops at - * [stopAfter] + 1, so an oversized event costs no more than the bound to reject. + * Counted as it's written, so the event isn't held in memory a second time. Stops writing at + * [stopAfter] + 1, though the serializer still builds the whole properties JSON tree first. */ internal fun PostHogConfig.serializedSize( event: PostHogEvent, From 2d22dbb8ffced834fd67d548557e9a290fee9ce1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 10:48:08 -0400 Subject: [PATCH 31/45] test: double bytes instead of Base64 in the AI encryption test java.util.Base64 isn't on API 23, so animalsnifferTest failed. Co-Authored-By: Claude Opus 5.5 --- .../java/com/posthog/PostHogCaptureAiTest.kt | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt index 768b2a3ff..b5e4bbd79 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -8,9 +8,10 @@ import okhttp3.mockwebserver.RecordedRequest import org.junit.Rule import org.junit.rules.TemporaryFolder import java.io.File +import java.io.FilterInputStream +import java.io.FilterOutputStream import java.io.InputStream import java.io.OutputStream -import java.util.Base64 import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import kotlin.test.AfterTest @@ -232,12 +233,35 @@ internal class PostHogCaptureAiTest { val sut = getSut(http) { captureAiMaxEventBytes = 1 - // Base64 stores a third more bytes than it's given + // writes every byte twice, so the stored event is double its JSON size encryption = object : PostHogEncryption { - override fun encrypt(outputStream: OutputStream): OutputStream = Base64.getEncoder().wrap(outputStream) - - override fun decrypt(inputStream: InputStream): InputStream = Base64.getDecoder().wrap(inputStream) + override fun encrypt(outputStream: OutputStream): OutputStream = + object : FilterOutputStream(outputStream) { + override fun write(b: Int) { + out.write(b) + out.write(b) + } + } + + override fun decrypt(inputStream: InputStream): InputStream = + object : FilterInputStream(inputStream) { + override fun read(): Int = super.read().also { super.read() } + + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + var n = 0 + while (n < len) { + val c = read() + if (c < 0) break + b[off + n++] = c.toByte() + } + return if (n == 0 && len > 0) -1 else n + } + } } } // under the 64 KiB + 1 byte limit as JSON, over it once encrypted From 2912dccc77908bf2098349b7dede54be0551a93f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 10:48:09 -0400 Subject: [PATCH 32/45] test: make the unserializable Turn a plain class The data class hashCode calls Double.hashCode(double), which isn't on API 23. Co-Authored-By: Claude Opus 5.5 --- .../test/java/com/posthog/internal/PostHogSerializerTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt index 19daaf390..256704b5d 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt @@ -315,7 +315,8 @@ internal class PostHogSerializerTest { assertEquals(expectedJson, actualJson) } - private data class Turn(val prompt: String, val score: Double) + // not a data class: its generated hashCode calls Double.hashCode(double), missing on API 23 + private class Turn(val prompt: String, val score: Double) @Test fun `an unserializable property is logged by key and type, never by value`() { From b3e29fb4bd08050bc976e1a4140f7f82df9eccec Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:47:52 -0400 Subject: [PATCH 33/45] test: write encrypted test streams in blocks Co-Authored-By: Claude Opus 5.5 --- .../test/java/com/posthog/PostHogCaptureAiTest.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt index b5e4bbd79..65da97680 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -242,6 +242,19 @@ internal class PostHogCaptureAiTest { out.write(b) out.write(b) } + + override fun write( + b: ByteArray, + off: Int, + len: Int, + ) { + val doubled = ByteArray(len * 2) + for (i in 0 until len) { + doubled[2 * i] = b[off + i] + doubled[2 * i + 1] = b[off + i] + } + out.write(doubled) + } } override fun decrypt(inputStream: InputStream): InputStream = From f4da7c87d997bbdeaa38f0735ebf5a7d478b5b4d Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:14:20 -0400 Subject: [PATCH 34/45] fix: retry capture v1 requests that get 404 or 429 (#897) * fix: retry capture v1 requests that get 404 or 429 A 404 usually means a misconfigured proxy and a 429 now only comes from an overloaded ingress, so keep the events queued and retry them with the usual backoff and Retry-After handling instead of dropping them. Matches PostHog/posthog-go#395. * fix: warn once when capture v1 returns 404 A 404 now keeps events queued, so nothing tells the app that the route is missing. Print one warning per client, even without debug, naming the URL and the path a reverse proxy or self-hosted instance must serve. * fix: leave host credentials and query out of the capture v1 404 warning Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changeset/android-v4-major.md | 4 +- docs/migration-3.x-to-4.0.md | 6 +-- .../java/com/posthog/internal/EndpointSpec.kt | 7 ++-- .../java/com/posthog/internal/PostHogApi.kt | 18 +++++++++ .../java/com/posthog/PostHogCaptureAiTest.kt | 8 ++-- .../com/posthog/internal/EndpointSpecTest.kt | 4 -- .../posthog/internal/PostHogCaptureV1Test.kt | 37 +++++++++++++++++-- .../com/posthog/internal/PostHogQueueTest.kt | 8 ++-- 8 files changed, 69 insertions(+), 23 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 60ec1b526..d6b6ab1c1 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -11,8 +11,8 @@ - **Breaking:** return `null` instead of the raw string for a malformed, empty or whitespace-only feature flag payload from `getFeatureFlagResult(key)?.payload`; payloads must be valid JSON (a JSON `null` payload now also reads as `null` instead of the string `"null"`) - **Breaking:** make SDK-internal helpers in the `com.posthog.internal` and `com.posthog.android.replay.internal` packages non-public - **Breaking:** remove the deprecated `remoteConfig` config option; the SDK now always loads remote config at setup -- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because a 404 or other non-retried response drops the events, and authenticate your proxy with a header other than `Authorization`, which capture requests replace -- **Breaking:** stop retrying 429 and 3xx responses for events, and cap a `Retry-After` pause at 30 seconds for events, session replay and logs +- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because events stay queued and are retried on a 404, with a warning printed once even when `debug` is off, and authenticate your proxy with a header other than `Authorization`, which capture requests replace +- **Breaking:** stop retrying 3xx responses for events, retry 404 responses, and cap a `Retry-After` pause at 30 seconds for events, session replay and logs - **Breaking:** add up to 25% random jitter to the wait after a failed send, which still doubles from 1 second to 30 seconds, and treat a `Retry-After` as a minimum wait instead of replacing the backoff, for events, session replay and logs; queued records are still kept until they're delivered - **Breaking:** follow only a 307 or 308 redirect of a capture request to the same origin as `host`, at most 5 times; any other redirect drops the events, so point `host` at the final host - **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options (`PostHogEvent.options`, a new trailing constructor parameter that Java callers of the full constructor and `copy` must pass), where PostHog drops an event whose option value it can't read diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 267ef2a93..daf5b3bee 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -24,7 +24,7 @@ You need to change something if your app does any of these: - registers a super property, or passes an event property, with the same key as one the SDK sets, such as `$os_name` or `$is_identified`, and expects the SDK's value to win - calls `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` or `captureException` from Java, implements `PostHogInterface`, or calls `identify` through a `PostHogCoreInterface` reference - sets the event `uuid` in `beforeSend` -- relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds +- relies on a `Retry-After` longer than 30 seconds - sets `maxRetries` on `PostHogConfig` or `PostHogAndroidConfig` ## Endpoints @@ -57,7 +57,7 @@ If you send events through your own reverse proxy, update it before you upgrade: PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). -The SDK doesn't fall back to `/batch`. A 404, or any other error status that isn't retried, such as 403 or 405, drops the events. +The SDK doesn't fall back to `/batch`. A 404 is retried, so the events stay queued until the path is served. Any other error status that isn't retried, such as 403 or 405, drops the events. ### Redirects @@ -172,7 +172,7 @@ PostHog.captureAi("$ai_generation", properties = mapOf("$ai_model" to "gpt-5", " ## Retries -- The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. +- The SDK retries network errors and 404, 408, 429, 500, 502, 503 and 504 responses. Other statuses drop the events, including 3xx. 3.x also retried 3xx responses, and didn't retry 404. - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. - A failed request is resent with the same events, request ID and `created_at`, and the next attempt number, before newer events are sent. OkHttp doesn't retry capture requests on its own. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x there was no cap. diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index 81fe9cf9e..41abd466f 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -194,10 +194,11 @@ internal class SendResult( ) /** - * Capture v1 retry policy: 408 and the listed 5xx. Everything else, including 429 and 3xx, is - * terminal; 413 is handled by the queue's batch halving. + * Capture v1 retry policy: 404, 408, 429 and the listed 5xx. A 404 usually means a misconfigured + * proxy and a 429 an overloaded ingress, so neither drops events. Everything else, including 3xx, + * is terminal; 413 is handled by the queue's batch halving. */ -internal fun isCaptureV1RetriableStatusCode(code: Int): Boolean = code == 408 || code in setOf(500, 502, 503, 504) +internal fun isCaptureV1RetriableStatusCode(code: Int): Boolean = code in setOf(404, 408, 429, 500, 502, 503, 504) /** Soft request size for the capture v1 analytics and AI endpoints, well under their body limits. */ internal const val CAPTURE_V1_MAX_BATCH_BYTES: Long = 5L * 1024 * 1024 diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 2fde08f2b..318fdee63 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -31,6 +31,7 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.UUID import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.SSLException /** @@ -62,6 +63,9 @@ public class PostHogApi( } } + // A 404 keeps the events queued, so the warning is printed once instead of on every retry. + private val warnedCaptureNotFound = AtomicBoolean(false) + private val captureMediaType by lazy { try { APP_JSON.toMediaType() @@ -221,6 +225,20 @@ public class PostHogApi( // The status only: the results name event uuids. config.logger.log("Response ${response.request.url}: ${response.code}") + if (response.code == 404 && warnedCaptureNotFound.compareAndSet(false, true)) { + // Warnings log even with debug off, so drop any credentials or query the host carries. + val safeUrl = + response.request.url.newBuilder() + .username("") + .password("") + .query(null) + .fragment(null) + .build() + config.logger.logWarning( + "$safeUrl returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves $path.", + ) + } if (response.code != 307 && response.code != 308) { return captureV1Result(response, path, events, identity) } diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt index 65da97680..13b89599b 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -168,18 +168,16 @@ internal class PostHogCaptureAiTest { } @Test - fun `a 404 from the AI endpoint is terminal`() { - val http = httpServers.mockHttp(total = 2, response = MockResponse().setResponseCode(404)) + fun `a 404 from the AI endpoint keeps the event queued`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(404)) val sut = getSut(http) sut.captureAi("\$ai_generation") aiExecutor.awaitExecution() - sut.flush() - aiExecutor.awaitExecution() assertEquals(1, http.requestCount) assertEquals("/i/v1/ai/events", takeRequest(http).path) - assertTrue(aiQueueFiles().isEmpty()) + assertEquals(1, aiQueueFiles().size) } @Test diff --git a/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt index 73a68f956..12946becc 100644 --- a/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt +++ b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt @@ -274,13 +274,9 @@ internal class EndpointSpecTest { @Test fun `events spec does not retry non-retriable status codes`() { - // Events keeps a curated retry set (408, 500, 502, 503, 504); it must not - // retry 429, 3xx, arbitrary 4xx, nor the extra 5xx codes the broad logs - // policy (500..599) covers. Guards against widening the set by mistake. assertEquals(false, isCaptureV1RetriableStatusCode(307)) assertEquals(false, isCaptureV1RetriableStatusCode(400)) assertEquals(false, isCaptureV1RetriableStatusCode(413)) - assertEquals(false, isCaptureV1RetriableStatusCode(429)) assertEquals(false, isCaptureV1RetriableStatusCode(501)) assertEquals(false, isCaptureV1RetriableStatusCode(505)) assertEquals(false, isCaptureV1RetriableStatusCode(599)) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index f2b4ea503..1239204e9 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -416,6 +416,37 @@ internal class PostHogCaptureV1Test { assertTrue(result.retryIds.isEmpty()) } + @Test + fun `a 404 warns once with the capture URL`() { + val http = httpServers.mockHttp(total = 2, response = MockResponse().setResponseCode(404)) + val host = http.url("/").toString().trimEnd('/') + val sut = api(host) + + repeat(2) { assertThrows(PostHogApiError::class.java) { sut.captureV1(listOf(event())) } } + + assertEquals( + listOf( + "$host/i/v1/analytics/events returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves /i/v1/analytics/events.", + ), + logger.warnings, + ) + } + + @Test + fun `the 404 warning leaves out credentials and the query in the host`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(404)) + val origin = http.url("/").toString().trimEnd('/') + val host = origin.replace("://", "://proxy-user:proxy-password@") + "?secret=s3cr3t" + val sut = api(host) + + assertThrows(PostHogApiError::class.java) { sut.captureV1(listOf(event())) } + + val warning = logger.warnings.single() + assertTrue(warning.startsWith("$origin/"), warning) + listOf("proxy-user", "proxy-password", "s3cr3t").forEach { assertFalse(warning.contains(it), warning) } + } + @Test fun `a 503 with Retry-After 0 is not resent by the HTTP client`() { val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503).setHeader("Retry-After", "0")) @@ -706,9 +737,9 @@ internal class PostHogCaptureV1Test { } @Test - fun `queue drops the batch on 429 and keeps it on 503`() { - val http = httpServers.mockHttp(response = MockResponse().setResponseCode(429)) - http.enqueue(MockResponse().setResponseCode(503)) + fun `queue drops the batch on 400 and keeps it on 429`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(400)) + http.enqueue(MockResponse().setResponseCode(429)) val sut = queue(http) sut.add(event()) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt index 9ad84b2e7..8e007ccc4 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt @@ -990,12 +990,14 @@ internal class PostHogQueueTest { } @Test - fun `does not retry on 429`() { - val e = PostHogApiError(429, "", null) + fun `retries on 404 and 429`() { val config = PostHogConfig(PROJECT_TOKEN) val limits = initialBatchLimits(config) - assertTrue(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) + for (status in listOf(404, 429)) { + val e = PostHogApiError(status, "", null) + assertFalse(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) + } } @Test From 739822ba489584c405ac261c87bb45511f6b8f96 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:15:50 -0400 Subject: [PATCH 35/45] docs: escape dollar signs in captureAi examples Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 5 ++++- posthog/src/main/java/com/posthog/PostHogInterface.kt | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index daf5b3bee..52d1021a1 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -158,7 +158,10 @@ When the event is sent, the SDK removes each one from `properties` and uses it f It takes the same arguments as `capture`, and the events go through the same `beforeSend` hooks. ```kotlin -PostHog.captureAi("$ai_generation", properties = mapOf("$ai_model" to "gpt-5", "$ai_trace_id" to traceId)) +PostHog.captureAi( + "\$ai_generation", + properties = mapOf("\$ai_model" to "gpt-5", "\$ai_trace_id" to traceId), +) ``` - `captureAi` returns the event's uuid, or `null` when the event isn't queued: the SDK is disabled or opted out, `beforeSend` dropped it, or it's over the size limit. diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 9cc064879..2da6f24cc 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -125,7 +125,10 @@ public interface PostHogInterface : PostHogCoreInterface { * * Example: * ``` - * PostHog.captureAi("$ai_generation", properties = mapOf("$ai_model" to "gpt-5", "$ai_trace_id" to traceId)) + * PostHog.captureAi( + * "\$ai_generation", + * properties = mapOf("\$ai_model" to "gpt-5", "\$ai_trace_id" to traceId), + * ) * ``` * @param event the AI event name, such as `$ai_generation` * @param distinctId the distinctId, the generated [distinctId] is used if not given or blank From ed59002e73fe1141ca3cab0cead8dda4ad128c69 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:15:50 -0400 Subject: [PATCH 36/45] test: give the serializer privacy fixture a prompt-bearing toString Co-Authored-By: Claude Opus 5.5 --- .../java/com/posthog/internal/PostHogSerializerTest.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt index 256704b5d..287c2fc62 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt @@ -315,8 +315,11 @@ internal class PostHogSerializerTest { assertEquals(expectedJson, actualJson) } - // not a data class: its generated hashCode calls Double.hashCode(double), missing on API 23 - private class Turn(val prompt: String, val score: Double) + // not a data class: its generated hashCode calls Double.hashCode(double), missing on API 23. + // toString carries the prompt so logging the value would leak it + private class Turn(val prompt: String, val score: Double) { + override fun toString(): String = "Turn(prompt=$prompt)" + } @Test fun `an unserializable property is logged by key and type, never by value`() { From 83e9b67ddb545574754a839edd957f06fcff8998 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:36:34 -0400 Subject: [PATCH 37/45] fix: make captureAiMaxEventBytes limit the whole serialized event Match posthog-ios, posthog-python and the Flutter desktop lane: the value is the serialized event size, defaulting to 8 MiB + 64 KiB, instead of a properties size the SDK added 64 KiB to. A value over the ceiling now logs a warning when it is clamped. Co-Authored-By: Claude Opus 5.5 --- .changeset/android-v4-major.md | 2 +- docs/migration-3.x-to-4.0.md | 2 +- posthog/src/main/java/com/posthog/PostHog.kt | 7 +++++++ .../main/java/com/posthog/PostHogConfig.kt | 16 ++++++++-------- .../main/java/com/posthog/PostHogInterface.kt | 4 ++-- .../java/com/posthog/internal/EndpointSpec.kt | 15 ++++----------- .../java/com/posthog/PostHogCaptureAiTest.kt | 19 +++++++++++++++---- .../internal/PostHogQueueByteLimitsTest.kt | 2 +- 8 files changed, 39 insertions(+), 28 deletions(-) diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index d6b6ab1c1..5e03a0731 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -30,5 +30,5 @@ - **Breaking:** skip deep link query parameters whose name starts with `$` in the `Deep Link Opened` event, so a link can't set a PostHog property such as `$process_person_profile` - stop storing or matching the person-properties deduplication hash for a `$set` sent with `process_person_profile: false` - debug logs for capture requests name the event count and the response status instead of printing the request and response bodies, and name each event PostHog drops with the reason it gives -- add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events; the defaults are gzip, 100 queued events (and at most 50 MiB), 60 seconds and 8 MiB +- add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events; the defaults are gzip, 100 queued events (and at most 50 MiB), 60 seconds and 8 MiB + 64 KiB per serialized event - cap a request at about 5 MiB of events, for analytics and AI events, and send as soon as that much is queued; an event larger than that is sent on its own diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 52d1021a1..ab5a49ba7 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -165,7 +165,7 @@ PostHog.captureAi( ``` - `captureAi` returns the event's uuid, or `null` when the event isn't queued: the SDK is disabled or opted out, `beforeSend` dropped it, or it's over the size limit. -- An AI event's properties can be up to 8 MiB. The SDK drops a serialized event over 8 MiB + 64 KiB, measured before `encryption`, and logs the event name and size, never its content. Set `captureAiMaxEventBytes` to lower the properties limit; the SDK adds 64 KiB to it for the rest of the event. +- An AI event's properties can be up to 8 MiB. The SDK drops a serialized event over 8 MiB + 64 KiB, measured before `encryption`, and logs the event name and size, never its content. Set `captureAiMaxEventBytes` to lower that limit; a larger value uses 8 MiB + 64 KiB and logs a warning. - PostHog drops an event sent with `captureAi` whose name doesn't start with `$ai_`. `capture` never sends events to the AI endpoint, even ones named `$ai_*`. - AI requests time out after 60 seconds without network activity, or `captureAiTimeout`. Other requests keep OkHttp's 10-second defaults. - AI events are queued on disk separately from other events (`posthog-disk-ai-queue` in the app's cache directory). Up to 100 events (`captureAiMaxQueueSize`) and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 97eff37e3..3edda9e0b 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -270,6 +270,13 @@ public class PostHog private constructor( EndpointSpec.logs(config, api, config.logsStoragePrefix), logsExecutor, ) + val aiEventBytesCeiling = PostHogConfig.DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES + if (config.captureAiMaxEventBytes > aiEventBytesCeiling) { + config.logger.log( + "captureAiMaxEventBytes ${config.captureAiMaxEventBytes} is over the " + + "$aiEventBytesCeiling byte ceiling, using $aiEventBytesCeiling.", + ) + } val aiQueue = PostHogQueue( config, diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index 188f56283..c2977be9f 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -454,14 +454,14 @@ public open class PostHogConfig( public var captureAiTimeout: Int = DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS /** - * Maximum size, in bytes, of a [PostHog.captureAi] event's properties. The SDK drops a serialized - * event over this plus 64 KiB for the rest of the event, measured before [encryption]; then - * `captureAi` returns null and logs only the event name and size. + * Maximum size, in bytes, of a serialized [PostHog.captureAi] event, measured before [encryption]. + * The SDK drops a larger event; then `captureAi` returns null and logs only the event name and size. * - * Use it to lower the limit. PostHog drops an AI event whose properties are over 8 MiB, so a - * larger value, or a value of 0 or less, uses 8 MiB. + * Use it to lower the limit. PostHog drops AI events whose properties are over 8 MiB, so the default + * allows 8 MiB plus 64 KiB for the rest of the event. A larger value uses the default and logs a + * warning. A value of 0 or less uses the default. * - * Default: 8388608 (8 MiB). + * Default: 8454144 (8 MiB + 64 KiB). */ public var captureAiMaxEventBytes: Int = DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES @@ -642,8 +642,8 @@ public open class PostHogConfig( /** Default for [captureAiTimeout]. */ public const val DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS: Int = 60 - /** Default for [captureAiMaxEventBytes]: PostHog's 8 MiB limit on an AI event's properties. */ - public const val DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES: Int = 8 * 1024 * 1024 + /** Default for [captureAiMaxEventBytes]: PostHog's 8 MiB limit on an AI event's properties, plus 64 KiB for the rest of the event. */ + public const val DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES: Int = 8 * 1024 * 1024 + 64 * 1024 public const val DEFAULT_US_HOST: String = "https://us.i.posthog.com" public const val DEFAULT_US_ASSETS_HOST: String = "https://us-assets.i.posthog.com" diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 2da6f24cc..eaa993bf4 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -118,8 +118,8 @@ public interface PostHogInterface : PostHogCoreInterface { * [PostHogConfig.flushIntervalSeconds], like other events, and * [PostHogConfig.captureAiCompression], [PostHogConfig.captureAiMaxQueueSize] and * [PostHogConfig.captureAiTimeout] instead of `compression`, `maxQueueSize` and the default - * timeouts. A serialized event over [PostHogConfig.captureAiMaxEventBytes] plus 64 KiB (8 MiB + - * 64 KiB by default) is dropped, with a log line naming the event and its size. Requests carry up to about + * timeouts. A serialized event over [PostHogConfig.captureAiMaxEventBytes] (8 MiB + 64 KiB by + * default) is dropped, with a log line naming the event and its size. Requests carry up to about * 5 MiB of events, and the SDK sends as soon as that much is queued. Up to 50 MiB of AI events * are kept on disk; beyond that, the oldest are dropped. * diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index 41abd466f..a628c87e0 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -79,7 +79,7 @@ public class EndpointSpec internal constructor( * Returns the [EndpointSpec] for the capture v1 AI endpoint (`/i/v1/ai/events`), used by * `captureAi`. Same codec, results and retry policy as [batch], on its own queue: AI events * can be megabytes, so the queue is capped by bytes as well as count, and an event over - * [PostHogConfig.captureAiMaxEventBytes] (plus the stored envelope) is dropped when queued. + * [PostHogConfig.captureAiMaxEventBytes] is dropped when queued. */ @JvmStatic public fun ai( @@ -206,12 +206,6 @@ internal const val CAPTURE_V1_MAX_BATCH_BYTES: Long = 5L * 1024 * 1024 /** Disk budget for queued AI events; the oldest are dropped first. */ internal const val AI_MAX_QUEUE_BYTES: Long = 50L * 1024 * 1024 -/** - * The stored AI event also holds its envelope (name, ids, timestamp), so this much is allowed on - * top of the properties limit, which PostHog measures on the properties alone. - */ -internal const val AI_EVENT_ENVELOPE_BYTES: Long = 64L * 1024 - /** [PostHogConfig.captureAiMaxQueueSize], or its default when it isn't positive. */ internal val PostHogConfig.effectiveCaptureAiMaxQueueSize: Int get() = if (captureAiMaxQueueSize > 0) captureAiMaxQueueSize else PostHogConfig.DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE @@ -260,14 +254,13 @@ internal fun PostHogConfig.serializedSize( } /** - * Size limit for a serialized AI event, before any [PostHogConfig.encryption]: [PostHogConfig.captureAiMaxEventBytes], capped at PostHog's - * 8 MiB limit, plus the envelope allowance. + * Size limit for a serialized AI event, before any [PostHogConfig.encryption]: [PostHogConfig.captureAiMaxEventBytes], + * capped at its default (PostHog's 8 MiB properties limit plus 64 KiB for the rest of the event). */ internal val PostHogConfig.captureAiMaxStoredEventBytes: Long get() { val limit = PostHogConfig.DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES - val bytes = if (captureAiMaxEventBytes > 0) minOf(captureAiMaxEventBytes, limit) else limit - return bytes + AI_EVENT_ENVELOPE_BYTES + return (if (captureAiMaxEventBytes > 0) minOf(captureAiMaxEventBytes, limit) else limit).toLong() } /** `/snapshot` (replay) retry policy: 408, 429, the listed 5xx, plus 3xx redirects. */ diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt index 13b89599b..938316749 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -183,7 +183,6 @@ internal class PostHogCaptureAiTest { @Test fun `an AI event over captureAiMaxEventBytes returns null and logs only its name and size`() { val http = httpServers.mockHttp() - // the limit is the configured bytes plus a 64 KiB envelope val sut = getSut(http) { captureAiMaxEventBytes = 1 } val prompt = "p".repeat(70_000) @@ -197,12 +196,24 @@ internal class PostHogCaptureAiTest { assertTrue(aiQueueFiles().isEmpty()) val dropped = logger.messages.single { it.startsWith("Dropping AI event") } assertTrue( - dropped.matches(Regex("Dropping AI event \\\$ai_generation of more than ${1 + 64 * 1024} bytes, over the limit\\.")), + dropped.matches(Regex("Dropping AI event \\\$ai_generation of more than 1 bytes, over the limit\\.")), dropped, ) assertTrue(logger.messages.none { it.contains("pppp") }) } + @Test + fun `a captureAiMaxEventBytes over the ceiling logs a warning`() { + getSut(httpServers.mockHttp()) { captureAiMaxEventBytes = 16 * 1024 * 1024 } + + assertTrue( + logger.messages.any { + it == "captureAiMaxEventBytes 16777216 is over the 8454144 byte ceiling, using 8454144." + }, + logger.messages.toString(), + ) + } + @Test fun `an AI event whose properties are exactly 8 MiB is sent`() { val ceiling = 8 * 1024 * 1024 @@ -230,7 +241,7 @@ internal class PostHogCaptureAiTest { val http = httpServers.mockHttp() val sut = getSut(http) { - captureAiMaxEventBytes = 1 + captureAiMaxEventBytes = 100_000 // writes every byte twice, so the stored event is double its JSON size encryption = object : PostHogEncryption { @@ -275,7 +286,7 @@ internal class PostHogCaptureAiTest { } } } - // under the 64 KiB + 1 byte limit as JSON, over it once encrypted + // under the 100,000 byte limit as JSON, over it once encrypted val prompt = "p".repeat(60_000) val uuid = sut.captureAi("\$ai_generation", properties = mapOf("\$ai_input" to prompt)) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt index d89344426..574c26c7c 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt @@ -191,7 +191,7 @@ internal class PostHogQueueByteLimitsTest { val api = PostHogApi(config) config.captureAiMaxEventBytes = 1024 - assertEquals(1024L + 64 * 1024, config.captureAiMaxStoredEventBytes) + assertEquals(1024L, config.captureAiMaxStoredEventBytes) config.captureAiMaxEventBytes = 16 * 1024 * 1024 assertEquals(8L * 1024 * 1024 + 64 * 1024, config.captureAiMaxStoredEventBytes) config.captureAiMaxEventBytes = 0 From 947a521a9e6938acc54359db948133484a41f897 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 17:08:19 -0400 Subject: [PATCH 38/45] fix: subtract a queued file's counted size when it's removed A file deleted from the cache dir reads as 0 bytes, so the queue kept counting it and evicted new AI events against bytes that were gone. Co-Authored-By: Claude Opus 5.5 --- .../java/com/posthog/internal/PostHogQueue.kt | 49 +++++++++++++------ .../internal/PostHogQueueByteLimitsTest.kt | 17 +++++++ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 455b65b09..4071f8343 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -39,6 +39,9 @@ public class PostHogQueue( // Total size of the files in the deque, kept only for byte-limited endpoints. Guarded by dequeLock. private val trackBytes = spec.maxBatchBytes != null || spec.maxQueueBytes != null private var queuedBytes = 0L + + // The size each file was counted with, so a file deleted from the cache dir is still subtracted in full. + private val fileBytes = HashMap() private val timerLock = Any() @PostHogVisibleForTesting @@ -106,7 +109,7 @@ public class PostHogQueue( if (trackBytes) { val size = file.length() synchronized(dequeLock) { - queuedBytes += size + countBytes(file, size) } } config.logger.log("Queued ${spec.describe(record)}: ${file.name}.") @@ -129,25 +132,42 @@ public class PostHogQueue( return true } - /** Removes [files] from the deque, subtracting their size while they still exist on disk. */ + /** Removes [files] from the deque and subtracts the size they were counted with. */ private fun removeFromDeque(files: Collection) { synchronized(dequeLock) { deque.removeAll(files) - if (trackBytes) { - queuedBytes -= files.sumOf { it.length() } - } + files.forEach { uncountBytes(it) } } } + // Must hold dequeLock. + private fun countBytes( + file: File, + size: Long, + ) { + if (!trackBytes) return + fileBytes[file] = size + queuedBytes += size + } + + // Must hold dequeLock. + private fun uncountBytes(file: File) { + queuedBytes -= fileBytes.remove(file) ?: 0L + } + + // Must hold dequeLock. + private fun resetBytes() { + fileBytes.clear() + queuedBytes = 0 + } + private fun removeRecordSync() { if (deque.size > spec.maxQueueSize(config).coerceAtLeast(1)) { try { val first: File synchronized(dequeLock) { first = deque.removeFirst() - if (trackBytes) { - queuedBytes -= first.length() - } + uncountBytes(first) } first.deleteSafely(config) config.logger.log("Queue is full, the oldest ${spec.recordsLabel} ${first.name} is dropped.") @@ -160,7 +180,7 @@ public class PostHogQueue( // The newest record is always kept, even when it's over the limit on its own. while (queuedBytes > maxQueueBytes && deque.size > 1) { val first = deque.removeFirst() - queuedBytes -= first.length() + uncountBytes(first) evicted.add(first) } } @@ -501,7 +521,7 @@ public class PostHogQueue( synchronized(dequeLock) { tempFiles = deque.toList() deque.clear() - queuedBytes = 0 + resetBytes() } tempFiles.forEach { it.deleteSafely(config) @@ -573,9 +593,7 @@ public class PostHogQueue( deque.clear() deque.addAll(files) deque.addAll(existingFiles) - if (trackBytes) { - queuedBytes += files.sumOf { it.length() } - } + files.forEach { countBytes(it, it.length()) } } config.logger.log("Loaded ${files.size} cached ${spec.recordsLabel} from disk.") } @@ -618,9 +636,8 @@ public class PostHogQueue( synchronized(dequeLock) { deque.clear() deque.addAll(files) - if (trackBytes) { - queuedBytes = files.sumOf { it.length() } - } + resetBytes() + files.forEach { countBytes(it, it.length()) } } cachedRecordsLoaded = true } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt index 574c26c7c..c3023d1b7 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt @@ -100,6 +100,23 @@ internal class PostHogQueueByteLimitsTest { assertEquals(0L, sut.queuedBytesForTesting) } + @Test + fun `a queued file deleted from disk is subtracted with the size it was counted with`() { + val sut = queue(maxBatchBytes = 100) + connected = false + sut.add(record("a", 40)) + sut.add(record("b", 40)) + executor.awaitExecution() + sut.dequeList.first().delete() + connected = true + + sut.flush() + executor.awaitExecution() + + assertEquals(listOf(listOf("b")), sent) + assertEquals(0L, sut.queuedBytesForTesting) + } + @Test fun `a record over the byte limit is sent alone and keeps its place`() { val sut = queue(maxBatchBytes = 100) From 5c0e7600c26afa40d3b0f823401636283664abac Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 17:09:00 -0400 Subject: [PATCH 39/45] fix: read lenient false person-processing values before caching person properties Capture reads "false", 0 and similar strings as false, so a $set sent with them is personless and must not store the person-properties hash. Co-Authored-By: Claude Opus 5.5 --- posthog/src/main/java/com/posthog/PostHog.kt | 5 +++-- .../main/java/com/posthog/internal/PostHogApi.kt | 16 ++++++++++++++++ .../com/posthog/PostHogCaptureOptionsTest.kt | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index 49f30c7df..ea0a756d9 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -35,6 +35,7 @@ import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory import com.posthog.internal.captureAiMaxStoredEventBytes import com.posthog.internal.errortracking.PostHogExceptionStepsBuffer +import com.posthog.internal.isFalseOptionValue import com.posthog.internal.logIntegrationFailure import com.posthog.internal.personPropertiesContext import com.posthog.internal.replay.PostHogSessionReplayHandler @@ -1000,9 +1001,9 @@ public class PostHog private constructor( private fun isPersonProcessed(options: Map?): Boolean { val option = options?.get("process_person_profile") if (option != null) { - return option != false + return !isFalseOptionValue(option) } - return getPreferences().getValue("\$process_person_profile") != false + return !isFalseOptionValue(getPreferences().getValue("\$process_person_profile")) } /** diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 318fdee63..c455a7874 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -882,6 +882,22 @@ internal fun liftStringProperty( return null } +/** + * Whether capture reads [value] as a false flag option: `false`, a zero number, or a string such as + * `"false"`, `"no"`, `"off"` or `"0"`, ignoring case and surrounding spaces. + */ +internal fun isFalseOptionValue(value: Any?): Boolean { + return when (value) { + is Boolean -> !value + is Number -> value.toDouble() == 0.0 + is String -> { + val text = value.trim().lowercase() + text in setOf("false", "f", "no", "n", "off") || text.toDoubleOrNull() == 0.0 + } + else -> false + } +} + private fun jsonTypeName(value: Any): String { return when (value) { is Boolean -> "bool" diff --git a/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt index be24342cc..76e5045b0 100644 --- a/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt @@ -335,6 +335,21 @@ internal class PostHogCaptureOptionsTest { sut.close() } + @Test + fun `a set with a false value capture reads leniently is personless too`() { + for (value in listOf("false", " Off ", 0, "0.0")) { + val sut = getSut() + sut.identify("user_id") + captured.clear() + + sut.setPersonProperties(userProps, options = mapOf("process_person_profile" to value)) + sut.setPersonProperties(userProps) + + assertEquals(2, captured.count { it.event == PostHogEventName.SET.event }, "for $value") + sut.close() + } + } + @Test fun `an event returned by beforeSend keeps the uuid of its own capture`() { val http = httpServers.mockHttp(total = 2) From 5a8c218ab8a7e553ca96c43651bae867d258d238 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 17:09:49 -0400 Subject: [PATCH 40/45] fix: send a fatal event even while a request waits to be retried The fatal flush resent only the retry set, so the crash report stayed on disk as the process exited. Co-Authored-By: Claude Opus 5.5 --- .../java/com/posthog/internal/PostHogQueue.kt | 6 ++++++ .../com/posthog/internal/PostHogQueueTest.kt | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 4071f8343..fd1213473 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -301,6 +301,12 @@ public class PostHogQueue( return } + if (isFatal) { + // The process may exit after this send, so it takes a FIFO window that can include the + // fatal record instead of resending only the request waiting to be retried. + keepForRetry(emptyList()) + } + executeBatch() } diff --git a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt index 8e007ccc4..670f95e88 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt @@ -1055,6 +1055,25 @@ internal class PostHogQueueTest { assertEquals(0, File(path, PROJECT_TOKEN).listFiles()!!.size) } + @Test + fun `a fatal event is sent with a request waiting to be retried`() { + val http = mockHttp(response = MockResponse().setResponseCode(503).setBody("error")) + http.enqueue(MockResponse().setBody("")) + val sut = getSut(host = http.url("/").toString(), flushAt = 1) + + sut.add(PostHogEvent("before", "123")) + awaitQueue() + http.takeRequest(5, TimeUnit.SECONDS) + + val props = mutableMapOf(ThrowableCoercer.EXCEPTION_LEVEL_ATTRIBUTE to ThrowableCoercer.EXCEPTION_LEVEL_FATAL) + sut.add(PostHogEvent(PostHogEventName.EXCEPTION.event, "123", properties = props)) + + val request = assertNotNull(http.takeRequest(5, TimeUnit.SECONDS)) + val batch = PostHogSerializer(PostHogConfig(PROJECT_TOKEN)).deserialize(request.body.unGzip().reader()) + assertEquals(listOf("before", PostHogEventName.EXCEPTION.event), batch.batch.map { it.event }) + assertEquals(0, sut.dequeList.size) + } + @Test fun `loads cached events from disk on first add`() { val http = mockHttp() From de165649ae4ce4d9e9fe5321e4bc2a8caea9bd07 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 17:10:20 -0400 Subject: [PATCH 41/45] chore(compliance): count only delivered events when capture asks to retry some Co-Authored-By: Claude Opus 5.5 --- .../posthog/compliance/ComplianceAdapter.kt | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt index 1bd55d60e..ee0eeea02 100644 --- a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt @@ -1,5 +1,6 @@ package com.posthog.compliance +import com.google.gson.JsonParser import com.posthog.PersonProfiles import com.posthog.PostHog import com.posthog.PostHogCompression @@ -208,8 +209,10 @@ class TrackingInterceptor : Interceptor { AdapterContext.state.requestsMade.add(record) if (response.isSuccessful) { - AdapterContext.state.totalEventsSent += eventCount - AdapterContext.state.pendingEvents = maxOf(0, AdapterContext.state.pendingEvents - eventCount) + // Events the server asks to retry stay queued and count once a later request delivers them. + val delivered = eventCount - retriedCount(response, uuidList) + AdapterContext.state.totalEventsSent += delivered + AdapterContext.state.pendingEvents = maxOf(0, AdapterContext.state.pendingEvents - delivered) } if (retryCount > 0) { @@ -226,6 +229,19 @@ class TrackingInterceptor : Interceptor { } } +// How many of [uuids] the capture v1 per-event results ask to retry. Peeks so the SDK still reads the body. +private fun retriedCount( + response: Response, + uuids: List, +): Int = + try { + val body = response.peekBody(1024 * 1024).string() + val results = JsonParser.parseString(body).asJsonObject.getAsJsonObject("results") + uuids.count { uuid -> results?.getAsJsonObject(uuid)?.get("result")?.asString == "retry" } + } catch (_: Exception) { + 0 + } + // The harness sends RFC 3339 timestamps, with any offset; the SDK serializes the instant in UTC. private fun parseHarnessTimestamp(timestamp: String?): Date? = timestamp?.let { From 50c19d2598941421a66d677ed7c9205455d1171e Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:47:30 -0400 Subject: [PATCH 42/45] chore(compliance): run the 1.26.0 test harness against the adapter (#902) * chore(compliance): run the 1.25.0 test harness against the adapter Bump the harness from 1.20.0 (CI) and 0.10.0 (local docker-compose) to 1.25.0, and declare the 1.25 capabilities the SDK supports: generated_payloads, log_capture, property_layers and fail_open. Co-Authored-By: Claude Opus 5.5 * chore(compliance): count the marker's serialized bytes when padding AI properties * chore(compliance): bump the test harness to 1.26.0 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/sdk-compliance-tests.yml | 2 +- .../IMPLEMENTATION_NOTES.md | 2 +- sdk_compliance_adapter/README.md | 2 +- sdk_compliance_adapter/docker-compose.yml | 2 +- .../posthog/compliance/ComplianceAdapter.kt | 71 ++++++++++++++++++- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sdk-compliance-tests.yml b/.github/workflows/sdk-compliance-tests.yml index 2f4dc4ee1..5a2f99035 100644 --- a/.github/workflows/sdk-compliance-tests.yml +++ b/.github/workflows/sdk-compliance-tests.yml @@ -26,5 +26,5 @@ jobs: with: adapter-dockerfile: sdk_compliance_adapter/Dockerfile adapter-context: . - test-harness-version: "1.20.0" + test-harness-version: "1.26.0" report-name: android-sdk-compliance-report diff --git a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md index 723403d76..b98c39d9a 100644 --- a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md +++ b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md @@ -76,7 +76,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and compliance test comma ## SDK Type -The Android SDK advertises `capture_v1`, `capture_ai_v1`, `encoding_gzip` and `event_options`: +The Android SDK advertises `capture_v1`, `capture_ai_v1`, `encoding_gzip`, `event_options`, `generated_payloads`, `log_capture`, `property_layers` and `fail_open`: - Endpoints: `POST /i/v1/analytics/events` (`/capture`) and `POST /i/v1/ai/events` (`/capture_ai`) - Format: `{created_at: "...", batch: [{event}, {event}]}` with the project token in an `Authorization: Bearer` header diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index ca843b536..c71675ef4 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -41,7 +41,7 @@ Events are tracked via: ### SDK Type -The Android SDK advertises `capture_v1`, `capture_ai_v1`, `encoding_gzip` and `event_options`: +The Android SDK advertises `capture_v1`, `capture_ai_v1`, `encoding_gzip`, `event_options`, `generated_payloads`, `log_capture`, `property_layers` and `fail_open`: - Endpoints: `POST /i/v1/analytics/events` (`/capture`) and `POST /i/v1/ai/events` (`/capture_ai`) - Format: `{created_at, batch}` with the project token in an `Authorization: Bearer` header diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index 2a9881cbd..0bb1241ce 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -11,7 +11,7 @@ services: - JAVA_TOOL_OPTIONS=-Xmx512m test-harness: - image: ghcr.io/posthog/sdk-test-harness:0.10.0 + image: ghcr.io/posthog/sdk-test-harness:1.26.0 command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081", "--sdk-type", "server", "--debug"] networks: - test-network diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt index ee0eeea02..885f3e3d1 100644 --- a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt @@ -1,5 +1,6 @@ package com.posthog.compliance +import com.google.gson.Gson import com.google.gson.JsonParser import com.posthog.PersonProfiles import com.posthog.PostHog @@ -8,6 +9,7 @@ import com.posthog.PostHogConfig import com.posthog.internal.GzipRequestInterceptor import com.posthog.internal.PostHogApi import com.posthog.internal.PostHogContext +import com.posthog.internal.PostHogLogger import io.ktor.http.HttpStatusCode import io.ktor.serialization.gson.gson import io.ktor.server.application.call @@ -25,6 +27,7 @@ import okhttp3.Response import java.io.File import java.time.OffsetDateTime import java.time.format.DateTimeParseException +import java.util.Collections import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit @@ -82,6 +85,12 @@ data class InitRequest( val flush_interval_ms: Int? = null, val max_retries: Int? = null, val enable_compression: Boolean? = null, + val super_properties: Map? = null, + val before_send: BeforeSendSpec? = null, +) + +data class BeforeSendSpec( + val set_properties: Map? = null, ) data class CaptureRequest( @@ -90,6 +99,8 @@ data class CaptureRequest( val properties: Map? = null, val timestamp: String? = null, val options: Map? = null, + val pad_properties_to_bytes: Int? = null, + val marker: String? = null, ) data class CaptureResponse( @@ -127,6 +138,7 @@ data class StateResponse( val total_retries: Int, val last_error: String?, val requests_made: List, + val log_lines: List, ) data class SuccessResponse( @@ -146,6 +158,16 @@ class TestPostHogContext(private val sdkName: String, private val sdkVersion: St ) } +// Records every SDK log line for /state, and still prints it. +class RecordingLogger : PostHogLogger { + override fun log(message: String) { + println(message) + AdapterContext.logLines.add(message) + } + + override fun isEnabled(): Boolean = true +} + // Global state object AdapterContext { val state = AdapterState() @@ -156,6 +178,9 @@ object AdapterContext { var postHog: com.posthog.PostHogInterface? = null var api: PostHogApi? = null val capturedEvents = mutableListOf() // Store UUIDs of captured events + + // Own lock: the SDK logs from its threads, which must not wait on [lock]. + val logLines: MutableList = Collections.synchronizedList(mutableListOf()) } // OkHttp Interceptor to track requests @@ -252,6 +277,21 @@ private fun parseHarnessTimestamp(timestamp: String?): Date? = } } +// Adds a $test_padding string so [properties], as compact UTF-8 JSON, is exactly [targetBytes] long. +private fun padProperties( + properties: Map?, + targetBytes: Int, + marker: String?, +): Map { + val padded = properties.orEmpty().toMutableMap() + val prefix = marker.orEmpty() + padded["\$test_padding"] = prefix + // Measure with the marker in place so its serialized (escaped, UTF-8) size is counted. + val baseBytes = Gson().toJson(padded).toByteArray(Charsets.UTF_8).size + padded["\$test_padding"] = prefix + "x".repeat(maxOf(0, targetBytes - baseBytes)) + return padded +} + // Block until [predicate] holds (signalled as each capture response lands) or [timeoutMs] elapses. private fun awaitBatch( timeoutMs: Long, @@ -282,7 +322,17 @@ fun main() { adapter_version = "1.0.0", // Opt into the capture suites (android posts /i/v1/analytics/events and // /i/v1/ai/events with gzip). - capabilities = listOf("capture_v1", "capture_ai_v1", "encoding_gzip", "event_options"), + capabilities = + listOf( + "capture_v1", + "capture_ai_v1", + "encoding_gzip", + "event_options", + "generated_payloads", + "log_capture", + "property_layers", + "fail_open", + ), ), ) } @@ -303,6 +353,7 @@ fun main() { // Close existing instance if any AdapterContext.postHog?.close() AdapterContext.api = null + AdapterContext.logLines.clear() // close() leaves queue files on disk; wipe so they don't replay into this test. File(QUEUE_STORAGE_PREFIX).deleteRecursively() @@ -321,6 +372,7 @@ fun main() { debug = true, preloadFeatureFlags = false, ) + config.logger = RecordingLogger() // The harness models a server SDK: it never identifies, and expects person // processing on unless an event opts out. config.personProfiles = PersonProfiles.ALWAYS @@ -359,9 +411,17 @@ fun main() { } event } + req.before_send?.set_properties?.let { setProperties -> + config.addBeforeSend { event -> + event.properties?.putAll(setProperties) + event + } + } // Create PostHog instance and direct API helper for per-request flag evaluation. - AdapterContext.postHog = PostHog.with(config) + val postHog = PostHog.with(config) + req.super_properties?.forEach { (key, value) -> postHog.register(key, value) } + AdapterContext.postHog = postHog AdapterContext.api = PostHogApi(config) } @@ -427,11 +487,14 @@ fun main() { return@post } + val properties = + req.pad_properties_to_bytes?.let { padProperties(req.properties, it, req.marker) } + ?: req.properties val uuid = ph.captureAi( event = req.event, distinctId = req.distinct_id, - properties = req.properties, + properties = properties, timestamp = parseHarnessTimestamp(req.timestamp), options = req.options, ) @@ -536,6 +599,7 @@ fun main() { total_retries = AdapterContext.state.totalRetries, last_error = AdapterContext.state.lastError, requests_made = AdapterContext.state.requestsMade.toList(), + log_lines = synchronized(AdapterContext.logLines) { AdapterContext.logLines.toList() }, ) } @@ -560,6 +624,7 @@ fun main() { AdapterContext.state.lastError = null AdapterContext.state.requestsMade.clear() AdapterContext.capturedEvents.clear() + AdapterContext.logLines.clear() } call.respond(SuccessResponse(success = true)) From 565a80d7eb324bd3ccecbf92f6fcd6382630a43d Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:18:17 -0400 Subject: [PATCH 43/45] feat: let wrapper SDKs run their own beforeSend on the fully built event (#904) * chore(api): keep @PostHogInternal declarations out of the API dumps * feat: let wrapper SDKs build an event, run their own beforeSend, then send it * fix: start the replay debug window when the wrapper's event is queued * fix: defer wrapper build state changes and the replay debug claim to send A built event the wrapper drops no longer caches the screen name, updates the person properties used for flags, or claims the replay debug bundle. The send does all three, as capture does, so the window starts with the event that carries the bundle. * fix: keep wrapper builds state-free and send each built event once Build defers person processing to send and records the built name for replay debug eligibility. Send adds only the throttled replay debug keys (API 23 safe, keeps keys the wrapper's hook removed), skips flag person properties when the user changed since the build, and ignores a second send of the same event. * fix: tie a wrapper send to the build's setup, instance and identity A send now drops an event this instance didn't build or built before setup()/close(), skips person processing and flag person properties after reset() (also when reuseAnonymousId keeps the distinct id), and adds the replay debug values snapshotted at build. * fix: don't restore a wrapper screen name after a reset since the build * fix: add the replay debug bundle to wrapper AI events, like captureAi * fix: read replay debug state once per wrapper build The build's required replay keys and the deferred bundle came from two debugProperties() reads, so a replay transition between them could send $recording_status=buffering without its hold reason. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changeset/wrapper-build-send.md | 5 + build.gradle.kts | 2 + posthog-android/api/posthog-android.api | 14 - posthog/api/posthog.api | 900 ------------------ posthog/src/main/java/com/posthog/PostHog.kt | 564 ++++++++--- .../main/java/com/posthog/PostHogInterface.kt | 64 ++ .../main/java/com/posthog/PostHogStateless.kt | 41 +- .../PostHogSessionReplayHandlerFake.kt | 5 +- .../com/posthog/PostHogWrapperEventTest.kt | 429 +++++++++ 9 files changed, 992 insertions(+), 1032 deletions(-) create mode 100644 .changeset/wrapper-build-send.md create mode 100644 posthog/src/test/java/com/posthog/PostHogWrapperEventTest.kt diff --git a/.changeset/wrapper-build-send.md b/.changeset/wrapper-build-send.md new file mode 100644 index 000000000..7a7fd89e5 --- /dev/null +++ b/.changeset/wrapper-build-send.md @@ -0,0 +1,5 @@ +--- +'posthog': patch +--- + +Add internal APIs so wrapper SDKs can run their own `beforeSend` on the fully built event before it's sent diff --git a/build.gradle.kts b/build.gradle.kts index 2d3d940ef..b44efdd53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,6 +66,8 @@ apiValidation { ignoredProjects.add("posthog-android-sample") // Pre-1.0 (0.x) module — public surface may change between minor versions until 1.0.0. ignoredProjects.add("posthog-android-surveys-compose") + // APIs only wrapper SDKs and the SDK's own modules may call; not part of the public API. + nonPublicMarkers.add("com.posthog.PostHogInternal") } nexusPublishing.postHogConfig() diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 1749b4128..59262210a 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -58,20 +58,6 @@ public final class com/posthog/android/errortracking/PostHogNativeCrashIntegrati public fun uninstall ()V } -public final class com/posthog/android/internal/MainHandler { - public fun ()V - public fun (Landroid/os/Looper;)V - public synthetic fun (Landroid/os/Looper;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getHandler ()Landroid/os/Handler; - public final fun getMainLooper ()Landroid/os/Looper; -} - -public final class com/posthog/android/internal/PostHogAndroidUtilsKt { - public static final fun base64 (Landroid/graphics/Bitmap;Landroid/graphics/Bitmap$CompressFormat;I)Ljava/lang/String; - public static synthetic fun base64$default (Landroid/graphics/Bitmap;Landroid/graphics/Bitmap$CompressFormat;IILjava/lang/Object;)Ljava/lang/String; - public static final fun getApplicationInfo (Landroid/content/Context;)Landroid/content/pm/ApplicationInfo; -} - public abstract interface class com/posthog/android/replay/PostHogDrawableConverter { public abstract fun convert (Landroid/graphics/drawable/Drawable;)Landroid/graphics/Bitmap; } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index dd1ef6174..389bcc9e9 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -166,17 +166,13 @@ public class com/posthog/PostHogConfig { public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogFeatureFlagsCallback;ZLkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V - public final fun getAiStoragePrefix ()Ljava/lang/String; public final fun getBeforeSendList ()Ljava/util/List; public final fun getBootstrap ()Lcom/posthog/PostHogBootstrapConfig; - public final fun getCachePreferences ()Lcom/posthog/internal/PostHogPreferences; public final fun getCaptureAiCompression ()Lcom/posthog/PostHogCompression; public final fun getCaptureAiMaxEventBytes ()I public final fun getCaptureAiMaxQueueSize ()I public final fun getCaptureAiTimeout ()I public final fun getCompression ()Lcom/posthog/PostHogCompression; - public final fun getContext ()Lcom/posthog/internal/PostHogContext; - public final fun getDateProvider ()Lcom/posthog/internal/PostHogDateProvider; public final fun getDebug ()Z public final fun getEncryption ()Lcom/posthog/PostHogEncryption; public final fun getErrorTrackingConfig ()Lcom/posthog/errortracking/PostHogErrorTrackingConfig; @@ -187,55 +183,36 @@ public class com/posthog/PostHogConfig { public final fun getFlushIntervalSeconds ()I public final fun getGetAnonymousId ()Lkotlin/jvm/functions/Function1; public final fun getHost ()Ljava/lang/String; - public final fun getHttpClient ()Lokhttp3/OkHttpClient; public final fun getIntegrations ()Ljava/util/List; - public final fun getLegacyStoragePrefix ()Ljava/lang/String; - public final fun getLogger ()Lcom/posthog/internal/PostHogLogger; public final fun getLogs ()Lcom/posthog/logs/PostHogLogsConfig; - public final fun getLogsStoragePrefix ()Ljava/lang/String; public final fun getMaxBatchSize ()I public final fun getMaxQueueSize ()I - public final fun getNetworkStatus ()Lcom/posthog/internal/PostHogNetworkStatus; public final fun getOnFeatureFlags ()Lcom/posthog/PostHogFeatureFlagsCallback; public final fun getOptOut ()Z - public final fun getPersistOptOut ()Z public final fun getPersonProfiles ()Lcom/posthog/PersonProfiles; public final fun getPreloadFeatureFlags ()Z public final fun getProjectToken ()Ljava/lang/String; public final fun getProxy ()Ljava/net/Proxy; public final fun getPushIdentityProvider ()Lkotlin/jvm/functions/Function3; public final fun getPushNotificationSubscriptionMaxRetries ()I - public final fun getQueueProvider ()Lkotlin/jvm/functions/Function5; public final fun getReleaseIdentifier ()Ljava/lang/String; - public final fun getRemoteConfigHolder ()Lcom/posthog/internal/PostHogRemoteConfig; public final fun getRemoteConfigProvider ()Lkotlin/jvm/functions/Function6; - public final fun getReplayStoragePrefix ()Ljava/lang/String; public final fun getRequestHeaders ()Ljava/util/Map; public final fun getReuseAnonymousId ()Z - public final fun getSampleRateProvider ()Lkotlin/jvm/functions/Function0; - public final fun getSdkName ()Ljava/lang/String; - public final fun getSdkVersion ()Ljava/lang/String; public final fun getSendFeatureFlagEvent ()Z - public final fun getSerializer ()Lcom/posthog/internal/PostHogSerializer; public final fun getSessionReplay ()Z public final fun getSetDefaultPersonProperties ()Z - public final fun getSnapshotEndpoint ()Ljava/lang/String; - public final fun getStoragePrefix ()Ljava/lang/String; public final fun getSurveys ()Z public final fun getSurveysConfig ()Lcom/posthog/surveys/PostHogSurveysConfig; public final synthetic fun getTracingHeaders ()Ljava/util/List; public final fun removeBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun removeIntegration (Lcom/posthog/PostHogIntegration;)V - public final fun setAiStoragePrefix (Ljava/lang/String;)V public final fun setBootstrap (Lcom/posthog/PostHogBootstrapConfig;)V - public final fun setCachePreferences (Lcom/posthog/internal/PostHogPreferences;)V public final fun setCaptureAiCompression (Lcom/posthog/PostHogCompression;)V public final fun setCaptureAiMaxEventBytes (I)V public final fun setCaptureAiMaxQueueSize (I)V public final fun setCaptureAiTimeout (I)V public final fun setCompression (Lcom/posthog/PostHogCompression;)V - public final fun setContext (Lcom/posthog/internal/PostHogContext;)V - public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V public final fun setDebug (Z)V public final fun setEncryption (Lcom/posthog/PostHogEncryption;)V public final fun setEvaluationContexts (Ljava/util/List;)V @@ -244,35 +221,22 @@ public class com/posthog/PostHogConfig { public final fun setFlushAt (I)V public final fun setFlushIntervalSeconds (I)V public final fun setGetAnonymousId (Lkotlin/jvm/functions/Function1;)V - public final fun setHttpClient (Lokhttp3/OkHttpClient;)V - public final fun setLegacyStoragePrefix (Ljava/lang/String;)V - public final fun setLogger (Lcom/posthog/internal/PostHogLogger;)V public final fun setLogs (Lcom/posthog/logs/PostHogLogsConfig;)V - public final fun setLogsStoragePrefix (Ljava/lang/String;)V public final fun setMaxBatchSize (I)V public final fun setMaxQueueSize (I)V - public final fun setNetworkStatus (Lcom/posthog/internal/PostHogNetworkStatus;)V public final fun setOnFeatureFlags (Lcom/posthog/PostHogFeatureFlagsCallback;)V public final fun setOptOut (Z)V - public final fun setPersistOptOut (Z)V public final fun setPersonProfiles (Lcom/posthog/PersonProfiles;)V public final fun setPreloadFeatureFlags (Z)V public final fun setProxy (Ljava/net/Proxy;)V public final fun setPushIdentityProvider (Lkotlin/jvm/functions/Function3;)V public final fun setPushNotificationSubscriptionMaxRetries (I)V public final fun setReleaseIdentifier (Ljava/lang/String;)V - public final fun setRemoteConfigHolder (Lcom/posthog/internal/PostHogRemoteConfig;)V - public final fun setReplayStoragePrefix (Ljava/lang/String;)V public final fun setRequestHeaders (Ljava/util/Map;)V public final fun setReuseAnonymousId (Z)V - public final fun setSampleRateProvider (Lkotlin/jvm/functions/Function0;)V - public final fun setSdkName (Ljava/lang/String;)V - public final fun setSdkVersion (Ljava/lang/String;)V public final fun setSendFeatureFlagEvent (Z)V public final fun setSessionReplay (Z)V public final fun setSetDefaultPersonProperties (Z)V - public final fun setSnapshotEndpoint (Ljava/lang/String;)V - public final fun setStoragePrefix (Ljava/lang/String;)V public final fun setSurveys (Z)V public final fun setSurveysConfig (Lcom/posthog/surveys/PostHogSurveysConfig;)V public final fun setTracingHeaders (Ljava/util/List;)V @@ -365,7 +329,6 @@ public abstract interface class com/posthog/PostHogFeatureFlagsCallback { } public final class com/posthog/PostHogFeatureFlagsLoaded { - public fun (Ljava/util/Map;Z)V public final fun getErrorsLoading ()Z public final fun getFlags ()Ljava/util/List; public final fun getVariants ()Ljava/util/Map; @@ -410,7 +373,6 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public abstract fun endSession ()V public abstract fun getAllFeatureFlags ()Ljava/util/List; public abstract fun getAnonymousId ()Ljava/lang/String; - public abstract fun getConfig ()Lcom/posthog/PostHogConfig; public abstract fun getDeviceId ()Ljava/lang/String; public abstract fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;)Ljava/lang/Object; public static synthetic fun getFeatureFlag$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Boolean;ILjava/lang/Object;)Ljava/lang/Object; @@ -501,11 +463,7 @@ public class com/posthog/PostHogStateless : com/posthog/PostHogStatelessInterfac public fun aliasStateless (Ljava/lang/String;Ljava/lang/String;)V protected final fun buildEvent (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;Ljava/util/UUID;)Lcom/posthog/PostHogEvent; public static synthetic fun buildEvent$default (Lcom/posthog/PostHogStateless;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;Ljava/util/UUID;ILjava/lang/Object;)Lcom/posthog/PostHogEvent; - protected final fun captureExceptionEvent (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;Ljava/util/Date;Lkotlin/jvm/functions/Function0;)V public fun captureExceptionStateless (Ljava/lang/Throwable;Ljava/lang/String;Ljava/util/Map;)V - protected final fun captureFeatureFlagCalledEvent (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;)V - protected final fun captureFeatureFlagCalledEvent (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;)V - public static synthetic fun captureFeatureFlagCalledEvent$default (Lcom/posthog/PostHogStateless;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public fun captureStateless (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;)V public fun close ()V public fun debug (Z)V @@ -597,19 +555,12 @@ public abstract interface annotation class com/posthog/PostHogVisibleForTesting public final class com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration : com/posthog/PostHogIntegration, java/lang/Thread$UncaughtExceptionHandler { public fun (Lcom/posthog/PostHogConfig;)V - public fun (Lcom/posthog/PostHogConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)V public fun install (Lcom/posthog/PostHogInterface;)V - public final fun installWith (Lcom/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration$CaptureTarget;)V public fun onRemoteConfig (Z)V public fun uncaughtException (Ljava/lang/Thread;Ljava/lang/Throwable;)V public fun uninstall ()V } -public abstract interface class com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration$CaptureTarget { - public abstract fun capture (Ljava/lang/Throwable;)V - public abstract fun flush ()V -} - public final class com/posthog/errortracking/PostHogErrorTrackingConfig { public fun ()V public fun (Z)V @@ -638,14 +589,6 @@ public final class com/posthog/errortracking/PostHogExceptionStepsConfig { public final fun setMaxBytes (I)V } -public final class com/posthog/internal/EndpointSpec { - public static final field Companion Lcom/posthog/internal/EndpointSpec$Companion; - public static final fun ai (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; - public static final fun batch (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; - public static final fun logs (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; - public static final fun snapshot (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; -} - public final class com/posthog/internal/EndpointSpec$Companion { public final fun ai (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public final fun batch (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; @@ -653,304 +596,11 @@ public final class com/posthog/internal/EndpointSpec$Companion { public final fun snapshot (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; } -public final class com/posthog/internal/EvaluationReason { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/Integer; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;)Lcom/posthog/internal/EvaluationReason; - public static synthetic fun copy$default (Lcom/posthog/internal/EvaluationReason;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/posthog/internal/EvaluationReason; - public fun equals (Ljava/lang/Object;)Z - public final fun getCode ()Ljava/lang/String; - public final fun getCondition_index ()Ljava/lang/Integer; - public final fun getDescription ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/FeatureFlag { - public fun (Ljava/lang/String;ZLjava/lang/String;Lcom/posthog/internal/FeatureFlagMetadata;Lcom/posthog/internal/EvaluationReason;Ljava/lang/Boolean;)V - public synthetic fun (Ljava/lang/String;ZLjava/lang/String;Lcom/posthog/internal/FeatureFlagMetadata;Lcom/posthog/internal/EvaluationReason;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Z - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Lcom/posthog/internal/FeatureFlagMetadata; - public final fun component5 ()Lcom/posthog/internal/EvaluationReason; - public final fun component6 ()Ljava/lang/Boolean; - public final fun copy (Ljava/lang/String;ZLjava/lang/String;Lcom/posthog/internal/FeatureFlagMetadata;Lcom/posthog/internal/EvaluationReason;Ljava/lang/Boolean;)Lcom/posthog/internal/FeatureFlag; - public static synthetic fun copy$default (Lcom/posthog/internal/FeatureFlag;Ljava/lang/String;ZLjava/lang/String;Lcom/posthog/internal/FeatureFlagMetadata;Lcom/posthog/internal/EvaluationReason;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/internal/FeatureFlag; - public fun equals (Ljava/lang/Object;)Z - public final fun getEnabled ()Z - public final fun getFailed ()Ljava/lang/Boolean; - public final fun getKey ()Ljava/lang/String; - public final fun getMetadata ()Lcom/posthog/internal/FeatureFlagMetadata; - public final fun getReason ()Lcom/posthog/internal/EvaluationReason; - public final fun getVariant ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/FeatureFlagMetadata { - public fun (ILjava/lang/String;ILjava/lang/Boolean;Ljava/lang/String;)V - public synthetic fun (ILjava/lang/String;ILjava/lang/Boolean;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()I - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()I - public final fun component4 ()Ljava/lang/Boolean; - public final fun component5 ()Ljava/lang/String; - public final fun copy (ILjava/lang/String;ILjava/lang/Boolean;Ljava/lang/String;)Lcom/posthog/internal/FeatureFlagMetadata; - public static synthetic fun copy$default (Lcom/posthog/internal/FeatureFlagMetadata;ILjava/lang/String;ILjava/lang/Boolean;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/FeatureFlagMetadata; - public fun equals (Ljava/lang/Object;)Z - public final fun getEvaluationRuntime ()Ljava/lang/String; - public final fun getHasExperiment ()Ljava/lang/Boolean; - public final fun getId ()I - public final fun getPayload ()Ljava/lang/String; - public final fun getVersion ()I - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/FlagConditionGroup { - public fun (Ljava/util/List;Ljava/lang/Double;Ljava/lang/String;)V - public final fun getProperties ()Ljava/util/List; - public final fun getRolloutPercentage ()Ljava/lang/Double; - public final fun getVariant ()Ljava/lang/String; -} - -public final class com/posthog/internal/FlagDefinition { - public fun (ILjava/lang/String;Ljava/lang/String;ZLcom/posthog/internal/FlagFilters;IZLjava/lang/Boolean;Ljava/lang/String;)V - public synthetic fun (ILjava/lang/String;Ljava/lang/String;ZLcom/posthog/internal/FlagFilters;IZLjava/lang/Boolean;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getActive ()Z - public final fun getEnsureExperienceContinuity ()Z - public final fun getEvaluationRuntime ()Ljava/lang/String; - public final fun getFilters ()Lcom/posthog/internal/FlagFilters; - public final fun getHasExperiment ()Ljava/lang/Boolean; - public final fun getId ()I - public final fun getKey ()Ljava/lang/String; - public final fun getName ()Ljava/lang/String; - public final fun getVersion ()I -} - -public final class com/posthog/internal/FlagFilters { - public fun (Ljava/util/List;Lcom/posthog/internal/MultiVariateConfig;Ljava/util/Map;Ljava/lang/Integer;)V - public final fun getAggregationGroupTypeIndex ()Ljava/lang/Integer; - public final fun getGroups ()Ljava/util/List; - public final fun getMultivariate ()Lcom/posthog/internal/MultiVariateConfig; - public final fun getPayloads ()Ljava/util/Map; -} - -public final class com/posthog/internal/FlagProperty { - public fun (Ljava/lang/String;Ljava/lang/Object;Lcom/posthog/internal/PropertyOperator;Lcom/posthog/internal/PropertyType;Ljava/lang/Boolean;Ljava/util/List;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/Object; - public final fun component3 ()Lcom/posthog/internal/PropertyOperator; - public final fun component4 ()Lcom/posthog/internal/PropertyType; - public final fun component5 ()Ljava/lang/Boolean; - public final fun component6 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Ljava/lang/Object;Lcom/posthog/internal/PropertyOperator;Lcom/posthog/internal/PropertyType;Ljava/lang/Boolean;Ljava/util/List;)Lcom/posthog/internal/FlagProperty; - public static synthetic fun copy$default (Lcom/posthog/internal/FlagProperty;Ljava/lang/String;Ljava/lang/Object;Lcom/posthog/internal/PropertyOperator;Lcom/posthog/internal/PropertyType;Ljava/lang/Boolean;Ljava/util/List;ILjava/lang/Object;)Lcom/posthog/internal/FlagProperty; - public fun equals (Ljava/lang/Object;)Z - public final fun getDependencyChain ()Ljava/util/List; - public final fun getKey ()Ljava/lang/String; - public final fun getNegation ()Ljava/lang/Boolean; - public final fun getPropertyOperator ()Lcom/posthog/internal/PropertyOperator; - public final fun getPropertyValue ()Ljava/lang/Object; - public final fun getType ()Lcom/posthog/internal/PropertyType; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/GzipRequestInterceptor : okhttp3/Interceptor { - public fun (Lcom/posthog/PostHogConfig;)V - public fun (Lcom/posthog/PostHogConfig;Lkotlin/jvm/functions/Function0;)V - public synthetic fun (Lcom/posthog/PostHogConfig;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; -} - -public final class com/posthog/internal/LocalEvaluationApiResponse { - public static final field Companion Lcom/posthog/internal/LocalEvaluationApiResponse$Companion; - public synthetic fun (Lcom/posthog/internal/LocalEvaluationResponse;Ljava/lang/String;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getEtag ()Ljava/lang/String; - public final fun getResult ()Lcom/posthog/internal/LocalEvaluationResponse; - public final fun getWasModified ()Z -} - public final class com/posthog/internal/LocalEvaluationApiResponse$Companion { public final fun notModified (Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationApiResponse; public final fun success (Lcom/posthog/internal/LocalEvaluationResponse;Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationApiResponse; } -public final class com/posthog/internal/LocalEvaluationResponse { - public fun (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;)V - public fun (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Integer;)V - public synthetic fun (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/util/List; - public final fun component2 ()Ljava/util/Map; - public final fun component3 ()Ljava/util/Map; - public final fun component4 ()Ljava/lang/Integer; - public final fun copy (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Integer;)Lcom/posthog/internal/LocalEvaluationResponse; - public static synthetic fun copy$default (Lcom/posthog/internal/LocalEvaluationResponse;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/posthog/internal/LocalEvaluationResponse; - public fun equals (Ljava/lang/Object;)Z - public final fun getCohorts ()Ljava/util/Map; - public final fun getFlags ()Ljava/util/List; - public final fun getGroupTypeMapping ()Ljava/util/Map; - public final fun getPropertyMatchingVersion ()Ljava/lang/Integer; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/LogicalOperator : java/lang/Enum { - public static final field AND Lcom/posthog/internal/LogicalOperator; - public static final field OR Lcom/posthog/internal/LogicalOperator; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/LogicalOperator; - public static fun values ()[Lcom/posthog/internal/LogicalOperator; -} - -public final class com/posthog/internal/MultiVariateConfig { - public fun (Ljava/util/List;)V - public final fun component1 ()Ljava/util/List; - public final fun copy (Ljava/util/List;)Lcom/posthog/internal/MultiVariateConfig; - public static synthetic fun copy$default (Lcom/posthog/internal/MultiVariateConfig;Ljava/util/List;ILjava/lang/Object;)Lcom/posthog/internal/MultiVariateConfig; - public fun equals (Ljava/lang/Object;)Z - public final fun getVariants ()Ljava/util/List; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/PostHogApi { - public fun (Lcom/posthog/PostHogConfig;)V - public final fun batch (Ljava/util/List;)V - public final fun flags (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;Z)Lcom/posthog/internal/PostHogFlagsResponse; - public static synthetic fun flags$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/List;ZILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; - public final fun localEvaluation (Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/LocalEvaluationApiResponse; - public static synthetic fun localEvaluation$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/LocalEvaluationApiResponse; - public final fun pushSubscription (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public static synthetic fun pushSubscription$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V - public final fun pushUnsubscription (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public static synthetic fun pushUnsubscription$default (Lcom/posthog/internal/PostHogApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V - public final fun remoteConfig ()Lcom/posthog/internal/PostHogRemoteConfigResponse; - public final fun snapshot (Ljava/util/List;)V -} - -public final class com/posthog/internal/PostHogApiEndpoint : java/lang/Enum { - public static final field BATCH Lcom/posthog/internal/PostHogApiEndpoint; - public static final field SNAPSHOT Lcom/posthog/internal/PostHogApiEndpoint; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/PostHogApiEndpoint; - public static fun values ()[Lcom/posthog/internal/PostHogApiEndpoint; -} - -public final class com/posthog/internal/PostHogApiError : java/lang/RuntimeException { - public fun (ILjava/lang/String;Lokhttp3/ResponseBody;Ljava/lang/Integer;Ljava/lang/String;)V - public synthetic fun (ILjava/lang/String;Lokhttp3/ResponseBody;Ljava/lang/Integer;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getBody ()Lokhttp3/ResponseBody; - public final fun getErrorCode ()Ljava/lang/String; - public fun getMessage ()Ljava/lang/String; - public final fun getRetryAfterSeconds ()Ljava/lang/Integer; - public final fun getStatusCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class com/posthog/internal/PostHogContext { - public abstract fun getDynamicContext ()Ljava/util/Map; - public abstract fun getSdkInfo ()Ljava/util/Map; - public abstract fun getStaticContext ()Ljava/util/Map; -} - -public abstract interface class com/posthog/internal/PostHogDateProvider { - public abstract fun addSecondsToCurrentDate (I)Ljava/util/Date; - public abstract fun currentDate ()Ljava/util/Date; - public abstract fun currentTimeMillis ()J - public abstract fun nanoTime ()J -} - -public final class com/posthog/internal/PostHogDateUtilsKt { - public static final fun formatISO8601Date (Ljava/util/Date;)Ljava/lang/String; - public static final fun parseISO8601Date (Ljava/lang/String;)Ljava/util/Date; -} - -public abstract interface class com/posthog/internal/PostHogDefaultPersonPropertiesProvider { - public abstract fun getDefaultPersonProperties ()Ljava/util/Map; -} - -public final class com/posthog/internal/PostHogDeviceDateProvider : com/posthog/internal/PostHogDateProvider { - public fun ()V - public fun addSecondsToCurrentDate (I)Ljava/util/Date; - public fun currentDate ()Ljava/util/Date; - public fun currentTimeMillis ()J - public fun nanoTime ()J -} - -public abstract interface class com/posthog/internal/PostHogFeatureFlagCalledProvider { - public abstract fun onFeatureFlagCalled (Ljava/lang/String;Ljava/lang/Object;)V -} - -public abstract interface class com/posthog/internal/PostHogFeatureFlagsInterface { - public abstract fun clear ()V - public abstract fun getEvaluatedAt (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Long; - public static synthetic fun getEvaluatedAt$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Long; - public fun getFeatureFlagDetails (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/FeatureFlag; - public static synthetic fun getFeatureFlagDetails$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/FeatureFlag; - public fun getFeatureFlagError (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; - public static synthetic fun getFeatureFlagError$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/String; - public abstract fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/FeatureFlagResult; - public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; - public abstract fun getFeatureFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/util/Map; - public static synthetic fun getFeatureFlags$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/Map; - public abstract fun getRequestId (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; - public static synthetic fun getRequestId$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/String; - public fun isMinimalFlagCalledEventsEnabled ()Z - public fun shutDown ()V -} - -public final class com/posthog/internal/PostHogFeatureFlagsInterface$DefaultImpls { - public static synthetic fun getEvaluatedAt$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Long; - public static fun getFeatureFlagDetails (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/internal/FeatureFlag; - public static synthetic fun getFeatureFlagDetails$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/internal/FeatureFlag; - public static fun getFeatureFlagError (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; - public static synthetic fun getFeatureFlagError$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/String; - public static synthetic fun getFeatureFlagResult$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/posthog/FeatureFlagResult; - public static synthetic fun getFeatureFlags$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/Map; - public static synthetic fun getRequestId$default (Lcom/posthog/internal/PostHogFeatureFlagsInterface;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/String; - public static fun isMinimalFlagCalledEventsEnabled (Lcom/posthog/internal/PostHogFeatureFlagsInterface;)Z - public static fun shutDown (Lcom/posthog/internal/PostHogFeatureFlagsInterface;)V -} - -public final class com/posthog/internal/PostHogFlagsResponse : com/posthog/internal/PostHogRemoteConfigResponse { - public fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;)V - public synthetic fun (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Z - public final fun component2 ()Ljava/util/Map; - public final fun component3 ()Ljava/util/Map; - public final fun component4 ()Ljava/util/Map; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Ljava/lang/Long; - public final fun component7 ()Ljava/lang/Boolean; - public final fun copy (ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;)Lcom/posthog/internal/PostHogFlagsResponse; - public static synthetic fun copy$default (Lcom/posthog/internal/PostHogFlagsResponse;ZLjava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Boolean;ILjava/lang/Object;)Lcom/posthog/internal/PostHogFlagsResponse; - public fun equals (Ljava/lang/Object;)Z - public final fun getErrorsWhileComputingFlags ()Z - public final fun getEvaluatedAt ()Ljava/lang/Long; - public final fun getFeatureFlagPayloads ()Ljava/util/Map; - public final fun getFeatureFlags ()Ljava/util/Map; - public final fun getFlags ()Ljava/util/Map; - public final fun getMinimalFlagCalledEvents ()Ljava/lang/Boolean; - public final fun getRequestId ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class com/posthog/internal/PostHogLogger { - public abstract fun isEnabled ()Z - public abstract fun log (Ljava/lang/String;)V - public fun logWarning (Ljava/lang/String;)V -} - -public final class com/posthog/internal/PostHogLogger$DefaultImpls { - public static fun logWarning (Lcom/posthog/internal/PostHogLogger;Ljava/lang/String;)V -} - public final class com/posthog/internal/PostHogMemoryPreferences : com/posthog/internal/PostHogPreferences { public fun ()V public fun clear (Ljava/util/List;)V @@ -960,50 +610,6 @@ public final class com/posthog/internal/PostHogMemoryPreferences : com/posthog/i public fun setValue (Ljava/lang/String;Ljava/lang/Object;)V } -public abstract interface class com/posthog/internal/PostHogNetworkStatus { - public abstract fun isConnected ()Z - public fun register (Lkotlin/jvm/functions/Function0;)V - public fun unregister ()V -} - -public final class com/posthog/internal/PostHogNetworkStatus$DefaultImpls { - public static fun register (Lcom/posthog/internal/PostHogNetworkStatus;Lkotlin/jvm/functions/Function0;)V - public static fun unregister (Lcom/posthog/internal/PostHogNetworkStatus;)V -} - -public final class com/posthog/internal/PostHogNoOpLogger : com/posthog/internal/PostHogLogger { - public fun ()V - public fun isEnabled ()Z - public fun log (Ljava/lang/String;)V -} - -public abstract interface class com/posthog/internal/PostHogOnRemoteConfigLoaded { - public abstract fun loaded ()V -} - -public abstract interface class com/posthog/internal/PostHogPreferences { - public static final field ANONYMOUS_ID Ljava/lang/String; - public static final field BUILD Ljava/lang/String; - public static final field Companion Lcom/posthog/internal/PostHogPreferences$Companion; - public static final field DEVICE_ID Ljava/lang/String; - public static final field DISTINCT_ID Ljava/lang/String; - public static final field GROUPS Ljava/lang/String; - public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; - public static final field PUSH_OPENED_MESSAGE_IDS Ljava/lang/String; - public static final field STRINGIFIED_KEYS Ljava/lang/String; - public static final field SURVEY_PROGRESS Ljava/lang/String; - public static final field SURVEY_SEEN Ljava/lang/String; - public static final field VERSION Ljava/lang/String; - public abstract fun clear (Ljava/util/List;)V - public static synthetic fun clear$default (Lcom/posthog/internal/PostHogPreferences;Ljava/util/List;ILjava/lang/Object;)V - public abstract fun getAll ()Ljava/util/Map; - public abstract fun getValue (Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object; - public static synthetic fun getValue$default (Lcom/posthog/internal/PostHogPreferences;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; - public fun isAvailable ()Z - public abstract fun remove (Ljava/lang/String;)V - public abstract fun setValue (Ljava/lang/String;Ljava/lang/Object;)V -} - public final class com/posthog/internal/PostHogPreferences$Companion { public static final field ANONYMOUS_ID Ljava/lang/String; public static final field BUILD Ljava/lang/String; @@ -1011,7 +617,6 @@ public final class com/posthog/internal/PostHogPreferences$Companion { public static final field DISTINCT_ID Ljava/lang/String; public static final field GROUPS Ljava/lang/String; public static final field LAST_SEEN_SURVEY_DATE Ljava/lang/String; - public static final field PUSH_OPENED_MESSAGE_IDS Ljava/lang/String; public static final field STRINGIFIED_KEYS Ljava/lang/String; public static final field SURVEY_PROGRESS Ljava/lang/String; public static final field SURVEY_SEEN Ljava/lang/String; @@ -1019,81 +624,6 @@ public final class com/posthog/internal/PostHogPreferences$Companion { public final fun getALL_INTERNAL_KEYS ()Ljava/util/Set; } -public final class com/posthog/internal/PostHogPreferences$DefaultImpls { - public static synthetic fun clear$default (Lcom/posthog/internal/PostHogPreferences;Ljava/util/List;ILjava/lang/Object;)V - public static synthetic fun getValue$default (Lcom/posthog/internal/PostHogPreferences;Ljava/lang/String;Ljava/lang/Object;ILjava/lang/Object;)Ljava/lang/Object; - public static fun isAvailable (Lcom/posthog/internal/PostHogPreferences;)Z -} - -public final class com/posthog/internal/PostHogQueue : com/posthog/internal/PostHogQueueInterface { - public fun (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/EndpointSpec;Ljava/util/concurrent/ExecutorService;)V - public fun add (Ljava/lang/Object;)V - public fun clear ()V - public fun flush ()V - public final fun getQueueDirectory ()Ljava/io/File; - public fun getSize ()I - public final fun reloadFromDisk ()V - public fun start ()V - public fun stop ()V -} - -public abstract interface class com/posthog/internal/PostHogQueueInterface { - public abstract fun add (Ljava/lang/Object;)V - public abstract fun clear ()V - public abstract fun flush ()V - public fun flushForShutdown ()V - public abstract fun getSize ()I - public abstract fun start ()V - public abstract fun stop ()V -} - -public final class com/posthog/internal/PostHogQueueInterface$DefaultImpls { - public static fun flushForShutdown (Lcom/posthog/internal/PostHogQueueInterface;)V -} - -public final class com/posthog/internal/PostHogRemoteConfig : com/posthog/internal/PostHogFeatureFlagsInterface { - public fun (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/util/concurrent/ExecutorService;Lcom/posthog/internal/PostHogDefaultPersonPropertiesProvider;Lcom/posthog/internal/PostHogFeatureFlagCalledProvider;Lcom/posthog/internal/PostHogOnRemoteConfigLoaded;)V - public synthetic fun (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/util/concurrent/ExecutorService;Lcom/posthog/internal/PostHogDefaultPersonPropertiesProvider;Lcom/posthog/internal/PostHogFeatureFlagCalledProvider;Lcom/posthog/internal/PostHogOnRemoteConfigLoaded;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun clear ()V - public final fun consumeNewlyRegisterablePushAppIds ()Ljava/util/Set; - public final fun getActiveFeatureFlagKeys ()Ljava/util/List; - public fun getEvaluatedAt (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Long; - public final fun getEventTriggers ()Ljava/util/Set; - public final fun getFeatureFlag (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; - public static synthetic fun getFeatureFlag$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; - public final fun getFeatureFlagPayload (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/Object; - public static synthetic fun getFeatureFlagPayload$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ljava/lang/Object; - public fun getFeatureFlagResult (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Lcom/posthog/FeatureFlagResult; - public fun getFeatureFlags (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/util/Map; - public final fun getFlagDetails (Ljava/lang/String;)Lcom/posthog/internal/FeatureFlag; - public final fun getPersonPropertiesForFlags ()Ljava/util/Map; - public final fun getPushAppIds ()Ljava/util/List; - public final fun getRecordingMinimumDurationMs ()Ljava/lang/Long; - public fun getRequestId (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;)Ljava/lang/String; - public final fun getSessionRecordingSampleRate ()Ljava/lang/Double; - public final fun getSurveys ()Ljava/util/List; - public final fun hasCachedErrorTrackingConfig ()Z - public final fun hasRemoteConfigFetched ()Z - public final fun isAutocaptureExceptionsEnabled ()Z - public final fun isCaptureNetworkTimingEnabled ()Z - public final fun isConsoleLogRecordingEnabled ()Z - public fun isMinimalFlagCalledEventsEnabled ()Z - public final fun isNativeCrashCaptureEnabled ()Z - public final fun isSessionReplayFlagActive ()Z - public final fun loadFeatureFlags (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function0;Lcom/posthog/PostHogFeatureFlagsCallback;)V - public static synthetic fun loadFeatureFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function0;Lcom/posthog/PostHogFeatureFlagsCallback;ILjava/lang/Object;)V - public final fun loadRemoteConfig (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function0;Lcom/posthog/PostHogFeatureFlagsCallback;)V - public static synthetic fun loadRemoteConfig$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlin/jvm/functions/Function0;Lcom/posthog/PostHogFeatureFlagsCallback;ILjava/lang/Object;)V - public final fun makeSamplingDecision (Ljava/lang/String;)Z - public final fun persistPushConfigCache ()V - public final fun resetGroupPropertiesForFlags (Ljava/lang/String;)V - public static synthetic fun resetGroupPropertiesForFlags$default (Lcom/posthog/internal/PostHogRemoteConfig;Ljava/lang/String;ILjava/lang/Object;)V - public final fun resetPersonPropertiesForFlags ()V - public final fun sessionReplayLinkedFlagSnapshot ()Lcom/posthog/internal/PostHogRemoteConfig$SessionReplayLinkedFlagSnapshot; - public final fun setGroupPropertiesForFlags (Ljava/lang/String;Ljava/util/Map;)V - public final fun setPersonPropertiesForFlags (Ljava/util/Map;)V -} - public final class com/posthog/internal/PostHogRemoteConfig$SessionReplayLinkedFlagSnapshot { public fun (ZZ)V public final fun component1 ()Z @@ -1107,129 +637,16 @@ public final class com/posthog/internal/PostHogRemoteConfig$SessionReplayLinkedF public fun toString ()Ljava/lang/String; } -public class com/posthog/internal/PostHogRemoteConfigResponse { - public fun ()V - public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Boolean;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;)V - public synthetic fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Boolean;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getCapturePerformance ()Ljava/lang/Object; - public final fun getErrorTracking ()Ljava/lang/Object; - public final fun getHasFeatureFlags ()Ljava/lang/Boolean; - public final fun getPush ()Ljava/lang/Object; - public final fun getQuotaLimited ()Ljava/util/List; - public final fun getSessionRecording ()Ljava/lang/Object; - public final fun getSurveys ()Ljava/lang/Object; -} - -public final class com/posthog/internal/PostHogSerializer { - public fun (Lcom/posthog/PostHogConfig;)V - public final fun deserializeString (Ljava/lang/String;)Ljava/lang/Object; - public final fun getGson ()Lcom/google/gson/Gson; - public final fun serializeObject (Ljava/lang/Object;)Ljava/lang/String; -} - -public final class com/posthog/internal/PostHogSessionManager { - public static final field INSTANCE Lcom/posthog/internal/PostHogSessionManager; - public final fun endSession ()V - public final fun getActiveSessionId ()Ljava/util/UUID; - public final fun isReactNative ()Z - public final fun isSessionActive ()Z - public final fun isSessionExceedingMaxDuration (J)Z - public final fun peekSessionId ()Ljava/util/UUID; - public final fun setAppInBackground (Z)V - public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V - public final fun setReactNative (Z)V - public final fun setSessionId (Ljava/util/UUID;)V - public final fun startSession ()V - public final fun touchSession ()V -} - -public final class com/posthog/internal/PostHogThreadFactory : java/util/concurrent/ThreadFactory { - public fun (Ljava/lang/String;)V - public fun newThread (Ljava/lang/Runnable;)Ljava/lang/Thread; -} - -public final class com/posthog/internal/PostHogUtilsKt { - public static final fun executeSafely (Ljava/util/concurrent/Executor;Ljava/lang/Runnable;)V - public static final fun interruptSafely (Ljava/lang/Thread;)V - public static final fun isNetworkingError (Ljava/lang/Throwable;)Z - public static final fun logIntegrationFailure (Lcom/posthog/PostHogConfig;Ljava/lang/String;Ljava/lang/Throwable;)V - public static final fun submitSyncSafely (Ljava/util/concurrent/ExecutorService;Ljava/lang/Runnable;)V -} - -public final class com/posthog/internal/PropertyGroup { - public fun (Lcom/posthog/internal/LogicalOperator;Lcom/posthog/internal/PropertyValue;)V - public final fun component1 ()Lcom/posthog/internal/LogicalOperator; - public final fun component2 ()Lcom/posthog/internal/PropertyValue; - public final fun copy (Lcom/posthog/internal/LogicalOperator;Lcom/posthog/internal/PropertyValue;)Lcom/posthog/internal/PropertyGroup; - public static synthetic fun copy$default (Lcom/posthog/internal/PropertyGroup;Lcom/posthog/internal/LogicalOperator;Lcom/posthog/internal/PropertyValue;ILjava/lang/Object;)Lcom/posthog/internal/PropertyGroup; - public fun equals (Ljava/lang/Object;)Z - public final fun getType ()Lcom/posthog/internal/LogicalOperator; - public final fun getValues ()Lcom/posthog/internal/PropertyValue; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/PropertyOperator : java/lang/Enum { - public static final field Companion Lcom/posthog/internal/PropertyOperator$Companion; - public static final field ENDS_WITH Lcom/posthog/internal/PropertyOperator; - public static final field EXACT Lcom/posthog/internal/PropertyOperator; - public static final field FLAG_EVALUATES_TO Lcom/posthog/internal/PropertyOperator; - public static final field GT Lcom/posthog/internal/PropertyOperator; - public static final field GTE Lcom/posthog/internal/PropertyOperator; - public static final field ICONTAINS Lcom/posthog/internal/PropertyOperator; - public static final field IN Lcom/posthog/internal/PropertyOperator; - public static final field IS_DATE_AFTER Lcom/posthog/internal/PropertyOperator; - public static final field IS_DATE_BEFORE Lcom/posthog/internal/PropertyOperator; - public static final field IS_NOT Lcom/posthog/internal/PropertyOperator; - public static final field IS_NOT_SET Lcom/posthog/internal/PropertyOperator; - public static final field IS_SET Lcom/posthog/internal/PropertyOperator; - public static final field LT Lcom/posthog/internal/PropertyOperator; - public static final field LTE Lcom/posthog/internal/PropertyOperator; - public static final field NOT_ENDS_WITH Lcom/posthog/internal/PropertyOperator; - public static final field NOT_ICONTAINS Lcom/posthog/internal/PropertyOperator; - public static final field NOT_REGEX Lcom/posthog/internal/PropertyOperator; - public static final field NOT_STARTS_WITH Lcom/posthog/internal/PropertyOperator; - public static final field REGEX Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_CARET Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_EQ Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_GT Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_GTE Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_LT Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_LTE Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_NEQ Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_TILDE Lcom/posthog/internal/PropertyOperator; - public static final field SEMVER_WILDCARD Lcom/posthog/internal/PropertyOperator; - public static final field STARTS_WITH Lcom/posthog/internal/PropertyOperator; - public static final field UNKNOWN Lcom/posthog/internal/PropertyOperator; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/PropertyOperator; - public static fun values ()[Lcom/posthog/internal/PropertyOperator; -} - public final class com/posthog/internal/PropertyOperator$Companion { public final fun fromString (Ljava/lang/String;)Lcom/posthog/internal/PropertyOperator; public final fun fromStringOrNull (Ljava/lang/String;)Lcom/posthog/internal/PropertyOperator; } -public final class com/posthog/internal/PropertyType : java/lang/Enum { - public static final field COHORT Lcom/posthog/internal/PropertyType; - public static final field Companion Lcom/posthog/internal/PropertyType$Companion; - public static final field FLAG Lcom/posthog/internal/PropertyType; - public static final field PERSON Lcom/posthog/internal/PropertyType; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/PropertyType; - public static fun values ()[Lcom/posthog/internal/PropertyType; -} - public final class com/posthog/internal/PropertyType$Companion { public final fun fromString (Ljava/lang/String;)Lcom/posthog/internal/PropertyType; public final fun fromStringOrNull (Ljava/lang/String;)Lcom/posthog/internal/PropertyType; } -public abstract interface class com/posthog/internal/PropertyValue { - public abstract fun isEmpty ()Z -} - public final class com/posthog/internal/PropertyValue$FlagProperties : com/posthog/internal/PropertyValue { public fun (Ljava/util/List;)V public final fun component1 ()Ljava/util/List; @@ -1254,153 +671,14 @@ public final class com/posthog/internal/PropertyValue$PropertyGroups : com/posth public fun toString ()Ljava/lang/String; } -public final class com/posthog/internal/VariantDefinition { - public fun (Ljava/lang/String;D)V - public final fun getKey ()Ljava/lang/String; - public final fun getRolloutPercentage ()D -} - -public final class com/posthog/internal/errortracking/ThrowableCoercer { - public static final field EXCEPTION_LEVEL_ATTRIBUTE Ljava/lang/String; - public static final field EXCEPTION_LEVEL_FATAL Ljava/lang/String; - public static final field MAX_EXCEPTION_LIST_SIZE I - public static final field MAX_FRAMES_PER_STACKTRACE I - public fun ()V -} - -public abstract interface class com/posthog/internal/replay/PostHogSessionReplayHandler { - public abstract fun debugProperties ()Ljava/util/Map; - public abstract fun isActive ()Z - public abstract fun onEvent (Ljava/lang/String;Ljava/util/Map;)V - public static synthetic fun onEvent$default (Lcom/posthog/internal/replay/PostHogSessionReplayHandler;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V - public abstract fun onSessionIdChanged ()V - public abstract fun start (Z)V - public abstract fun stop ()V -} - -public final class com/posthog/internal/replay/PostHogSessionReplayHandler$DefaultImpls { - public static synthetic fun onEvent$default (Lcom/posthog/internal/replay/PostHogSessionReplayHandler;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V -} - -public final class com/posthog/internal/replay/RRCustomEvent : com/posthog/internal/replay/RREvent { - public fun (Ljava/lang/String;Ljava/lang/Object;J)V -} - -public class com/posthog/internal/replay/RREvent { - public fun (Lcom/posthog/internal/replay/RREventType;JLjava/lang/Object;)V - public synthetic fun (Lcom/posthog/internal/replay/RREventType;JLjava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getData ()Ljava/lang/Object; - public final fun getTimestamp ()J - public final fun getType ()Lcom/posthog/internal/replay/RREventType; -} - -public final class com/posthog/internal/replay/RREventType : java/lang/Enum { - public static final field Companion Lcom/posthog/internal/replay/RREventType$Companion; - public static final field Custom Lcom/posthog/internal/replay/RREventType; - public static final field DomContentLoaded Lcom/posthog/internal/replay/RREventType; - public static final field FullSnapshot Lcom/posthog/internal/replay/RREventType; - public static final field IncrementalSnapshot Lcom/posthog/internal/replay/RREventType; - public static final field Load Lcom/posthog/internal/replay/RREventType; - public static final field Meta Lcom/posthog/internal/replay/RREventType; - public static final field Plugin Lcom/posthog/internal/replay/RREventType; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getValue ()I - public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/replay/RREventType; - public static fun values ()[Lcom/posthog/internal/replay/RREventType; -} - public final class com/posthog/internal/replay/RREventType$Companion { public final fun fromValue (I)Lcom/posthog/internal/replay/RREventType; } -public final class com/posthog/internal/replay/RRFullSnapshotEvent : com/posthog/internal/replay/RREvent { - public fun (Ljava/util/List;IIJ)V -} - -public final class com/posthog/internal/replay/RRIncrementalMouseInteractionData { - public fun (ILcom/posthog/internal/replay/RRMouseInteraction;IILcom/posthog/internal/replay/RRIncrementalSource;ILjava/util/List;)V - public synthetic fun (ILcom/posthog/internal/replay/RRMouseInteraction;IILcom/posthog/internal/replay/RRIncrementalSource;ILjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getId ()I - public final fun getPointerType ()I - public final fun getPositions ()Ljava/util/List; - public final fun getSource ()Lcom/posthog/internal/replay/RRIncrementalSource; - public final fun getType ()Lcom/posthog/internal/replay/RRMouseInteraction; - public final fun getX ()I - public final fun getY ()I -} - -public final class com/posthog/internal/replay/RRIncrementalMouseInteractionEvent : com/posthog/internal/replay/RREvent { - public fun (Lcom/posthog/internal/replay/RRIncrementalMouseInteractionData;J)V - public synthetic fun (Lcom/posthog/internal/replay/RRIncrementalMouseInteractionData;JILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - -public final class com/posthog/internal/replay/RRIncrementalMutationData { - public fun ()V - public fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/posthog/internal/replay/RRIncrementalSource;)V - public synthetic fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;Lcom/posthog/internal/replay/RRIncrementalSource;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getAdds ()Ljava/util/List; - public final fun getRemoves ()Ljava/util/List; - public final fun getSource ()Lcom/posthog/internal/replay/RRIncrementalSource; - public final fun getUpdates ()Ljava/util/List; -} - -public final class com/posthog/internal/replay/RRIncrementalSnapshotEvent : com/posthog/internal/replay/RREvent { - public fun (Lcom/posthog/internal/replay/RRIncrementalMutationData;J)V - public synthetic fun (Lcom/posthog/internal/replay/RRIncrementalMutationData;JILkotlin/jvm/internal/DefaultConstructorMarker;)V -} - -public final class com/posthog/internal/replay/RRIncrementalSource : java/lang/Enum { - public static final field AdoptedStyleSheet Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field CanvasMutation Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field Companion Lcom/posthog/internal/replay/RRIncrementalSource$Companion; - public static final field CustomElement Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field Drag Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field Font Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field Input Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field Log Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field MediaInteraction Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field MouseInteraction Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field MouseMove Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field Mutation Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field Scroll Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field Selection Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field StyleDeclaration Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field StyleSheetRule Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field TouchMove Lcom/posthog/internal/replay/RRIncrementalSource; - public static final field ViewportResize Lcom/posthog/internal/replay/RRIncrementalSource; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getValue ()I - public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/replay/RRIncrementalSource; - public static fun values ()[Lcom/posthog/internal/replay/RRIncrementalSource; -} - public final class com/posthog/internal/replay/RRIncrementalSource$Companion { public final fun fromValue (I)Lcom/posthog/internal/replay/RRIncrementalSource; } -public final class com/posthog/internal/replay/RRMetaEvent : com/posthog/internal/replay/RREvent { - public fun (IIJLjava/lang/String;)V -} - -public final class com/posthog/internal/replay/RRMouseInteraction : java/lang/Enum { - public static final field Blur Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field Click Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field Companion Lcom/posthog/internal/replay/RRMouseInteraction$Companion; - public static final field ContextMenu Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field DblClick Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field Focus Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field MouseDown Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field MouseUp Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field TouchCancel Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field TouchEnd Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field TouchMoveDeparted Lcom/posthog/internal/replay/RRMouseInteraction; - public static final field TouchStart Lcom/posthog/internal/replay/RRMouseInteraction; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public final fun getValue ()I - public static fun valueOf (Ljava/lang/String;)Lcom/posthog/internal/replay/RRMouseInteraction; - public static fun values ()[Lcom/posthog/internal/replay/RRMouseInteraction; -} - public final class com/posthog/internal/replay/RRMouseInteraction$Companion { public final fun fromValue (I)Lcom/posthog/internal/replay/RRMouseInteraction; } @@ -1414,178 +692,6 @@ public final class com/posthog/internal/replay/RRMousePosition { public final fun getY ()I } -public final class com/posthog/internal/replay/RRMutatedNode { - public fun (Lcom/posthog/internal/replay/RRWireframe;Ljava/lang/Integer;)V - public synthetic fun (Lcom/posthog/internal/replay/RRWireframe;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getParentId ()Ljava/lang/Integer; - public final fun getWireframe ()Lcom/posthog/internal/replay/RRWireframe; -} - -public final class com/posthog/internal/replay/RRPluginEvent : com/posthog/internal/replay/RREvent { - public fun (Ljava/lang/String;Ljava/util/Map;J)V -} - -public final class com/posthog/internal/replay/RRRemovedNode { - public fun (ILjava/lang/Integer;)V - public synthetic fun (ILjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getId ()I - public final fun getParentId ()Ljava/lang/Integer; -} - -public final class com/posthog/internal/replay/RRStyle { - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component10 ()Ljava/lang/String; - public final fun component11 ()Ljava/lang/Integer; - public final fun component12 ()Ljava/lang/Integer; - public final fun component13 ()Ljava/lang/Integer; - public final fun component14 ()Ljava/lang/Integer; - public final fun component15 ()Ljava/lang/String; - public final fun component16 ()Ljava/lang/String; - public final fun component17 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Ljava/lang/Integer; - public final fun component5 ()Ljava/lang/Integer; - public final fun component6 ()Ljava/lang/String; - public final fun component7 ()Ljava/lang/Integer; - public final fun component8 ()Ljava/lang/String; - public final fun component9 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/posthog/internal/replay/RRStyle; - public static synthetic fun copy$default (Lcom/posthog/internal/replay/RRStyle;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/posthog/internal/replay/RRStyle; - public fun equals (Ljava/lang/Object;)Z - public final fun getBackgroundColor ()Ljava/lang/String; - public final fun getBackgroundImage ()Ljava/lang/String; - public final fun getBar ()Ljava/lang/String; - public final fun getBorderColor ()Ljava/lang/String; - public final fun getBorderRadius ()Ljava/lang/Integer; - public final fun getBorderWidth ()Ljava/lang/Integer; - public final fun getColor ()Ljava/lang/String; - public final fun getFontFamily ()Ljava/lang/String; - public final fun getFontSize ()Ljava/lang/Integer; - public final fun getHorizontalAlign ()Ljava/lang/String; - public final fun getIconLeft ()Ljava/lang/String; - public final fun getIconRight ()Ljava/lang/String; - public final fun getPaddingBottom ()Ljava/lang/Integer; - public final fun getPaddingLeft ()Ljava/lang/Integer; - public final fun getPaddingRight ()Ljava/lang/Integer; - public final fun getPaddingTop ()Ljava/lang/Integer; - public final fun getVerticalAlign ()Ljava/lang/String; - public fun hashCode ()I - public final fun setBackgroundColor (Ljava/lang/String;)V - public final fun setBackgroundImage (Ljava/lang/String;)V - public final fun setBar (Ljava/lang/String;)V - public final fun setBorderColor (Ljava/lang/String;)V - public final fun setBorderRadius (Ljava/lang/Integer;)V - public final fun setBorderWidth (Ljava/lang/Integer;)V - public final fun setColor (Ljava/lang/String;)V - public final fun setFontFamily (Ljava/lang/String;)V - public final fun setFontSize (Ljava/lang/Integer;)V - public final fun setHorizontalAlign (Ljava/lang/String;)V - public final fun setIconLeft (Ljava/lang/String;)V - public final fun setIconRight (Ljava/lang/String;)V - public final fun setPaddingBottom (Ljava/lang/Integer;)V - public final fun setPaddingLeft (Ljava/lang/Integer;)V - public final fun setPaddingRight (Ljava/lang/Integer;)V - public final fun setPaddingTop (Ljava/lang/Integer;)V - public final fun setVerticalAlign (Ljava/lang/String;)V - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/replay/RRUtilsKt { - public static final fun capture (Ljava/util/List;)V - public static final fun capture (Ljava/util/List;Lcom/posthog/PostHogInterface;)V - public static synthetic fun capture$default (Ljava/util/List;Lcom/posthog/PostHogInterface;ILjava/lang/Object;)V -} - -public final class com/posthog/internal/replay/RRWireframe { - public fun (IIIIILjava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Lcom/posthog/internal/replay/RRStyle;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (IIIIILjava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Lcom/posthog/internal/replay/RRStyle;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()I - public final fun component10 ()Ljava/lang/String; - public final fun component11 ()Ljava/lang/Object; - public final fun component12 ()Ljava/lang/String; - public final fun component13 ()Lcom/posthog/internal/replay/RRStyle; - public final fun component14 ()Ljava/lang/Boolean; - public final fun component15 ()Ljava/lang/Boolean; - public final fun component16 ()Ljava/util/List; - public final fun component17 ()Ljava/lang/Integer; - public final fun component18 ()Ljava/lang/Integer; - public final fun component2 ()I - public final fun component3 ()I - public final fun component4 ()I - public final fun component5 ()I - public final fun component6 ()Ljava/util/List; - public final fun component7 ()Ljava/lang/String; - public final fun component8 ()Ljava/lang/String; - public final fun component9 ()Ljava/lang/String; - public final fun copy (IIIIILjava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Lcom/posthog/internal/replay/RRStyle;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;)Lcom/posthog/internal/replay/RRWireframe; - public static synthetic fun copy$default (Lcom/posthog/internal/replay/RRWireframe;IIIIILjava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Lcom/posthog/internal/replay/RRStyle;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/posthog/internal/replay/RRWireframe; - public fun equals (Ljava/lang/Object;)Z - public final fun getBase64 ()Ljava/lang/String; - public final fun getChecked ()Ljava/lang/Boolean; - public final fun getChildWireframes ()Ljava/util/List; - public final fun getDisabled ()Ljava/lang/Boolean; - public final fun getHeight ()I - public final fun getId ()I - public final fun getInputType ()Ljava/lang/String; - public final fun getLabel ()Ljava/lang/String; - public final fun getMax ()Ljava/lang/Integer; - public final fun getOptions ()Ljava/util/List; - public final fun getParentId ()Ljava/lang/Integer; - public final fun getStyle ()Lcom/posthog/internal/replay/RRStyle; - public final fun getText ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public final fun getValue ()Ljava/lang/Object; - public final fun getWidth ()I - public final fun getX ()I - public final fun getY ()I - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class com/posthog/internal/surveys/PostHogSurveysHandler { - public abstract fun onEvent (Ljava/lang/String;Ljava/util/Map;)V - public static synthetic fun onEvent$default (Lcom/posthog/internal/surveys/PostHogSurveysHandler;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V - public abstract fun onReset ()V - public abstract fun onSurveysLoaded (Ljava/util/List;)V -} - -public final class com/posthog/internal/surveys/PostHogSurveysHandler$DefaultImpls { - public static synthetic fun onEvent$default (Lcom/posthog/internal/surveys/PostHogSurveysHandler;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V -} - -public final class com/posthog/internal/surveys/ResolvedSurveyTranslations { - public fun (Ljava/lang/String;Lcom/posthog/surveys/SurveyTranslation;Ljava/util/List;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lcom/posthog/surveys/SurveyTranslation; - public final fun component3 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Lcom/posthog/surveys/SurveyTranslation;Ljava/util/List;)Lcom/posthog/internal/surveys/ResolvedSurveyTranslations; - public static synthetic fun copy$default (Lcom/posthog/internal/surveys/ResolvedSurveyTranslations;Ljava/lang/String;Lcom/posthog/surveys/SurveyTranslation;Ljava/util/List;ILjava/lang/Object;)Lcom/posthog/internal/surveys/ResolvedSurveyTranslations; - public fun equals (Ljava/lang/Object;)Z - public final fun getMatchedKey ()Ljava/lang/String; - public final fun getQuestions ()Ljava/util/List; - public final fun getSurvey ()Lcom/posthog/surveys/SurveyTranslation; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class com/posthog/internal/surveys/SurveyLanguageDetectionKt { - public static final fun detectSurveyLanguage (Ljava/lang/String;Ljava/util/Map;Ljava/lang/String;)Ljava/lang/String; -} - -public final class com/posthog/internal/surveys/SurveyTranslationApplierKt { - public static final fun resolveSurveyTranslations (Lcom/posthog/surveys/Survey;Ljava/lang/String;)Lcom/posthog/internal/surveys/ResolvedSurveyTranslations; -} - -public final class com/posthog/internal/surveys/SurveyUtilsKt { - public static final fun canActivateRepeatedly (Lcom/posthog/surveys/Survey;)Z - public static final fun hasEvents (Lcom/posthog/surveys/Survey;)Z - public static final fun hasWaitPeriodPassed (Lcom/posthog/surveys/Survey;Ljava/util/Date;Ljava/util/Date;)Z -} - public abstract interface class com/posthog/logs/PostHogBeforeSendLog { public abstract fun run (Lcom/posthog/logs/PostHogLogRecord;)Lcom/posthog/logs/PostHogLogRecord; } @@ -2418,9 +1524,3 @@ public final class com/posthog/surveys/SurveyType$Companion { public final fun fromValue (Ljava/lang/String;)Lcom/posthog/surveys/SurveyType; } -public final class com/posthog/vendor/uuid/TimeBasedEpochGenerator { - public static final field INSTANCE Lcom/posthog/vendor/uuid/TimeBasedEpochGenerator; - public final fun generate ()Ljava/util/UUID; - public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V -} - diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index ea0a756d9..58aeb49cb 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -65,6 +65,20 @@ private val REQUIRED_REPLAY_DEBUG_PROPERTY_KEYS = "\$sdk_debug_replay_internal_buffer_length", ) +// On every event whether or not the bundle is claimed; the rest of the bundle needs the claim. +private val UNTHROTTLED_DEBUG_PROPERTY_KEYS = + REQUIRED_REPLAY_DEBUG_PROPERTY_KEYS + setOf("\$sdk_debug_pending_queue_size", "\$sdk_debug_error_capturing_properties") + +// Wrapper builds waiting for their send, and recent sends. A build the wrapper drops is evicted by +// newer ones; an evicted build can no longer be sent. +private const val MAX_TRACKED_WRAPPER_EVENTS = 1000 + +private fun requiresPersonProcessing( + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, +): Boolean = userProperties?.isEmpty() == false || userPropertiesSetOnce?.isEmpty() == false || groups?.isEmpty() == false + private fun isReplayDebugEvent(event: String): Boolean = event.startsWith("\$") && event != PostHogEventName.FEATURE_FLAG_CALLED.event && @@ -129,6 +143,15 @@ public class PostHog private constructor( private var lastReplayDebugPropertiesAt: Long? = null private var replayDebugClaimOutstanding = false + // Keyed by event uuid in build order. Both guarded by pendingWrapperBuilds. + private val pendingWrapperBuilds = LinkedHashMap() + private val sentWrapperEvents = LinkedHashSet() + + // Incremented by reset(), so a send can tell the user changed since the build even when + // reuseAnonymousId keeps the distinct id the same. + @Volatile + private var resetGeneration = 0L + private var replayQueue: PostHogQueueInterface? = null private var logsQueue: PostHogQueueInterface? = null @@ -388,6 +411,7 @@ public class PostHog private constructor( applyBootstrapIfNeeded(config) + clearWrapperBuilds() super.enabled = true // Initialize device_id if not already set. getDeviceId() handles lazy init @@ -667,6 +691,7 @@ public class PostHog private constructor( featureFlagsCalled.clear() lastScreenName = null + clearWrapperBuilds() synchronized(replayDebugPropertiesLock) { lastReplayDebugPropertiesAt = null replayDebugClaimOutstanding = false @@ -771,6 +796,7 @@ public class PostHog private constructor( appendGroups: Boolean = true, timestamp: Date? = null, claimReplayDebugBundle: () -> Boolean = { false }, + readReplayDebugProperties: () -> Map = ::readReplayDebugProperties, ): MutableMap { val props = mutableMapOf() @@ -839,7 +865,7 @@ public class PostHog private constructor( if (appendSharedProps) { // After session resolution so the debug snapshot never precedes a rotation triggered by // getActiveSessionId() above. - props.putAll(sdkDebugProperties(sessionIdString, timestamp, claimReplayDebugBundle)) + props.putAll(sdkDebugProperties(sessionIdString, timestamp, claimReplayDebugBundle, readReplayDebugProperties)) // Registered super properties win over everything the SDK set above. val registeredPrefs = getPreferences().getAll() @@ -880,10 +906,14 @@ public class PostHog private constructor( return props } + private fun readReplayDebugProperties(): Map = + sessionReplayHandler?.debugProperties() ?: mapOf("\$recording_status" to "disabled") + private fun sdkDebugProperties( sessionId: String?, timestamp: Date?, claimReplayDebugBundle: () -> Boolean, + readReplayDebugProperties: () -> Map = ::readReplayDebugProperties, ): Map { val props = mutableMapOf() val managerStart = PostHogSessionManager.getSessionStartedAt() @@ -896,7 +926,7 @@ public class PostHog private constructor( // Guarded separately from the session and queue keys below: those come from the session // manager and the queue, not from replay, so a handler that throws must not suppress them. try { - val replayProps = sessionReplayHandler?.debugProperties() ?: mapOf("\$recording_status" to "disabled") + val replayProps = readReplayDebugProperties() if (claimed) { props.putAll(replayProps) } else { @@ -976,15 +1006,20 @@ public class PostHog private constructor( * Adds `process_person_profile: false` when the SDK doesn't process person profiles for this * event, unless the caller set that option or [properties] carry a non-null legacy * `$process_person_profile` property (per call or registered). Nothing is added otherwise: - * PostHog processes person profiles by default. Returns a copy, so `beforeSend` edits never - * reach the caller's map. + * PostHog processes person profiles by default. [personProcessingPending] counts a person + * processing change a wrapper build defers to its send. Returns a copy, so `beforeSend` edits + * never reach the caller's map. */ private fun personProcessingOptions( options: Map?, properties: Map, + personProcessingPending: Boolean = false, ): MutableMap? { val eventOptions = options?.toMutableMap() - if (hasPersonProcessing() || properties["\$process_person_profile"] != null) { + if ((personProcessingPending && config?.personProfiles != PersonProfiles.NEVER) || + hasPersonProcessing() || + properties["\$process_person_profile"] != null + ) { return eventOptions } val result = eventOptions ?: mutableMapOf() @@ -1104,132 +1139,398 @@ public class PostHog private constructor( // or replacing the event. var claimedReplayDebugBundle = false try { - if (!isEnabled()) { + val prepared = + prepareEvent( + event, + distinctId, + properties, + userProperties, + userPropertiesSetOnce, + groups, + timestamp, + options, + aiLane, + claimReplayDebugBundle = { claimReplayDebugPropertiesWindow().also { claimedReplayDebugBundle = it } }, + ) ?: return null + val postHogEvent = runBeforeSend(prepared.event, prepared.event.uuid) + if (postHogEvent == null) { + logDroppedEvent(event) return null } - if (isOptedOut()) { - config?.logger?.log("PostHog is in OptOut state.") - return null + return enqueueEvent(postHogEvent, event, prepared.mergedProperties, aiLane) { + if (claimedReplayDebugBundle) { + commitReplayDebugPropertiesWindow() + } + } + } catch (e: Throwable) { + config?.logger?.log("Capture failed: $e.") + return null + } finally { + if (claimedReplayDebugBundle) { + releaseReplayDebugPropertiesClaim() } + } + } - // A blank distinct ID falls back to the current one, like null, instead of PostHog dropping the event. - val newDistinctId = distinctId?.takeIf { it.isNotBlank() } ?: this.distinctId + /** + * An event enriched for capture, before the `beforeSend` chain runs. + * + * @property mergedProperties the enriched properties before the `$feature_flag_called` + * minimization, which the surveys and replay handlers read. + */ + private class PreparedEvent( + val event: PostHogEvent, + val mergedProperties: Map, + ) - // if the user isn't identified but passed userProperties, userPropertiesSetOnce or groups, - // we should still enable person processing since this is intentional - if (userProperties?.isEmpty() == false || userPropertiesSetOnce?.isEmpty() == false || groups?.isEmpty() == false) { - requirePersonProcessing("capture", ignoreMessage = true) - } + /** + * What [buildEventForWrapper] decided from the call, read back by [sendBuiltEventForWrapper]: + * the wrapper's hook can rename the event, and a build changes no SDK state. + * [deferredReplayDebug] holds the replay debug values from the build, sent only if the send + * claims the window; null when the event can't carry them. + */ + private class WrapperBuild( + val deferredReplayDebug: Map?, + val requiresPersonProcessing: Boolean, + val sdkDistinctId: String, + val resetGeneration: Long, + ) - // Automatically set person properties for feature flags during capture event + /** + * Checks and enriches one event without running `beforeSend` or queueing it. Returns null when + * the SDK wouldn't capture it. [claimReplayDebugBundle] is called only for events that may carry + * the replay debug bundle. + */ + private fun prepareEvent( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + options: Map?, + aiLane: Boolean, + claimReplayDebugBundle: () -> Boolean, + readReplayDebugProperties: () -> Map = ::readReplayDebugProperties, + setFlagPersonProperties: Boolean = true, + deferPersonProcessing: Boolean = false, + ): PreparedEvent? { + if (!isEnabled()) { + return null + } + if (isOptedOut()) { + config?.logger?.log("PostHog is in OptOut state.") + return null + } + + // A blank distinct ID falls back to the current one, like null, instead of PostHog dropping the event. + val newDistinctId = distinctId?.takeIf { it.isNotBlank() } ?: this.distinctId + + // if the user isn't identified but passed userProperties, userPropertiesSetOnce or groups, + // we should still enable person processing since this is intentional + val requiresPersonProcessing = requiresPersonProcessing(userProperties, userPropertiesSetOnce, groups) + if (requiresPersonProcessing && !deferPersonProcessing) { + requirePersonProcessing("capture", ignoreMessage = true) + } + + // Automatically set person properties for feature flags during capture event + if (setFlagPersonProperties) { setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce) + } - if (newDistinctId.isBlank()) { - config?.logger?.log("capture call not allowed, distinctId is invalid: $newDistinctId.") - return null + if (newDistinctId.isBlank()) { + config?.logger?.log("capture call not allowed, distinctId is invalid: $newDistinctId.") + return null + } + + // The AI lane always uses its own queue. + val isSnapshotEvent = !aiLane && event == PostHogEventName.SNAPSHOT.event + var groupIdentify = false + if (event == PostHogEventName.GROUP_IDENTIFY.event) { + groupIdentify = true + } + + // Externally-built $exception events (e.g. the Flutter/RN bridge) carry only + // serialized class names, so they are matched by name instead of isInstance. + val ignored = config?.errorTrackingConfig?.ignoredExceptionTypes + if (!ignored.isNullOrEmpty() && + event == PostHogEventName.EXCEPTION.event && + hasIgnoredTypeInExceptionList(properties, ignored) + ) { + config?.logger?.log( + "Skipping \$exception: an entry in \$exception_list matches ignoredExceptionTypes", + ) + return null + } + + // Attach the buffered exception steps to any $exception event (unless the caller + // already supplied them), so externally-built exceptions (e.g. from the Flutter/RN + // bridge) carry steps too, not only those captured via captureException(throwable). + val stepsBuffer = exceptionStepsBuffer + val effectiveProperties = + if (event == PostHogEventName.EXCEPTION.event && stepsBuffer != null) { + val mutableProperties = properties?.toMutableMap() ?: mutableMapOf() + stepsBuffer.attachTo(mutableProperties) + mutableProperties + } else { + properties } - // The AI lane always uses its own queue. - var isSnapshotEvent = !aiLane && event == PostHogEventName.SNAPSHOT.event - var groupIdentify = false - if (event == PostHogEventName.GROUP_IDENTIFY.event) { - groupIdentify = true + val mergedProperties = + buildProperties( + newDistinctId, + properties = effectiveProperties, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + groups = groups, + // only append shared props if not a snapshot event + appendSharedProps = !isSnapshotEvent, + // only append groups if not a group identify event and not a snapshot + appendGroups = !groupIdentify, + timestamp = timestamp, + claimReplayDebugBundle = { isReplayDebugEvent(event) && claimReplayDebugBundle() }, + readReplayDebugProperties = readReplayDebugProperties, + ) + + val eventProperties = minimizeIfFeatureFlagCalled(event, mergedProperties) + + // Snapshots don't carry the SDK's person-processing option. + val eventOptions = + if (isSnapshotEvent) { + options?.toMutableMap() + } else { + personProcessingOptions(options, mergedProperties, deferPersonProcessing && requiresPersonProcessing) } - // Externally-built $exception events (e.g. the Flutter/RN bridge) carry only - // serialized class names, so they are matched by name instead of isInstance. - val ignored = config?.errorTrackingConfig?.ignoredExceptionTypes - if (!ignored.isNullOrEmpty() && - event == PostHogEventName.EXCEPTION.event && - hasIgnoredTypeInExceptionList(properties, ignored) - ) { - config?.logger?.log( - "Skipping \$exception: an entry in \$exception_list matches ignoredExceptionTypes", - ) - return null + // This capture's identity, kept through beforeSend. + val uuid = TimeBasedEpochGenerator.generate() + return PreparedEvent(newEvent(event, newDistinctId, eventProperties, timestamp, eventOptions, uuid), mergedProperties) + } + + private fun logDroppedEvent(event: String) { + val originalMessage = "PostHog event $event was dropped" + val message = + if (PostHogEventName.isUnsafeEditable(event)) { + "$originalMessage. This can cause unexpected behavior." + } else { + originalMessage } + config?.logger?.log(message) + } - // Attach the buffered exception steps to any $exception event (unless the caller - // already supplied them), so externally-built exceptions (e.g. from the Flutter/RN - // bridge) carry steps too, not only those captured via captureException(throwable). - val stepsBuffer = exceptionStepsBuffer - val effectiveProperties = - if (event == PostHogEventName.EXCEPTION.event && stepsBuffer != null) { - val mutableProperties = properties?.toMutableMap() ?: mutableMapOf() - stepsBuffer.attachTo(mutableProperties) - mutableProperties + /** + * Queues [postHogEvent], already through `beforeSend`, on its lane and notifies the surveys and + * replay handlers with [event] and [mergedProperties] from before the hook. Returns the event's + * uuid when it's queued. [onQueued] runs right after the event is added to the analytics or AI + * queue. + */ + private fun enqueueEvent( + postHogEvent: PostHogEvent, + event: String, + mergedProperties: Map, + aiLane: Boolean, + onQueued: () -> Unit = {}, + ): UUID? { + // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook + val isSnapshotEvent = !aiLane && postHogEvent.event == PostHogEventName.SNAPSHOT.event + // if this is a $snapshot event and $session_id is missing, don't process then event + if (isSnapshotEvent && postHogEvent.properties?.get("\$session_id") == null) { + config?.logger?.log("${postHogEvent.event} event dropped, because the \$session_id property is missing") + return null + } + // Replay has its own queue + if (isSnapshotEvent) { + replayQueue?.add(postHogEvent) + return postHogEvent.uuid + } + val targetQueue = if (aiLane) aiQueue ?: return null else queue + var queued = false + // beforeSend may have opted out + if (isOptedOut()) { + config?.logger?.log("PostHog is in OptOut state.") + } else if (aiLane && isOverAiEventLimit(postHogEvent)) { + // dropped here rather than in the queue, so captureAi returns null + } else if (targetQueue != null) { + targetQueue.add(postHogEvent) + queued = true + onQueued() + } + // Notify surveys integration about the event + surveysHandler?.onEvent(event, mergedProperties) + // Notify session replay handler about the event for event triggers + sessionReplayHandler?.onEvent(event, mergedProperties) + return if (queued) postHogEvent.uuid else null + } + + @PostHogInternal + override fun buildEventForWrapper( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + options: Map?, + ai: Boolean, + timestamp: Date?, + ): PostHogEvent? { + try { + if (!isEnabled()) { + return null + } + // The wrapper may drop the event after this, so state changes wait for the send. + val eventProperties = + if (!ai && event == PostHogEventName.SCREEN.event) { + val screenTitle = properties?.get("\$screen_name") as? String ?: return null + screenProperties(screenTitle, properties, cacheScreenName = false) ?: return null } else { properties } + // One replay read for the build's required keys and the deferred bundle, so a replay + // transition between them can't pair keys from two different states. + val replayDebugSnapshot by lazy { runCatching { readReplayDebugProperties() } } + val readReplayDebugSnapshot = { replayDebugSnapshot.getOrThrow() } + val built = + prepareEvent( + event, + distinctId, + eventProperties, + userProperties, + userPropertiesSetOnce, + groups, + timestamp, + options, + ai, + claimReplayDebugBundle = { false }, + readReplayDebugProperties = readReplayDebugSnapshot, + setFlagPersonProperties = false, + deferPersonProcessing = true, + )?.event ?: return null + built.uuid?.let { uuid -> + // Snapshotted now, so a new session during the round trip doesn't change them. + val deferredReplayDebug = + if (isReplayDebugEvent(event)) { + sdkDebugProperties( + built.properties?.get("\$session_id") as? String, + built.timestamp, + claimReplayDebugBundle = { true }, + readReplayDebugProperties = readReplayDebugSnapshot, + ) + .filterKeys { it !in UNTHROTTLED_DEBUG_PROPERTY_KEYS } + .takeIf { it.isNotEmpty() } + } else { + null + } + val build = + WrapperBuild( + deferredReplayDebug = deferredReplayDebug, + requiresPersonProcessing = requiresPersonProcessing(userProperties, userPropertiesSetOnce, groups), + sdkDistinctId = this.distinctId, + resetGeneration = resetGeneration, + ) + synchronized(pendingWrapperBuilds) { + pendingWrapperBuilds[uuid] = build + if (pendingWrapperBuilds.size > MAX_TRACKED_WRAPPER_EVENTS) { + pendingWrapperBuilds.remove(pendingWrapperBuilds.keys.first()) + } + } + } + return built + } catch (e: Throwable) { + config?.logger?.log("Capture failed: $e.") + return null + } + } - val mergedProperties = - buildProperties( - newDistinctId, - properties = effectiveProperties, - userProperties = userProperties, - userPropertiesSetOnce = userPropertiesSetOnce, - groups = groups, - // only append shared props if not a snapshot event - appendSharedProps = !isSnapshotEvent, - // only append groups if not a group identify event and not a snapshot - appendGroups = !groupIdentify, - timestamp = timestamp, - claimReplayDebugBundle = { - isReplayDebugEvent(event) && claimReplayDebugPropertiesWindow().also { claimedReplayDebugBundle = it } - }, - ) - - val eventProperties = minimizeIfFeatureFlagCalled(event, mergedProperties) - - // Snapshots don't carry the SDK's person-processing option. - val eventOptions = - if (isSnapshotEvent) options?.toMutableMap() else personProcessingOptions(options, mergedProperties) + private fun clearWrapperBuilds() { + synchronized(pendingWrapperBuilds) { + pendingWrapperBuilds.clear() + } + } - // This capture's identity, kept through beforeSend. - val uuid = TimeBasedEpochGenerator.generate() - val postHogEvent = buildEvent(event, newDistinctId, eventProperties, timestamp, eventOptions, uuid) - if (postHogEvent == null) { - val originalMessage = "PostHog event $event was dropped" - val message = - if (PostHogEventName.isUnsafeEditable(event)) { - "$originalMessage. This can cause unexpected behavior." - } else { - originalMessage + @PostHogInternal + override fun sendBuiltEventForWrapper( + event: PostHogEvent, + ai: Boolean, + ): UUID? { + var claimedReplayDebugBundle = false + try { + var alreadySent = false + val build = + event.uuid?.let { uuid -> + synchronized(pendingWrapperBuilds) { + // Checked and recorded together, so two concurrent sends can't both queue it. + alreadySent = !sentWrapperEvents.add(uuid) + if (sentWrapperEvents.size > MAX_TRACKED_WRAPPER_EVENTS) { + sentWrapperEvents.remove(sentWrapperEvents.first()) + } + pendingWrapperBuilds.remove(uuid) } - config?.logger?.log(message) + } + if (alreadySent) { + config?.logger?.log("Event ${event.uuid} was already sent.") return null } - // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook - isSnapshotEvent = !aiLane && postHogEvent.event == PostHogEventName.SNAPSHOT.event - // if this is a $snapshot event and $session_id is missing, don't process then event - if (isSnapshotEvent && postHogEvent.properties?.get("\$session_id") == null) { - config?.logger?.log("${postHogEvent.event} event dropped, because the \$session_id property is missing") + // Builds are cleared on setup() and close() and kept per instance, so this also drops an + // event built by another instance or before a reconfigure. + if (build == null) { + config?.logger?.log("Event ${event.event} wasn't built by buildEventForWrapper on this setup, so it was dropped.") return null } - // Replay has its own queue - if (isSnapshotEvent) { - replayQueue?.add(postHogEvent) - return uuid + if (!isEnabled()) { + return null } - val targetQueue = if (aiLane) aiQueue ?: return null else queue - var queued = false - // beforeSend may have opted out if (isOptedOut()) { config?.logger?.log("PostHog is in OptOut state.") - } else if (aiLane && isOverAiEventLimit(postHogEvent)) { - // dropped here rather than in the queue, so captureAi returns null - } else if (targetQueue != null) { - targetQueue.add(postHogEvent) - queued = true + return null + } + // Person processing and flag person properties apply only while the SDK still has the + // identity the event was built for: after a reset() or identify(), it belongs to the previous one. + val sameIdentity = build.sdkDistinctId == this.distinctId && build.resetGeneration == resetGeneration + if (sameIdentity && build.requiresPersonProcessing) { + requirePersonProcessing("capture", ignoreMessage = true) + } + val uuid = event.uuid + val properties = event.properties ?: mutableMapOf() + if (sameIdentity) { + @Suppress("UNCHECKED_CAST") + setPersonPropertiesForFlagsIfNeeded( + properties["\$set"] as? Map, + properties["\$set_once"] as? Map, + ) + } + // A reset() since the build cleared the screen name, so the old one isn't restored. + if (!ai && build.resetGeneration == resetGeneration && event.event == PostHogEventName.SCREEN.event) { + (properties["\$screen_name"] as? String)?.trim()?.takeIf { it.isNotEmpty() }?.let { cacheScreenName(it) } + } + // The build added only the required replay debug keys. The window is claimed here, so a + // built event the wrapper drops never holds or starts it. Eligibility and values come from + // the build, since the wrapper's hook can rename the event and the session can change. + val deferredReplayDebug = build.deferredReplayDebug + if (deferredReplayDebug != null && claimReplayDebugPropertiesWindow()) { + claimedReplayDebugBundle = true + // Only keys the event lacks, so the wrapper's values win. + deferredReplayDebug.forEach { (key, value) -> + if (!properties.containsKey(key)) { + properties[key] = value + } + } + } + val builtEvent = if (event.properties == null) event.copy(properties = properties) else event + // Copied before the hooks run, which can change the map in place. + val handlerProperties = + if (surveysHandler != null || sessionReplayHandler != null) properties.toMap() else emptyMap() + val postHogEvent = runBeforeSend(builtEvent, uuid) + if (postHogEvent == null) { + logDroppedEvent(builtEvent.event) + return null + } + return enqueueEvent(postHogEvent, builtEvent.event, handlerProperties, ai) { if (claimedReplayDebugBundle) { commitReplayDebugPropertiesWindow() } } - // Notify surveys integration about the event - surveysHandler?.onEvent(event, mergedProperties) - // Notify session replay handler about the event for event triggers - sessionReplayHandler?.onEvent(event, mergedProperties) - return if (queued) uuid else null } catch (e: Throwable) { config?.logger?.log("Capture failed: $e.") return null @@ -1602,16 +1903,28 @@ public class PostHog private constructor( return } + val props = screenProperties(screenTitle, properties) ?: return + + capture(PostHogEventName.SCREEN.event, properties = props, options = options) + } + + /** + * Caches [screenTitle], trimmed, as the current screen and returns [properties] with it as + * `$screen_name`. Returns null for a blank title. + */ + private fun screenProperties( + screenTitle: String, + properties: Map?, + cacheScreenName: Boolean = true, + ): MutableMap? { val trimmedTitle = screenTitle.trim() if (trimmedTitle.isEmpty()) { - return + return null } - if (lastScreenName != trimmedTitle) config.notifyIntegrationsChanged() - - // Cache for capture-time context snapshot on log records and for the - // $screen_name auto-attach on subsequent events (see buildProperties). - this.lastScreenName = trimmedTitle + if (cacheScreenName) { + cacheScreenName(trimmedTitle) + } val props = mutableMapOf() properties?.let { @@ -1620,8 +1933,14 @@ public class PostHog private constructor( // The title wins over a caller-supplied $screen_name, matching the cached value. props["\$screen_name"] = trimmedTitle + return props + } - capture(PostHogEventName.SCREEN.event, properties = props, options = options) + // Cache for capture-time context snapshot on log records and for the + // $screen_name auto-attach on subsequent events (see buildProperties). + private fun cacheScreenName(screenName: String) { + if (lastScreenName != screenName) config.notifyIntegrationsChanged() + this.lastScreenName = screenName } public override fun alias( @@ -2368,6 +2687,7 @@ public class PostHog private constructor( } config.notifyIntegrationsChanged() + resetGeneration++ // Capture the logging-out identity before preferences are cleared, so the push token can be // unregistered for it and re-registered under the new anonymous id (decision 5/6). val previousDistinctId = distinctId @@ -3078,6 +3398,36 @@ public class PostHog private constructor( return shared.getConfig() } + @PostHogInternal + override fun buildEventForWrapper( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + options: Map?, + ai: Boolean, + timestamp: Date?, + ): PostHogEvent? = + shared.buildEventForWrapper( + event, + distinctId = distinctId, + properties = properties, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + groups = groups, + options = options, + ai = ai, + timestamp = timestamp, + ) + + @PostHogInternal + override fun sendBuiltEventForWrapper( + event: PostHogEvent, + ai: Boolean, + ): UUID? = shared.sendBuiltEventForWrapper(event, ai) + override fun isSessionActive(): Boolean { return shared.isSessionActive() } diff --git a/posthog/src/main/java/com/posthog/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 108335003..9d116baca 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -577,4 +577,68 @@ public interface PostHogInterface : PostHogCoreInterface { @PostHogInternal public fun getConfig(): T? + + /** + * For wrapper SDKs only (Flutter, React Native); apps use [capture] and [captureAi]. + * + * Builds the event that [capture] (or [captureAi] when [ai] is true) would send: the same + * checks, SDK and registered properties, per-call values, session, screen and options, with a + * new uuid and timestamp. It doesn't run `beforeSend` and doesn't queue the event, so a wrapper + * can run its own hook on the full event, then pass the result to [sendBuiltEventForWrapper]. + * It changes no SDK state, so a wrapper can drop the event: the current screen, the person + * properties used for flags, person processing turned on by [userProperties], + * [userPropertiesSetOnce] or [groups], and the replay debug window are updated by + * [sendBuiltEventForWrapper]. That send uses the event name passed here to decide whether the + * replay debug bundle applies, even if the wrapper's hook renamed the event. + * + * Returns null when the SDK wouldn't capture the event: disabled, opted out, an ignored + * exception type, or a `$screen` event (not on the AI lane) without a non-blank `$screen_name`. Defaults to null. + * + * ```kotlin + * val event = PostHog.buildEventForWrapper("purchase", properties = mapOf("plan" to "pro")) + * ?: return + * PostHog.sendBuiltEventForWrapper(wrapperBeforeSend(event) ?: return) + * ``` + * + * @suppress + */ + @PostHogInternal + public fun buildEventForWrapper( + event: String, + distinctId: String? = null, + properties: Map? = null, + userProperties: Map? = null, + userPropertiesSetOnce: Map? = null, + groups: Map? = null, + options: Map? = null, + ai: Boolean = false, + timestamp: Date? = null, + ): PostHogEvent? = null + + /** + * For wrapper SDKs only (Flutter, React Native); apps use [capture] and [captureAi]. + * + * Sends an event from [buildEventForWrapper] without adding properties again: runs the native + * `beforeSend` chain, then queues it on the analytics lane, or the AI lane when [ai] is true. + * The event keeps its uuid and timestamp. Legacy option properties are moved into options when + * the event is sent, as for [capture]. + * Surveys and session replay event triggers see [event] as passed in, after the wrapper's hook. + * Like [capture], it updates the person properties used for flags from `$set` and `$set_once`, + * caches a `$screen` event's `$screen_name` as the current screen, turns person processing on + * when the build had user properties or groups, and may add the replay debug values snapshotted + * at build that the event lacks. Keys the wrapper's hook removed aren't added back. + * + * Person processing and the person properties for flags are skipped when `reset()` or + * `identify()` changed the user since the build. The event is dropped when this instance didn't + * build it, when `setup()` or `close()` ran since the build, or when it was already sent. + * + * Returns the event's uuid once it's queued, or null when it's dropped. Defaults to null. + * + * @suppress + */ + @PostHogInternal + public fun sendBuiltEventForWrapper( + event: PostHogEvent, + ai: Boolean = false, + ): UUID? = null } diff --git a/posthog/src/main/java/com/posthog/PostHogStateless.kt b/posthog/src/main/java/com/posthog/PostHogStateless.kt index 104829ce5..eb5673141 100644 --- a/posthog/src/main/java/com/posthog/PostHogStateless.kt +++ b/posthog/src/main/java/com/posthog/PostHogStateless.kt @@ -308,16 +308,37 @@ public open class PostHogStateless protected constructor( timestamp: Date? = null, options: MutableMap? = null, uuid: UUID? = null, + ): PostHogEvent? = runBeforeSend(newEvent(event, distinctId, properties, timestamp, options, uuid), uuid) + + /** + * Creates the event, without running `beforeSend`. A null [timestamp] means now, and a null + * [uuid] gets a new UUID v7. + */ + internal fun newEvent( + event: String, + distinctId: String, + properties: MutableMap, + timestamp: Date? = null, + options: MutableMap? = null, + uuid: UUID? = null, + ): PostHogEvent = + PostHogEvent( + event, + distinctId, + properties = properties, + timestamp = timestamp ?: config?.dateProvider?.currentDate() ?: Date(), + uuid = uuid ?: TimeBasedEpochGenerator.generate(), + options = options, + ) + + /** + * Runs the `beforeSend` chain on [postHogEvent]. Returns null when a hook drops the event or + * throws. When [uuid] is given, the result keeps it. + */ + internal fun runBeforeSend( + postHogEvent: PostHogEvent, + uuid: UUID?, ): PostHogEvent? { - val postHogEvent = - PostHogEvent( - event, - distinctId, - properties = properties, - timestamp = timestamp ?: config?.dateProvider?.currentDate() ?: Date(), - uuid = uuid ?: TimeBasedEpochGenerator.generate(), - options = options, - ) var eventChecked: PostHogEvent = postHogEvent val beforeSendList = config?.beforeSendList ?: emptyList() @@ -326,7 +347,7 @@ public open class PostHogStateless protected constructor( try { val result = beforeSend.run(eventChecked) if (result == null) { - config?.logger?.log("Event $event was rejected in beforeSend function") + config?.logger?.log("Event ${postHogEvent.event} was rejected in beforeSend function") return null } eventChecked = result diff --git a/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt b/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt index 060ab3854..117e5ac59 100644 --- a/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt +++ b/posthog/src/test/java/com/posthog/PostHogSessionReplayHandlerFake.kt @@ -49,11 +49,14 @@ public class PostHogSessionReplayHandlerFake(private var isActive: Boolean) : Po return isActive } + // runs after each debugProperties() read, so a test can change the state between reads + public var onDebugProperties: (() -> Unit)? = null + override fun debugProperties(): Map { if (throwOnDebugProperties) { throw RuntimeException("debugProperties failed") } - return debugProperties + return debugProperties.also { onDebugProperties?.invoke() } } override fun onEvent( diff --git a/posthog/src/test/java/com/posthog/PostHogWrapperEventTest.kt b/posthog/src/test/java/com/posthog/PostHogWrapperEventTest.kt new file mode 100644 index 000000000..00ca720c9 --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogWrapperEventTest.kt @@ -0,0 +1,429 @@ +package com.posthog + +import com.posthog.internal.PostHogMemoryPreferences +import com.posthog.internal.PostHogPreferences.Companion.PERSON_PROPERTIES_FOR_FLAGS +import com.posthog.internal.PostHogThreadFactory +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.Date +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +internal class PostHogWrapperEventTest { + @get:Rule + val tmpDir = TemporaryFolder() + + @get:Rule + val httpServers = TestHttpServers() + + private val queueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestQueue")) + private val replayQueueExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestReplayQueue")) + private val logsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestLogsQueue")) + private val aiExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestAiQueue")) + private val remoteConfigExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestRemoteConfig")) + private val cachedEventsExecutor = Executors.newSingleThreadScheduledExecutor(PostHogThreadFactory("TestCachedEvents")) + private val clients = mutableListOf() + + private fun getSut( + http: MockWebServer, + configure: PostHogConfig.() -> Unit = {}, + ): PostHogInterface { + val storage = tmpDir.newFolder().absolutePath + val config = + PostHogConfig(PROJECT_TOKEN, http.url("/").toString()).apply { + storagePrefix = File(storage, "events").absolutePath + replayStoragePrefix = File(storage, "snapshots").absolutePath + logsStoragePrefix = File(storage, "logs").absolutePath + aiStoragePrefix = File(storage, "ai").absolutePath + flushAt = 1 + preloadFeatureFlags = false + sendFeatureFlagEvent = false + cachePreferences = PostHogMemoryPreferences() + context = TestPostHogContext() + configure() + } + return PostHog.withInternal( + config, + queueExecutor, + replayQueueExecutor, + remoteConfigExecutor, + cachedEventsExecutor, + reloadFeatureFlags = false, + logsExecutor = logsExecutor, + aiExecutor = aiExecutor, + ).also { clients.add(it) } + } + + @AfterTest + fun cleanup() { + clients.forEach { it.close() } + listOf(queueExecutor, replayQueueExecutor, logsExecutor, aiExecutor, remoteConfigExecutor, cachedEventsExecutor).forEach { + it.shutdownAndAwaitTermination() + } + tmpDir.root.deleteRecursively() + } + + private fun takeRequest(http: MockWebServer): RecordedRequest = assertNotNull(http.takeRequest(5, TimeUnit.SECONDS)) + + private fun events(request: RecordedRequest): List> = captureV1Events(request.body.unGzip()) + + @Suppress("UNCHECKED_CAST") + private fun Map.properties(): Map = this["properties"] as Map + + @Test + fun `build fills the event without running beforeSend or queueing it`() { + val http = httpServers.mockHttp() + var hookCalls = 0 + val sut = + getSut(http) { + addBeforeSend { event -> + hookCalls++ + event + } + } + sut.register("plan", "free") + + val event = + assertNotNull( + sut.buildEventForWrapper( + "purchase", + properties = mapOf("plan" to "pro"), + userProperties = mapOf("email" to "a@b.c"), + options = mapOf("cookieless_mode" to true), + ), + ) + + queueExecutor.awaitExecution() + assertEquals(0, http.requestCount) + assertEquals(0, hookCalls) + assertEquals("purchase", event.event) + assertEquals(sut.distinctId(), event.distinctId) + assertNotNull(event.uuid) + assertEquals("pro", event.properties?.get("plan")) + assertEquals("Android", event.properties?.get("\$os_name")) + assertEquals(mapOf("email" to "a@b.c"), event.properties?.get("\$set")) + assertEquals(true, event.options?.get("cookieless_mode")) + } + + @Test + fun `send keeps the built event's identity and properties, then runs beforeSend`() { + val http = httpServers.mockHttp() + val sut = + getSut(http) { + addBeforeSend { event -> + event.properties?.put("hooked", true) + event + } + } + val timestamp = Date(1_700_000_000_000) + val built = assertNotNull(sut.buildEventForWrapper("purchase", timestamp = timestamp)) + built.properties?.remove("\$os_name") + sut.register("late", "value") + + val uuid = sut.sendBuiltEventForWrapper(built) + + queueExecutor.awaitExecution() + val sent = events(takeRequest(http)).single() + assertEquals(built.uuid, uuid) + assertEquals(built.uuid.toString(), sent["uuid"]) + assertEquals("2023-11-14T22:13:20.000Z", sent["timestamp"]) + assertEquals(true, sent.properties()["hooked"]) + assertFalse(sent.properties().containsKey("\$os_name")) + assertFalse(sent.properties().containsKey("late")) + } + + @Test + fun `send on the AI lane posts to the AI endpoint`() { + val http = httpServers.mockHttp() + val sut = getSut(http) + sut.startSession() + val built = assertNotNull(sut.buildEventForWrapper("\$ai_generation", ai = true)) + + assertEquals(built.uuid, sut.sendBuiltEventForWrapper(built, ai = true)) + + aiExecutor.awaitExecution() + val request = takeRequest(http) + assertEquals("/i/v1/ai/events", request.path) + assertNotNull(events(request).single().properties()["\$sdk_debug_session_start"]) + } + + @Test + fun `send returns null when beforeSend drops the event`() { + val http = httpServers.mockHttp() + val sut = getSut(http) { addBeforeSend { null } } + val built = assertNotNull(sut.buildEventForWrapper("purchase")) + + assertNull(sut.sendBuiltEventForWrapper(built)) + + queueExecutor.awaitExecution() + assertEquals(0, http.requestCount) + } + + @Test + fun `send returns null after an opt-out since the build`() { + val http = httpServers.mockHttp() + val sut = getSut(http) + val built = assertNotNull(sut.buildEventForWrapper("purchase")) + sut.optOut() + + assertNull(sut.sendBuiltEventForWrapper(built)) + + queueExecutor.awaitExecution() + assertEquals(0, http.requestCount) + } + + @Test + fun `build returns null when opted out`() { + val sut = getSut(httpServers.mockHttp()) + sut.optOut() + + assertNull(sut.buildEventForWrapper("purchase")) + } + + @Test + fun `build of a screen event without a screen name returns null`() { + val sut = getSut(httpServers.mockHttp()) + + assertNull(sut.buildEventForWrapper("\$screen", properties = mapOf("\$screen_name" to " "))) + assertNull(sut.buildEventForWrapper("\$screen")) + } + + @Test + fun `a built event the wrapper drops leaves no state behind`() { + val prefs = PostHogMemoryPreferences() + val sut = getSut(httpServers.mockHttp()) { cachePreferences = prefs } + + assertNotNull( + sut.buildEventForWrapper( + "\$screen", + properties = mapOf("\$screen_name" to "Home"), + userProperties = mapOf("plan" to "pro"), + ), + ) + val next = assertNotNull(sut.buildEventForWrapper("tap")) + + assertFalse(next.properties!!.containsKey("\$screen_name")) + assertNull(prefs.getValue(PERSON_PROPERTIES_FOR_FLAGS)) + assertEquals(false, next.options?.get("process_person_profile")) + } + + @Test + fun `send turns on person processing for a build with user properties`() { + val sut = getSut(httpServers.mockHttp()) + val identified = assertNotNull(sut.buildEventForWrapper("signup", userProperties = mapOf("plan" to "pro"))) + + assertNotNull(sut.sendBuiltEventForWrapper(identified)) + val next = assertNotNull(sut.buildEventForWrapper("tap")) + + assertNull(identified.options?.get("process_person_profile")) + assertNull(next.options?.get("process_person_profile")) + } + + @Test + fun `send caches the screen name and the person properties for flags`() { + val prefs = PostHogMemoryPreferences() + val sut = getSut(httpServers.mockHttp()) { cachePreferences = prefs } + val screen = + assertNotNull( + sut.buildEventForWrapper( + "\$screen", + properties = mapOf("\$screen_name" to " Home "), + userProperties = mapOf("plan" to "pro"), + ), + ) + + assertNotNull(sut.sendBuiltEventForWrapper(screen)) + val next = assertNotNull(sut.buildEventForWrapper("tap")) + + assertEquals("Home", screen.properties?.get("\$screen_name")) + assertEquals("Home", next.properties?.get("\$screen_name")) + assertEquals(mapOf("plan" to "pro"), prefs.getValue(PERSON_PROPERTIES_FOR_FLAGS)) + } + + @Test + fun `send after a reset delivers the screen without restoring its name`() { + val sut = getSut(httpServers.mockHttp()) + val screen = assertNotNull(sut.buildEventForWrapper("\$screen", properties = mapOf("\$screen_name" to "Home"))) + + sut.reset() + assertNotNull(sut.sendBuiltEventForWrapper(screen)) + val next = assertNotNull(sut.buildEventForWrapper("tap")) + + assertNull(next.properties?.get("\$screen_name")) + } + + @Test + fun `the replay debug bundle is claimed at send`() { + val http = httpServers.mockHttp() + val sut = getSut(http) { flushAt = 2 } + sut.startSession() + val first = assertNotNull(sut.buildEventForWrapper("\$first")) + val second = assertNotNull(sut.buildEventForWrapper("\$second")) + + assertNotNull(sut.sendBuiltEventForWrapper(first)) + assertNotNull(sut.sendBuiltEventForWrapper(second)) + + queueExecutor.awaitExecution() + val (sentFirst, sentSecond) = events(takeRequest(http)) + assertNotNull(sentFirst.properties()["\$sdk_debug_session_start"]) + assertFalse(sentSecond.properties().containsKey("\$sdk_debug_session_start")) + } + + @Test + fun `replay debug eligibility follows the built name across a rename`() { + val http = httpServers.mockHttp() + val sut = getSut(http) { flushAt = 2 } + sut.startSession() + val renamedAway = assertNotNull(sut.buildEventForWrapper("\$first")) + val renamedTo = assertNotNull(sut.buildEventForWrapper("tap")) + + assertNotNull(sut.sendBuiltEventForWrapper(renamedTo.copy(event = "\$second"))) + assertNotNull(sut.sendBuiltEventForWrapper(renamedAway.copy(event = "custom"))) + + queueExecutor.awaitExecution() + val (sentTo, sentAway) = events(takeRequest(http)) + assertNotNull(sentAway.properties()["\$sdk_debug_session_start"]) + assertFalse(sentTo.properties().containsKey("\$sdk_debug_session_start")) + } + + @Test + fun `send keeps a replay debug key the wrapper removed`() { + val http = httpServers.mockHttp() + val sut = getSut(http) + sut.startSession() + val event = assertNotNull(sut.buildEventForWrapper("\$first")) + event.properties?.remove("\$recording_status") + + assertNotNull(sut.sendBuiltEventForWrapper(event)) + + queueExecutor.awaitExecution() + val sent = events(takeRequest(http)).single().properties() + assertNotNull(sent["\$sdk_debug_session_start"]) + assertFalse(sent.containsKey("\$recording_status")) + } + + @Test + fun `a second send of the same built event is a no-op`() { + val http = httpServers.mockHttp() + val sut = getSut(http) { flushAt = 2 } + val event = assertNotNull(sut.buildEventForWrapper("tap")) + + assertNotNull(sut.sendBuiltEventForWrapper(event)) + assertNull(sut.sendBuiltEventForWrapper(event)) + assertNotNull(sut.sendBuiltEventForWrapper(assertNotNull(sut.buildEventForWrapper("next")))) + + queueExecutor.awaitExecution() + assertEquals(listOf("tap", "next"), events(takeRequest(http)).map { it["event"] }) + } + + @Test + fun `send skips the person properties for flags after the user changed`() { + val prefs = PostHogMemoryPreferences() + val sut = getSut(httpServers.mockHttp()) { cachePreferences = prefs } + val event = assertNotNull(sut.buildEventForWrapper("signup", userProperties = mapOf("plan" to "pro"))) + + sut.reset() + assertNotNull(sut.sendBuiltEventForWrapper(event)) + + assertNull(prefs.getValue(PERSON_PROPERTIES_FOR_FLAGS)) + } + + @Test + fun `send skips the person properties for flags after a reset that kept the anonymous id`() { + val prefs = PostHogMemoryPreferences() + val sut = + getSut(httpServers.mockHttp()) { + cachePreferences = prefs + reuseAnonymousId = true + } + val event = assertNotNull(sut.buildEventForWrapper("signup", userProperties = mapOf("plan" to "pro"))) + + sut.reset() + assertNotNull(sut.sendBuiltEventForWrapper(event)) + + assertNull(prefs.getValue(PERSON_PROPERTIES_FOR_FLAGS)) + } + + @Test + fun `send drops an event built by another instance`() { + val built = assertNotNull(getSut(httpServers.mockHttp()).buildEventForWrapper("tap")) + val other = getSut(httpServers.mockHttp()) + + assertNull(other.sendBuiltEventForWrapper(built)) + } + + @Test + fun `send drops an event built before the SDK was set up again`() { + val http = httpServers.mockHttp() + var setupConfig: PostHogConfig? = null + val sut = getSut(http) { setupConfig = this } + val built = assertNotNull(sut.buildEventForWrapper("tap")) + + sut.close() + sut.setup(assertNotNull(setupConfig)) + + assertNull(sut.sendBuiltEventForWrapper(built)) + } + + @Test + fun `send keeps the replay debug values from the build`() { + val http = httpServers.mockHttp() + val sut = getSut(http) + sut.startSession() + val built = assertNotNull(sut.buildEventForWrapper("\$first")) + sut.endSession() + Thread.sleep(5) + sut.startSession() + + assertNotNull(sut.sendBuiltEventForWrapper(built)) + + queueExecutor.awaitExecution() + val sent = events(takeRequest(http)).single().properties() + assertNotNull(sent["\$sdk_debug_session_start"]) + } + + @Test + fun `build reads the replay debug state once for the required and deferred keys`() { + val http = httpServers.mockHttp() + val replay = + PostHogSessionReplayHandlerFake(true).apply { + debugProperties = + mapOf( + "\$recording_status" to "buffering", + "\$sdk_debug_replay_flush_hold_reason" to "below_minimum_duration", + ) + // A replay stop between two reads would pair buffering with no hold reason. + onDebugProperties = { debugProperties = mapOf("\$recording_status" to "disabled") } + } + val sut = getSut(http) { addIntegration(replay) } + sut.startSession() + + val screen = assertNotNull(sut.buildEventForWrapper("\$screen", properties = mapOf("\$screen_name" to "Home"))) + assertNotNull(sut.sendBuiltEventForWrapper(screen)) + + queueExecutor.awaitExecution() + val sent = events(takeRequest(http)).single().properties() + assertEquals("buffering", sent["\$recording_status"]) + assertEquals("below_minimum_duration", sent["\$sdk_debug_replay_flush_hold_reason"]) + } + + @Test + fun `send skips person processing after the user changed`() { + val sut = getSut(httpServers.mockHttp()) + val identified = assertNotNull(sut.buildEventForWrapper("signup", userProperties = mapOf("plan" to "pro"))) + + sut.reset() + assertNotNull(sut.sendBuiltEventForWrapper(identified)) + + assertEquals(false, assertNotNull(sut.buildEventForWrapper("tap")).options?.get("process_person_profile")) + } +} From 68ee436d07570cf64680e426da76f417f262c5f6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 17:40:17 -0400 Subject: [PATCH 44/45] fix: record the wrapper build's identity before enriching the event A reset() or identify() during the build left the event with the old distinct ID while the build recorded the new identity, so the send applied the old user's $set to the new user's flag properties. Co-Authored-By: Claude Opus 5.5 --- posthog/src/main/java/com/posthog/PostHog.kt | 8 +++++-- .../com/posthog/PostHogWrapperEventTest.kt | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index d06433b76..f82e7abb7 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -1471,6 +1471,10 @@ public class PostHog private constructor( } else { properties } + // Read before the event takes its distinct ID, so a reset() or identify() during the build + // makes the send see a different identity instead of pairing the old event with the new one. + val sdkDistinctId = this.distinctId + val buildResetGeneration = resetGeneration // One replay read for the build's required keys and the deferred bundle, so a replay // transition between them can't pair keys from two different states. val replayDebugSnapshot by lazy { runCatching { readReplayDebugProperties() } } @@ -1510,8 +1514,8 @@ public class PostHog private constructor( WrapperBuild( deferredReplayDebug = deferredReplayDebug, requiresPersonProcessing = requiresPersonProcessing(userProperties, userPropertiesSetOnce, groups), - sdkDistinctId = this.distinctId, - resetGeneration = resetGeneration, + sdkDistinctId = sdkDistinctId, + resetGeneration = buildResetGeneration, ) synchronized(pendingWrapperBuilds) { pendingWrapperBuilds[uuid] = build diff --git a/posthog/src/test/java/com/posthog/PostHogWrapperEventTest.kt b/posthog/src/test/java/com/posthog/PostHogWrapperEventTest.kt index 00ca720c9..2ec40faf0 100644 --- a/posthog/src/test/java/com/posthog/PostHogWrapperEventTest.kt +++ b/posthog/src/test/java/com/posthog/PostHogWrapperEventTest.kt @@ -416,6 +416,30 @@ internal class PostHogWrapperEventTest { assertEquals("below_minimum_duration", sent["\$sdk_debug_replay_flush_hold_reason"]) } + @Test + fun `send skips the person properties for flags after a reset during the build`() { + val prefs = PostHogMemoryPreferences() + lateinit var sut: PostHogInterface + val replay = + PostHogSessionReplayHandlerFake(true).apply { + // Runs while the build enriches the event, after it took its distinct ID. + onDebugProperties = { + onDebugProperties = null + sut.reset() + } + } + sut = + getSut(httpServers.mockHttp()) { + cachePreferences = prefs + addIntegration(replay) + } + val event = assertNotNull(sut.buildEventForWrapper("signup", userProperties = mapOf("plan" to "pro"))) + + assertNotNull(sut.sendBuiltEventForWrapper(event)) + + assertNull(prefs.getValue(PERSON_PROPERTIES_FOR_FLAGS)) + } + @Test fun `send skips person processing after the user changed`() { val sut = getSut(httpServers.mockHttp()) From 2e3a9de70f357a088707047b0559351ab2fe414d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 17:40:17 -0400 Subject: [PATCH 45/45] chore(compliance): read capture responses outside the adapter lock Co-Authored-By: Claude Opus 5.5 --- .../posthog/compliance/ComplianceAdapter.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt index 885f3e3d1..40384f7f1 100644 --- a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt @@ -220,6 +220,8 @@ class TrackingInterceptor : Interceptor { try { // PostHog-Attempt starts at 1; the harness counts retries from 0. val retryCount = (request.header("PostHog-Attempt")?.toIntOrNull() ?: 1) - 1 + // Read before taking the lock, so a slow body can't stall /state, /reset or the other lane. + val retried = if (response.isSuccessful) retriedCount(response, uuidList) else 0 AdapterContext.lock.withLock { val record = @@ -235,7 +237,7 @@ class TrackingInterceptor : Interceptor { if (response.isSuccessful) { // Events the server asks to retry stay queued and count once a later request delivers them. - val delivered = eventCount - retriedCount(response, uuidList) + val delivered = eventCount - retried AdapterContext.state.totalEventsSent += delivered AdapterContext.state.pendingEvents = maxOf(0, AdapterContext.state.pendingEvents - delivered) } @@ -254,18 +256,26 @@ class TrackingInterceptor : Interceptor { } } -// How many of [uuids] the capture v1 per-event results ask to retry. Peeks so the SDK still reads the body. +// How many of [uuids] the capture v1 per-event results ask to retry. Peeks so the SDK still reads the +// body, within a deadline so a body that trickles in can't hold the request. private fun retriedCount( response: Response, uuids: List, -): Int = - try { +): Int { + val timeout = response.body?.source()?.timeout() + timeout?.deadline(RESPONSE_PEEK_DEADLINE_SECONDS, TimeUnit.SECONDS) + return try { val body = response.peekBody(1024 * 1024).string() val results = JsonParser.parseString(body).asJsonObject.getAsJsonObject("results") uuids.count { uuid -> results?.getAsJsonObject(uuid)?.get("result")?.asString == "retry" } } catch (_: Exception) { 0 + } finally { + timeout?.clearDeadline() } +} + +private const val RESPONSE_PEEK_DEADLINE_SECONDS = 5L // The harness sends RFC 3339 timestamps, with any offset; the SDK serializes the instant in UTC. private fun parseHarnessTimestamp(timestamp: String?): Date? =