Repository navigation
feat!: send events through capture v1 and add captureAi - #881
turnipdabeets wants to merge 48 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-29T21:52:25.680387+00:00 ✅ All Tests Passed!232/232 tests passed Capture_V1 Tests✅ 105/105 tests passed View Details
Capture_Ai_V1 Tests✅ 110/110 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
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 <[email protected]>
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 <[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]>
* 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]>
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 <[email protected]>
…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 <[email protected]>
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 <[email protected]>
…etry some Co-Authored-By: Claude Opus 5.5 <[email protected]>
) * chore(compliance): run the 1.25.0 test harness against the adapter Bump the harness from 1.20.0 (CI) and 0.10.0 (local docker-compose) to 1.25.0, and declare the 1.25 capabilities the SDK supports: generated_payloads, log_capture, property_layers and fail_open. Co-Authored-By: Claude Opus 5.5 <[email protected]> * chore(compliance): count the marker's serialized bytes when padding AI properties * chore(compliance): bump the test harness to 1.26.0 Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
…ent (#904) * chore(api): keep @PostHogInternal declarations out of the API dumps * feat: let wrapper SDKs build an event, run their own beforeSend, then send it * fix: start the replay debug window when the wrapper's event is queued * fix: defer wrapper build state changes and the replay debug claim to send A built event the wrapper drops no longer caches the screen name, updates the person properties used for flags, or claims the replay debug bundle. The send does all three, as capture does, so the window starts with the event that carries the bundle. * fix: keep wrapper builds state-free and send each built event once Build defers person processing to send and records the built name for replay debug eligibility. Send adds only the throttled replay debug keys (API 23 safe, keeps keys the wrapper's hook removed), skips flag person properties when the user changed since the build, and ignores a second send of the same event. * fix: tie a wrapper send to the build's setup, instance and identity A send now drops an event this instance didn't build or built before setup()/close(), skips person processing and flag person properties after reset() (also when reuseAnonymousId keeps the distinct id), and adds the replay debug values snapshotted at build. * fix: don't restore a wrapper screen name after a reset since the build * fix: add the replay debug bundle to wrapper AI events, like captureAi * fix: read replay debug state once per wrapper build The build's required replay keys and the deferred bundle came from two debugProperties() reads, so a replay transition between them could send $recording_status=buffering without its hold reason. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 565a80d: 2 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 5 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 # posthog/api/posthog.api # posthog/src/main/java/com/posthog/PostHog.kt # posthog/src/test/java/com/posthog/PostHogBootstrapTest.kt
A reset() or identify() during the build left the event with the old distinct ID while the build recorded the new identity, so the send applied the old user's $set to the new user's flag properties. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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