Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/v4-capture-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Breaking:** send events to capture V1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header.

- If you use a reverse proxy, forward `/i/v1/analytics/events`. A 404 falls back to `/batch` until the app restarts; other errors such as 403 or 405 drop the events.
- If you use a reverse proxy or self-host PostHog, make sure `/i/v1/analytics/events` is served: a 404 or other non-retriable response drops the events.

@dustinbyrne dustinbyrne Oct 20, 2026 •

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.

fwiw, (nit; paranoid take) i think we could be more resilient than this. a routing misconfiguration would mean certain data loss. considering the route is not configurable and we expect it to exist, it seems reasonable to take a position that a 404 would be retriable (or at least, shouldn't discard the event data) in that it should never happen, and if it did, is likely going to be rectified quickly.

i'm not sure there's many situations where i'd consider outright dropping data from the client (aside from a full event buffer), and perhaps this is something we should think about

@turnipdabeets turnipdabeets Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

hm I see what you mean. It looks like all the sdks drop this on 404: python, go and node on capture v1, and android/RN/iOS on /batch, replay and logs today. I was removing it in this PR to make it easier to review instead of all in 1. Ultimately we would want it to use capture v1 and not /batch here, but I agree it's worth rethinking, especially for mobile. It might be a bigger change that affects more than capture v1 adn the fallback probably isn’t /batch but some sort of retry.

- Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header.
- Only a 307 or 308 redirect to the same origin as `host` is followed, at most 5 times. Other redirects drop the events, so point `host` at the final host.
- 429 responses are no longer retried, and `Retry-After` pauses sending for at most 30 seconds, including session replay and logs.
Expand Down
75 changes: 4 additions & 71 deletions PostHog/PostHogApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import Foundation

/// Common URLSession upload-response handler shared by `/batch`, `/snapshot`,
/// Common URLSession upload-response handler shared by capture V1, `/snapshot`,
/// and `/i/v1/logs`. Routes through `as?` so a missing HTTP response can't
/// crash inside a customer process.
func processUploadResponse(
Expand Down Expand Up @@ -70,9 +70,6 @@ class PostHogApi {
private let captureV1Lock = NSLock()
// Keyed by endpoint path, so analytics and AI retries keep separate identities.
private var captureV1PendingRetry: [String: CaptureV1RequestIdentity] = [:]
// Set when V1 returns 404 so the rest of this session uses `/batch`.
// Remove with /batch.
private var captureV1Unavailable = false

init(_ config: PostHogConfig) {
self.config = config
Expand Down Expand Up @@ -107,7 +104,7 @@ class PostHogApi {
}

/// `gzipped: true` adds `Content-Encoding: gzip` for upload endpoints
/// (/batch, /s/, /i/v1/logs) whose bodies are gzipped.
/// (capture V1, /s/, /i/v1/logs) whose bodies are gzipped.
private func getURLRequest(_ url: URL, gzipped: Bool = false, httpMethod: String = "POST") -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = httpMethod
Expand Down Expand Up @@ -189,51 +186,16 @@ class PostHogApi {
return request
}

func batch(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) {
guard let url = getEndpointURL("/batch", relativeTo: config.host) else {
hedgeLog("Malformed batch URL error.")
return completion(PostHogUploadInfo(statusCode: nil, error: nil))
}

let toSend: [String: Any] = [
// Wire field name remains api_key, but it carries the PostHog project token.
"api_key": config.projectToken,
"batch": events.map(Self.batchJSON),
"sent_at": toISO8601String(Date()),
]

guard let data = try? JSONSerialization.data(withJSONObject: toSend) else {
hedgeLog("Error parsing the batch body")
return completion(PostHogUploadInfo(statusCode: nil, error: nil))
}

let (request, payload) = requestAndPayload(url: url, data: data, endpointName: "batch")

session.uploadTask(with: request, from: payload) { data, response, error in
processUploadResponse(endpointName: "batch", data: data, response: response, error: error, completion: completion)
}.resume()
}

/// Whether analytics events still go to capture V1 this session.
var usesCaptureV1: Bool {
captureV1Lock.withLock { !captureV1Unavailable }
}

/// POSTs analytics events to capture V1 (`/i/v1/analytics/events`).
///
/// The completion's `retryRecordIds` holds the UUIDs of events a 200 asked
/// to retry. A 404 resends the batch through `/batch` and keeps using it for
/// the rest of the session.
/// to retry.
func captureV1(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) {
guard usesCaptureV1 else {
return batch(events: events, completion: completion)
}
postCaptureV1(path: Self.captureV1AnalyticsPath, events: events, completion: completion)
}

/// POSTs AI events to the capture V1 AI endpoint (`/i/v1/ai/events`).
/// Same wire format and per-event results as `captureV1`, but a 404 is
/// terminal: AI events never fall back to `/batch`.
/// Same wire format, per-event results and retry policy as `captureV1`.
func captureAi(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) {
postCaptureV1(path: Self.captureV1AiPath, events: events, completion: completion)
}
Expand Down Expand Up @@ -274,20 +236,6 @@ class PostHogApi {
session.uploadTask(with: request, from: payload) { [weak self] data, response, error in
processUploadResponse(endpointName: "capture", data: data, response: response, error: error) { info in
guard let self else { return completion(info) }

// iOS-only fallback for hosts without capture V1. Remove with /batch.
if info.statusCode == 404, path == Self.captureV1AnalyticsPath {
let firstFallback = self.captureV1Lock.withLock {
defer { self.captureV1Unavailable = true }
self.captureV1PendingRetry[path] = nil
return !self.captureV1Unavailable
}
if firstFallback {
hedgeLog("Capture V1 returned 404, using /batch for the rest of this session.")
}
return self.batch(events: events, completion: completion)
}

completion(self.captureV1Result(
info, path: path, data: data, uuids: uuids, identity: identity,
duplicates: events.count - uniqueEvents.count
Expand Down Expand Up @@ -379,21 +327,6 @@ class PostHogApi {
("$process_person_profile", "process_person_profile"),
]

/// Shapes a stored event for `/batch`, which has no options: each option
/// goes back into its legacy property and wins over it. Remove with /batch.
private static func batchJSON(_ event: PostHogEvent) -> [String: Any] {
var json = event.toJSON()
guard let options = json.removeValue(forKey: "options") as? [String: Any] else { return json }
var properties = event.properties
for (property, option) in legacyOptionProperties {
if let value = options[option], !(value is NSNull) {
properties[property] = value
}
}
json["properties"] = properties
return json
}

/// Shapes a stored event for capture V1. `$session_id` and `$window_id`
/// move to the event root, and `$lib`/`$lib_version` are dropped because
/// the server reads them from `PostHog-Sdk-Info`.
Expand Down
20 changes: 7 additions & 13 deletions PostHog/QueueEndpoint+Factories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@

import Foundation

/// Retry policy shared by `/batch` (events fallback) and `/snapshot` (replay): 408, 429,
/// the listed 5xx, plus 3xx redirects.
private func isEventsRetriableStatusCode(_ code: Int) -> Bool {
/// `/snapshot` (replay) retry policy: 408, 429, the listed 5xx, plus 3xx redirects.
private func isSnapshotRetriableStatusCode(_ code: Int) -> Bool {
[408, 429, 500, 502, 503, 504].contains(code)
|| (300 ... 399).contains(code)
}
Expand Down Expand Up @@ -35,8 +34,7 @@ let aiMaxQueueSize = 100
let aiMaxQueueBytes = 50 * 1024 * 1024

extension QueueEndpoint where Record == PostHogEvent {
/// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`), or
/// `/batch` after V1 returned 404 this session.
/// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`).
static func batch(api: PostHogApi) -> QueueEndpoint<PostHogEvent> {
QueueEndpoint<PostHogEvent>(
storageKey: .queue,
Expand All @@ -55,15 +53,12 @@ extension QueueEndpoint where Record == PostHogEvent {
send: { events, completion in
api.captureV1(events: events, completion: completion)
},
isRetriableStatusCode: { code in
// The /batch fallback keeps the /batch policy. Remove with /batch.
api.usesCaptureV1 ? isCaptureV1RetriableStatusCode(code) : isEventsRetriableStatusCode(code)
}
isRetriableStatusCode: isCaptureV1RetriableStatusCode
)
}

/// Capture V1 AI endpoint (`/i/v1/ai/events`) for events sent with
/// `captureAi`. Same retry policy as capture V1, with no `/batch` fallback.
/// `captureAi`. Same retry policy as capture V1.
/// Batches by bytes as well as count, since AI events can be megabytes.
static func ai(api: PostHogApi) -> QueueEndpoint<PostHogEvent> {
QueueEndpoint<PostHogEvent>(
Expand Down Expand Up @@ -97,8 +92,7 @@ extension QueueEndpoint where Record == PostHogEvent {
)
}

/// `/snapshot` endpoint for session-replay snapshots. Shares its retry
/// policy with `/batch`.
/// `/snapshot` endpoint for session-replay snapshots.
static func snapshot(api: PostHogApi) -> QueueEndpoint<PostHogEvent> {
QueueEndpoint<PostHogEvent>(
storageKey: .replayQeueue,
Expand All @@ -121,7 +115,7 @@ extension QueueEndpoint where Record == PostHogEvent {
send: { events, completion in
api.snapshot(events: events, completion: completion)
},
isRetriableStatusCode: isEventsRetriableStatusCode
isRetriableStatusCode: isSnapshotRetriableStatusCode
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
}

let path = strippingAPIHostPathPrefix(from: url.path, apiHostPath: apiHost.path)
let ignoredPaths = [snapshotEndpoint, "/batch", "/e/", "/i/"]
let ignoredPaths = [snapshotEndpoint, "/e/", "/i/"]

return ignoredPaths.contains { ignoredPath in
pathMatches(path, ignoredPath: ignoredPath)
Expand Down
66 changes: 34 additions & 32 deletions PostHogTests/PostHogApiTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,13 @@ enum PostHogApiTests {
#expect(server.flagsRequests.count == 1)
}

func testBatchEndpoint(forHost host: String) async throws {
func testCaptureV1Endpoint(forHost host: String) async throws {
let sut = getSut(host: host)
let resp = await getApiResponse { completion in
sut.batch(events: [], completion: completion)
sut.captureV1(events: [], completion: completion)
}

try requireEndpoint(server.batchRequests.last, host: host, path: "batch")
try requireEndpoint(server.batchRequests.last, host: host, path: "i/v1/analytics/events")
#expect(resp.error == nil)
#expect(resp.statusCode == 200)
}
Expand Down Expand Up @@ -230,41 +230,41 @@ enum PostHogApiTests {
}
}

@Suite("Test batch endpoint with different host paths")
class TestBatchEndpoint: BaseTestSuite {
@Suite("Test capture V1 endpoint with different host paths")
class TestCaptureV1Endpoint: BaseTestSuite {
@Test("with host containing no path")
func hostWithNoPath() async throws {
try await testBatchEndpoint(forHost: "http://localhost")
try await testCaptureV1Endpoint(forHost: "http://localhost")
}

@Test("with host containing no path and trailing slash")
func hostWithNoPathAndTrailingSlash() async throws {
try await testBatchEndpoint(forHost: "http://localhost/")
try await testCaptureV1Endpoint(forHost: "http://localhost/")
}

@Test("with host containing path")
func hostWithPath() async throws {
try await testBatchEndpoint(forHost: "http://localhost/api/v1")
try await testCaptureV1Endpoint(forHost: "http://localhost/api/v1")
}

@Test("with host containing path and trailing slash")
func hostWithPathAndTrailingSlash() async throws {
try await testBatchEndpoint(forHost: "http://localhost/api/v1/")
try await testCaptureV1Endpoint(forHost: "http://localhost/api/v1/")
}

@Test("with host containing port number")
func hostWithPortNumber() async throws {
try await testBatchEndpoint(forHost: "http://localhost:9000")
try await testCaptureV1Endpoint(forHost: "http://localhost:9000")
}

@Test("with host containing port number and path")
func hostWithPortNumberAndPath() async throws {
try await testBatchEndpoint(forHost: "http://localhost:9000/api/v1")
try await testCaptureV1Endpoint(forHost: "http://localhost:9000/api/v1")
}

@Test("with host containing port number, path and trailing slash")
func hostWithPortNumberAndTrailingSlash() async throws {
try await testBatchEndpoint(forHost: "http://localhost:9000/api/v1/")
try await testCaptureV1Endpoint(forHost: "http://localhost:9000/api/v1/")
}
}

Expand Down Expand Up @@ -349,11 +349,11 @@ enum PostHogApiTests {
/// session-level gzip would silently mis-label /flags on the wire.
@Suite("Content-Encoding header per endpoint")
class TestContentEncodingHeader: BaseTestSuite {
@Test("/batch declares gzip Content-Encoding")
func batchDeclaresGzip() async throws {
@Test("capture V1 declares gzip Content-Encoding")
func captureV1DeclaresGzip() async throws {
let sut = getSut(host: "http://localhost")
_ = await getApiResponse { completion in
sut.batch(events: [], completion: completion)
sut.captureV1(events: [], completion: completion)
}
let request = try #require(server.batchRequests.first)
#expect(request.value(forHTTPHeaderField: "Content-Encoding") == "gzip")
Expand Down Expand Up @@ -389,15 +389,15 @@ enum PostHogApiTests {
#expect(request.value(forHTTPHeaderField: "Content-Encoding") == "gzip")
}

@Test("/batch falls back to uncompressed when gzip fails")
func batchFallsBackToUncompressedWhenGzipFails() async throws {
@Test("capture V1 falls back to uncompressed when gzip fails")
func captureV1FallsBackToUncompressedWhenGzipFails() async throws {
let originalGzipData = PostHogApi.gzipData
PostHogApi.gzipData = { _ in throw NSError(domain: "PostHogApiTests", code: 1) }
defer { PostHogApi.gzipData = originalGzipData }

let sut = getSut(host: "http://localhost")
_ = await getApiResponse { completion in
sut.batch(events: [], completion: completion)
sut.captureV1(events: [], completion: completion)
}
let request = try #require(server.batchRequests.first)
#expect(request.value(forHTTPHeaderField: "Content-Encoding") == nil)
Expand Down Expand Up @@ -427,11 +427,11 @@ enum PostHogApiTests {
#expect(PostHogConfig(projectToken: "test_project_token", host: "http://localhost").compression == .gzip)
}

@Test("/batch sends an uncompressed body when compression is none")
func batchSendsUncompressedWhenCompressionIsNone() async throws {
@Test("capture V1 sends an uncompressed body when compression is none")
func captureV1SendsUncompressedWhenCompressionIsNone() async throws {
let sut = getUncompressedSut()
_ = await getApiResponse { completion in
sut.batch(events: [], completion: completion)
sut.captureV1(events: [], completion: completion)
}
let request = try #require(server.batchRequests.first)
#expect(request.value(forHTTPHeaderField: "Content-Encoding") == nil)
Expand Down Expand Up @@ -461,16 +461,16 @@ enum PostHogApiTests {
#expect(server.parseRequest(request, gzip: false)?["resourceLogs"] != nil)
}

@Test("/batch drops a session-level Content-Encoding when compression is none")
func batchDropsSessionContentEncodingWhenCompressionIsNone() async throws {
@Test("capture V1 drops a session-level Content-Encoding when compression is none")
func captureV1DropsSessionContentEncodingWhenCompressionIsNone() async throws {
let config = PostHogConfig(projectToken: "test_project_token", host: "http://localhost")
config.compression = .none
let sessionConfig = URLSessionConfiguration.default
sessionConfig.httpAdditionalHeaders = ["content-encoding": "gzip"]
config.urlSessionConfiguration = sessionConfig
let sut = PostHogApi(config)
_ = await getApiResponse { completion in
sut.batch(events: [], completion: completion)
sut.captureV1(events: [], completion: completion)
}
let request = try #require(server.batchRequests.first)
#expect(request.value(forHTTPHeaderField: "Content-Encoding") == nil)
Expand All @@ -497,14 +497,14 @@ enum PostHogApiTests {
return PostHogApi(config)
}

@Test("attaches custom headers to /batch requests")
func attachesToBatch() async throws {
let sut = getSut(host: "http://localhost", requestHeaders: ["Authorization": "Bearer test-jwt"])
@Test("attaches custom headers to capture V1 requests")
func attachesToCaptureV1() async throws {
let sut = getSut(host: "http://localhost", requestHeaders: ["X-Proxy-Token": "test-token"])
_ = await getApiResponse { completion in
sut.batch(events: [], completion: completion)
sut.captureV1(events: [], completion: completion)
}
let request = try #require(server.batchRequests.first)
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-jwt")
#expect(request.value(forHTTPHeaderField: "X-Proxy-Token") == "test-token")
}

@Test("attaches custom headers to /flags requests")
Expand All @@ -523,17 +523,19 @@ enum PostHogApiTests {
func noHeaderWhenUnset() async throws {
let sut = getSut(host: "http://localhost", requestHeaders: nil)
_ = await getApiResponse { completion in
sut.batch(events: [], completion: completion)
sut.flags(distinctId: "x", anonymousId: nil, groups: [:], personProperties: [:]) { data, _ in
completion(data)
}
}
let request = try #require(server.batchRequests.first)
let request = try #require(server.flagsRequests.first)
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
}

@Test("does not let custom headers override SDK-managed headers")
func doesNotOverrideSDKManagedHeaders() async throws {
let sut = getSut(host: "http://localhost", requestHeaders: ["content-type": "text/plain", "User-Agent": "evil"])
_ = await getApiResponse { completion in
sut.batch(events: [], completion: completion)
sut.captureV1(events: [], completion: completion)
}
let request = try #require(server.batchRequests.first)
#expect(request.value(forHTTPHeaderField: "Content-Type") != "text/plain")
Expand Down
Loading
Loading