diff --git a/.changeset/android-v4-major.md b/.changeset/android-v4-major.md index 946bd027d..5e03a0731 100644 --- a/.changeset/android-v4-major.md +++ b/.changeset/android-v4-major.md @@ -11,8 +11,8 @@ - **Breaking:** return `null` instead of the raw string for a malformed, empty or whitespace-only feature flag payload from `getFeatureFlagResult(key)?.payload`; payloads must be valid JSON (a JSON `null` payload now also reads as `null` instead of the string `"null"`) - **Breaking:** make SDK-internal helpers in the `com.posthog.internal` and `com.posthog.android.replay.internal` packages non-public - **Breaking:** remove the deprecated `remoteConfig` config option; the SDK now always loads remote config at setup -- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because a 404 or other non-retried response drops the events, and authenticate your proxy with a header other than `Authorization`, which capture requests replace -- **Breaking:** stop retrying 429 and 3xx responses for events, and cap a `Retry-After` pause at 30 seconds for events, session replay and logs +- **Breaking:** send events to capture v1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header; if you use a reverse proxy or self-host PostHog, make sure the path is served, because events stay queued and are retried on a 404, with a warning printed once even when `debug` is off, and authenticate your proxy with a header other than `Authorization`, which capture requests replace +- **Breaking:** stop retrying 3xx responses for events, retry 404 responses, and cap a `Retry-After` pause at 30 seconds for events, session replay and logs - **Breaking:** add up to 25% random jitter to the wait after a failed send, which still doubles from 1 second to 30 seconds, and treat a `Retry-After` as a minimum wait instead of replacing the backoff, for events, session replay and logs; queued records are still kept until they're delivered - **Breaking:** follow only a 307 or 308 redirect of a capture request to the same origin as `host`, at most 5 times; any other redirect drops the events, so point `host` at the final host - **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options (`PostHogEvent.options`, a new trailing constructor parameter that Java callers of the full constructor and `copy` must pass), where PostHog drops an event whose option value it can't read @@ -25,7 +25,10 @@ - **Breaking:** registered super properties now override the properties the SDK sets itself, such as `$os_name`; super and event properties also override `$is_identified`, `$process_person_profile` and the session replay debug properties, which the SDK no longer re-applies after the caller's values - **Breaking:** the SDK sets the `process_person_profile` option to `false` only when person processing is off, and no longer adds a `$process_person_profile` property to every event - **Breaking:** keep the uuid of its own capture on the event `beforeSend` returns, so a uuid set in `beforeSend`, for example with `copy(uuid = …)`, is replaced +- **Breaking:** add `captureAi` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events 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 +- 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/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 4550a7ee7..ab5a49ba7 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -24,7 +24,7 @@ You need to change something if your app does any of these: - registers a super property, or passes an event property, with the same key as one the SDK sets, such as `$os_name` or `$is_identified`, and expects the SDK's value to win - calls `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` or `captureException` from Java, implements `PostHogInterface`, or calls `identify` through a `PostHogCoreInterface` reference - sets the event `uuid` in `beforeSend` -- relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds +- relies on a `Retry-After` longer than 30 seconds - sets `maxRetries` on `PostHogConfig` or `PostHogAndroidConfig` ## Endpoints @@ -33,6 +33,7 @@ You need to change something if your app does any of these: | --- | --- | --- | | Analytics events | `/batch` | `/i/v1/analytics/events` | | Session replay | `/s/` | `/s/`, unchanged | +| AI events (`captureAi`) | – | `/i/v1/ai/events` (see [AI events](#ai-events)) | | Logs | `/i/v1/logs` | `/i/v1/logs`, unchanged | | Feature flags | `/flags` | `/flags`, unchanged | @@ -50,13 +51,13 @@ PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves it. If you send events through your own reverse proxy, update it before you upgrade: -- Forward `/i/v1/analytics/events` to PostHog. +- Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. - Pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged. PostHog answers `401` without `Authorization` and `400` without the others, and the events are dropped. -- Accept request bodies up to 20 MB. nginx and ingress-nginx default to 1 MB. A `413` from the proxy makes the SDK halve the batch until it fits, and drops an event that doesn't fit on its own. +- Accept request bodies up to 20 MB. AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. A `413` from the proxy makes the SDK halve the batch until it fits, and drops an event that doesn't fit on its own. PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). -The SDK doesn't fall back to `/batch`. A 404, or any other error status that isn't retried, such as 403 or 405, drops the events. +The SDK doesn't fall back to `/batch`. A 404 is retried, so the events stay queued until the path is served. Any other error status that isn't retried, such as 403 or 405, drops the events. ### Redirects @@ -69,7 +70,7 @@ If a proxy redirects capture requests to another host, point `host` at the final Capture v1 checks each event on its own and drops only the invalid ones, so the rest of the batch is delivered. The SDK doesn't check these before sending. PostHog drops the event, and the SDK removes it from the queue. -With `debug` on, the SDK logs how many events were dropped. +With `debug` on, the SDK logs the name and uuid of each dropped event and the reason PostHog gives, such as `misrouted_event`, never its properties. - An event with an empty event name is dropped, for example `capture("")`. - An event with an empty or whitespace-only distinct ID is dropped, for example one `beforeSend` sets to `""`. @@ -151,13 +152,35 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - The event `beforeSend` returns keeps the uuid of the capture it came from. A uuid you set in `beforeSend`, for example with `copy(uuid = …)`, is replaced, so a hook that returns an event kept from an earlier call still sends one uuid per capture. - Events still queued on disk by 3.x are sent to capture v1 after the update, with their legacy properties moved to options. +## AI events + +4.0 adds `captureAi`, which sends AI events, such as `$ai_generation`, to PostHog's AI endpoint (`/i/v1/ai/events`) instead of the analytics endpoint. +It takes the same arguments as `capture`, and the events go through the same `beforeSend` hooks. + +```kotlin +PostHog.captureAi( + "\$ai_generation", + properties = mapOf("\$ai_model" to "gpt-5", "\$ai_trace_id" to traceId), +) +``` + +- `captureAi` returns the event's uuid, or `null` when the event isn't queued: the SDK is disabled or opted out, `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 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. +- The SDK retries network errors and 404, 408, 429, 500, 502, 503 and 504 responses. Other statuses drop the events, including 3xx. 3.x also retried 3xx responses, and didn't retry 404. - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. - A failed request is resent with the same events, request ID and `created_at`, and the next attempt number, before newer events are sent. OkHttp doesn't retry capture requests on its own. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x there was no cap. - After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter, rounded to whole seconds. In 3.x it doubled without jitter and a `Retry-After` replaced it. This applies to events, session replay and logs. - Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). - `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, with no deprecated alias. It only limits push-notification subscription retries. +- A request carries at most `maxBatchSize` events and about 5 MiB, and the SDK sends as soon as about 5 MiB of events is queued, even before `flushAt` is reached. In 3.x, batches were limited by event count only. - A 413 still splits the batch into smaller requests. 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/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/api/posthog.api b/posthog/api/posthog.api index f99193f55..bdad93b91 100644 --- a/posthog/api/posthog.api +++ b/posthog/api/posthog.api @@ -25,6 +25,7 @@ public final class com/posthog/PostHog : com/posthog/PostHogStateless, com/posth public fun addExceptionStep (Ljava/lang/String;Ljava/util/Map;)V public fun alias (Ljava/lang/String;Ljava/util/Map;)V public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V + public fun captureAi (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)Ljava/util/UUID; public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V public fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V @@ -73,6 +74,7 @@ public final class com/posthog/PostHog$Companion : com/posthog/PostHogInterface public fun addExceptionStep (Ljava/lang/String;Ljava/util/Map;)V public fun alias (Ljava/lang/String;Ljava/util/Map;)V public fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V + public fun captureAi (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)Ljava/util/UUID; public fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V public fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V public fun captureFeatureView (Ljava/lang/String;Ljava/lang/String;)V @@ -147,6 +149,9 @@ public final class com/posthog/PostHogCompression : java/lang/Enum { public class com/posthog/PostHogConfig { public static final field Companion Lcom/posthog/PostHogConfig$Companion; + public static final field DEFAULT_CAPTURE_AI_MAX_EVENT_BYTES I + public static final field DEFAULT_CAPTURE_AI_MAX_QUEUE_SIZE I + public static final field DEFAULT_CAPTURE_AI_TIMEOUT_SECONDS I public static final field DEFAULT_EU_ASSETS_HOST Ljava/lang/String; public static final field DEFAULT_EU_HOST Ljava/lang/String; public static final field DEFAULT_FEATURE_FLAG_CALLED_CACHE_SIZE I @@ -161,9 +166,14 @@ public class com/posthog/PostHogConfig { public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZZZIZLjava/util/List;ZIIIIIILcom/posthog/PostHogEncryption;Lcom/posthog/PostHogOnFeatureFlags;ZLkotlin/jvm/functions/Function1;ZLcom/posthog/PersonProfiles;ZLjava/net/Proxy;Lcom/posthog/surveys/PostHogSurveysConfig;Lcom/posthog/logs/PostHogLogsConfig;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function5;Lcom/posthog/errortracking/PostHogErrorTrackingConfig;Ljava/lang/String;Lcom/posthog/PostHogBootstrapConfig;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun addBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun addIntegration (Lcom/posthog/PostHogIntegration;)V + public final fun getAiStoragePrefix ()Ljava/lang/String; public final fun getBeforeSendList ()Ljava/util/List; public final fun getBootstrap ()Lcom/posthog/PostHogBootstrapConfig; public final fun getCachePreferences ()Lcom/posthog/internal/PostHogPreferences; + public final fun getCaptureAiCompression ()Lcom/posthog/PostHogCompression; + public final fun getCaptureAiMaxEventBytes ()I + public final fun getCaptureAiMaxQueueSize ()I + public final fun getCaptureAiTimeout ()I public final fun getCompression ()Lcom/posthog/PostHogCompression; public final fun getContext ()Lcom/posthog/internal/PostHogContext; public final fun getDateProvider ()Lcom/posthog/internal/PostHogDateProvider; @@ -216,8 +226,13 @@ public class com/posthog/PostHogConfig { public final synthetic fun getTracingHeaders ()Ljava/util/List; public final fun removeBeforeSend (Lcom/posthog/PostHogBeforeSend;)V public final fun removeIntegration (Lcom/posthog/PostHogIntegration;)V + public final fun setAiStoragePrefix (Ljava/lang/String;)V public final fun setBootstrap (Lcom/posthog/PostHogBootstrapConfig;)V public final fun setCachePreferences (Lcom/posthog/internal/PostHogPreferences;)V + public final fun setCaptureAiCompression (Lcom/posthog/PostHogCompression;)V + public final fun setCaptureAiMaxEventBytes (I)V + public final fun setCaptureAiMaxQueueSize (I)V + public final fun setCaptureAiTimeout (I)V public final fun setCompression (Lcom/posthog/PostHogCompression;)V public final fun setContext (Lcom/posthog/internal/PostHogContext;)V public final fun setDateProvider (Lcom/posthog/internal/PostHogDateProvider;)V @@ -368,6 +383,8 @@ public abstract interface class com/posthog/PostHogInterface : com/posthog/PostH public static synthetic fun alias$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun capture (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)V public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)V + public abstract fun captureAi (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;)Ljava/util/UUID; + public static synthetic fun captureAi$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/UUID; public abstract fun captureException (Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;)V public static synthetic fun captureException$default (Lcom/posthog/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public abstract fun captureFeatureInteraction (Ljava/lang/String;Ljava/lang/String;)V @@ -427,6 +444,7 @@ public final class com/posthog/PostHogInterface$DefaultImpls { public static synthetic fun addExceptionStep$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun alias$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun capture$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)V + public static synthetic fun captureAi$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/Date;Ljava/util/Map;ILjava/lang/Object;)Ljava/util/UUID; public static synthetic fun captureException$default (Lcom/posthog/PostHogInterface;Ljava/lang/Throwable;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)V public static synthetic fun captureFeatureInteraction$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V public static synthetic fun captureFeatureView$default (Lcom/posthog/PostHogInterface;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V @@ -615,12 +633,14 @@ public final class com/posthog/errortracking/PostHogExceptionStepsConfig { public final class com/posthog/internal/EndpointSpec { public static final field Companion Lcom/posthog/internal/EndpointSpec$Companion; + public static final fun ai (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public static final fun batch (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public static final fun logs (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public static final fun snapshot (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; } public final class com/posthog/internal/EndpointSpec$Companion { + public final fun ai (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public final fun batch (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public final fun logs (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; public final fun snapshot (Lcom/posthog/PostHogConfig;Lcom/posthog/internal/PostHogApi;Ljava/lang/String;)Lcom/posthog/internal/EndpointSpec; @@ -735,6 +755,8 @@ public final class com/posthog/internal/FlagProperty { public final class com/posthog/internal/GzipRequestInterceptor : okhttp3/Interceptor { public fun (Lcom/posthog/PostHogConfig;)V + public fun (Lcom/posthog/PostHogConfig;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lcom/posthog/PostHogConfig;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; } diff --git a/posthog/src/main/java/com/posthog/PostHog.kt b/posthog/src/main/java/com/posthog/PostHog.kt index a36d2b480..3edda9e0b 100644 --- a/posthog/src/main/java/com/posthog/PostHog.kt +++ b/posthog/src/main/java/com/posthog/PostHog.kt @@ -33,10 +33,12 @@ import com.posthog.internal.PostHogSendCachedEventsIntegration import com.posthog.internal.PostHogSerializer import com.posthog.internal.PostHogSessionManager import com.posthog.internal.PostHogThreadFactory +import com.posthog.internal.captureAiMaxStoredEventBytes import com.posthog.internal.errortracking.PostHogExceptionStepsBuffer import com.posthog.internal.logIntegrationFailure import com.posthog.internal.personPropertiesContext import com.posthog.internal.replay.PostHogSessionReplayHandler +import com.posthog.internal.serializedSize import com.posthog.internal.sortMapRecursively import com.posthog.internal.surveys.PostHogSurveysHandler import com.posthog.logs.PostHogLogRecord @@ -94,6 +96,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 +132,10 @@ public class PostHog private constructor( private var logsQueue: PostHogQueueInterface? = null + // The AI lane never shares a queue, executor or retry state with analytics events. + @Volatile + private var aiQueue: PostHogQueueInterface? = null + private var pushSubscriptionManager: PostHogPushSubscriptionManager? = null /** @@ -260,6 +270,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; @@ -329,6 +352,7 @@ public class PostHog private constructor( this.queue = queue this.replayQueue = replayQueue this.logsQueue = logsQueue + this.aiQueue = aiQueue this.pushSubscriptionManager = PostHogPushSubscriptionManager( config, @@ -372,6 +396,7 @@ public class PostHog private constructor( queue.start() logsQueue.start() + aiQueue.start() // Force the persisted OPT_OUT to hydrate before this passive push trigger: the // manager reads config.optOut directly, and it is otherwise resolved lazily on @@ -631,6 +656,7 @@ public class PostHog private constructor( queue?.stop() replayQueue?.stop() logsQueue?.stop() + aiQueue?.stop() pushSubscriptionManager?.close() pushSubscriptionManager = null @@ -999,16 +1025,86 @@ public class PostHog private constructor( timestamp: Date?, options: Map?, ) { + captureInternal( + event, + distinctId, + properties, + userProperties, + userPropertiesSetOnce, + groups, + timestamp, + options, + aiLane = false, + ) + } + + public override fun captureAi( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + options: Map?, + ): UUID? { + if (!event.startsWith("\$ai_")) { + config?.logger?.log("captureAi event $event doesn't start with \$ai_, so PostHog will drop it. Use capture for non-AI events.") + } + return captureInternal( + event, + distinctId, + properties, + userProperties, + userPropertiesSetOnce, + groups, + timestamp, + options, + aiLane = true, + ) + } + + /** + * 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 } // A blank distinct ID falls back to the current one, like null, instead of PostHog dropping the event. @@ -1025,10 +1121,11 @@ public class PostHog private constructor( if (newDistinctId.isBlank()) { config?.logger?.log("capture call not allowed, distinctId is invalid: $newDistinctId.") - return + return null } - var isSnapshotEvent = event == PostHogEventName.SNAPSHOT.event + // The AI lane always uses its own queue. + var isSnapshotEvent = !aiLane && event == PostHogEventName.SNAPSHOT.event var groupIdentify = false if (event == PostHogEventName.GROUP_IDENTIFY.event) { groupIdentify = true @@ -1044,7 +1141,7 @@ public class PostHog private constructor( config?.logger?.log( "Skipping \$exception: an entry in \$exception_list matches ignoredExceptionTypes", ) - return + return null } // Attach the buffered exception steps to any $exception event (unless the caller @@ -1095,36 +1192,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() @@ -2191,6 +2294,7 @@ public class PostHog private constructor( super.flush() replayQueue?.flush() logsQueue?.flush() + aiQueue?.flush() // See the setup() call site: hydrate opt-out before the manager reads the raw config field. isOptedOut() pushSubscriptionManager?.retryPending() @@ -2713,12 +2817,17 @@ public class PostHog private constructor( Executors.newSingleThreadScheduledExecutor( PostHogThreadFactory("PostHogLogsQueueThread"), ), + aiExecutor: ExecutorService = + Executors.newSingleThreadScheduledExecutor( + PostHogThreadFactory("PostHogAiQueueThread"), + ), ): PostHogInterface { val instance = PostHog( queueExecutor = queueExecutor, replayExecutor = replayExecutor, logsExecutor = logsExecutor, + aiExecutor = aiExecutor, remoteConfigExecutor = featureFlagsExecutor, cachedEventsExecutor = cachedEventsExecutor, reloadFeatureFlags = reloadFeatureFlags, @@ -2757,6 +2866,27 @@ public class PostHog private constructor( ) } + public override fun captureAi( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + options: Map?, + ): UUID? = + shared.captureAi( + event, + distinctId = distinctId, + properties = properties, + userProperties = userProperties, + userPropertiesSetOnce = userPropertiesSetOnce, + groups = groups, + timestamp = timestamp, + options = options, + ) + public override fun captureException( throwable: Throwable, properties: Map?, diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index da0268a16..c2977be9f 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -421,6 +421,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. * @@ -475,6 +519,9 @@ public open class PostHogConfig( @PostHogInternal public var logsStoragePrefix: String? = null + @PostHogInternal + public var aiStoragePrefix: String? = null + @PostHogInternal public var cachePreferences: PostHogPreferences? = null @@ -589,6 +636,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/PostHogInterface.kt b/posthog/src/main/java/com/posthog/PostHogInterface.kt index 15476fc54..eaa993bf4 100644 --- a/posthog/src/main/java/com/posthog/PostHogInterface.kt +++ b/posthog/src/main/java/com/posthog/PostHogInterface.kt @@ -103,6 +103,55 @@ public interface PostHogInterface : PostHogCoreInterface { options: Map? = null, ) + /** + * Captures an AI observability event, such as `$ai_generation`, `$ai_span`, `$ai_trace` or + * `$ai_embedding`, and sends it to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts much + * larger events than [capture]. + * + * Takes the same arguments as [capture], merges properties in the same order and goes through + * the same `beforeSend` hooks. The event is queued on disk separately from other events and sent + * unchanged: the SDK doesn't redact or truncate prompts, outputs or media. PostHog drops an event + * sent here whose name doesn't start with `$ai_`, and the SDK logs a debug message when it's + * given one. [capture] never sends events here, even ones named `$ai_*`. + * + * AI events use [PostHogConfig.flushAt], [PostHogConfig.maxBatchSize] and + * [PostHogConfig.flushIntervalSeconds], like other events, and + * [PostHogConfig.captureAiCompression], [PostHogConfig.captureAiMaxQueueSize] and + * [PostHogConfig.captureAiTimeout] instead of `compression`, `maxQueueSize` and the default + * timeouts. 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 diff --git a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt index 5ee8d7f99..a628c87e0 100644 --- a/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt +++ b/posthog/src/main/java/com/posthog/internal/EndpointSpec.kt @@ -18,6 +18,13 @@ import java.io.OutputStream * Endpoints without per-record results return an empty result and leave * [recordId] null. * + * The byte limits are measured on the stored record files. [maxBatchBytes] is + * a soft limit on one request: a record that doesn't fit starts the next + * request, a record larger than the limit goes alone, and the queue flushes as + * soon as that many bytes are queued. [maxQueueBytes] is a FIFO limit on the + * queued files on top of [maxQueueSize]; the newest record is always kept. + * Null means no limit. + * * The queue itself stays record-type-agnostic; everything that varies * between endpoints lives here. */ @@ -37,6 +44,8 @@ public class EndpointSpec internal constructor( internal val isFatalRecord: (Record) -> Boolean = { false }, internal val canBatchTogether: ((Record, Record) -> Boolean)? = null, internal val recordId: ((Record) -> String?)? = null, + internal val maxBatchBytes: Long? = null, + internal val maxQueueBytes: Long? = null, ) { public companion object { @JvmStatic @@ -63,6 +72,41 @@ public class EndpointSpec internal constructor( isRetriableStatusCode = ::isCaptureV1RetriableStatusCode, isFatalRecord = { it.isFatalExceptionEvent() }, recordId = { event -> event.uuid?.toString() }, + maxBatchBytes = CAPTURE_V1_MAX_BATCH_BYTES, + ) + + /** + * Returns the [EndpointSpec] for the capture v1 AI endpoint (`/i/v1/ai/events`), used by + * `captureAi`. Same codec, results and retry policy as [batch], on its own queue: AI events + * can be megabytes, so the queue is capped by bytes as well as count, and an event over + * [PostHogConfig.captureAiMaxEventBytes] 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 @@ -150,10 +194,74 @@ internal class SendResult( ) /** - * Capture v1 retry policy: 408 and the listed 5xx. Everything else, including 429 and 3xx, is - * terminal; 413 is handled by the queue's batch halving. + * Capture v1 retry policy: 404, 408, 429 and the listed 5xx. A 404 usually means a misconfigured + * proxy and a 429 an overloaded ingress, so neither drops events. Everything else, including 3xx, + * is terminal; 413 is handled by the queue's batch halving. + */ +internal fun isCaptureV1RetriableStatusCode(code: Int): Boolean = code 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 fun isCaptureV1RetriableStatusCode(code: Int): Boolean = code == 408 || code in setOf(500, 502, 503, 504) +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() + } /** `/snapshot` (replay) retry policy: 408, 429, the listed 5xx, plus 3xx redirects. */ internal fun isSnapshotRetriableStatusCode(code: Int): Boolean = code in setOf(408, 429, 500, 502, 503, 504) || code in 300..399 diff --git a/posthog/src/main/java/com/posthog/internal/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 752dab9ee..318fdee63 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -31,6 +31,7 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import java.util.UUID import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.SSLException /** @@ -48,6 +49,7 @@ public class PostHogApi( private const val FLAGS_MAX_RETRY_DELAY_MS = 30_000L private const val ERROR_BODY_PEEK_BYTES = 2048L private const val CAPTURE_V1_ANALYTICS_PATH = "/i/v1/analytics/events" + private const val CAPTURE_V1_AI_PATH = "/i/v1/ai/events" private const val CAPTURE_V1_MAX_REDIRECTS = 5 private const val CAPTURE_V1_MAX_RESULTS_BYTES = 1024L * 1024L } @@ -61,6 +63,9 @@ public class PostHogApi( } } + // A 404 keeps the events queued, so the warning is printed once instead of on every retry. + private val warnedCaptureNotFound = AtomicBoolean(false) + private val captureMediaType by lazy { try { APP_JSON.toMediaType() @@ -112,6 +117,24 @@ public class PostHogApi( .build() } + // AI batches can be several MB, so they get their own timeout and compression setting. + private val aiCaptureClient: OkHttpClient by lazy { + val timeoutSeconds = config.effectiveCaptureAiTimeoutSeconds.toLong() + val builder = + // Idle timeouts only: a multi-MB upload that is still making progress on a slow link + // must not be cut off by a whole-call deadline. + captureClient.newBuilder() + .readTimeout(timeoutSeconds, TimeUnit.SECONDS) + .writeTimeout(timeoutSeconds, TimeUnit.SECONDS) + val interceptors = builder.interceptors() + for (index in interceptors.indices) { + if (interceptors[index] is GzipRequestInterceptor) { + interceptors[index] = GzipRequestInterceptor(config) { config.captureAiCompression } + } + } + builder.build() + } + private val captureV1Lock = Any() // The identity of the last capture v1 request per endpoint path whose events are still to be @@ -155,8 +178,22 @@ public class PostHogApi( * Throws [PostHogApiError] for a non-2xx status and [IOException] for a transport failure. */ @Throws(PostHogApiError::class, IOException::class) - internal fun captureV1(events: List): SendResult { - val path = CAPTURE_V1_ANALYTICS_PATH + internal fun captureV1(events: List): SendResult = postCaptureV1(CAPTURE_V1_ANALYTICS_PATH, events, captureClient) + + /** + * Sends AI events to the capture v1 AI endpoint (`POST /i/v1/ai/events`). Same wire format, + * per-event results, redirect rule and retry identity (kept per path) as [captureV1], with + * [PostHogConfig.captureAiTimeout] and [PostHogConfig.captureAiCompression]. + */ + @Throws(PostHogApiError::class, IOException::class) + internal fun captureAi(events: List): SendResult = postCaptureV1(CAPTURE_V1_AI_PATH, events, aiCaptureClient) + + @Throws(PostHogApiError::class, IOException::class) + private fun postCaptureV1( + path: String, + events: List, + client: OkHttpClient, + ): SendResult { val uuids = events.mapNotNull { it.uuid?.toString() } val identity = nextCaptureV1Identity(path, uuids.toSet()) val body = @@ -168,7 +205,7 @@ public class PostHogApi( val url = "$theHost$path" var request = makeRequest(url, contentType = captureMediaType) { - // Events hold user data, so the debug log names only the count. + // Events hold user data, and AI events prompts and media, so the debug log names only the count. config.logger.log("Request $url: ${events.size} events") config.serializer.serialize(body, it.bufferedWriter()) @@ -184,12 +221,26 @@ public class PostHogApi( while (true) { logRequestHeaders(request) - captureClient.newCall(request).execute().use { response -> + client.newCall(request).execute().use { response -> // The status only: the results name event uuids. config.logger.log("Response ${response.request.url}: ${response.code}") + 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, uuids, identity) + return captureV1Result(response, path, events, identity) } val target = response.header("Location")?.let { location -> request.url.resolve(location) } @@ -247,7 +298,7 @@ public class PostHogApi( private fun captureV1Result( response: Response, path: String, - uuids: List, + events: List, identity: CaptureV1RequestIdentity, ): SendResult { if (!response.isSuccessful) { @@ -260,22 +311,27 @@ public class PostHogApi( val results = readCaptureV1Results(response) if (results == null) { config.logger.log( - "Capture returned ${response.code} without readable per-event results, treating ${uuids.size} events as delivered.", + "Capture returned ${response.code} without readable per-event results, treating ${events.size} events as delivered.", ) } val retryUuids = mutableSetOf() var dropped = 0 // Events missing from the results count as delivered. - for (uuid in uuids) { + for (event in events) { + val uuid = event.uuid?.toString() ?: continue val entry = results?.get(uuid) as? Map<*, *> ?: continue when (entry["result"]) { "retry" -> retryUuids.add(uuid) - "drop" -> dropped++ + "drop" -> { + dropped++ + // The event name, uuid and the server's reason (such as misrouted_event or + // ai_event_too_big), never the properties. + config.logger.log("Capture dropped event ${event.event} ($uuid): ${entry["details"] ?: "no details"}.") + } } } - // Counts only: event uuids and server details can identify users. if (dropped > 0 || retryUuids.isNotEmpty()) { config.logger.log("Capture: $dropped dropped, ${retryUuids.size} to retry.") } diff --git a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt index 830638b6c..455b65b09 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogQueue.kt @@ -35,6 +35,10 @@ public class PostHogQueue( // The files of the last request that failed or came back with records to retry. The next flush // resends exactly these, so the request keeps its id and counts the attempt. Guarded by dequeLock. private var retryFiles: List = emptyList() + + // Total size of the files in the deque, kept only for byte-limited endpoints. Guarded by dequeLock. + private val trackBytes = spec.maxBatchBytes != null || spec.maxQueueBytes != null + private var queuedBytes = 0L private val timerLock = Any() @PostHogVisibleForTesting @@ -99,6 +103,12 @@ public class PostHogQueue( os.use { theOutputStream -> spec.encode(record, theOutputStream) } + if (trackBytes) { + val size = file.length() + synchronized(dequeLock) { + queuedBytes += size + } + } config.logger.log("Queued ${spec.describe(record)}: ${file.name}.") return true @@ -119,18 +129,45 @@ public class PostHogQueue( return true } + /** Removes [files] from the deque, subtracting their size while they still exist on disk. */ + private fun removeFromDeque(files: Collection) { + synchronized(dequeLock) { + deque.removeAll(files) + if (trackBytes) { + queuedBytes -= files.sumOf { it.length() } + } + } + } + private fun removeRecordSync() { if (deque.size > spec.maxQueueSize(config).coerceAtLeast(1)) { try { val first: File synchronized(dequeLock) { first = deque.removeFirst() + if (trackBytes) { + queuedBytes -= first.length() + } } first.deleteSafely(config) config.logger.log("Queue is full, the oldest ${spec.recordsLabel} ${first.name} is dropped.") } catch (ignored: NoSuchElementException) { } } + val maxQueueBytes = spec.maxQueueBytes ?: return + val evicted = mutableListOf() + synchronized(dequeLock) { + // The newest record is always kept, even when it's over the limit on its own. + while (queuedBytes > maxQueueBytes && deque.size > 1) { + val first = deque.removeFirst() + queuedBytes -= first.length() + evicted.add(first) + } + } + if (evicted.isNotEmpty()) { + evicted.forEach { it.deleteSafely(config) } + config.logger.log("Queue is over $maxQueueBytes bytes, the oldest ${evicted.size} ${spec.recordsLabel} are dropped.") + } } /** @@ -181,7 +218,7 @@ public class PostHogQueue( } private fun isAboveThreshold(flushAt: Int): Boolean { - if (deque.size >= flushAt) { + if (deque.size >= flushAt || isAboveByteThreshold()) { return true } else if (deque.size > 0) { // only log if there are records in the queue @@ -199,6 +236,18 @@ public class PostHogQueue( return true } + /** A full request's worth of queued bytes flushes like [BatchLimits.flushAt] does. */ + private fun isAboveByteThreshold(): Boolean { + val maxBatchBytes = spec.maxBatchBytes ?: return false + return synchronized(dequeLock) { queuedBytes } >= maxBatchBytes + } + + /** + * The next FIFO window: at most [BatchLimits.cap] files and, for a byte-limited endpoint, about + * [EndpointSpec.maxBatchBytes]. The check runs before each file is added, so a file that doesn't + * fit starts the next window and a file over the limit goes alone. The files of a request to + * retry go again on their own first. + */ private fun takeFiles(): List { synchronized(dequeLock) { val retry = retryFiles @@ -207,7 +256,17 @@ public class PostHogQueue( if (retry.isNotEmpty() && deque.containsAll(retry)) { return retry } - return deque.take(batchLimits.cap) + val maxBatchBytes = spec.maxBatchBytes ?: return deque.take(batchLimits.cap) + val files = mutableListOf() + var bytes = 0L + for (file in deque) { + if (files.size >= batchLimits.cap) break + val size = file.length() + if (files.isNotEmpty() && bytes + size > maxBatchBytes) break + files.add(file) + bytes += size + } + return files } } @@ -274,9 +333,7 @@ public class PostHogQueue( file: File, throwable: Throwable? = null, ) { - synchronized(dequeLock) { - deque.remove(file) - } + removeFromDeque(listOf(file)) file.deleteSafely(config) config.logger.log("File: ${file.name} failed to parse: $throwable.") } @@ -378,9 +435,7 @@ public class PostHogQueue( } finally { if (deleteFiles) { val delivered = files - retained.toSet() - synchronized(dequeLock) { - deque.removeAll(delivered) - } + removeFromDeque(delivered) delivered.forEach { it.deleteSafely(config) @@ -446,6 +501,7 @@ public class PostHogQueue( synchronized(dequeLock) { tempFiles = deque.toList() deque.clear() + queuedBytes = 0 } tempFiles.forEach { it.deleteSafely(config) @@ -517,6 +573,9 @@ public class PostHogQueue( deque.clear() deque.addAll(files) deque.addAll(existingFiles) + if (trackBytes) { + queuedBytes += files.sumOf { it.length() } + } } config.logger.log("Loaded ${files.size} cached ${spec.recordsLabel} from disk.") } @@ -537,7 +596,16 @@ public class PostHogQueue( files.sortBy { file -> file.lastModified() } val maxQueueSize = spec.maxQueueSize(config).coerceAtLeast(1) - val overflow = (files.size - maxQueueSize).coerceAtLeast(0) + var overflow = (files.size - maxQueueSize).coerceAtLeast(0) + val maxQueueBytes = spec.maxQueueBytes + if (maxQueueBytes != null) { + // Oldest first, keeping at least the newest file, as when a record is added. + var bytes = files.drop(overflow).sumOf { it.length() } + while (bytes > maxQueueBytes && overflow < files.size - 1) { + bytes -= files[overflow].length() + overflow++ + } + } if (overflow > 0) { files.take(overflow).forEach { it.deleteSafely(config) } config.logger.log("Dropped $overflow oldest cached ${spec.recordsLabel} to enforce queue capacity.") @@ -550,6 +618,9 @@ public class PostHogQueue( synchronized(dequeLock) { deque.clear() deque.addAll(files) + if (trackBytes) { + queuedBytes = files.sumOf { it.length() } + } } cachedRecordsLoaded = true } @@ -604,6 +675,10 @@ public class PostHogQueue( internal val currentRetryCountForTesting: Int @PostHogVisibleForTesting get() = retryCount + + internal val queuedBytesForTesting: Long + @PostHogVisibleForTesting + get() = synchronized(dequeLock) { queuedBytes } } internal class BatchLimits( diff --git a/posthog/src/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/internal/EndpointSpecTest.kt b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt index 73a68f956..12946becc 100644 --- a/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt +++ b/posthog/src/test/java/com/posthog/internal/EndpointSpecTest.kt @@ -274,13 +274,9 @@ internal class EndpointSpecTest { @Test fun `events spec does not retry non-retriable status codes`() { - // Events keeps a curated retry set (408, 500, 502, 503, 504); it must not - // retry 429, 3xx, arbitrary 4xx, nor the extra 5xx codes the broad logs - // policy (500..599) covers. Guards against widening the set by mistake. assertEquals(false, isCaptureV1RetriableStatusCode(307)) assertEquals(false, isCaptureV1RetriableStatusCode(400)) assertEquals(false, isCaptureV1RetriableStatusCode(413)) - assertEquals(false, isCaptureV1RetriableStatusCode(429)) assertEquals(false, isCaptureV1RetriableStatusCode(501)) assertEquals(false, isCaptureV1RetriableStatusCode(505)) assertEquals(false, isCaptureV1RetriableStatusCode(599)) diff --git a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt index 492cec17b..1239204e9 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt @@ -66,6 +66,7 @@ internal class PostHogCaptureV1Test { http: MockWebServer, flushAt: Int = 1, storagePrefix: String = tmpDir.newFolder().absolutePath, + ai: Boolean = false, ): PostHogQueue { val config = config(http.url("/").toString()).apply { @@ -74,7 +75,8 @@ internal class PostHogCaptureV1Test { } // pause time pinned to the past so a backoff never blocks the next flush clock.setAddSecondsToCurrentDate(parseISO8601Date("1970-01-01T00:00:00.000Z")!!) - val spec = EndpointSpec.batch(config, PostHogApi(config), config.storagePrefix) + val api = PostHogApi(config) + val spec = if (ai) EndpointSpec.ai(config, api, config.storagePrefix) else EndpointSpec.batch(config, api, config.storagePrefix) return PostHogQueue(config, spec, executor).also { queues.add(it) } } @@ -132,6 +134,57 @@ internal class PostHogCaptureV1Test { assertEquals("gzip", request.headers["Content-Encoding"]) } + @Test + fun `captureAi posts to the AI endpoint with the same headers and body shape`() { + val http = httpServers.mockHttp() + clock.setCurrentDate(parseISO8601Date("2024-05-01T10:20:30.456Z")!!) + val event = event("\$ai_generation") + + api(http.url("/").toString()).captureAi(listOf(event)) + + val request = http.takeRequest() + assertEquals("POST", request.method) + assertEquals("/i/v1/ai/events", request.path) + assertEquals("Bearer $PROJECT_TOKEN", request.headers["Authorization"]) + assertEquals("posthog-java/${BuildConfig.VERSION_NAME}", request.headers["PostHog-Sdk-Info"]) + assertEquals("1", request.headers["PostHog-Attempt"]) + UUID.fromString(assertNotNull(request.headers["PostHog-Request-Id"])) + assertEquals("2024-05-01T10:20:30.456Z", request.headers["PostHog-Request-Timestamp"]) + assertEquals("application/json", request.headers["Content-Type"]) + assertEquals("gzip", request.headers["Content-Encoding"]) + val body = body(request) + assertEquals(setOf("created_at", "batch"), body.keys) + @Suppress("UNCHECKED_CAST") + assertEquals(listOf(event.uuid.toString()), (body["batch"] as List>).map { it["uuid"] }) + } + + @Test + fun `analytics and AI retries keep separate request identities`() { + val analytics = event() + val ai = event("\$ai_generation") + val http = httpServers.mockHttp(response = resultsResponse(analytics.uuid!! to "retry")) + http.enqueue(resultsResponse(ai.uuid!! to "retry")) + http.enqueue(resultsResponse(analytics.uuid!! to "ok")) + http.enqueue(resultsResponse(ai.uuid!! to "ok")) + val sut = api(http.url("/").toString()) + + sut.captureV1(listOf(analytics)) + sut.captureAi(listOf(ai)) + sut.captureV1(listOf(analytics)) + sut.captureAi(listOf(ai)) + + val requests = List(4) { http.takeRequest() } + assertEquals( + listOf("/i/v1/analytics/events", "/i/v1/ai/events", "/i/v1/analytics/events", "/i/v1/ai/events"), + requests.map { it.path }, + ) + assertEquals(listOf("1", "1", "2", "2"), requests.map { it.headers["PostHog-Attempt"] }) + val ids = requests.map { it.headers["PostHog-Request-Id"] } + assertNotEquals(ids[0], ids[1]) + assertEquals(ids[0], ids[2]) + assertEquals(ids[1], ids[3]) + } + @Test fun `the capture Bearer token replaces a custom Authorization header`() { val http = httpServers.mockHttp() @@ -279,9 +332,32 @@ internal class PostHogCaptureV1Test { assertEquals(setOf(retry.uuid.toString()), result.retryIds) assertEquals(7, result.retryAfterSeconds) assertTrue(logger.messages.contains("Capture: 1 dropped, 1 to retry.")) + assertTrue(logger.messages.contains("Capture dropped event dropped (${dropped.uuid}): no details.")) assertTrue(logger.messages.none { it.contains(retry.uuid.toString()) }) } + @Test + fun `logs each dropped event with the details capture gives, without its properties`() { + val misrouted = event("button_clicked", properties = mapOf("prompt" to "secret prompt")) + val tooBig = event("\$ai_generation", properties = mapOf("prompt" to "secret prompt")) + val http = + httpServers.mockHttp( + response = + MockResponse().setBody( + "{\"results\":{" + + "\"${misrouted.uuid}\":{\"result\":\"drop\",\"details\":\"misrouted_event\"}," + + "\"${tooBig.uuid}\":{\"result\":\"drop\",\"details\":\"ai_event_too_big\"}}}", + ), + ) + + api(http.url("/").toString()).captureAi(listOf(misrouted, tooBig)) + + assertTrue(logger.messages.contains("Capture dropped event button_clicked (${misrouted.uuid}): misrouted_event.")) + assertTrue(logger.messages.contains("Capture dropped event \$ai_generation (${tooBig.uuid}): ai_event_too_big.")) + assertTrue(logger.messages.contains("Capture: 2 dropped, 0 to retry.")) + assertTrue(logger.messages.none { it.contains("secret prompt") }) + } + @Test fun `a 2xx without readable results counts as delivered`() { val http = httpServers.mockHttp(response = MockResponse().setBody("not json")) @@ -340,6 +416,37 @@ internal class PostHogCaptureV1Test { assertTrue(result.retryIds.isEmpty()) } + @Test + fun `a 404 warns once with the capture URL`() { + val http = httpServers.mockHttp(total = 2, response = MockResponse().setResponseCode(404)) + val host = http.url("/").toString().trimEnd('/') + val sut = api(host) + + repeat(2) { assertThrows(PostHogApiError::class.java) { sut.captureV1(listOf(event())) } } + + assertEquals( + listOf( + "$host/i/v1/analytics/events returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves /i/v1/analytics/events.", + ), + logger.warnings, + ) + } + + @Test + fun `the 404 warning leaves out credentials and the query in the host`() { + val http = httpServers.mockHttp(response = MockResponse().setResponseCode(404)) + val origin = http.url("/").toString().trimEnd('/') + val host = origin.replace("://", "://proxy-user:proxy-password@") + "?secret=s3cr3t" + val sut = api(host) + + assertThrows(PostHogApiError::class.java) { sut.captureV1(listOf(event())) } + + val warning = logger.warnings.single() + assertTrue(warning.startsWith("$origin/"), warning) + listOf("proxy-user", "proxy-password", "s3cr3t").forEach { assertFalse(warning.contains(it), warning) } + } + @Test fun `a 503 with Retry-After 0 is not resent by the HTTP client`() { val http = httpServers.mockHttp(response = MockResponse().setResponseCode(503).setHeader("Retry-After", "0")) @@ -611,9 +718,28 @@ internal class PostHogCaptureV1Test { } @Test - fun `queue drops the batch on 429 and keeps it on 503`() { - val http = httpServers.mockHttp(response = MockResponse().setResponseCode(429)) - http.enqueue(MockResponse().setResponseCode(503)) + fun `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()) 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..574c26c7c --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueByteLimitsTest.kt @@ -0,0 +1,210 @@ +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 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/PostHogQueueTest.kt b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt index 9ad84b2e7..8e007ccc4 100644 --- a/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt +++ b/posthog/src/test/java/com/posthog/internal/PostHogQueueTest.kt @@ -990,12 +990,14 @@ internal class PostHogQueueTest { } @Test - fun `does not retry on 429`() { - val e = PostHogApiError(429, "", null) + fun `retries on 404 and 429`() { val config = PostHogConfig(PROJECT_TOKEN) val limits = initialBatchLimits(config) - assertTrue(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) + for (status in listOf(404, 429)) { + val e = PostHogApiError(status, "", null) + assertFalse(deleteFilesIfAPIError(e, limits, actualBatchSize = limits.cap, logger = config.logger)) + } } @Test 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 e7db1d47f..957123a20 100644 --- a/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt +++ b/posthog/src/testFixtures/java/com/posthog/PostHogFake.kt @@ -51,6 +51,22 @@ public class PostHogFake : PostHogInterface { captures++ } + override fun captureAi( + event: String, + distinctId: String?, + properties: Map?, + userProperties: Map?, + userPropertiesSetOnce: Map?, + groups: Map?, + timestamp: Date?, + options: Map?, + ): UUID? { + this.event = event + this.properties = properties + captures++ + return UUID.randomUUID() + } + override fun captureLog( message: String, severity: PostHogLogSeverity, diff --git a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md index a678f02e4..723403d76 100644 --- a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md +++ b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md @@ -76,11 +76,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for local build and compliance test comma ## SDK Type -The Android SDK advertises `capture_v1` and `encoding_gzip`: -- Endpoint: `POST /i/v1/analytics/events` +The Android SDK advertises `capture_v1`, `capture_ai_v1`, `encoding_gzip` and `event_options`: +- Endpoints: `POST /i/v1/analytics/events` (`/capture`) and `POST /i/v1/ai/events` (`/capture_ai`) - Format: `{created_at: "...", batch: [{event}, {event}]}` with the project token in an `Authorization: Bearer` header -Tests run with `--sdk-type server` flag; the harness selects the `capture_v1` suite from the capabilities. +Tests run with `--sdk-type server` flag; the harness selects the `capture_v1` and `capture_ai_v1` suites from the capabilities. ## API Endpoints @@ -89,6 +89,7 @@ The adapter implements the standard compliance adapter API: - `GET /health` - Health check with SDK version info - `POST /init` - Initialize SDK with config - `POST /capture` - Capture a single event +- `POST /capture_ai` - Capture a single AI event through `captureAi` - `POST /flush` - Force flush all pending events - `GET /state` - Get adapter state for assertions - `POST /reset` - Reset SDK and adapter state diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index c39b7e409..ca843b536 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -41,11 +41,11 @@ Events are tracked via: ### SDK Type -The Android SDK advertises `capture_v1` and `encoding_gzip`: -- Endpoint: `POST /i/v1/analytics/events` +The Android SDK advertises `capture_v1`, `capture_ai_v1`, `encoding_gzip` and `event_options`: +- Endpoints: `POST /i/v1/analytics/events` (`/capture`) and `POST /i/v1/ai/events` (`/capture_ai`) - Format: `{created_at, batch}` with the project token in an `Authorization: Bearer` header -Tests run with `--sdk-type server`; the harness selects the `capture_v1` suite from the capabilities. +Tests run with `--sdk-type server`; the harness selects the `capture_v1` and `capture_ai_v1` suites from the capabilities. ## Files Created diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt index 46e217163..1bd55d60e 100644 --- a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/ComplianceAdapter.kt @@ -22,6 +22,9 @@ import io.ktor.server.routing.routing import okhttp3.Interceptor import okhttp3.Response import java.io.File +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException +import java.util.Date import java.util.UUID import java.util.concurrent.TimeUnit import java.util.concurrent.locks.Condition @@ -34,8 +37,11 @@ private const val QUEUE_STORAGE_PREFIX = "/tmp/posthog-queue" // Upper bound on the wait for a flush()'d batch to reach the mock (generous for Docker). private const val FLUSH_SETTLE_MS = 2000L -// The capture endpoint the SDK posts analytics events to. +// The capture v1 endpoints the SDK posts analytics and AI events to. private const val CAPTURE_PATH = "/i/v1/analytics/events" +private const val CAPTURE_AI_PATH = "/i/v1/ai/events" + +private fun isCaptureUrl(url: String): Boolean = url.contains(CAPTURE_PATH) || url.contains(CAPTURE_AI_PATH) /** * PostHog Android SDK Compliance Adapter @@ -87,7 +93,8 @@ data class CaptureRequest( data class CaptureResponse( val success: Boolean, - val uuid: String, + // Null (omitted from the JSON) when the SDK didn't queue the event. + val uuid: String? = null, ) data class FlagRequest( @@ -162,7 +169,7 @@ class TrackingInterceptor : Interceptor { val uuidList = mutableListOf() // Parse request body before sending - if (url.contains(CAPTURE_PATH) && requestBody != null) { + if (isCaptureUrl(url) && requestBody != null) { try { val buffer = okio.Buffer() requestBody.writeTo(buffer) @@ -183,7 +190,7 @@ class TrackingInterceptor : Interceptor { val response = chain.proceed(request) // Track the response - if (url.contains(CAPTURE_PATH)) { + if (isCaptureUrl(url)) { try { // PostHog-Attempt starts at 1; the harness counts retries from 0. val retryCount = (request.header("PostHog-Attempt")?.toIntOrNull() ?: 1) - 1 @@ -219,6 +226,16 @@ class TrackingInterceptor : Interceptor { } } +// The harness sends RFC 3339 timestamps, with any offset; the SDK serializes the instant in UTC. +private fun parseHarnessTimestamp(timestamp: String?): Date? = + timestamp?.let { + try { + Date.from(OffsetDateTime.parse(it).toInstant()) + } catch (_: DateTimeParseException) { + null + } + } + // Block until [predicate] holds (signalled as each capture response lands) or [timeoutMs] elapses. private fun awaitBatch( timeoutMs: Long, @@ -247,8 +264,9 @@ fun main() { sdk_name = "posthog-android", sdk_version = PostHogConfig(projectToken = "").sdkVersion, adapter_version = "1.0.0", - // Opt into the capture suites (android posts /i/v1/analytics/events with gzip). - capabilities = listOf("capture_v1", "encoding_gzip", "event_options"), + // Opt into the capture suites (android posts /i/v1/analytics/events and + // /i/v1/ai/events with gzip). + capabilities = listOf("capture_v1", "capture_ai_v1", "encoding_gzip", "event_options"), ), ) } @@ -272,6 +290,7 @@ fun main() { // close() leaves queue files on disk; wipe so they don't replay into this test. File(QUEUE_STORAGE_PREFIX).deleteRecursively() + File("$QUEUE_STORAGE_PREFIX-ai").deleteRecursively() // Create new config val flushIntervalMs = req.flush_interval_ms ?: 100 @@ -300,12 +319,17 @@ fun main() { config.httpClient = httpClient req.max_retries?.let { config.pushNotificationSubscriptionMaxRetries = it } + // The harness sends one enable_compression flag and no AI-lane setting, so it sets + // both lanes. With it unset, the AI lane keeps its documented default (gzip). req.enable_compression?.let { - config.compression = if (it) PostHogCompression.GZIP else PostHogCompression.NONE + val compression = if (it) PostHogCompression.GZIP else PostHogCompression.NONE + config.compression = compression + config.captureAiCompression = compression } - // Set storage prefix for file-backed queue + // Set storage prefixes for the file-backed queues (both wiped on /init and /reset) config.storagePrefix = QUEUE_STORAGE_PREFIX + config.aiStoragePrefix = "$QUEUE_STORAGE_PREFIX-ai" // Set minimal context to provide $lib and $lib_version config.context = TestPostHogContext("posthog-android", config.sdkVersion) @@ -350,6 +374,7 @@ fun main() { event = req.event, distinctId = req.distinct_id, properties = properties, + timestamp = parseHarnessTimestamp(req.timestamp), options = req.options, ) @@ -371,6 +396,42 @@ fun main() { call.respond(CaptureResponse(success = true, uuid = uuid)) } + // Same request as /capture, sent with captureAi to /i/v1/ai/events. Ktor's Netty engine + // streams request bodies without a size limit, so an 8 MiB AI event is received whole. + post("/capture_ai") { + val req = call.receive() + + val ph = + AdapterContext.lock.withLock { + AdapterContext.postHog + } + + if (ph == null) { + call.respond(HttpStatusCode.BadRequest, mapOf("error" to "SDK not initialized")) + return@post + } + + val uuid = + ph.captureAi( + event = req.event, + distinctId = req.distinct_id, + properties = req.properties, + timestamp = parseHarnessTimestamp(req.timestamp), + options = req.options, + ) + + // 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() @@ -474,6 +535,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