Skip to content

feat!: send events through capture v1 and add captureAi - #881

Open
turnipdabeets wants to merge 43 commits into
chore/v4-remove-deprecatedfrom
feat/v4-capture-v1
Open

turnipdabeets wants to merge 43 commits into
chore/v4-remove-deprecatedfrom
feat/v4-capture-v1

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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/events and the new captureAi(...) sends AI events to POST /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 with captureAi in every v1 release. Flutter and React Native need captureAi on 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> plus PostHog-Sdk-Info (<sdkName>/<sdkVersion>, which Flutter, React Native and KMP already set), PostHog-Attempt, PostHog-Request-Id and PostHog-Request-Timestamp. The body is {created_at, batch}, with no api_key or sent_at.
  • $session_id/$window_id move to the event root, and $lib/$lib_version are no longer sent as properties (the server reads them from PostHog-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.
  • The server's per-event results are applied: ok/warning/drop events leave the queue, only retry events are resent, and the response Retry-After is 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 and created_at and 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.
  • Network errors and 404, 408, 429, 500, 502, 503 and 504 are retried (fix: retry capture v1 requests that get 404 or 429 #897, following fix: retry capture requests that get 404 or 429 posthog-go#395: drop events only when they can never be delivered or the buffer is full). 3xx and other statuses are terminal. 413 keeps halving the batch.
  • No /batch fallback: a host that doesn't serve the v1 route keeps the events queued (404 is retried) until it does. PostHogApi.batch() stays only for posthog-server, which keeps /batch until 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-After is 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).
  • Capture follows only 307/308 redirects to the same origin as host, at most 5 hops, re-sending body and headers. Other redirects are terminal. OkHttp would otherwise follow any 3xx, drop Authorization cross-host and turn a 302 into a GET, so automatic redirects are off for the capture client. So is retryOnConnectionFailure: an OkHttp resend would repeat the attempt number and timestamp.
  • Requests carry at most maxBatchSize events 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.
  • With debug on, 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. With debug on, each event a 2xx drops is logged with its name, uuid and the server's details (e.g. misrouted_event, ai_event_too_big), never its properties.

Per-event options (new public API)

  • options: Map<String, Any?>? = null as the last parameter of capture, screen, identify, setPersonProperties, alias, group and captureException on PostHogInterface, PostHog and the PostHog companion, and PostHogEvent.options (persisted in the queue, editable in beforeSend). For identify the options apply to the $identify or $set event it sends; for group to $groupidentify; for alias to $create_alias.
  • Known keys: process_person_profile, cookieless_mode, disable_skew_correction and product_tour_id. Any other key is sent as-is; values are never coerced (the server parses them leniently).
  • The legacy $process_person_profile, $cookieless_mode, $ignore_sent_at and $product_tour_id properties are converted when the request is built, after beforeSend. Each is always removed from properties and only fills an option that is unset or null.
  • Precedence, lowest first: legacy property, SDK default, caller option, beforeSend. An option set to null counts as unset.
  • The SDK sets process_person_profile: false only when person processing is off (personProfiles = NEVER, or IDENTIFIED_ONLY before identify), and no longer writes a $process_person_profile property on every event. A null legacy value keeps the personless default.
  • The 3-arg identify moved from PostHogCoreInterface to PostHogStatelessInterface; PostHogInterface has the 4-arg one.

Property precedence (PostHog/sdk-specs#143)

  • Properties the SDK sets itself (context, SDK info, feature flags, $screen_name, session id, $is_identified, replay debug values) → registered super properties → the properties passed to the call (userProperties, userPropertiesSetOnce and groups are per-call values) → beforeSend. In 3.x the SDK's values beat super properties, and $is_identified, $process_person_profile and the replay debug values also beat caller properties.
  • A blank caller $screen_name, $session_id or distinct_id still replaces the SDK's value, as in 3.x.

captureAi (new public API)

  • captureAi takes the same arguments as capture (including options) and goes through the same enrichment, precedence, personless default and beforeSend. Only the queue differs. capture never reroutes $ai_* events; a captureAi name that doesn't start with $ai_ logs a debug line and is sent unchanged (the server reports misrouted_event).
  • AI events go to their own disk queue (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 by flush() and close(). A failing or slow AI lane never delays or drops analytics events.
  • captureAi returns the queued event's uuid, or null when the event isn't queued: disabled, opted out, dropped by beforeSend, or over the size limit.
  • A serialized AI event over captureAiMaxEventBytes, measured before encryption, is dropped in captureAi, which logs (debug) only the event name and the limit. The AI queue keeps at most captureAiMaxQueueSize events and 50 MiB on disk, dropping the oldest first.
  • New PostHogConfig settings, 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

  • maxRetries is renamed pushNotificationSubscriptionMaxRetries (no alias): it only limits push-subscription retries. Event, replay and log retries have no limit, as before.
  • A projectToken starting with phx_ (a personal API key) disables the SDK at setup, like an empty token. posthog-server inherits this through PostHogStateless.setup, so it gets its own patch changeset.
  • An empty or whitespace-only distinctId argument to capture uses the current distinct ID instead of dropping the event.
  • An event beforeSend returns keeps the uuid of the capture it came from, so a hook that returns a cached earlier event can't repeat a uuid.
  • A $set/$identify sent with process_person_profile: false neither stores nor matches the person-properties dedup hash.
  • The minimal $feature_flag_called event also keeps $cookieless_mode, $ignore_sent_at and $product_tour_id, so they survive until they are converted to options.
  • Deep Link Opened skips query parameters whose name starts with $, so a link can't set a PostHog property such as $process_person_profile.
  • An unserializable property is logged by key and type, never by value.
  • Compliance adapter: advertises capture_v1, capture_ai_v1, event_options and encoding_gzip; tracks both v1 routes; forwards options and timestamp; runs with personProfiles = ALWAYS like a server SDK. The workflow pins harness 1.20.0 and also runs for PRs into chore/v4-*/feat/v4-*.
  • docs/migration-3.x-to-4.0.md and the 4.0 changeset bullets.

Breaking changes

Change Who notices
Endpoint moves to /i/v1/analytics/events with Bearer auth; no /batch fallback Reverse proxies, allowlists, WAF rules and self-hosted instances must serve the new path. A 404 keeps events queued; a 403/405 drops them.
Capture sends Authorization: Bearer, replacing a custom Authorization from requestHeaders Proxies that authenticate with Authorization need another header
3xx is terminal, 404 is retried; Retry-After capped at 30 s; jittered backoff Redirecting or rate-limited setups; log-based expectations on retry timing
Only same-origin 307/308 redirects are followed host pointing at something that redirects elsewhere
Legacy option properties are sent as options; the server drops events with unreadable values Apps that set these properties to odd values
New trailing options parameter on seven PostHogInterface methods, PostHogEvent.options constructor parameter, new captureAi Java callers (pass null), anything implementing PostHogInterface, Java callers of the full PostHogEvent constructor / copy
PostHogCoreInterface.identify moved to PostHogStatelessInterface / PostHogInterface Code that holds a PostHogCoreInterface reference and calls identify
Super and event properties override SDK-set properties, including $is_identified, $process_person_profile and replay debug values Apps that register or pass those keys and expected the SDK's value
$process_person_profile is sent only when person processing is off, as an option Filters on $process_person_profile = true
beforeSend can't change an event's uuid; $-prefixed deep link parameters are skipped Hooks that set uuids; links that set PostHog properties
maxRetries → pushNotificationSubscriptionMaxRetries; phx_ tokens disable the SDK Apps that set it; misconfigured apps (they got 401s before)

All of these are in .changeset/android-v4-major.md (plus .changeset/server-personal-api-key-token.md) and in docs/migration-3.x-to-4.0.md.

Differences from the spec and the other v1 SDKs (same as posthog-ios 4.0 unless noted)

  • No retry limit: events stay in the persisted queue bounded by maxQueueSize. Server SDKs give up after 4 attempts.
  • 413 halves the batch until a single oversized event is dropped (proxies often cap below 10 MiB).
  • Backoff starts at 1 s, not 100 ms, with jitter in milliseconds.
  • Gzip on by default, for analytics (compression) and AI (captureAiCompression).
  • No global or context options. A registered legacy $ key becomes its option at send time.
  • A caller or registered legacy $process_person_profile suppresses 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).
  • A 2xx whose results can't be read counts as delivered; the spec makes it a terminal failure of the batch (fixed in fix: keep the personless option over a legacy property and drop a 2xx without results #905).
  • Typed $set/$set_once/$groups call values beat a registered property of the same key; posthog-ios keeps the registered one.
  • process_person_profile: false goes in options, not properties. capture/spec.md "Caller can't override person-processing state" expects the property; the compliance adapter runs with personProfiles = ALWAYS.
  • captureAi (the capture-ai spec covers server SDKs): no uuid argument, like Android capture, 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?

  • New suites cover the stack:
    • PostHogCaptureV1Test: the wire format and headers, $lib strip, 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-After on a 2xx, the AI path and the per-event drop log.
    • PostHogQueueTest, PostHogQueueDurabilityTest: partial retention, jitter bounds, the 30 s Retry-After cap, the partial-retry Retry-After floor, and a fatal event sent while a request waits to be retried.
    • PostHogCaptureOptionsTest: options reaching PostHogEvent.options for every method, beforeSend edits, the personless option under each personProfiles mode, precedence, the blank-distinctId fallback, the dedup hash with a personless $set (including lenient false values such as "false" and 0), the allowlist and uuid stability.
    • PostHogCaptureAiTest: routing, beforeSend and 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.
  • The Android event-shape snapshot was re-recorded on purpose (v1 envelope, options, no $lib or $process_person_profile).
  • After merging feat!: add captureAi on the capture v1 AI endpoint #884 and feat!: add per-event capture options and one property precedence #882 and the latest 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:test pass locally. The compliance adapter compiles.
  • New tests for the last review round were checked against the old code: each fails without its fix.
  • Compliance harness 1.20.0 in CI on de16564: 212/219 (capture_v1 98/101, capture_ai_v1 97/101, feature_flags 17/17). The failures:
    • max_retries_respected on 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: captureAi takes no uuid. The test aborts before flushing, so its request lands in the next test and fails event_has_required_root_fields_batch too.
    • geoip_disable_injected_into_properties on both suites (no SDK surface) and historical_migration_set_in_body (no historical-migration mode).
  • CI passed on feat!: add captureAi on the capture v1 AI endpoint #884 (Build Job, compliance) before it was merged.
  • Not done: docs for captureAi on posthog.com / LLM analytics; a device run against PostHog Cloud.

📝 Checklist

  • All commits are signed
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to 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_v1 contracts, sdk-specs#143, the sdk-specs capture-ai spec 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 /batch fallback from the start (iOS added and then removed one); keep PostHogApi.batch() for posthog-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 move identify off PostHogCoreInterface rather 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

@turnipdabeets turnipdabeets self-assigned this Oct 24, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread posthog/src/main/java/com/posthog/PostHogEvent.kt
Comment thread posthog/src/main/java/com/posthog/internal/PostHogSendCachedEventsIntegration.kt Outdated
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-10-28T21:28:51.681105+00:00
Duration: 450275ms

⚠️ Some Tests Failed

212/219 tests passed, 7 failed


Capture_V1 Tests

⚠️ 98/101 tests passed, 3 failed

View Details
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

⚠️ 97/101 tests passed, 4 failed

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

@turnipdabeets
turnipdabeets force-pushed the feat/v4-capture-v1 branch 2 times, most recently from e4e5fc5 to 8a765aa Compare October 24, 2026 18:13
@turnipdabeets
turnipdabeets changed the base branch from chore/v4-remove-deprecated to main October 26, 2026 18:03
@turnipdabeets
turnipdabeets added this pull request to stack #888 October 26, 2026 18:03
@turnipdabeets
turnipdabeets removed this pull request from stack #888 October 26, 2026 18:06
@turnipdabeets
turnipdabeets changed the base branch from main to chore/v4-remove-deprecated October 26, 2026 18:06
@turnipdabeets
turnipdabeets added this pull request to stack #890 October 26, 2026 18:06
@turnipdabeets
turnipdabeets marked this pull request as ready for review October 26, 2026 18:44
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 26, 2026 18:44

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 483626f: no findings. This pull request is large, so the review ran in 4 parts with less depth than usual.

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed c7fec3a: 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

Comment thread posthog/src/main/java/com/posthog/internal/PostHogApi.kt
Comment thread posthog/src/main/java/com/posthog/internal/PostHogApi.kt Outdated
@veria-ai

veria-ai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

Comment thread posthog/src/main/java/com/posthog/internal/PostHogQueue.kt Outdated
Comment thread posthog/src/test/java/com/posthog/internal/PostHogCaptureV1Test.kt
Comment thread docs/migration-3.x-to-4.0.md Outdated

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread posthog/src/main/java/com/posthog/internal/PostHogApi.kt
turnipdabeets and others added 2 commits October 27, 2026 12:58
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>
turnipdabeets and others added 16 commits October 27, 2026 18:43
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>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread posthog/src/main/java/com/posthog/PostHog.kt Outdated
Comment thread posthog/src/main/java/com/posthog/PostHog.kt
Comment thread posthog/src/main/java/com/posthog/PostHog.kt
Comment thread posthog/src/main/java/com/posthog/internal/PostHogQueue.kt
turnipdabeets and others added 5 commits October 28, 2026 17:06
… 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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants