Skip to content

feat!: send analytics events through capture v1 - #938

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

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

Conversation

@turnipdabeets

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Moves analytics capture to capture V1 (POST /i/v1/analytics/events) for 4.0, in line with 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 Ingestion's goal of moving mobile SDKs to the V1 endpoints.

Transport

  • Authorization: Bearer <projectToken> plus PostHog-Sdk-Info, PostHog-Attempt, PostHog-Request-Id and PostHog-Request-Timestamp headers. 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).
  • The server's per-event results are applied: ok/warning/drop events leave the queue, and only retry events are resent. A retry of the same events keeps its request ID and created_at and increments the attempt.
  • Only 408, 500, 502, 503, 504 and network errors are retried. 429 and 3xx are terminal. 413 keeps halving the batch.
  • Retry-After is honored up to 30 seconds. The cap is in the shared queue, so replay and logs get it too.
  • Duplicate UUIDs are removed from a batch before sending, because the server rejects the whole batch with a 400 if one repeats.
  • Capture follows only 307/308 redirects to the same origin as host, re-sends the headers URLSession strips, and stops after 5 hops. Other redirects are returned as terminal. Without this, URLSession drops Authorization on every redirect, POSTs the body to a cross-origin 307/308 target, and turns a 302 into a GET.
  • Drop/retry logging is one count summary per batch, with no UUIDs or payloads.

Per-event options (new public API)

  • capture(..., timestamp:, options:) and PostHogEvent.options, which is persisted in the queue and can be edited in beforeSend. The Objective-C selector is captureWithEvent:...:timestamp:options:; the old ...:timestamp: selector stays as a shim that is hidden from Swift.
  • Known keys: process_person_profile, cookieless_mode, disable_skew_correction and product_tour_id. Any other key is sent as-is.
  • 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 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).
  • Precedence, lowest first: legacy property, SDK default (hasPersonProcessing()), caller option, beforeSend. These are the same rules as the Python, Go and Rust V1 branches.

Self-hosted fallback (iOS-only)
A host without the V1 route (self-hosted without CAPTURE_V1_SINKS) returns 404. On a 404 the SDK resends that batch to /batch and keeps using /batch for the rest of the app session. Options are folded back into the legacy properties there. The next launch tries V1 again. Rust, Go and Python treat a 404 as terminal and document "confirm your instance serves the v1 route"; an installed mobile app can't be redeployed quickly, so iOS falls back instead. A follow-up PR stacked on this one removes /batch and the fallback, so we can decide separately whether 4.0 keeps it.

Follow-ups (separate PRs)

Breaking changes

Change Who notices
Endpoint moves to /i/v1/analytics/events with Bearer auth Reverse proxies, allowlists and WAF rules must forward the new path. Only a 404 triggers the /batch fallback; a 403/405 from a proxy drops the events.
Capture sends Authorization: Bearer, replacing a custom Authorization from requestHeaders Proxies that authenticate with Authorization need another header. The requestHeaders doc is updated.
429 and 3xx are terminal on V1; Retry-After is capped at 30s Rate-limited or redirecting setups
Only same-origin 307/308 redirects are followed host pointing at something that redirects elsewhere
Legacy option properties are sent as options, and the server drops events with unreadable option values Apps that set these properties
New capture(..., options:) and PostHogEvent.options Additive

All of the above are in .changeset/v4-capture-v1.md.

Differences from the other V1 SDKs

  • /batch fallback on 404: iOS-only, see above.
  • 413 halves the batch until a single oversized event is dropped. Python and Go treat 413 as terminal for the whole request.
  • Retry identity resets when new events join a retried batch: the request then gets a new request ID and PostHog-Attempt: 1. These headers only feed server logs and metrics.
  • No retry limit: events stay in the persisted offline queue, bounded by maxQueueSize, as before. The server SDKs give up after 3 retries.
  • Minimal $feature_flag_called allowlist also keeps $cookieless_mode, $ignore_sent_at and $product_tour_id, so they survive until they are converted to options.
  • process_person_profile is always sent: false for anonymous users under the default personProfiles = .identifiedOnly, as 3.x sends $process_person_profile. Server SDKs omit it, so harness unset_options_omitted can't pass on mobile.

💚 How did you test it?

  • New Swift Testing suites cover:
    • the V1 wire format and headers
    • per-event results and partial retry through the queue
    • retry identity and status-code classification
    • the duplicate-UUID guard
    • the 404 fallback: that it sticks for the session, its /batch retry policy, and a failing fallback request
    • the redirect decisions
    • the Retry-After cap and empty session/window IDs
    • options: conversion of each legacy property, precedence, persistence round-trip including 3.x events without options, the /batch fold-back, and the $feature_flag_called allowlist
  • The event-shape golden was re-recorded on purpose; the only change is $process_person_profile moving into options.
  • make test: 1149 tests. The only failures are 13 group-container storage issues ("Operation not permitted"), which also fail on chore/v4-remove-deprecated locally.
  • make lint and make buildSdk pass. make testOniOSSimulator passed (1544 tests) on the transport commit.
  • SDK compliance harness 1.14.0 with capture_v1 + event_options: 114/118, the same result in two consecutive runs. The 4 failures:
    • non_utc_event_timestamp_is_converted_to_utc: adapter fix in ci: run the SDK compliance harness 1.14.0 #936
    • max_retries_respected: iOS backoff timing; also fails on main
    • unset_options_omitted: mobile always sends process_person_profile, see above
    • historical_migration_set_in_body: iOS has no historical-migration mode
  • URLSession's redirect behaviour was checked against local servers: it strips Authorization on same- and cross-origin redirects, re-sends the body on 307, and returns a 302 response when the redirect isn't followed.
  • Not verified: make apiCheck on CI's toolchain. Local Xcode 26.4.1 also re-records 7 unrelated @Sendable lines, which are left out of this PR.

📝 Checklist

  • 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

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code. Subagents implemented the transport and options work against the V1 server source (rust/capture/src/v1), the harness yaml-parity-v1 features and the Rust, Go and Python V1 branches, including their open stacked PRs. Three review cycles plus a per-SDK parity audit fed fixes back in. The redirect handling, the Retry-After cap, privacy-safe logging and the options rules came from that audit.

Maintainer decisions: keep /batch as a 404 fallback, with removal as its own stacked PR; add a public options API in 4.0; keep the Objective-C shim for the old selector; put captureAi in a separate PR.

🤖 Generated with Claude Code

@turnipdabeets turnipdabeets self-assigned this Oct 10, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-10 17:37:08 UTC
Duration: 472501ms

⚠️ Some Tests Failed

107/110 tests passed, 3 failed


Capture_V1 Tests

⚠️ 91/94 tests passed, 3 failed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 2961ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 2699ms
Required Headers.Has Authorization Bearer Header ✅ 2664ms
Required Headers.Has Content Type Json ✅ 2689ms
Required Headers.Has Posthog Sdk Info Format ✅ 2821ms
Required Headers.Has Posthog Attempt Header ✅ 2737ms
Required Headers.Has Posthog Request Id ✅ 2679ms
Required Headers.Has Posthog Request Timestamp ✅ 2805ms
Required Headers.Has User Agent ✅ 2805ms
Body Format.Body Has Created At And Batch ✅ 2799ms
Body Format.No Api Key In Body ✅ 2734ms
Body Format.No Sent At In Body ✅ 2701ms
Event Format.Event Has Required Root Fields ✅ 2854ms
Event Format.Event Uuid Is Valid ✅ 2688ms
Event Format.Event Timestamp Is Rfc3339 ✅ 2795ms
Event Format.Distinct Id Is String ✅ 2802ms
Event Format.Distinct Id At Root Not Properties ✅ 2825ms
Event Format.Custom Properties Preserved ✅ 2797ms
Event Format.Set Properties Preserved ✅ 2697ms
Event Format.Set Once Properties Preserved ✅ 2787ms
Event Format.Groups Properties Preserved ✅ 2754ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 2713ms
Event Format.Event Has Required Root Fields Batch ✅ 2914ms
Event Format.Event Uuid Is Valid Batch ✅ 2862ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 2723ms
Event Format.Distinct Id Is String Batch ✅ 2888ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 2832ms
Event Format.Custom Properties Preserved Batch ✅ 2768ms
Event Format.Set Properties Preserved Batch ✅ 2899ms
Event Format.Set Once Properties Preserved Batch ✅ 2805ms
Event Format.Groups Properties Preserved Batch ✅ 2827ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 2799ms
Batch Behavior.Multiple Events In Single Batch ✅ 2933ms
Batch Behavior.Batch Envelope Smoke ✅ 2879ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 198ms
Batch Behavior.Flush At Triggers Batch ✅ 1173ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 356ms
Deduplication.Generates Unique Uuids ✅ 2796ms
Deduplication.Different Events Same Content Different Uuids ✅ 3278ms
Deduplication.Preserves Uuid On Retry ✅ 9132ms
Deduplication.Preserves Timestamp On Retry ✅ 9261ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7867ms
Deduplication.No Duplicate Events In Batch ✅ 2904ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 315ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 16252ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 9215ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 4888ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 9346ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 2680ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 2828ms
Response Format Validation.Success No Retry After When All Ok ✅ 2757ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 2766ms
Response Format Validation.Success No Retry After When Drop Only ✅ 2852ms
Response Format Validation.Response Echoes Request Id ✅ 2704ms
Retry Behavior.Retries On 408 ✅ 9208ms
Retry Behavior.Retries On 500 ✅ 8752ms
Retry Behavior.Retries On 503 ✅ 11755ms
Retry Behavior.Retries On 504 ✅ 5421ms
Retry Behavior.Retryable Errors Have Retry After ✅ 5787ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 10819ms
Retry Behavior.Does Not Retry On 400 ✅ 4827ms
Retry Behavior.Does Not Retry On 401 ✅ 4688ms
Retry Behavior.Does Not Retry On 402 ✅ 2405ms
Retry Behavior.Does Not Retry On 413 ✅ 4793ms
Retry Behavior.Does Not Retry On 415 ✅ 4695ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 4790ms
Retry Behavior.Implements Backoff ✅ 21169ms
Retry Behavior.Max Retries Respected ❌ 15322ms
Partial Batch Handling.Handles 200 Full Success ✅ 4739ms
Partial Batch Handling.Handles 200 With All Ok ✅ 5805ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 5841ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 5942ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 7832ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 7730ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 7887ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 7843ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 7805ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 7865ms
Partial Batch Handling.Respects Retry After On Partial ✅ 7732ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 5764ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 5902ms
Compression.Sends Gzip Content Encoding ✅ 2764ms
Compression.No Content Encoding When Disabled ✅ 2784ms
Compression.Compressed Body Is Decompressible ✅ 2678ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 4742ms
Event Options.Cookieless Mode Override ✅ 2752ms
Event Options.Disable Skew Correction Override ✅ 407ms
Event Options.Process Person Profile Override ✅ 2864ms
Event Options.Product Tour Id Override ✅ 2805ms
Event Options.Unset Options Omitted ❌ 2801ms
Event Options.Options Override In Batch ✅ 2881ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 3183ms
Geoip And Historical Migration.Historical Migration Set In Body ❌ 2993ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 312ms

Failures

retry_behavior.max_retries_respected

Expected 4 requests, got 5

event_options.unset_options_omitted

Event option 'process_person_profile' should be absent, but options={'process_person_profile': False}

geoip_and_historical_migration.historical_migration_set_in_body

Body missing 'historical_migration' field. Body keys: ['batch', 'created_at']

Feature_Flags Tests

✅ 16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 2861ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2832ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2774ms
Request Payload.Flags Request Omits Authorization Header ✅ 2788ms
Request Payload.Token In Flags Body Matches Init ✅ 2970ms
Request Payload.Groups Round Trip ✅ 2790ms
Request Payload.Groups Default To Empty Object ✅ 2841ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It ✅ 2952ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 3000ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2903ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2847ms
Request Lifecycle.No Flags Request On Init Alone ✅ 108ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2885ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5528ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2945ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 3061ms

turnipdabeets and others added 10 commits October 10, 2026 11:26
Post analytics events to /i/v1/analytics/events with Bearer auth and the
PostHog-Sdk-Info/Attempt/Request-Id/Request-Timestamp headers. The V1
request builder promotes $session_id/$window_id to the event root, drops
$lib/$lib_version, and sends each UUID once per batch.

Per-event results drive the queue: ok/warning/drop are removed and only
retry events stay queued with backoff, reusing the request ID with an
incremented attempt. Only 408/500/502/503/504 and network errors are
retried. A 404 falls back to /batch for the rest of the session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… adapter init options

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…clarify changeset

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Capture V1 follows only same-origin 307/308 redirects (at most 5) and
re-sets the original headers; any other redirect is returned and handled
as terminal. Other endpoints keep stripping custom headers cross-host.
Also caps Retry-After at the 30s backoff ceiling, logs per-batch drop
counts instead of event UUIDs, and omits empty session/window IDs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Adds an options parameter to the most complete capture overload and a
mutable PostHogEvent.options that beforeSend can edit and the queue
persists. At send time the legacy $cookieless_mode, $ignore_sent_at,
$product_tour_id and $process_person_profile properties are removed and
fill their unset options, matching posthog-python/go/rs. The /batch
fallback folds options back into those properties, and replay snapshots
never send options. The old seven-argument Objective-C selector keeps
working. The compliance adapter passes options and declares
event_options.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
/flush waits for intercepted requests to settle, but the interceptor
only matched legacy paths, so it returned before V1 requests reached
the mock and fast tests intermittently saw no requests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…plits

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
OHHTTPStubs delivers a stubbed 3xx before the redirect decision, so
URLSession can end the task with no response. Check the 5-redirect
limit against the delegate directly, and check the followed request
rather than the final result.

Co-Authored-By: Claude Opus 5.5 (1M context) <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.

1 participant