diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index fc2077550..4bc23ca33 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -12,3 +12,24 @@ - **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:** replace `PostHogOnFeatureFlags` with `PostHogFeatureFlagsCallback` in `PostHogConfig.onFeatureFlags` and `reloadFeatureFlags()`; it receives a `PostHogFeatureFlagsLoaded` with the enabled flags, their values, and `errorsLoading` +- **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 +- **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 +- **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 +- **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` +- 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 + 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/.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/.github/workflows/sdk-compliance-tests.yml b/.github/workflows/sdk-compliance-tests.yml index a2a2854cb..5a2f99035 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.26.0" report-name: android-sdk-compliance-report 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..9ed5b3d8d --- /dev/null +++ b/docs/migration-3.x-to-4.0.md @@ -0,0 +1,186 @@ +# 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"` +- 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 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 | +| 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 | + +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 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` 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. 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). + +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 + +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 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 `""`. +- 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. + +## 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. +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`. +`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: + +| 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` | + +- 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. + +## 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, `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 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. +- 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 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. +- 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 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. 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-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/PostHogAndroidEventSnapshotsTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidEventSnapshotsTest.kt index a29635090..3ee32a6ba 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/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-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/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" 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 c9d3b3997..57fb49cab 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,24 @@ { "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", "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -41,9 +47,12 @@ "$groups": { "company": "posthog" }, - "$lib": "posthog-android", - "$lib_version": "", - "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", + "$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": [ @@ -51,21 +60,15 @@ "sku": "sku-1", "quantity": 2 } - ], - "$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": "" + ] + } }, { "event": "$identify", + "uuid": "", "distinct_id": "identified-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -94,26 +97,23 @@ "$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": "", + "$anon_distinct_id": "anonymous-user" + } }, { "event": "$groupidentify", + "uuid": "", "distinct_id": "identified-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -133,29 +133,26 @@ "$timezone": "UTC", "$network_type": "wifi", "$network_carrier": "Snapshot Telecom", - "$lib": "posthog-android", - "$lib_version": "", - "$session_id": "018bcfe5-687b-7abc-8def-0123456789ab", + "$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": "", "$group_type": "company", "$group_key": "posthog", "$group_set": { "industry": "analytics", "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": "" + } + } }, { "event": "$feature_flag_called", + "uuid": "", "distinct_id": "identified-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -178,33 +175,30 @@ "$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", + "$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": "", "$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, - "$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": "" + "$feature_flag_reason": "" + } }, { "event": "$exception", + "uuid": "", "distinct_id": "identified-user", + "timestamp": "2023-11-14T22:13:20.123Z", + "session_id": "018bcfe5-687b-7abc-8def-0123456789ab", "properties": { "$app_name": "Snapshot App", "$app_namespace": "com.example.snapshot", @@ -227,13 +221,16 @@ "$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", + "$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": [ { @@ -291,19 +288,9 @@ } ], "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": "" + "component": "checkout" + } } - ], - "sent_at": "2023-11-14T22:13:20.123Z" + ] } } diff --git a/posthog/api/posthog.api b/posthog/api/posthog.api index c949a9dbd..dd1ef6174 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -23,9 +23,10 @@ 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 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 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 +43,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 +58,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 +72,10 @@ 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 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 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 +93,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 +109,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 @@ -146,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 @@ -160,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/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; @@ -184,7 +195,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/PostHogFeatureFlagsCallback; public final fun getOptOut ()Z @@ -194,6 +204,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; @@ -215,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 @@ -235,7 +251,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/PostHogFeatureFlagsCallback;)V public final fun setOptOut (Z)V @@ -244,6 +259,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 @@ -270,8 +286,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 +294,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 { @@ -289,9 +302,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 +314,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 +331,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; } @@ -374,11 +390,14 @@ 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 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 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 @@ -399,8 +418,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 @@ -414,12 +435,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 @@ -432,22 +453,25 @@ 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 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 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/PostHogFeatureFlagsCallback;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 } @@ -475,8 +499,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 @@ -551,6 +575,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 } @@ -562,6 +588,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 } @@ -613,12 +640,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; @@ -733,6 +762,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 63a8c0f65..ea0a756d9 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -33,10 +33,13 @@ 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.isFalseOptionValue 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 @@ -94,6 +97,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 +133,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 /** @@ -218,6 +229,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.") } @@ -252,6 +271,19 @@ 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, + EndpointSpec.ai(config, api, config.aiStoragePrefix), + aiExecutor, + ) val onRemoteConfigLoaded = PostHogOnRemoteConfigLoaded { // The callback fires whether the attempt succeeded or terminally failed; @@ -321,6 +353,7 @@ public class PostHog private constructor( this.queue = queue this.replayQueue = replayQueue this.logsQueue = logsQueue + this.aiQueue = aiQueue this.pushSubscriptionManager = PostHogPushSubscriptionManager( config, @@ -364,6 +397,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 @@ -627,6 +661,7 @@ public class PostHog private constructor( queue?.stop() replayQueue?.stop() logsQueue?.stop() + aiQueue?.stop() pushSubscriptionManager?.close() pushSubscriptionManager = null @@ -739,12 +774,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) } @@ -772,22 +804,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 @@ -814,22 +836,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 @@ -935,6 +972,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 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. + */ + private fun personProcessingOptions( + options: Map?, + properties: Map, + ): MutableMap? { + val eventOptions = options?.toMutableMap() + if (hasPersonProcessing() || properties["\$process_person_profile"] != null) { + 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 !isFalseOptionValue(option) + } + return !isFalseOptionValue(getPreferences().getValue("\$process_person_profile")) + } + /** * Stamps `$screen_name = lastScreenName` into [props]. Called inside * the `appendSharedProps` block in [buildProperties] BEFORE @@ -957,20 +1028,92 @@ public class PostHog private constructor( userPropertiesSetOnce: Map?, groups: Map?, 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, + ) + } + + /** + * Whether [event], serialized and before [PostHogConfig.encryption], is over the AI event limit. + * 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 + val limit = config.captureAiMaxStoredEventBytes + if (config.serializedSize(event, stopAfter = limit) <= limit) { + return false + } + config.logger.log("Dropping AI event ${event.event} of more than $limit bytes, over the 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. + */ + 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 } - 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 @@ -983,10 +1126,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 @@ -1002,7 +1146,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 @@ -1037,7 +1181,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 = @@ -1047,36 +1197,42 @@ 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 (aiLane && isOverAiEventLimit(postHogEvent)) { + // dropped here rather than in the queue, so captureAi returns null + } 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() @@ -1087,6 +1243,7 @@ public class PostHog private constructor( override fun captureException( throwable: Throwable, properties: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1111,7 +1268,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 @@ -1439,6 +1596,7 @@ public class PostHog private constructor( public override fun screen( screenTitle: String, properties: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1463,10 +1621,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 } @@ -1491,7 +1652,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( @@ -1583,6 +1744,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 @@ -1636,6 +1806,7 @@ public class PostHog private constructor( properties = props, userProperties = userProperties, userPropertiesSetOnce = userPropertiesSetOnce, + options = options, ) if (config?.reuseAnonymousId != true) { @@ -1675,12 +1846,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 @@ -1689,7 +1863,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, @@ -1701,6 +1876,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 @@ -1712,6 +1888,7 @@ public class PostHog private constructor( public override fun setPersonProperties( userPropertiesToSet: Map?, userPropertiesToSetOnce: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1727,7 +1904,8 @@ public class PostHog private constructor( val currentDistinctId = this.distinctId - if (!shouldCapturePersonPropertiesEvent( + if (isPersonProcessed(options) && + !shouldCapturePersonPropertiesEvent( currentDistinctId, userPropertiesToSet, userPropertiesToSetOnce, @@ -1751,6 +1929,7 @@ public class PostHog private constructor( distinctId = currentDistinctId, userProperties = userPropertiesToSet, userPropertiesSetOnce = userPropertiesToSetOnce, + options = options, ) } @@ -1862,6 +2041,7 @@ public class PostHog private constructor( type: String, key: String, groupProperties: Map?, + options: Map?, ) { if (!isEnabled()) { return @@ -1907,7 +2087,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) @@ -2123,6 +2303,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() @@ -2645,12 +2826,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, @@ -2675,6 +2861,7 @@ public class PostHog private constructor( userPropertiesSetOnce: Map?, groups: Map?, timestamp: Date?, + options: Map?, ) { shared.capture( event, @@ -2684,14 +2871,37 @@ public class PostHog private constructor( userPropertiesSetOnce = userPropertiesSetOnce, groups = groups, timestamp = timestamp, + options = options, ) } + 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?, + options: Map?, ) { - shared.captureException(throwable, properties) + shared.captureException(throwable, properties, options) } public override fun addExceptionStep( @@ -2705,11 +2915,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 +2962,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 +3009,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/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index ee45ad3c4..6c47c6096 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. @@ -397,7 +397,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. * @@ -419,6 +422,50 @@ 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 + + /** + * 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. + * + * Default: 60. + */ + public var captureAiTimeout: Int = DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS + + /** + * 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 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: 8454144 (8 MiB + 64 KiB). + */ + public var captureAiMaxEventBytes: Int = DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES + /** * Your PostHog project token, trimmed of leading and trailing whitespace. * @@ -473,6 +520,9 @@ public open class PostHogConfig( @PostHogInternal public var logsStoragePrefix: String? = null + @PostHogInternal + public var aiStoragePrefix: String? = null + @PostHogInternal public var cachePreferences: PostHogPreferences? = null @@ -587,6 +637,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, 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/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/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/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index e56fbf98b..108335003 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,69 @@ public interface PostHogInterface : PostHogCoreInterface { userPropertiesSetOnce: Map? = null, groups: Map? = null, timestamp: Date? = null, + 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. 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. + * + * 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, `beforeSend` dropped it, or it's over the size limit + */ + 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 * @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, ) /** @@ -203,29 +272,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 @@ -327,6 +407,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) @@ -340,10 +436,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 bd6505876..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 @@ -65,6 +66,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.") } @@ -297,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( @@ -304,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 @@ -323,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 } @@ -787,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/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index 4ed7bcf48..a628c87e0 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -9,9 +9,21 @@ 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 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. + * Null means no limit. * * The queue itself stays record-type-agnostic; everything that varies * between endpoints lives here. @@ -27,10 +39,13 @@ 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, + internal val maxBatchBytes: Long? = null, + internal val maxQueueBytes: Long? = null, ) { public companion object { @JvmStatic @@ -53,9 +68,45 @@ 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() }, + 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] 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, ) @JvmStatic @@ -83,8 +134,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 +173,101 @@ 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: 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 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 + +/** Disk budget for queued AI events; the oldest are dropped first. */ +internal const val AI_MAX_QUEUE_BYTES: Long = 50L * 1024 * 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 + +/** 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. Stops writing at + * [stopAfter] + 1, though the serializer still builds the whole properties JSON tree first. + */ +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( + b: ByteArray, + off: Int, + len: Int, + ) { + count += len + if (count > stopAfter) throw SerializedSizeExceeded + } + } + try { + serializer.serialize(event, counter.writer().buffered()) + } catch (e: SerializedSizeExceeded) { + return stopAfter + 1 + } + return count +} + +/** + * 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 + return (if (captureAiMaxEventBytes > 0) minOf(captureAiMaxEventBytes, limit) else limit).toLong() + } -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/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/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 c3cdbec6d..c455a7874 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -11,6 +11,10 @@ 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.Interceptor +import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -25,6 +29,9 @@ import java.io.OutputStream import java.net.SocketException 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 /** @@ -37,9 +44,14 @@ 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_AI_PATH = "/i/v1/ai/events" + private const val CAPTURE_V1_MAX_REDIRECTS = 5 + private const val CAPTURE_V1_MAX_RESULTS_BYTES = 1024L * 1024L } private val mediaType by lazy { @@ -51,6 +63,21 @@ 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() + } catch (ignored: Throwable) { + null + } + } + + // 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) @@ -65,6 +92,55 @@ 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. 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() + } + + // 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 + // 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 +163,207 @@ 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 = 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 = + mapOf( + "created_at" to identity.createdAt, + "batch" to events.map { captureV1Json(it, config.logger) }, + ) + + val url = "$theHost$path" + var request = + makeRequest(url, contentType = captureMediaType) { + // 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()) + }.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) + + client.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 == 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) + } + + 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, + events: 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 ${events.size} events as delivered.", + ) + } + + val retryUuids = mutableSetOf() + var dropped = 0 + // Events missing from the results count as delivered. + 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++ + // 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"}.") + } + } + } + + 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 { + // 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<*, *> + } 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 +501,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 +799,112 @@ 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 +} + +/** + * 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" + 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..fd1213473 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.roundToLong /** * Generic file-backed queue. One implementation, multiple instances — @@ -28,14 +31,41 @@ 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() + + // 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() - 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. + 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 @@ -76,6 +106,12 @@ public class PostHogQueue( os.use { theOutputStream -> spec.encode(record, theOutputStream) } + if (trackBytes) { + val size = file.length() + synchronized(dequeLock) { + countBytes(file, size) + } + } config.logger.log("Queued ${spec.describe(record)}: ${file.name}.") return true @@ -96,18 +132,62 @@ public class PostHogQueue( return true } + /** Removes [files] from the deque and subtracts the size they were counted with. */ + private fun removeFromDeque(files: Collection) { + synchronized(dequeLock) { + deque.removeAll(files) + 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() + uncountBytes(first) } 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() + uncountBytes(first) + 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.") + } } /** @@ -158,7 +238,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 @@ -176,12 +256,38 @@ 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 { - 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 + } + 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 } - return events } private fun flushBatch(isFatal: Boolean) { @@ -195,16 +301,32 @@ 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() } - 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 +337,7 @@ public class PostHogQueue( retryAfterSeconds = e.retryAfterSeconds } } finally { + partialRetryAfterSeconds = null calculateDelay(retry, retryAfterSeconds) isFlushing.set(false) @@ -236,15 +359,17 @@ 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.") } + /** + * 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 +396,50 @@ 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 + keepForRetry(retained) + } + } + 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) @@ -295,23 +447,34 @@ 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 { if (deleteFiles) { - synchronized(dequeLock) { - deque.removeAll(files) - } + val delivered = files - retained.toSet() + removeFromDeque(delivered) - files.forEach { + delivered.forEach { it.deleteSafely(config) } } } + return retained.isNotEmpty() + } + + private fun keepForRetry(files: List) { + synchronized(dequeLock) { + retryFiles = files + } } override fun flush() { @@ -342,9 +505,11 @@ public class PostHogQueue( } executeWithRetry { - while (deque.isNotEmpty()) { - batchRecords() + var retryPending = false + while (deque.isNotEmpty() && !retryPending) { + retryPending = batchRecords() } + retryPending } } } @@ -362,24 +527,36 @@ public class PostHogQueue( synchronized(dequeLock) { tempFiles = deque.toList() deque.clear() + resetBytes() } tempFiles.forEach { it.deleteSafely(config) } } + /** + * 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 milliseconds. 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 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) } } @@ -422,6 +599,7 @@ public class PostHogQueue( deque.clear() deque.addAll(files) deque.addAll(existingFiles) + files.forEach { countBytes(it, it.length()) } } config.logger.log("Loaded ${files.size} cached ${spec.recordsLabel} from disk.") } @@ -442,7 +620,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.") @@ -455,6 +642,8 @@ public class PostHogQueue( synchronized(dequeLock) { deque.clear() deque.addAll(files) + resetBytes() + files.forEach { countBytes(it, it.length()) } } cachedRecordsLoaded = true } @@ -509,6 +698,10 @@ public class PostHogQueue( internal val currentRetryCountForTesting: Int @PostHogVisibleForTesting get() = retryCount + + internal val queuedBytesForTesting: Long + @PostHogVisibleForTesting + get() = synchronized(dequeLock) { queuedBytes } } internal class BatchLimits( @@ -540,13 +733,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 +745,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 19903d96a..6870a6f98 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.properties?.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/PostHogCaptureAiTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt new file mode 100644 index 000000000..938316749 --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogCaptureAiTest.kt @@ -0,0 +1,386 @@ +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.io.FilterInputStream +import java.io.FilterOutputStream +import java.io.InputStream +import java.io.OutputStream +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 keeps the event queued`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(404)) + val sut = getSut(http) + + sut.captureAi("\$ai_generation") + aiExecutor.awaitExecution() + + assertEquals(1, http.requestCount) + assertEquals("/i/v1/ai/events", takeRequest(http).path) + assertEquals(1, aiQueueFiles().size) + } + + @Test + fun `an AI event over captureAiMaxEventBytes returns null and logs only its name and size`() { + val http = httpServers.mockHttp() + val sut = getSut(http) { captureAiMaxEventBytes = 1 } + val prompt = "p".repeat(70_000) + + val uuid = sut.captureAi("\$ai_generation", properties = mapOf("\$ai_input" to prompt)) + + assertNull(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 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 + 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() + val sut = + getSut(http) { + captureAiMaxEventBytes = 100_000 + // writes every byte twice, so the stored event is double its JSON size + encryption = + object : PostHogEncryption { + override fun encrypt(outputStream: OutputStream): OutputStream = + object : FilterOutputStream(outputStream) { + override fun write(b: Int) { + 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 = + 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 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)) + + 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() + 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/PostHogCaptureOptionsTest.kt b/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt new file mode 100644 index 000000000..76e5045b0 --- /dev/null +++ b/posthog/src/test/java/com/posthog/PostHogCaptureOptionsTest.kt @@ -0,0 +1,375 @@ +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 `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() + 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 `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) + 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() + } +} diff --git a/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt b/posthog/src/test/java/com/posthog/PostHogPersonProfilesTest.kt index 0f76373d9..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.properties?.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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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.properties!!["\$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/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() diff --git a/posthog/src/test/java/com/posthog/PostHogTest.kt b/posthog/src/test/java/com/posthog/PostHogTest.kt index 0288881f1..0422397e6 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,13 +702,10 @@ 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")) + assertEquals(false, theEvent.options?.get("process_person_profile")) assertEquals("IAmInactive", theEvent.properties!!["\$feature_flag"]) assertEquals(false, theEvent.properties!!["\$feature_flag_response"]) @@ -700,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") @@ -1767,8 +1823,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) @@ -1806,10 +1861,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() } @@ -2099,8 +2153,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) @@ -2636,7 +2689,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")) @@ -2797,8 +2850,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) @@ -3110,7 +3162,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() @@ -3133,7 +3185,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) @@ -3452,7 +3504,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 = @@ -3461,6 +3513,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)) @@ -3471,8 +3524,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() } @@ -5341,11 +5395,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].properties?.get("\$process_person_profile")) + assertNull(batch.batch[0].options?.get("process_person_profile")) sut.close() } @@ -6324,8 +6379,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/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/test/java/com/posthog/internal/EndpointSpecTest.kt b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt index b3ac02c26..12946becc 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,12 @@ 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 - // 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(501)) + assertEquals(false, isCaptureV1RetriableStatusCode(505)) + assertEquals(false, isCaptureV1RetriableStatusCode(599)) } @Test @@ -359,11 +359,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..1239204e9 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -0,0 +1,803 @@ +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.File +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, + storagePrefix: String = tmpDir.newFolder().absolutePath, + ai: Boolean = false, + ): PostHogQueue { + val config = + config(http.url("/").toString()).apply { + this.flushAt = flushAt + 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")!!) + 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) } + } + + 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 `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() + 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.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")) + + 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 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 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 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")) + 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() + 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 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() + 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 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 `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 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()) + 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 `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() + 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/PostHogQueueByteLimitsTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt new file mode 100644 index 000000000..c3023d1b7 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt @@ -0,0 +1,227 @@ +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, + 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, + ) + 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 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) + 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 `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 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) + + val ai = EndpointSpec.ai(config, api, null) + assertEquals(5L * 1024 * 1024, ai.maxBatchBytes) + assertEquals(50L * 1024 * 1024, ai.maxQueueBytes) + 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, config.captureAiMaxStoredEventBytes) + config.captureAiMaxEventBytes = 16 * 1024 * 1024 + assertEquals(8L * 1024 * 1024 + 64 * 1024, config.captureAiMaxStoredEventBytes) + config.captureAiMaxEventBytes = 0 + assertEquals(8L * 1024 * 1024 + 64 * 1024, config.captureAiMaxStoredEventBytes) + + 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) + } +} 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..670f95e88 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 kept in milliseconds`() { + val http = mockHttp(total = 20, response = MockResponse().setResponseCode(503).setBody("error")) + val dateProvider = + object : PostHogDateProvider { + override fun currentDate() = Date() + + // 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) + + repeat(5) { + sut.flush() + executor.awaitExecution() + delays.add(sut.pausedUntil!!.time) + } + jitter = 0.999 + repeat(3) { + sut.flush() + executor.awaitExecution() + delays.add(sut.pausedUntil!!.time) + } + // -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() } @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,23 @@ internal class PostHogQueueTest { } @Test - fun `retries on 429`() { - val e = PostHogApiError(429, "", null) + fun `retries on 404 and 429`() { val config = PostHogConfig(PROJECT_TOKEN) val limits = initialBatchLimits(config) - assertFalse(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 + 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 @@ -958,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() diff --git a/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogReplayQueueTest.kt index 50820f459..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"))) @@ -271,7 +274,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)) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogSerializerTest.kt index cd7a5da72..287c2fc62 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,32 @@ internal class PostHogSerializerTest { assertEquals(expectedJson, actualJson) } + + // 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`() { + 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) + } } diff --git a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt index ca61394fe..70f484dcc 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -44,12 +44,29 @@ public class PostHogFake : PostHogInterface { userPropertiesSetOnce: Map?, groups: Map?, timestamp: Date?, + options: Map?, ) { this.event = event this.properties = properties 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, @@ -63,6 +80,7 @@ public class PostHogFake : PostHogInterface { override fun captureException( throwable: Throwable, properties: Map?, + options: Map?, ) { } @@ -76,6 +94,7 @@ public class PostHogFake : PostHogInterface { distinctId: String, userProperties: Map?, userPropertiesSetOnce: Map?, + options: Map?, ) { } @@ -116,6 +135,7 @@ public class PostHogFake : PostHogInterface { override fun setPersonProperties( userPropertiesToSet: Map?, userPropertiesToSetOnce: Map?, + options: Map?, ) { } @@ -170,17 +190,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/IMPLEMENTATION_NOTES.md b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md index 8e45c9564..b98c39d9a 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`, `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 -Tests run with `--sdk-type server` flag. +Tests run with `--sdk-type server` flag; the harness selects the `capture_v1` and `capture_ai_v1` suites from the capabilities. ## API Endpoints @@ -88,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 acfce3905..c71675ef4 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`, `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 -Tests run with `--sdk-type server`. +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/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 faa5c9962..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,11 +1,15 @@ package com.posthog.compliance +import com.google.gson.Gson +import com.google.gson.JsonParser +import com.posthog.PersonProfiles import com.posthog.PostHog import com.posthog.PostHogCompression 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 @@ -21,6 +25,10 @@ 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.Collections +import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit import java.util.concurrent.locks.Condition @@ -33,6 +41,12 @@ 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 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 * @@ -71,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( @@ -78,11 +98,15 @@ data class CaptureRequest( val event: String, 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( 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( @@ -114,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( @@ -133,16 +158,29 @@ 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() 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 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 @@ -157,14 +195,14 @@ class TrackingInterceptor : Interceptor { val uuidList = mutableListOf() // Parse request body before sending - if (url.contains("/batch") && !url.contains("/flags") && requestBody != null) { + if (isCaptureUrl(url) && 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 +216,10 @@ class TrackingInterceptor : Interceptor { val response = chain.proceed(request) // Track the response - if (url.contains("/batch") && !url.contains("/flags")) { + if (isCaptureUrl(url)) { 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 = @@ -196,8 +234,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) { @@ -214,7 +254,45 @@ class TrackingInterceptor : Interceptor { } } -// Block until [predicate] holds (signalled as each /batch response lands) or [timeoutMs] elapses. +// 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 { + try { + Date.from(OffsetDateTime.parse(it).toInstant()) + } catch (_: DateTimeParseException) { + null + } + } + +// 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, predicate: () -> Boolean, @@ -242,8 +320,19 @@ 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 and + // /i/v1/ai/events with gzip). + capabilities = + listOf( + "capture_v1", + "capture_ai_v1", + "encoding_gzip", + "event_options", + "generated_payloads", + "log_capture", + "property_layers", + "fail_open", + ), ), ) } @@ -264,9 +353,11 @@ 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() + File("$QUEUE_STORAGE_PREFIX-ai").deleteRecursively() // Create new config val flushIntervalMs = req.flush_interval_ms ?: 100 @@ -281,6 +372,10 @@ 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 // Create OkHttpClient with tracking and gzip interceptors val httpClient = @@ -291,13 +386,18 @@ fun main() { config.httpClient = httpClient - req.max_retries?.let { config.maxRetries = it } + 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 { - 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) @@ -311,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) } @@ -342,6 +450,8 @@ fun main() { event = req.event, distinctId = req.distinct_id, properties = properties, + timestamp = parseHarnessTimestamp(req.timestamp), + options = req.options, ) // Get the UUID that was just captured (via beforeSend hook) @@ -362,6 +472,45 @@ 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 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 = properties, + timestamp = parseHarnessTimestamp(req.timestamp), + options = req.options, + ) + + // 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?.toString())) + } + post("/get_feature_flag") { val req = call.receive() @@ -450,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() }, ) } @@ -465,6 +615,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 @@ -473,6 +624,7 @@ fun main() { AdapterContext.state.lastError = null AdapterContext.state.requestsMade.clear() AdapterContext.capturedEvents.clear() + AdapterContext.logLines.clear() } call.respond(SuccessResponse(success = true))