diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index df983dcc6..121a537f3 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 f17a9fd87..eb92a8551 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", timeout: TimeInterval? = nil) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = httpMethod @@ -192,51 +189,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) } @@ -280,20 +242,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)) } }.resume() @@ -381,21 +329,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 7b50de1bd..58ea7b8eb 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,9 +34,9 @@ let captureV1MaxBatchBytes = 5 * 1024 * 1024 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. Batches by bytes as well - /// as count, so a batch of large events stays under the request limit. + /// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`). + /// Batches by bytes as well as count, so a batch of large events stays + /// under the request limit. static func batch(api: PostHogApi) -> QueueEndpoint { QueueEndpoint( storageKey: .queue, @@ -57,15 +56,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 { let maxEventBytes = api.config.captureAiMaxStoredEventBytes @@ -100,8 +96,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, @@ -124,7 +119,7 @@ extension QueueEndpoint where Record == PostHogEvent { send: { events, completion in api.snapshot(events: events, completion: completion) }, - isRetriableStatusCode: isEventsRetriableStatusCode + isRetriableStatusCode: isSnapshotRetriableStatusCode ) } } 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/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 f45553402..06520a831 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -184,58 +184,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("sends empty and whitespace session and window IDs at the root unchanged", arguments: ["", " "]) @@ -459,34 +424,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]) @@ -980,7 +917,7 @@ final class PostHogCaptureAiTest { #expect(queue.depth == 1) } - @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") @@ -1000,7 +937,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 c35076905..1d2f2f856 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -115,7 +115,7 @@ final class PostHogQueueTest { ) // 0.5 is a jitter factor of 1, so backoff waits are exact unless a test injects its own. let sut = PostHogQueue(config, storage, endpoint, nil, jitterUnit: jitterUnit) - // 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 266aafb3a..557261f2f 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -492,7 +492,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/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 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/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index ad874b512..e265f781b 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -28,7 +28,7 @@ You need to change something if your app does any of these: | Data | 3.x | 4.0 | | --- | --- | --- | -| Analytics events | `/batch` | `/i/v1/analytics/events`, or `/batch` after a 404 (see below) | +| Analytics events | `/batch` | `/i/v1/analytics/events` | | Session replay | `/s/` | `/s/`, unchanged | | AI events (`captureAi`) | – | `/i/v1/ai/events` (see [AI events](#ai-events)) | | Logs | `/i/v1/logs` | `/i/v1/logs`, unchanged | @@ -43,11 +43,10 @@ In 3.x, the SDK started and PostHog rejected every event with a 401. Use your pr ### Hosts without capture v1 -If you use a reverse proxy, forward `/i/v1/analytics/events` before you upgrade. +If you use a reverse proxy or self-host PostHog, make sure `/i/v1/analytics/events` is served before you upgrade. PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves it. -When `/i/v1/analytics/events` returns 404, the SDK resends that batch to `/batch` and keeps using `/batch` until the app restarts. -Any other error status, such as 403 or 405, drops the events. +The SDK doesn't fall back to `/batch`. A 404, or any other error status that isn't retried, such as 403 or 405, drops the events. ### Redirects @@ -134,7 +133,6 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - The SDK sends option values as given. PostHog reads common forms of a value, such as `"false"` or `0` for a boolean, and drops an event whose option value it can't read. - Other option keys are sent unchanged, and PostHog ignores keys it doesn't know. - Events still queued on disk by 3.x are sent to capture v1 after the update, with their legacy properties moved to options. -- After a 404 fallback to `/batch`, the SDK puts each option back into its legacy property. ## AI events @@ -165,4 +163,3 @@ In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:use - `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, with no deprecated alias. Since 3.73.0 it has only limited push-notification subscription retries. - A request carries at most `maxBatchSize` events and about 5 MiB, and the SDK sends as soon as about 5 MiB of events is queued, even before `flushAt` is reached. In 3.x, batches were limited by event count only. - A 413 still splits the batch into smaller requests. -- After a 404 fallback, `/batch` keeps the 3.x retry rules. 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")