From f11308fa5e14e450c65b3cb5799264177d551e5a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 22:15:10 -0400 Subject: [PATCH 1/2] feat!: remove the /batch fallback from capture V1 Analytics events now only go to /i/v1/analytics/events. A 404 is terminal like any other non-retriable status, matching posthog-python, posthog-go and posthog-rs, instead of resending the batch to /batch for the session. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 2 +- PostHog/PostHogApi.swift | 75 +----------------- PostHog/QueueEndpoint+Factories.swift | 20 ++--- PostHogTests/PostHogApiTest.swift | 66 ++++++++-------- PostHogTests/PostHogCaptureV1Test.swift | 76 ++----------------- PostHogTests/PostHogQueueTest.swift | 2 +- PostHogTests/PostHogSDKTest.swift | 2 +- .../TestUtils/MockPostHogServer.swift | 25 ++---- .../Sources/RequestInterceptor.swift | 3 +- 9 files changed, 60 insertions(+), 211 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 849092c17..eff7cfbb6 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -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. - 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. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index b0b76c2e8..63269e32c 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -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( @@ -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 @@ -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 @@ -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) } @@ -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 @@ -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`. diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 31bb45815..2bcdfa056 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -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) } @@ -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 { QueueEndpoint( storageKey: .queue, @@ -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 { QueueEndpoint( @@ -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 { QueueEndpoint( storageKey: .replayQeueue, @@ -121,7 +115,7 @@ extension QueueEndpoint where Record == PostHogEvent { send: { events, completion in api.snapshot(events: events, completion: completion) }, - isRetriableStatusCode: isEventsRetriableStatusCode + isRetriableStatusCode: isSnapshotRetriableStatusCode ) } } diff --git a/PostHogTests/PostHogApiTest.swift b/PostHogTests/PostHogApiTest.swift index 6a5497d75..29f59d840 100644 --- a/PostHogTests/PostHogApiTest.swift +++ b/PostHogTests/PostHogApiTest.swift @@ -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) } @@ -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/") } } @@ -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") @@ -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) @@ -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) @@ -461,8 +461,8 @@ 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 @@ -470,7 +470,7 @@ enum PostHogApiTests { 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) @@ -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") @@ -523,9 +523,11 @@ 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) } @@ -533,7 +535,7 @@ enum PostHogApiTests { 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") diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 3be106e77..bd06e4e1d 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -193,58 +193,23 @@ final class PostHogCaptureV1Test { #expect(Set(createdAt[0 ... 3]).count == 1) } - @Test("falls back to /batch on 404 for the rest of the session") - func fallsBackToBatchOn404() async throws { - server.captureV1ResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) - } - let api = makeApi() - let endpoint = QueueEndpoint.batch(api: api) - let event = PostHogEvent(event: "first", distinctId: "user") - #expect(!endpoint.isRetriableStatusCode(429)) - - let result = await send(api, [event]) - - #expect(result.statusCode == 200) - #expect(!api.usesCaptureV1) - // The fallback uses the /batch retry policy. - #expect(endpoint.isRetriableStatusCode(429)) - var paths = server.batchRequests.map { $0.url?.path } - #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/batch"]) - let legacyRequest = try #require(server.batchRequests.last) - #expect(try body(legacyRequest)["api_key"] as? String == "phc_capture_v1") - #expect(server.parsePostHogEvents(legacyRequest).map(\.uuid) == [event.uuid]) - - _ = await send(api, [PostHogEvent(event: "second", distinctId: "user")]) - - paths = server.batchRequests.map { $0.url?.path } - #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/batch", "/proxy/batch"]) - // A new API instance (next launch) tries V1 again. - #expect(makeApi().usesCaptureV1) - } - - @Test("a failing /batch fallback is retried on /batch") - func failingFallbackIsRetriedOnBatch() async throws { + @Test("a 404 is terminal and doesn't fall back to /batch") + func notFoundIsTerminal() async throws { server.captureV1ResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) } - server.batchResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 503, headers: nil) - } let api = makeApi() - let endpoint = QueueEndpoint.batch(api: api) let result = await send(api, [PostHogEvent(event: "first", distinctId: "user")]) - let statusCode = try #require(result.statusCode) - #expect(statusCode == 503) - #expect(endpoint.isRetriableStatusCode(statusCode)) + #expect(result.statusCode == 404) #expect(result.retryRecordIds == nil) + #expect(!QueueEndpoint.batch(api: api).isRetriableStatusCode(404)) _ = await send(api, [PostHogEvent(event: "second", distinctId: "user")]) let paths = server.batchRequests.map { $0.url?.path } - #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/batch", "/proxy/batch"]) + #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/i/v1/analytics/events"]) } @Test("omits empty session and window IDs from the root and properties") @@ -435,34 +400,6 @@ final class PostHogCaptureV1OptionsTest { #expect((sent["options"] as? [String: Any])?.isEmpty == true) } - @Test("the /batch fallback folds options into their legacy properties and sends no options") - func batchFallbackFoldsOptions() async throws { - server.captureV1ResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) - } - let api = makeApi() - let event = PostHogEvent( - event: "test", - distinctId: "user", - properties: ["$process_person_profile": false, "$ignore_sent_at": true, "$product_tour_id": "tour"], - options: ["process_person_profile": true, "cookieless_mode": true, "product_tour_id": NSNull(), "future_option": 1] - ) - await withCheckedContinuation { continuation in - api.captureV1(events: [event]) { _ in continuation.resume() } - } - - let request = try #require(server.batchRequests.last) - #expect(request.url?.path == "/batch") - let sent = try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.first) - #expect(sent["options"] == nil) - let properties = try #require(sent["properties"] as? [String: Any]) - #expect(properties["$process_person_profile"] as? Bool == true) - #expect(properties["$cookieless_mode"] as? Bool == true) - #expect(properties["$ignore_sent_at"] as? Bool == true) - #expect(properties["$product_tour_id"] as? String == "tour") - #expect(properties["future_option"] == nil) - } - @Test("replay snapshots don't send options") func snapshotsOmitOptions() async throws { let event = PostHogEvent(event: "$snapshot", distinctId: "user", properties: ["$session_id": "s"], options: ["cookieless_mode": true]) @@ -687,7 +624,7 @@ final class PostHogCaptureAiTest { #expect(sut.captureAi("$ai_generation") == nil) } - @Test("a 404 from the AI endpoint drops the batch without falling back to /batch") + @Test("a 404 from the AI endpoint drops the batch") func notFoundIsTerminal() async throws { server.aiResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [:], statusCode: 404, headers: nil) } let config = PostHogConfig(projectToken: "phc_ai_404", host: "http://localhost:9001") @@ -707,7 +644,6 @@ final class PostHogCaptureAiTest { #expect(queue.depth == 0) #expect(server.aiRequests.count == 1) #expect(server.batchRequests.isEmpty) - #expect(api.usesCaptureV1) } @Test("drops an AI event over the size limit when it is queued") diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index e7169507f..e140aba8d 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -112,7 +112,7 @@ final class PostHogQueueTest { isRetriableStatusCode: base.isRetriableStatusCode ) let sut = PostHogQueue(config, storage, endpoint, nil) - // Only count /batch requests from this fixture's queue: the stub and the activation hook are + // Only count capture requests from this fixture's queue: the stub and the activation hook are // process-global, so a stray request from another queue/SDK instance would otherwise shift // batchRequests.count and the request number the batchResponseHandler keys on. server.batchProjectToken = config.projectToken diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index a51e7ae52..59d9ae0f4 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -471,7 +471,7 @@ final class PostHogSDKTests { @Test("excludes $recording_status and $sdk_debug_* properties from $snapshot events") func excludesRecordingStatusAndDebugPropertiesFromSnapshotEvents() throws { - // $snapshot events route to the replay queue and the /s/ endpoint, not /batch — + // $snapshot events route to the replay queue and the /s/ endpoint, not capture V1 — // read the raw request body rather than getBatchedEvents. server.reset(batchCount: 0, snapshotCount: 1) let sut = getSut() diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index 63988a277..d4a64a31d 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -84,7 +84,7 @@ class MockPostHogServer { var logsResponseHandler: ((URLRequest, Int) -> HTTPStubsResponse)? var version: Int = 3 - /// When set, `/batch` requests whose `api_key` differs are not recorded, so batches from other + /// When set, capture V1 requests whose Bearer token differs are not recorded, so batches from other /// tests' still-running SDK instances can't satisfy or inflate this server's expectations. var batchProjectToken: String? /// When set, `/flags` requests whose `api_key` differs are not recorded, for the same reason. @@ -95,9 +95,7 @@ class MockPostHogServer { if let batchProjectToken { // Buffer the body: a stream-backed body can only be read once. request.httpBody = request.body() - let token = Self.isCaptureV1(request) - ? request.value(forHTTPHeaderField: "Authorization").map { String($0.dropFirst("Bearer ".count)) } - : parseRequest(request)?["api_key"] as? String + let token = request.value(forHTTPHeaderField: "Authorization").map { String($0.dropFirst("Bearer ".count)) } guard token == batchProjectToken else { return } } @@ -153,7 +151,7 @@ class MockPostHogServer { var errorsWhileComputingFlags = false var return500 = false - /// Optional override for `/batch` responses. When set, takes precedence + /// Optional override for capture V1 responses. When set, takes precedence /// over `return500`. The closure receives the incoming request and the /// 1-based index of that request in the batch sequence so callers can /// vary the response (e.g. 413 then 200). @@ -425,17 +423,6 @@ class MockPostHogServer { return response }) - stubDescriptors.append(stub(condition: pathEndsWith("/batch")) { request in - if let handler = self.batchResponseHandler { - let index = self.batchRequests.count + 1 - return handler(request, index) - } - if self.return500 { - return HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) - } - return HTTPStubsResponse(jsonObject: ["status": "ok"], statusCode: 200, headers: nil) - }) - stubDescriptors.append(stub(condition: pathEndsWith("/i/v1/analytics/events")) { request in // Buffer the body and record here: a stream-backed body can only be // read once, and the default response needs the event UUIDs. @@ -630,9 +617,7 @@ class MockPostHogServer { HTTPStubs.onStubActivation { request, _, _ in // Capture V1 requests are recorded by their stub. - if request.url?.lastPathComponent == "batch" { - self.trackBatchRequest(request) - } else if request.url?.lastPathComponent == "s" { + if request.url?.lastPathComponent == "s" { self.trackSnapshotRequest(request) } else if request.url?.lastPathComponent == "logs" { self.trackLogsRequest(request) @@ -701,7 +686,7 @@ class MockPostHogServer { request.url.map { $0.path.hasSuffix("/i/v1/analytics/events") || $0.path.hasSuffix("/i/v1/ai/events") } == true } - /// Decodes the events of a `/batch` or capture V1 request. For V1 it puts + /// Decodes the events of a capture V1 request. It puts /// back what the server injects (`$session_id`, `$window_id`, `$lib`, /// `$lib_version` and the legacy property of each known option), so event /// assertions don't depend on the transport. V1 events keep `options` too. diff --git a/sdk_compliance_adapter/Sources/RequestInterceptor.swift b/sdk_compliance_adapter/Sources/RequestInterceptor.swift index 416a9cea3..778b829c8 100644 --- a/sdk_compliance_adapter/Sources/RequestInterceptor.swift +++ b/sdk_compliance_adapter/Sources/RequestInterceptor.swift @@ -106,7 +106,6 @@ class RequestInterceptor: URLProtocol { // Intercept capture endpoints, but not /flags/ or /config let urlString = url.absoluteString if urlString.contains("/i/v1/analytics/events") || urlString.contains("/i/v1/ai/events") - || urlString.contains("/batch") || urlString.contains("/e/") || urlString.contains("/s/"), !urlString.contains("/flags"), !urlString.contains("/config") @@ -230,7 +229,7 @@ class RequestInterceptor: URLProtocol { } if let json = try JSONSerialization.jsonObject(with: decompressed) as? [String: Any] { - // Server SDK format: {"api_key": "...", "batch": [...]} where api_key carries the project token. + // Capture V1 format: {"created_at": "...", "batch": [...]}. if let batch = json["batch"] as? [[String: Any]] { events = batch print("[INTERCEPTOR] Found batch with \(events.count) events") From 2330981c95ddeef6c3def45428bcfcad59e084a0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 20 Oct 2026 22:49:14 -0400 Subject: [PATCH 2/2] chore: stop ignoring /batch in replay network capture The SDK no longer sends to /batch, so replay doesn't need to filter it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Network/PostHogSessionReplayNetworkPlugin.swift | 2 +- PostHogTests/PostHogSessionReplayPluginTest.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift index 3934c29f9..477c4fa01 100644 --- a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift +++ b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift @@ -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) diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index 148c3f049..943e49767 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -153,8 +153,8 @@ expected: true ), IgnoredNetworkRequestCase( - name: "ignores batch ingestion on the configured host", - requestURL: "https://us.i.posthog.com/batch", + name: "ignores capture V1 ingestion on the configured host", + requestURL: "https://us.i.posthog.com/i/v1/analytics/events", apiHost: "https://us.i.posthog.com", snapshotEndpoint: "/s/", expected: true @@ -167,8 +167,8 @@ expected: true ), IgnoredNetworkRequestCase( - name: "ignores batch ingestion behind a reverse proxy path", - requestURL: "https://app.posthog.com/ingest/batch", + name: "ignores capture V1 ingestion behind a reverse proxy path", + requestURL: "https://app.posthog.com/ingest/i/v1/analytics/events", apiHost: "https://app.posthog.com/ingest", snapshotEndpoint: "/s/", expected: true