Repository navigation
feat!: send events through capture v1 and add captureAi - #881
turnipdabeets wants to merge 43 commits into
Conversation
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 71d07c1: 2 Must fix, 2 Should fix.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.
posthog-android Compliance ReportDate: 2026-10-28T21:28:51.681105+00:00
|
| Test | Status | Duration |
|---|---|---|
| Endpoint And Method.Targets V1 Endpoint | ✅ | 403ms |
| Endpoint And Method.Does Not Use Legacy Endpoints | ✅ | 33ms |
| Required Headers.Has Authorization Bearer Header | ✅ | 33ms |
| Required Headers.Has Content Type Json | ✅ | 29ms |
| Required Headers.Has Posthog Sdk Info Format | ✅ | 28ms |
| Required Headers.Has Posthog Attempt Header | ✅ | 30ms |
| Required Headers.Has Posthog Request Id | ✅ | 27ms |
| Required Headers.Has Posthog Request Timestamp | ✅ | 26ms |
| Required Headers.Has User Agent | ✅ | 25ms |
| Body Format.Body Has Created At And Batch | ✅ | 26ms |
| Body Format.No Api Key In Body | ✅ | 29ms |
| Body Format.No Sent At In Body | ✅ | 23ms |
| Event Format.Event Has Required Root Fields | ✅ | 24ms |
| Event Format.Event Uuid Is Valid | ✅ | 21ms |
| Event Format.Event Timestamp Is Rfc3339 | ✅ | 24ms |
| Event Format.Non Utc Event Timestamp Is Converted To Utc | ✅ | 28ms |
| Event Format.Distinct Id Is String | ✅ | 22ms |
| Event Format.Distinct Id At Root Not Properties | ✅ | 24ms |
| Event Format.Custom Properties Preserved | ✅ | 23ms |
| Event Format.Set Properties Preserved | ✅ | 25ms |
| Event Format.Set Once Properties Preserved | ✅ | 26ms |
| Event Format.Groups Properties Preserved | ✅ | 26ms |
| Event Format.Sdk Generates Uuid If Not Provided | ✅ | 24ms |
| Event Format.Event Has Required Root Fields Batch | ✅ | 42ms |
| Event Format.Event Uuid Is Valid Batch | ✅ | 30ms |
| Event Format.Event Timestamp Is Rfc3339 Batch | ✅ | 27ms |
| Event Format.Distinct Id Is String Batch | ✅ | 29ms |
| Event Format.Distinct Id At Root Not Properties Batch | ✅ | 28ms |
| Event Format.Custom Properties Preserved Batch | ✅ | 31ms |
| Event Format.Set Properties Preserved Batch | ✅ | 31ms |
| Event Format.Set Once Properties Preserved Batch | ✅ | 26ms |
| Event Format.Groups Properties Preserved Batch | ✅ | 33ms |
| Event Format.Sdk Generates Uuid If Not Provided Batch | ✅ | 34ms |
| Batch Behavior.Multiple Events In Single Batch | ✅ | 36ms |
| Batch Behavior.Batch Envelope Smoke | ✅ | 31ms |
| Batch Behavior.Flush With No Events Sends Nothing | ✅ | 40ms |
| Batch Behavior.Flush At Triggers Batch | ✅ | 1024ms |
| Batch Behavior.Created At Reflects Batch Creation Time | ✅ | 19ms |
| Deduplication.Generates Unique Uuids | ✅ | 49ms |
| Deduplication.Different Events Same Content Different Uuids | ✅ | 24ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 7015ms |
| Deduplication.Preserves Timestamp On Retry | ✅ | 7017ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 7026ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 31ms |
| Header Behavior On Retry.Attempt Header Starts At One | ✅ | 15ms |
| Header Behavior On Retry.Attempt Header Increments On Retry | ✅ | 12022ms |
| Header Behavior On Retry.Request Id Preserved On Retry | ✅ | 7013ms |
| Header Behavior On Retry.Different Requests Have Different Request Ids | ✅ | 2029ms |
| Header Behavior On Retry.Request Timestamp Changes On Retry | ✅ | 7017ms |
| Response Format Validation.Success Response Has Uuid Keyed Results | ✅ | 17ms |
| Response Format Validation.Success Response Has Ok For Each Event | ✅ | 23ms |
| Response Format Validation.Success No Retry After When All Ok | ✅ | 19ms |
| Response Format Validation.Success Retry After Present When Retry Events | ✅ | 2011ms |
| Response Format Validation.Success No Retry After When Drop Only | ✅ | 18ms |
| Response Format Validation.Response Echoes Request Id | ✅ | 19ms |
| Retry Behavior.Retries On 408 | ✅ | 7016ms |
| Retry Behavior.Retries On 500 | ✅ | 7016ms |
| Retry Behavior.Retries On 503 | ✅ | 7023ms |
| Retry Behavior.Retries On 504 | ✅ | 7016ms |
| Retry Behavior.Retryable Errors Have Retry After | ✅ | 4012ms |
| Retry Behavior.Respects Retry After On Retryable Error | ✅ | 10025ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 4019ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 4018ms |
| Retry Behavior.Does Not Retry On 402 | ✅ | 4020ms |
| Retry Behavior.Does Not Retry On 413 | ✅ | 4019ms |
| Retry Behavior.Does Not Retry On 415 | ✅ | 4016ms |
| Retry Behavior.Non Retryable Errors Have No Retry After | ✅ | 4020ms |
| Retry Behavior.Implements Backoff | ✅ | 17034ms |
| Retry Behavior.Max Retries Respected | ❌ | 17034ms |
| Partial Batch Handling.Handles 200 Full Success | ✅ | 2018ms |
| Partial Batch Handling.Handles 200 With All Ok | ✅ | 3024ms |
| Partial Batch Handling.Does Not Retry Dropped Events | ✅ | 3024ms |
| Partial Batch Handling.Does Not Retry Limited Events | ✅ | 3024ms |
| Partial Batch Handling.Prunes Ok Events On Partial Retry | ✅ | 7017ms |
| Partial Batch Handling.Prunes Dropped Events On Partial Retry | ✅ | 7015ms |
| Partial Batch Handling.Retries Only Retry Events From Partial | ✅ | 7013ms |
| Partial Batch Handling.Partial Retry Preserves Uuids | ✅ | 7017ms |
| Partial Batch Handling.Partial Retry Attempt Header Increments | ✅ | 7016ms |
| Partial Batch Handling.Partial Retry Request Id Preserved | ✅ | 7015ms |
| Partial Batch Handling.Respects Retry After On Partial | ✅ | 7025ms |
| Partial Batch Handling.Unknown Result Treated As Terminal | ✅ | 3023ms |
| Partial Batch Handling.Mixed Ok Drop Limited No Retry | ✅ | 3029ms |
| Compression.Sends Gzip Content Encoding | ✅ | 15ms |
| Compression.No Content Encoding When Disabled | ✅ | 15ms |
| Compression.Compressed Body Is Decompressible | ✅ | 13ms |
| Error Handling.Does Not Retry On Unknown 4Xx | ✅ | 4017ms |
| Event Options.Cookieless Mode Override | ✅ | 17ms |
| Event Options.Disable Skew Correction Override | ✅ | 18ms |
| Event Options.Process Person Profile Override | ✅ | 21ms |
| Event Options.Product Tour Id Override | ✅ | 23ms |
| Event Options.Unset Options Omitted | ✅ | 20ms |
| Event Options.Options Override In Batch | ✅ | 25ms |
| Event Options.Unknown Option Passes Through | ✅ | 18ms |
| Event Options.Option Value Is Not Converted | ✅ | 15ms |
| Event Options.Lenient Bool Option Values Are Not Converted | ✅ | 15ms |
| Event Options.Option Wins Over Legacy Property | ✅ | 16ms |
| Event Options.Legacy Property Fills Unset Option | ✅ | 22ms |
| Event Options.Null Option Falls Back To Legacy Property | ✅ | 26ms |
| Geoip And Historical Migration.Geoip Disable Injected Into Properties | ❌ | 16ms |
| Geoip And Historical Migration.Historical Migration Set In Body | ❌ | 14ms |
| Geoip And Historical Migration.Historical Migration Absent By Default | ✅ | 12ms |
Failures
retry_behavior.max_retries_respected
Expected 4 requests, got 5
geoip_and_historical_migration.geoip_disable_injected_into_properties
Expected $geoip_disable='True', got 'None'. Available properties: ['$is_identified', '$recording_status', '$sdk_debug_pending_queue_size']
geoip_and_historical_migration.historical_migration_set_in_body
Body missing 'historical_migration' field. Body keys: ['created_at', 'batch']
Capture_Ai_V1 Tests
View Details
| Test | Status | Duration |
|---|---|---|
| Endpoint And Method.Targets V1 Endpoint | ✅ | 25ms |
| Endpoint And Method.Does Not Use Legacy Endpoints | ✅ | 22ms |
| Endpoint And Method.Capture Does Not Reroute Ai Named Events | ✅ | 21ms |
| Required Headers.Has Authorization Bearer Header | ✅ | 19ms |
| Required Headers.Has Content Type Json | ✅ | 22ms |
| Required Headers.Has Posthog Sdk Info Format | ✅ | 22ms |
| Required Headers.Has Posthog Attempt Header | ✅ | 21ms |
| Required Headers.Has Posthog Request Id | ✅ | 22ms |
| Required Headers.Has Posthog Request Timestamp | ✅ | 22ms |
| Required Headers.Has User Agent | ✅ | 30ms |
| Body Format.Body Has Created At And Batch | ✅ | 28ms |
| Body Format.No Api Key In Body | ✅ | 30ms |
| Body Format.No Sent At In Body | ✅ | 24ms |
| Event Format.Event Has Required Root Fields | ✅ | 26ms |
| Event Format.Event Uuid Is Valid | ✅ | 24ms |
| Event Format.Event Timestamp Is Rfc3339 | ✅ | 18ms |
| Event Format.Non Utc Event Timestamp Is Converted To Utc | ✅ | 17ms |
| Event Format.Distinct Id Is String | ✅ | 19ms |
| Event Format.Distinct Id At Root Not Properties | ✅ | 25ms |
| Event Format.Custom Properties Preserved | ✅ | 24ms |
| Event Format.Set Properties Preserved | ✅ | 20ms |
| Event Format.Set Once Properties Preserved | ✅ | 19ms |
| Event Format.Groups Properties Preserved | ✅ | 19ms |
| Event Format.Sdk Generates Uuid If Not Provided | ✅ | 22ms |
| Event Format.Capture Ai Keeps Supplied Uuid | ❌ | 12ms |
| Event Format.Event Has Required Root Fields Batch | ❌ | 31ms |
| Event Format.Event Uuid Is Valid Batch | ✅ | 27ms |
| Event Format.Event Timestamp Is Rfc3339 Batch | ✅ | 24ms |
| Event Format.Distinct Id Is String Batch | ✅ | 26ms |
| Event Format.Distinct Id At Root Not Properties Batch | ✅ | 28ms |
| Event Format.Custom Properties Preserved Batch | ✅ | 23ms |
| Event Format.Set Properties Preserved Batch | ✅ | 22ms |
| Event Format.Set Once Properties Preserved Batch | ✅ | 26ms |
| Event Format.Groups Properties Preserved Batch | ✅ | 24ms |
| Event Format.Sdk Generates Uuid If Not Provided Batch | ✅ | 23ms |
| Batch Behavior.Multiple Events In Single Batch | ✅ | 29ms |
| Batch Behavior.Batch Envelope Smoke | ✅ | 30ms |
| Batch Behavior.Flush At Triggers Batch | ✅ | 1020ms |
| Batch Behavior.Created At Reflects Batch Creation Time | ✅ | 16ms |
| Deduplication.Generates Unique Uuids | ✅ | 40ms |
| Deduplication.Different Events Same Content Different Uuids | ✅ | 21ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 7015ms |
| Deduplication.Preserves Timestamp On Retry | ✅ | 7016ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 7015ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 26ms |
| Header Behavior On Retry.Attempt Header Starts At One | ✅ | 15ms |
| Header Behavior On Retry.Attempt Header Increments On Retry | ✅ | 12015ms |
| Header Behavior On Retry.Request Id Preserved On Retry | ✅ | 7016ms |
| Header Behavior On Retry.Different Requests Have Different Request Ids | ✅ | 2025ms |
| Header Behavior On Retry.Request Timestamp Changes On Retry | ✅ | 7015ms |
| Response Format Validation.Success Response Has Uuid Keyed Results | ✅ | 17ms |
| Response Format Validation.Success Response Has Ok For Each Event | ✅ | 20ms |
| Response Format Validation.Success No Retry After When All Ok | ✅ | 16ms |
| Response Format Validation.Success Retry After Present When Retry Events | ✅ | 2010ms |
| Response Format Validation.Success No Retry After When Drop Only | ✅ | 17ms |
| Response Format Validation.Response Echoes Request Id | ✅ | 13ms |
| Retry Behavior.Retries On 408 | ✅ | 7014ms |
| Retry Behavior.Retries On 500 | ✅ | 7015ms |
| Retry Behavior.Retries On 503 | ✅ | 7020ms |
| Retry Behavior.Retries On 504 | ✅ | 7015ms |
| Retry Behavior.Retryable Errors Have Retry After | ✅ | 4011ms |
| Retry Behavior.Respects Retry After On Retryable Error | ✅ | 10025ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 4017ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 4021ms |
| Retry Behavior.Does Not Retry On 402 | ✅ | 4018ms |
| Retry Behavior.Does Not Retry On 413 | ✅ | 4017ms |
| Retry Behavior.Does Not Retry On 415 | ✅ | 4016ms |
| Retry Behavior.Non Retryable Errors Have No Retry After | ✅ | 4017ms |
| Retry Behavior.Implements Backoff | ✅ | 17033ms |
| Retry Behavior.Max Retries Respected | ❌ | 17030ms |
| Partial Batch Handling.Handles 200 Full Success | ✅ | 2016ms |
| Partial Batch Handling.Handles 200 With All Ok | ✅ | 3021ms |
| Partial Batch Handling.Does Not Retry Dropped Events | ✅ | 3021ms |
| Partial Batch Handling.Does Not Retry Limited Events | ✅ | 3020ms |
| Partial Batch Handling.Prunes Ok Events On Partial Retry | ✅ | 7015ms |
| Partial Batch Handling.Prunes Dropped Events On Partial Retry | ✅ | 7016ms |
| Partial Batch Handling.Retries Only Retry Events From Partial | ✅ | 7013ms |
| Partial Batch Handling.Partial Retry Preserves Uuids | ✅ | 7016ms |
| Partial Batch Handling.Partial Retry Attempt Header Increments | ✅ | 7014ms |
| Partial Batch Handling.Partial Retry Request Id Preserved | ✅ | 7016ms |
| Partial Batch Handling.Respects Retry After On Partial | ✅ | 7021ms |
| Partial Batch Handling.Unknown Result Treated As Terminal | ✅ | 3018ms |
| Partial Batch Handling.Mixed Ok Drop Limited No Retry | ✅ | 3030ms |
| Compression.Sends Gzip Content Encoding | ✅ | 14ms |
| Compression.No Content Encoding When Disabled | ✅ | 13ms |
| Compression.Compressed Body Is Decompressible | ✅ | 13ms |
| Error Handling.Does Not Retry On Unknown 4Xx | ✅ | 4015ms |
| Event Options.Cookieless Mode Override | ✅ | 14ms |
| Event Options.Disable Skew Correction Override | ✅ | 15ms |
| Event Options.Process Person Profile Override | ✅ | 12ms |
| Event Options.Product Tour Id Override | ✅ | 15ms |
| Event Options.Unset Options Omitted | ✅ | 19ms |
| Event Options.Options Override In Batch | ✅ | 26ms |
| Event Options.Unknown Option Passes Through | ✅ | 17ms |
| Event Options.Option Value Is Not Converted | ✅ | 14ms |
| Event Options.Lenient Bool Option Values Are Not Converted | ✅ | 14ms |
| Event Options.Option Wins Over Legacy Property | ✅ | 14ms |
| Event Options.Legacy Property Fills Unset Option | ✅ | 13ms |
| Event Options.Null Option Falls Back To Legacy Property | ✅ | 12ms |
| Geoip And Historical Migration.Geoip Disable Injected Into Properties | ❌ | 13ms |
| Geoip And Historical Migration.Historical Migration Absent By Default | ✅ | 11ms |
Failures
event_format.capture_ai_keeps_supplied_uuid
Expected last action result '0198c0de-0000-7000-8000-000000000abc', got '01a18467-0883-7118-9942-851f45020617'
event_format.event_has_required_root_fields_batch
Expected 1 requests, got 2
retry_behavior.max_retries_respected
Expected 4 requests, got 5
geoip_and_historical_migration.geoip_disable_injected_into_properties
Expected $geoip_disable='True', got 'None'. Available properties: ['$is_identified', '$recording_status', '$sdk_debug_session_start', '$sdk_debug_pending_queue_size']
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 28ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 19ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 18ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 18ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 18ms |
| Request Payload.Groups Round Trip | ✅ | 16ms |
| Request Payload.Groups Default To Empty Object | ✅ | 17ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 15ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 17ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 17ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 8ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 14ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 28ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 20ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 323ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 319ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 18ms |
e4e5fc5 to
8a765aa
Compare
8a765aa to
483626f
Compare
483626f to
c7fec3a
Compare
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 1426e51: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
010be59 to
4c56faa
Compare
A `phx_` token is a personal API key; capture rejects every request made with it. Setup now logs once and stays disabled, as for an empty token. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Analytics events now go to `POST /i/v1/analytics/events` with the project
token in an `Authorization: Bearer` header, `PostHog-Sdk-Info`,
`PostHog-Attempt`, `PostHog-Request-Id` and `PostHog-Request-Timestamp`.
The body is `{created_at, batch}`; each event drops `$lib`/`$lib_version`,
lifts string `$session_id`/`$window_id` to the root, and hoists the legacy
`$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and
`$product_tour_id` properties into the new `PostHogEvent.options`.
The queue reads the per-event results: `ok`, `warning` and `drop` leave the
queue, only `retry` is kept and resent under the same request id and
`created_at` with the next attempt number. A 2xx without readable results
counts as delivered. 408 and 500/502/503/504 are retried; 429, 404 and
other 4xx are terminal, as are redirects other than a same-origin 307/308
(followed at most 5 times with the same body and headers). Backoff doubles
from 1 to 30 seconds with 25% jitter, and `Retry-After` is a floor capped
at 30 seconds for every queue. Replay and logs keep their retry sets.
Legacy cached events (`PostHogSendCachedEventsIntegration`) are sent
through capture v1 too; the ones capture asks to retry are re-queued at
the tail of the legacy file for the next launch.
`PostHogConfig.maxRetries` is renamed `pushNotificationSubscriptionMaxRetries`
since it only limits push-subscription retries. `PostHogApi.batch()` stays
for posthog-server.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The queue measured an AI event after `encryption`, so an encryption that grows the data dropped events PostHog would accept, and captureAi had already returned the uuid. captureAi now measures the serialized event before encryption and returns null when it's over the limit. The queue's per-record limit had no other user and is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The limit covers an AI event's properties up to 8 MiB, with 64 KiB for the rest of the serialized event, measured before encryption. captureAiTimeout is a timeout without network activity, not a call timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Debug logs named only the drop count, so a misrouted_event or ai_event_too_big drop was invisible. Each dropped event is now logged with its name, uuid and the server's details, never its properties. Adds a test that an AI event with exactly 8 MiB of properties is sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
captureAi checks the size on the caller's thread, so a huge prompt or base64 payload no longer costs a full serialization just to be dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Properties can hold prompts and other user content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
java.util.Base64 isn't on API 23, so animalsnifferTest failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The data class hashCode calls Double.hashCode(double), which isn't on API 23. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: retry capture v1 requests that get 404 or 429 A 404 usually means a misconfigured proxy and a 429 now only comes from an overloaded ingress, so keep the events queued and retry them with the usual backoff and Retry-After handling instead of dropping them. Matches PostHog/posthog-go#395. * fix: warn once when capture v1 returns 404 A 404 now keeps events queued, so nothing tells the app that the route is missing. Print one warning per client, even without debug, naming the URL and the path a reverse proxy or self-hosted instance must serve. * fix: leave host credentials and query out of the capture v1 404 warning Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match posthog-ios, posthog-python and the Flutter desktop lane: the value is the serialized event size, defaulting to 8 MiB + 64 KiB, instead of a properties size the SDK added 64 KiB to. A value over the ceiling now logs a warning when it is clamped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 3106d18: 6 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 4 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.
… feat/v4-capture-v1 # Conflicts: # .changeset/android-v4-major.md
A file deleted from the cache dir reads as 0 bytes, so the queue kept counting it and evicted new AI events against bytes that were gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n properties Capture reads "false", 0 and similar strings as false, so a $set sent with them is personless and must not store the person-properties hash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fatal flush resent only the retry set, so the crash report stayed on disk as the process exited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…etry some Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #871 (
chore/v4-remove-deprecated).💡 Motivation and Context
Moves capture to capture v1 for posthog-android 4.0: analytics events go to
POST /i/v1/analytics/eventsand the newcaptureAi(...)sends AI events toPOST /i/v1/ai/events. This matches posthog-ios 4.0 (PostHog/posthog-ios#938) and the posthog-python 8.0, posthog-go 2.0 and posthog-rs 1.0 v1 releases and their AI lanes, and Ingestion's goal of moving the mobile SDKs to the v1 endpoints withcaptureAiin every v1 release. Flutter and React Native needcaptureAion both natives before they can expose it.This PR now contains the whole capture v1 stack: #884 (
captureAi, including #897, which retries 404 and 429) was merged into #882 (per-event options and one property precedence), and #882 was merged into this PR.Transport
Authorization: Bearer <projectToken>plusPostHog-Sdk-Info(<sdkName>/<sdkVersion>, which Flutter, React Native and KMP already set),PostHog-Attempt,PostHog-Request-IdandPostHog-Request-Timestamp. The body is{created_at, batch}, with noapi_keyorsent_at.$session_id/$window_idmove to the event root, and$lib/$lib_versionare no longer sent as properties (the server reads them fromPostHog-Sdk-Info). A string session/window ID, including"", is sent unchanged; a non-string is dropped with a debug log naming the key and JSON type; null drops silently.ok/warning/dropevents leave the queue, onlyretryevents are resent, and the responseRetry-Afteris honored for that resend. The queue resends exactly the events of a request that failed or came back with events to retry, before newer ones, so the retry keeps its request ID andcreated_atand increments the attempt. Retry identity is kept per endpoint, so the analytics and AI lanes don't reset each other's attempt numbers. A 413 halving is a new request. Events missing from the results, and a 2xx whose results can't be read (malformed body, a transfer that failed after the status, or a response over 1 MiB), count as delivered: a resend would duplicate stored events./batchfallback: a host that doesn't serve the v1 route keeps the events queued (404 is retried) until it does.PostHogApi.batch()stays only forposthog-server, which keeps/batchuntil its own major. Events cached by pre-3.0 versions (PostHogSendCachedEventsIntegration) and 3.x events still queued on disk are sent through v1 too.Retry-Afteris honored as a floor, capped at 30 seconds, and the backoff doubles from 1 s to 30 s with ±25% jitter kept in milliseconds. Both apply to every queue (events, replay, logs).host, at most 5 hops, re-sending body and headers. Other redirects are terminal. OkHttp would otherwise follow any 3xx, dropAuthorizationcross-host and turn a 302 into a GET, so automatic redirects are off for the capture client. So isretryOnConnectionFailure: an OkHttp resend would repeat the attempt number and timestamp.maxBatchSizeevents and about 5 MiB (checked before appending; an event larger than that is sent on its own), and the queue sends as soon as that much is queued, for analytics and AI.debugon, drop/retry logging is one count line per batch, with no UUIDs or payloads; a batch dropped by a terminal status gets no count line. Debug logs name the event count and response status, never request or response bodies. Withdebugon, each event a 2xx drops is logged with its name, uuid and the server'sdetails(e.g.misrouted_event,ai_event_too_big), never its properties.Per-event options (new public API)
options: Map<String, Any?>? = nullas the last parameter ofcapture,screen,identify,setPersonProperties,alias,groupandcaptureExceptiononPostHogInterface,PostHogand thePostHogcompanion, andPostHogEvent.options(persisted in the queue, editable inbeforeSend). Foridentifythe options apply to the$identifyor$setevent it sends; forgroupto$groupidentify; foraliasto$create_alias.process_person_profile,cookieless_mode,disable_skew_correctionandproduct_tour_id. Any other key is sent as-is; values are never coerced (the server parses them leniently).$process_person_profile,$cookieless_mode,$ignore_sent_atand$product_tour_idproperties are converted when the request is built, afterbeforeSend. Each is always removed from properties and only fills an option that is unset or null.beforeSend. An option set to null counts as unset.process_person_profile: falseonly when person processing is off (personProfiles = NEVER, orIDENTIFIED_ONLYbefore identify), and no longer writes a$process_person_profileproperty on every event. A null legacy value keeps the personless default.identifymoved fromPostHogCoreInterfacetoPostHogStatelessInterface;PostHogInterfacehas the 4-arg one.Property precedence (PostHog/sdk-specs#143)
$screen_name, session id,$is_identified, replay debug values) → registered super properties → the properties passed to the call (userProperties,userPropertiesSetOnceandgroupsare per-call values) →beforeSend. In 3.x the SDK's values beat super properties, and$is_identified,$process_person_profileand the replay debug values also beat caller properties.$screen_name,$session_idordistinct_idstill replaces the SDK's value, as in 3.x.captureAi(new public API)captureAitakes the same arguments ascapture(includingoptions) and goes through the same enrichment, precedence, personless default andbeforeSend. Only the queue differs.capturenever reroutes$ai_*events; acaptureAiname that doesn't start with$ai_logs a debug line and is sent unchanged (the server reportsmisrouted_event).posthog-disk-ai-queue,PostHogConfig.aiStoragePrefix) with its own executor and retry state, created at setup so events left on disk by a previous launch still send, and flushed byflush()andclose(). A failing or slow AI lane never delays or drops analytics events.captureAireturns the queued event's uuid, or null when the event isn't queued: disabled, opted out, dropped bybeforeSend, or over the size limit.captureAiMaxEventBytes, measured beforeencryption, is dropped incaptureAi, which logs (debug) only the event name and the limit. The AI queue keeps at mostcaptureAiMaxQueueSizeevents and 50 MiB on disk, dropping the oldest first.PostHogConfigsettings, named after the server SDKs':captureAiCompression(gzip),captureAiMaxQueueSize(100),captureAiTimeout(60 s read/write idle timeouts on a derived OkHttp client),captureAiMaxEventBytes(8 MiB + 64 KiB for the whole serialized event: PostHog's 8 MiB properties limit plus room for the rest; it can only lower, and a larger value logs a debug line). Non-positive values fall back to the defaults.Other changes in the stack
maxRetriesis renamedpushNotificationSubscriptionMaxRetries(no alias): it only limits push-subscription retries. Event, replay and log retries have no limit, as before.projectTokenstarting withphx_(a personal API key) disables the SDK at setup, like an empty token.posthog-serverinherits this throughPostHogStateless.setup, so it gets its own patch changeset.distinctIdargument tocaptureuses the current distinct ID instead of dropping the event.beforeSendreturns keeps the uuid of the capture it came from, so a hook that returns a cached earlier event can't repeat a uuid.$set/$identifysent withprocess_person_profile: falseneither stores nor matches the person-properties dedup hash.$feature_flag_calledevent also keeps$cookieless_mode,$ignore_sent_atand$product_tour_id, so they survive until they are converted to options.Deep Link Openedskips query parameters whose name starts with$, so a link can't set a PostHog property such as$process_person_profile.capture_v1,capture_ai_v1,event_optionsandencoding_gzip; tracks both v1 routes; forwardsoptionsandtimestamp; runs withpersonProfiles = ALWAYSlike a server SDK. The workflow pins harness1.20.0and also runs for PRs intochore/v4-*/feat/v4-*.docs/migration-3.x-to-4.0.mdand the 4.0 changeset bullets.Breaking changes
/i/v1/analytics/eventswith Bearer auth; no/batchfallbackAuthorization: Bearer, replacing a customAuthorizationfromrequestHeadersAuthorizationneed another headerRetry-Aftercapped at 30 s; jittered backoffhostpointing at something that redirects elsewhereoptions; the server drops events with unreadable valuesoptionsparameter on sevenPostHogInterfacemethods,PostHogEvent.optionsconstructor parameter, newcaptureAinull), anything implementingPostHogInterface, Java callers of the fullPostHogEventconstructor /copyPostHogCoreInterface.identifymoved toPostHogStatelessInterface/PostHogInterfacePostHogCoreInterfacereference and callsidentify$is_identified,$process_person_profileand replay debug values$process_person_profileis sent only when person processing is off, as an option$process_person_profile = truebeforeSendcan't change an event's uuid;$-prefixed deep link parameters are skippedmaxRetries→pushNotificationSubscriptionMaxRetries;phx_tokens disable the SDKAll of these are in
.changeset/android-v4-major.md(plus.changeset/server-personal-api-key-token.md) and indocs/migration-3.x-to-4.0.md.Differences from the spec and the other v1 SDKs (same as posthog-ios 4.0 unless noted)
maxQueueSize. Server SDKs give up after 4 attempts.compression) and AI (captureAiCompression).$key becomes its option at send time.$process_person_profilesuppresses the SDK's personless option instead of losing to it (fixed in fix: keep the personless option over a legacy property and drop a 2xx without results #905).$set/$set_once/$groupscall values beat a registered property of the same key; posthog-ios keeps the registered one.process_person_profile: falsegoes inoptions, not properties.capture/spec.md"Caller can't override person-processing state" expects the property; the compliance adapter runs withpersonProfiles = ALWAYS.captureAi(thecapture-aispec covers server SDKs): nouuidargument, like Androidcapture, but it returns the uuid; oversize events are dropped at the call; the queue is created at setup, not lazily; the queue limit is 100 events and 50 MiB, not 1000 events.💚 How did you test it?
PostHogCaptureV1Test: the wire format and headers,$libstrip, session/window lift, legacy-property hoisting and precedence, per-event results and the kept retry set, retry identity, status classification (404 and 429 retried), redirects, 2xx with unreadable results or a failed transfer,Retry-Afteron a 2xx, the AI path and the per-event drop log.PostHogQueueTest,PostHogQueueDurabilityTest: partial retention, jitter bounds, the 30 sRetry-Aftercap, the partial-retryRetry-Afterfloor, and a fatal event sent while a request waits to be retried.PostHogCaptureOptionsTest: options reachingPostHogEvent.optionsfor every method,beforeSendedits, the personless option under eachpersonProfilesmode, precedence, the blank-distinctId fallback, the dedup hash with a personless$set(including lenient false values such as"false"and0), the allowlist and uuid stability.PostHogCaptureAiTest: routing,beforeSendand options, 404 retried, oversize events dropped when queued, splitting at the byte limit, flushing once a batch's worth of bytes is queued, the return value, per-lane timeout and compression, separate retry identity,flush()/close()draining the AI queue, an event with exactly 8 MiB of properties.PostHogQueueByteLimitsTest: byte accounting on add/load/remove (including a file deleted from disk), eviction of the oldest, check-before-append windows, an oversize record alone.PostHogSendCachedLegacyEventsIntegrationTest: the legacy queue on v1.options, no$libor$process_person_profile).chore/v4-remove-deprecated(chore!: pass the load result to the feature flags callback #877 conflicted only in the changeset):./gradlew apiCheck spotlessCheck :posthog:test :posthog-android:testReleaseUnitTest :posthog-server:testpass locally. The compliance adapter compiles.1.20.0in CI on de16564: 212/219 (capture_v198/101,capture_ai_v197/101,feature_flags17/17). The failures:max_retries_respectedon both suites: there's no retry limit on purpose. The analytics one is timing-dependent: the 1 s, 2 s, 4 s, 8 s backoff with ±25% jitter puts a 5th attempt at about 15 s, the edge of the harness wait, so it passed on feat!: add captureAi on the capture v1 AI endpoint #884's run.capture_ai_keeps_supplied_uuid:captureAitakes no uuid. The test aborts before flushing, so its request lands in the next test and failsevent_has_required_root_fields_batchtoo.geoip_disable_injected_into_propertieson both suites (no SDK surface) andhistorical_migration_set_in_body(no historical-migration mode).Build Job, compliance) before it was merged.captureAion posthog.com / LLM analytics; a device run against PostHog Cloud.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file (bullets added to.changeset/android-v4-major.md, plus.changeset/server-personal-api-key-token.md)🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code. Subagents implemented the transport, options and AI lane against the capture v1 server contract, the harness
capture_v1/capture_ai_v1contracts, sdk-specs#143, the sdk-specscapture-aispec and the posthog-ios 4.0 branch; review cycles on each PR of the stack fed fixes back. #884 and #882 were squash-merged into this branch after approval.Maintainer decisions: no
/batchfallback from the start (iOS added and then removed one); keepPostHogApi.batch()forposthog-server; keep unlimited retries and 413 halving as mobile divergences; retry 404 and 429 (posthog-go#395); add a public options API in 4.0 and moveidentifyoffPostHogCoreInterfacerather than carry two overloads; keep the uuid pin client-only; a separate AI lane with a small disk queue, config named after the server SDKs.🤖 Generated with Claude Code