Repository navigation
feat!: send events through capture v1 and add captureAi - #938
turnipdabeets wants to merge 65 commits into
Conversation
posthog-ios Compliance ReportDate: 2026-10-29T16:11:14.394268+00:00 ✅ All Tests Passed!233/233 tests passed Capture_V1 Tests✅ 106/106 tests passed View Details
Capture_Ai_V1 Tests✅ 110/110 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
0074220 to
90ca8a1
Compare
|
This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the |
da14915 to
d22dd3a
Compare
There was a problem hiding this comment.
PostHog Review · Standard · reviewed d22dd3a: 1 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.
d22dd3a to
799c91c
Compare
799c91c to
5c48793
Compare
1e6de37 to
bc7f223
Compare
bc7f223 to
6674ffe
Compare
|
make sure this is not adding breaking changes to the public API, it should be addition otherwise part of the major |
@marandaneto These are going into iOS major release |
The changeset is minor 👀 |
That's only because it's going into the major branch. I don't think there's breaking changes anyway, but if there are we're safe since it's going into the major branch. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alse Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Log each event a 2xx drops at debug level with its name, UUID and the server's details (e.g. ai_event_too_big), never its properties. - Check the AI event size limit in the queue via maxRecordBytes, so an oversized event logs only the size line, not "unserialisable record". - Warn once at setup when captureAiMaxEventBytes is over the ceiling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-remove-batch-merge # Conflicts: # PostHog/PostHogApi.swift
…one major entry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 078c6e4: 3 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.
The analytics queue sets maxBatchBytes but no maxQueueBytes, and the file queue only tracked byteCount when maxQueueBytes was set, so byteCount stayed 0 and the byte threshold never triggered a flush. Track bytes whenever the endpoint batches by bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds an @_spi(PostHogInternal) captureAi overload that takes the event UUID, and the compliance adapter passes the harness's uuid through it, so capture_ai_keeps_supplied_uuid gets the same UUID back and on the wire. 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>
This reverts commit 1e0cc55. captureAi takes no UUID, like capture, so capture_ai_keeps_supplied_uuid tests a server-SDK feature iOS does not offer. Passing it through an internal overload only the adapter can call made the harness test code apps never run. The test is an expected failure again, as on Android. 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 Envoy, so neither should drop events. Matches 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 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>
The early flush at maxBatchBytes read the file queue's byteCount, which was only tracked when a disk byte cap (maxQueueBytes) was set. Analytics has a batch byte limit but no disk cap, so byteCount stayed 0 and large events waited for flushAt or the timer. Track file sizes whenever the endpoint has a batch byte limit, without adding eviction. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore(compliance): bump the test harness to 1.25.0 Declare disable_geoip, generated_payloads, property_layers and fail_open, and add the adapter fields they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(compliance): cap generated payload size before allocating padding * chore(compliance): bump the test harness to 1.26.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
💡 Motivation and Context
Moves capture to capture V1 for 4.0: analytics events go to
POST /i/v1/analytics/eventsand the newcaptureAi(...)sends AI events toPOST /i/v1/ai/events. This matches the posthog-python 8.0, posthog-go 2.0 and posthog-rs 1.0 V1 releases (PostHog/posthog-python#1016, PostHog/posthog-go#320, PostHog/posthog-rs#231) and their AI lanes (PostHog/posthog-rs#250, PostHog/posthog-go#323, PostHog/posthog-python#1017, #1022), and Ingestion's goal of moving mobile SDKs to the V1 endpoints withcaptureAiin every V1 release.This PR now contains the whole capture V1 stack: #939 (
captureAi) and #940 (removing the/batchfallback) were merged into it.Transport
Authorization: Bearer <projectToken>plusPostHog-Sdk-Info,PostHog-Attempt,PostHog-Request-IdandPostHog-Request-Timestampheaders. 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 warning naming the key and JSON type; null drops silently.ok/warning/dropevents leave the queue, and onlyretryevents are resent. A retry of the same events keeps its request ID andcreated_atand increments the attempt. Retry identity is tracked per endpoint, so the analytics and AI lanes don't reset each other's attempt numbers./batchfallback: a host that doesn't serve the V1 route (a self-hosted instance withoutCAPTURE_V1_SINKS, or a reverse proxy that doesn't forward it) drops events until it does.PostHogApi.batch(events:)and the options-to-legacy-properties fold-back are gone./snapshotkeeps its own retry policy.Retry-Afteris honored up to 30 seconds. The cap is in the shared queue, so replay and logs get it too.host, re-sends the headers URLSession strips, and stops after 5 hops. Other redirects are returned as terminal. Without this, URLSession dropsAuthorizationon every redirect, POSTs the body to a cross-origin 307/308 target, and turns a 302 into a GET.debugon, drop/retry logging is one count summary per batch, with no UUIDs or payloads, and each event a 2xx drops is logged with its name, UUID and the server'sdetails(e.g.ai_event_too_big,misrouted_event), never its properties.Per-event options (new public API)
capture(..., timestamp:, options:)andPostHogEvent.options, which is persisted in the queue and can be edited inbeforeSend. The Objective-C selector iscaptureWithEvent:...:timestamp:options:; the old...:timestamp:selector stays as a shim that is hidden from Swift.screen,identify,setPersonProperties,alias,groupandcaptureExceptiontake the sameoptions, applied to the event each sends (Objective-C: new selectors ending inoptions:; the old selectors still work). Spec divergence: sdk-specs defines event options only forcapture; these follow the go/python V1 branches (posthog-rsalias/group_identifytake no options).process_person_profile,cookieless_mode,disable_skew_correctionandproduct_tour_id. Any other key is sent as-is.$process_person_profile,$cookieless_mode,$ignore_sent_atand$product_tour_idproperties are converted when the request is built, afterbeforeSend. Each one is always removed from properties and only fills an option that is unset or null. Values are not coerced (the server parses them leniently).process_person_profile: falseonly when person processing is off), caller option,beforeSend. These are the same rules as the Python, Go and Rust V1 branches.Property precedence
beforeSend. Per-call$set,$set_onceand groups win over registered ones.captureAi(new public API)captureAitakes the same arguments ascaptureand goes through the same enrichment andbeforeSend. Only the delivery route differs.capturenever reroutes$ai_*events.posthog.aiFolder). It is flushed byflush()andclose(), and created with the other queues so events left on disk by a previous launch still send.captureAireturns the queued event's UUID (@discardableResult), ornilwhen the event isn't queued: opted out, disabled, dropped bybeforeSend, or over the size limit.PostHogConfigsettings tune the AI lane separately:captureAiCompression(default.gzip), independent ofconfig.compression.captureAiMaxQueueSize(default100).captureAiTimeout(default60s), instead of 10 s.captureAiMaxEventBytes(default 8 MiB + 64 KiB). It can only lower the limit: a larger value is clamped to the default with a debug warning.0or less for the numeric settings uses the default.captureAiMaxEventByteswhen it is queued, flushes as soon as it holds 5 MiB, and keeps at mostcaptureAiMaxQueueSizeevents and 50 MiB on disk, dropping the oldest first. Without the byte cap, 100 events at the size limit could take ~800 MiB.Other fixes in the stack
maxRetriesis renamed topushNotificationSubscriptionMaxRetries(no alias).phx_...) asprojectTokendisables the SDK at setup.distinctIdargument uses the current distinct ID.beforeSendreturns an earlier event; event UUIDs are no longer deduplicated before capture.Breaking changes
/i/v1/analytics/eventswith Bearer auth, no/batchfallbackAuthorization: Bearer, replacing a customAuthorizationfromrequestHeadersAuthorizationneed another header. TherequestHeadersdoc is updated.Retry-Afteris capped at 30shostpointing at something that redirects elsewhereoptions, and the server drops events with unreadable option values$is_identified,$process_person_profile,$geoip_disablemaxRetriesis renamed topushNotificationSubscriptionMaxRetries(no alias)capture(..., options:),PostHogEvent.options,captureAiandcaptureAi*configAll of the above are in one major changeset,
.changeset/v4-capture-v1.md, and indocs/migration-3.x-to-4.0.md.Differences from the spec and the other V1 SDKs
PostHog-Attempt: 1. These headers only feed server logs and metrics.maxQueueSize, as before. The server SDKs give up after 3 retries. Mobile devices are offline often, and a 3-attempt cap wiped buffered events on flaky networks until fix(queue): preserve durable records across retries #788 removed it.$feature_flag_calledallowlist also keeps$cookieless_mode,$ignore_sent_atand$product_tour_id, so they survive until they are converted to options.config.compressionand AI followscaptureAiCompression, both.gzipby default. The server SDKs send uncompressed by default.flushAtcan trigger a send while retries have no limit.Retry-Aftermaximum drops from 5 minutes to 30 seconds: the 30-second cap applies to every queue, replacing the 5-minute logs maximum from fix(logs): clamp Retry-After to a documented 5-minute maximum #925.process_person_profile: falsefor anonymous users: sent only when person processing is off, which under the defaultpersonProfiles = .identifiedOnlyincludes anonymous users. Spec divergence:capture/spec.md"Caller can't override person-processing state" expects$process_person_profile: falsein the queued event's properties; iOS puts it inoptions. The compliance adapter runs withpersonProfiles = .alwaysto model a server SDK.captureAi(thecapture-aispec only covers server SDKs):uuidargument, like iOScapture; it does return the UUID, like the spec.💚 How did you test it?
/batchrequest)Retry-Aftercap, jittered exponential backoff, and empty and non-string session/window IDsbeforeSendreturns from an earlier call still gets its own UUID$feature_flag_calledallowlistMockPostHogServer's new/i/v1/ai/eventsstub: routing, 404 retried, oversized events dropped when queued, thecaptureAiMaxEventBytesclamp, splitting at the byte limit, the returned UUID, and flushing once a batch's worth of bytes is queuedPostHogFileBackedQueuebyte cap (evicts the oldest, keeps an oversized newest record, trims on load) and byte-bounded reads$process_person_profilemoving intooptions.test-ios-simulator,test-no-traitsandPublic API snapshot.chore/v4-remove-deprecated:swift build --build-tests -Xswiftc -DTESTINGpasses, and the capture, queue, API and SDK suites (243 tests) pass. One SDK identity test ("identifies an anonymous user via identify() when the id already matches the persisted distinct id") failed in 2 of 4 local runs of that combination and passes on its own.capture_v198/101 andcapture_ai_v197/101. The failures:max_retries_respected: iOS has no retry limit on purpose; also fails onmainunset_options_omitted: mobile-only failurecapture_ai_keeps_supplied_uuid:captureAidoesn't take a UUID. The test aborts before flushing, so its request lands in the next test and failsevent_has_required_root_fields_batchtoo.Authorizationon same- and cross-origin redirects, re-sends the body on 307, and returns a 302 response when the redirect isn't followed.captureAion posthog.com / LLM analytics.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code. Subagents implemented the transport, options and AI lane against the V1 server source (
rust/capture/src/v1), the harnessyaml-parity-v1features, the sdk-specscapture-aispec and the Rust, Go and Python V1 branches. Three review cycles plus a per-SDK parity audit fed fixes back in.Maintainer decisions: add a public options API in 4.0; keep the Objective-C shim for the old selector; ship 4.0 without the
/batchfallback; give AI events a separate lane with a small disk queue.🤖 Generated with Claude Code