Repository navigation
feat!: add per-event capture options and one property precedence - #882
Conversation
There was a problem hiding this comment.
PostHog Review · Standard · reviewed b0edae2: 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-27T22:52:04.860887+00:00
|
| Test | Status | Duration |
|---|---|---|
| Endpoint And Method.Targets V1 Endpoint | ✅ | 378ms |
| Endpoint And Method.Does Not Use Legacy Endpoints | ✅ | 36ms |
| Required Headers.Has Authorization Bearer Header | ✅ | 25ms |
| Required Headers.Has Content Type Json | ✅ | 25ms |
| Required Headers.Has Posthog Sdk Info Format | ✅ | 23ms |
| Required Headers.Has Posthog Attempt Header | ✅ | 24ms |
| Required Headers.Has Posthog Request Id | ✅ | 18ms |
| Required Headers.Has Posthog Request Timestamp | ✅ | 23ms |
| Required Headers.Has User Agent | ✅ | 20ms |
| Body Format.Body Has Created At And Batch | ✅ | 23ms |
| Body Format.No Api Key In Body | ✅ | 20ms |
| Body Format.No Sent At In Body | ✅ | 19ms |
| Event Format.Event Has Required Root Fields | ✅ | 19ms |
| Event Format.Event Uuid Is Valid | ✅ | 19ms |
| Event Format.Event Timestamp Is Rfc3339 | ✅ | 17ms |
| Event Format.Non Utc Event Timestamp Is Converted To Utc | ❌ | 20ms |
| Event Format.Distinct Id Is String | ✅ | 16ms |
| Event Format.Distinct Id At Root Not Properties | ✅ | 20ms |
| Event Format.Custom Properties Preserved | ✅ | 20ms |
| Event Format.Set Properties Preserved | ✅ | 21ms |
| Event Format.Set Once Properties Preserved | ✅ | 21ms |
| Event Format.Groups Properties Preserved | ✅ | 25ms |
| Event Format.Sdk Generates Uuid If Not Provided | ✅ | 20ms |
| Event Format.Event Has Required Root Fields Batch | ✅ | 27ms |
| Event Format.Event Uuid Is Valid Batch | ✅ | 26ms |
| Event Format.Event Timestamp Is Rfc3339 Batch | ✅ | 25ms |
| Event Format.Distinct Id Is String Batch | ✅ | 25ms |
| Event Format.Distinct Id At Root Not Properties Batch | ✅ | 25ms |
| Event Format.Custom Properties Preserved Batch | ✅ | 24ms |
| Event Format.Set Properties Preserved Batch | ✅ | 23ms |
| Event Format.Set Once Properties Preserved Batch | ✅ | 27ms |
| Event Format.Groups Properties Preserved Batch | ✅ | 25ms |
| Event Format.Sdk Generates Uuid If Not Provided Batch | ✅ | 26ms |
| Batch Behavior.Multiple Events In Single Batch | ✅ | 35ms |
| Batch Behavior.Batch Envelope Smoke | ✅ | 31ms |
| Batch Behavior.Flush With No Events Sends Nothing | ✅ | 11ms |
| Batch Behavior.Flush At Triggers Batch | ✅ | 1019ms |
| Batch Behavior.Created At Reflects Batch Creation Time | ✅ | 22ms |
| Deduplication.Generates Unique Uuids | ✅ | 34ms |
| Deduplication.Different Events Same Content Different Uuids | ✅ | 20ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 7012ms |
| Deduplication.Preserves Timestamp On Retry | ✅ | 7016ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 7023ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 31ms |
| Header Behavior On Retry.Attempt Header Starts At One | ✅ | 16ms |
| Header Behavior On Retry.Attempt Header Increments On Retry | ✅ | 12019ms |
| Header Behavior On Retry.Request Id Preserved On Retry | ✅ | 7016ms |
| Header Behavior On Retry.Different Requests Have Different Request Ids | ✅ | 2028ms |
| 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 | ✅ | 20ms |
| Response Format Validation.Success No Retry After When All Ok | ✅ | 17ms |
| Response Format Validation.Success Retry After Present When Retry Events | ✅ | 18ms |
| Response Format Validation.Success No Retry After When Drop Only | ✅ | 19ms |
| Response Format Validation.Response Echoes Request Id | ✅ | 16ms |
| Retry Behavior.Retries On 408 | ✅ | 7012ms |
| Retry Behavior.Retries On 500 | ✅ | 7018ms |
| Retry Behavior.Retries On 503 | ✅ | 7022ms |
| Retry Behavior.Retries On 504 | ✅ | 7016ms |
| Retry Behavior.Retryable Errors Have Retry After | ✅ | 4014ms |
| Retry Behavior.Respects Retry After On Retryable Error | ✅ | 10018ms |
| 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 | ✅ | 4018ms |
| Retry Behavior.Non Retryable Errors Have No Retry After | ✅ | 4016ms |
| Retry Behavior.Implements Backoff | ✅ | 17033ms |
| Retry Behavior.Max Retries Respected | ❌ | 17034ms |
| Partial Batch Handling.Handles 200 Full Success | ✅ | 2017ms |
| Partial Batch Handling.Handles 200 With All Ok | ✅ | 3030ms |
| Partial Batch Handling.Does Not Retry Dropped Events | ✅ | 3024ms |
| Partial Batch Handling.Does Not Retry Limited Events | ✅ | 3025ms |
| Partial Batch Handling.Prunes Ok Events On Partial Retry | ✅ | 5028ms |
| Partial Batch Handling.Prunes Dropped Events On Partial Retry | ✅ | 5026ms |
| Partial Batch Handling.Retries Only Retry Events From Partial | ✅ | 5029ms |
| Partial Batch Handling.Partial Retry Preserves Uuids | ✅ | 5026ms |
| Partial Batch Handling.Partial Retry Attempt Header Increments | ✅ | 5026ms |
| Partial Batch Handling.Partial Retry Request Id Preserved | ✅ | 5022ms |
| Partial Batch Handling.Respects Retry After On Partial | ✅ | 5026ms |
| Partial Batch Handling.Unknown Result Treated As Terminal | ✅ | 3025ms |
| Partial Batch Handling.Mixed Ok Drop Limited No Retry | ✅ | 3035ms |
| Compression.Sends Gzip Content Encoding | ✅ | 16ms |
| Compression.No Content Encoding When Disabled | ✅ | 13ms |
| Compression.Compressed Body Is Decompressible | ✅ | 15ms |
| Error Handling.Does Not Retry On Unknown 4Xx | ✅ | 4017ms |
| Event Options.Cookieless Mode Override | ✅ | 17ms |
| Event Options.Disable Skew Correction Override | ✅ | 13ms |
| Event Options.Process Person Profile Override | ✅ | 15ms |
| Event Options.Product Tour Id Override | ✅ | 12ms |
| Event Options.Unset Options Omitted | ✅ | 13ms |
| Event Options.Options Override In Batch | ✅ | 17ms |
| Event Options.Unknown Option Passes Through | ✅ | 15ms |
| Event Options.Option Value Is Not Converted | ✅ | 13ms |
| Event Options.Lenient Bool Option Values Are Not Converted | ✅ | 13ms |
| Event Options.Option Wins Over Legacy Property | ✅ | 13ms |
| Event Options.Legacy Property Fills Unset Option | ✅ | 14ms |
| Event Options.Null Option Falls Back To Legacy Property | ✅ | 14ms |
| Geoip And Historical Migration.Geoip Disable Injected Into Properties | ❌ | 14ms |
| Geoip And Historical Migration.Historical Migration Set In Body | ❌ | 12ms |
| Geoip And Historical Migration.Historical Migration Absent By Default | ✅ | 13ms |
Failures
event_format.non_utc_event_timestamp_is_converted_to_utc
Event 0 field 'timestamp' instant '2026-10-27T22:48:36.169Z' != expected '2025-01-02T03:04:05Z'
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']
Feature_Flags Tests
✅ 17/17 tests passed
View Details
| Test | Status | Duration |
|---|---|---|
| Request Payload.Request With Person Properties Device Id | ✅ | 35ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 22ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 19ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 20ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 19ms |
| Request Payload.Groups Round Trip | ✅ | 16ms |
| Request Payload.Groups Default To Empty Object | ✅ | 20ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 15ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 15ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 17ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 7ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 21ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 34ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 21ms |
| Retry Behavior.Retries Flags On 502 | ✅ | 324ms |
| Retry Behavior.Retries Flags On 504 | ✅ | 321ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 17ms |
b0edae2 to
c9bd2ed
Compare
9f36d83 to
a5c46f8
Compare
a5c46f8 to
07b389d
Compare
07b389d to
80d25e2
Compare
80d25e2 to
6d338e3
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 |
6d338e3 to
840c59b
Compare
840c59b to
43ac7c5
Compare
`capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take an `options` map that is copied onto the new `PostHogEvent.options`, so PostHog can be told how to process one event apart from its properties and `beforeSend` can edit it. Properties are merged in one order: SDK-set values, registered super properties, the caller's properties (with `$set`, `$set_once` and `$groups` as per-call values), then `beforeSend`. `$is_identified`, `$process_person_profile` and the replay debug properties are no longer re-applied after the caller's values. The SDK sets the `process_person_profile` option to `false` only when person processing is off, and only when neither a caller option nor a caller or registered `$process_person_profile` property already says so. It no longer writes a `$process_person_profile` property. Also: a blank `distinctId` argument on `capture` uses the current distinct id; a `$set` sent with `process_person_profile: false` neither stores nor matches the person-properties hash; an event returned by `beforeSend` keeps the uuid of the client capture it came from; the minimal `$feature_flag_called` allowlist keeps `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id`; the compliance adapter forwards `options` and advertises `event_options`. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L
…ption Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L
dustinbyrne
left a comment
There was a problem hiding this comment.
The deep-link correction resolves the automatic processing override. One person-processing regression remains: a null legacy value can suppress the personless default. There are also two Kotlin migration expressions to escape.
AI-assisted review.
A Java caller can pass a null `$process_person_profile`. It no longer counts as an override, so the SDK still sends `process_person_profile: false` when person processing is off. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`captureAi` takes the same arguments as `capture`, goes through the same enrichment, precedence, personless default and `beforeSend`, and queues the event on its own disk queue, executor and retry identity for `POST /i/v1/ai/events`. It returns the event's uuid once it's handed to the queue, or null when the SDK is disabled or opted out or `beforeSend` dropped it. A name not starting with `$ai_` logs one debug line and is sent unchanged; `capture` never reroutes `$ai_*` events. The AI request client uses `captureAiTimeout` for its call, read and write timeouts and honors `captureAiCompression` through its own gzip interceptor instance. `PostHogQueue` becomes byte-aware through `EndpointSpec`: `maxBatchBytes` bounds a request window (check before append, an oversize record goes alone) and flushes as soon as that much is queued; `maxQueueBytes` is a FIFO disk cap on top of `maxQueueSize`, enforced on add and on load; `maxRecordBytes` drops a record over the limit when it's queued, logging only its name and size. The analytics spec gets a 5 MiB request cap; the AI spec gets 5 MiB requests, 50 MiB on disk and `captureAiMaxEventBytes` (at most 8 MiB) plus a 64 KiB envelope per event. New `PostHogConfig` knobs: `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes`; non-positive values use the defaults. `PostHogAndroid` stores the AI queue under `posthog-disk-ai-queue`. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L
SDK-level: routing by method, beforeSend and options, null returns, a terminal 404, the oversize drop (name and size only in the log), the non-`$ai_` debug line, flush and close draining the AI queue, compression per lane and the AI timeout. API-level: the AI endpoint's headers and body, separate retry identities per lane, and an AI queue keeping only the events asked to retry. Queue-level, on a synthetic record: byte-limited request windows, an oversize record sent alone, the flush at a request's worth of bytes, the per-record drop, the disk cap on add and on load, and the spec limits and config fallbacks. The Android setup test covers the AI storage prefix. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L
Adds `POST /capture_ai`, which routes through `captureAi` and returns the uuid it sent, tracks `/i/v1/ai/events` requests next to the analytics ones and advertises `capture_ai_v1`. Also restores the capture v1 adapter changes (tracking by capture path, `PostHog-Attempt` as the retry count, `personProfiles = ALWAYS`, the renamed push retries field) that the options commit reverted. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Properties can hold prompts and other user content. Co-Authored-By: Claude Opus 5.5 <[email protected]>
java.util.Base64 isn't on API 23, so animalsnifferTest failed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The data class hashCode calls Double.hashCode(double), which isn't on API 23. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
dustinbyrne
left a comment
There was a problem hiding this comment.
Both earlier findings are fixed: null legacy person-processing values retain the personless default, and the Kotlin migration examples compile. I rechecked the updated implementation and tests and found no additional issues.
AI-assisted review.
* 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 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
Stacked on #881 (capture v1 transport), which is stacked on #871. #884 (
captureAi) follows.💡 Motivation and Context
Second capture v1 PR for posthog-android 4.0. #881 moved the wire to
/i/v1/analytics/eventsand hoists the legacy$option properties intooptionswhen a request is built. This PR adds the public side of options and aligns event building with the client precedence the SDK team agreed on for 4.0 (PostHog/sdk-specs#143, posthog-ios 4.0 in PostHog/posthog-ios#938).Per-event options (new public API)
options: Map<String, Any?>? = nullas the last parameter ofcapture,screen,identify,setPersonProperties,alias,groupandcaptureExceptiononPostHogInterface,PostHogand thePostHogcompanion. The map is copied intoPostHogEvent.optionsbeforebeforeSend, which can read and change it. 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).$property (hoisted by feat!: send events through capture v1 and add captureAi #881 at send time), SDK default, caller option,beforeSend. An option set to null counts as unset.identifymoved fromPostHogCoreInterfacetoPostHogStatelessInterface;PostHogInterfacehas the 4-arg one. Nothing outside this repo implementsPostHogCoreInterface, and Kotlin callers ofPostHog.identify(...)keep compiling.Person processing
process_person_profile: falseoption only when person processing is off (personProfiles = NEVER, orIDENTIFIED_ONLYbefore identify). It sends nothing otherwise, like the server v1 SDKs. It no longer writes a$process_person_profileproperty on every event. A caller option, a caller or registered$process_person_profileproperty, orbeforeSendwins.$set/$identifysent withprocess_person_profile: falseneither stores nor matches the person-properties dedup hash, so a later processed call with the same properties goes out.One property precedence (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. No SDK-owned keys: 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 (explicit unset).Smaller changes
distinctIdargument tocaptureuses the current distinct ID, like null, 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 (capture v1 keeps only the first copy of a repeated uuid).$feature_flag_calledevent also keeps$cookieless_mode,$ignore_sent_atand$product_tour_id, so they survive until they are converted to options.optionson/captureand advertisesevent_options.Breaking changes
optionsparameter on sevenPostHogInterfacemethodsnull) and anything implementingPostHogInterfacePostHogCoreInterface.identifymoved toPostHogStatelessInterface/PostHogInterfacePostHogCoreInterfacereference and callsidentify$is_identified,$process_person_profileand replay debug values$process_person_profileis sent only when person processing is off$process_person_profile = truebeforeSendcan't change an event's uuid on the client pathdistinctIdargument no longer drops the eventDifferences from the server v1 SDKs
$key becomes its option at send time.$process_person_profilesuppresses the SDK's personless option instead of losing to it, so the hoist can make the caller's value win (locked client order: SDK-set, super, event,beforeSend).$set/$set_once/$groupscall values beat a registered property of the same key (per-call values win); posthog-ios keeps the registered one.💚 How did you test it?
./gradlew apiCheck spotlessCheck :posthog:test :posthog-android:testReleaseUnitTest :posthog-server:testpass (core 1110, Android 765, server 609); the compliance adapter compiles.PostHogCaptureOptionsTestcovers options reachingPostHogEvent.optionsfor every method, the map copy,beforeSendedits, the personless option under eachpersonProfilesmode, caller/registered overrides, precedence (registered beats SDK, caller beats registered,beforeSendbeats caller), typed$set/$groups, the blank-distinctId fallback, the dedup hash with a personless$set, the allowlist and uuid stability.PostHogPersonProfilesTest/PostHogTest/PostHogBootstrapTestexpectations were updated to the new semantics.$process_person_profilegone; key order).1.20.0in CI:capture_v1suite 115/118. All event-options scenarios pass now, includingunset_options_omitted. The 3 failures:non_utc_event_timestamp_is_converted_to_utc(adapter never forwardedtimestamp; fixed in feat!: add captureAi on the capture v1 AI endpoint #884),geoip_disable_injected_into_properties(no SDK surface) andhistorical_migration_set_in_body(no historical-migration mode).📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file (bullets added to.changeset/android-v4-major.md)🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code (Claude Fable 5.1). One subagent implemented the options API and precedence against sdk-specs#143 and the posthog-ios 4.0 branch, in parallel with #881; a review and triage pass fed fixes back. Maintainer decisions: keep the uuid pin client-only (server hooks may still set uuids), keep Android's blank-caller-value semantics, move
identifyoffPostHogCoreInterfacerather than carry two overloads.🤖 Generated with Claude Code
https://claude.ai/code/session_01DRbQPidntd4hVufNpMT19L