Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9003ae0
feat: add captureAi on the capture v1 AI endpoint
turnipdabeets Oct 24, 2026
5c104bd
test: cover captureAi, the AI lane and byte-aware batching
turnipdabeets Oct 24, 2026
51070c1
chore(compliance): run the capture v1 AI suite against the adapter
turnipdabeets Oct 24, 2026
423bb80
docs: changeset bullets and migration guide for captureAi
turnipdabeets Oct 24, 2026
0fa7a6f
docs: list the AI endpoint in the migration guide's endpoint table
turnipdabeets Oct 26, 2026
0a5fa6c
fix: check the AI event size before queuing and return null when over
turnipdabeets Oct 27, 2026
1084259
docs: describe the AI size limit and timeout as they behave
turnipdabeets Oct 27, 2026
f153814
chore(compliance): report no uuid when captureAi doesn't queue the event
turnipdabeets Oct 27, 2026
941cd6c
fix: log each event capture v1 drops with the reason it gives
turnipdabeets Oct 27, 2026
117bc04
fix: stop measuring an AI event once it passes the size limit
turnipdabeets Oct 27, 2026
f219836
fix: log an unserializable property by key and type, not by value
turnipdabeets Oct 27, 2026
d44bc39
docs: say the AI size check builds the full JSON tree before it stops…
turnipdabeets Oct 27, 2026
2d22dbb
test: double bytes instead of Base64 in the AI encryption test
turnipdabeets Oct 28, 2026
2912dcc
test: make the unserializable Turn a plain class
turnipdabeets Oct 28, 2026
b3e29fb
test: write encrypted test streams in blocks
turnipdabeets Oct 28, 2026
f4da7c8
fix: retry capture v1 requests that get 404 or 429 (#897)
turnipdabeets Oct 28, 2026
739822b
docs: escape dollar signs in captureAi examples
turnipdabeets Oct 28, 2026
ed59002
test: give the serializer privacy fixture a prompt-bearing toString
turnipdabeets Oct 28, 2026
83e9b67
fix: make captureAiMaxEventBytes limit the whole serialized event
turnipdabeets Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .changeset/android-v4-major.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
35 changes: 29 additions & 6 deletions docs/migration-3.x-to-4.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

Expand All @@ -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 <token>`), 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

Expand All @@ -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 `""`.
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
),
)
}

Expand All @@ -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
Expand Down
Loading
Loading