From 2e46974994f5987031d80197a71fffb038e9a6a7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 15:17:10 -0400 Subject: [PATCH 01/67] feat!: send analytics events through capture v1 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) --- .changeset/v4-capture-v1.md | 5 + PostHog.xcodeproj/project.pbxproj | 4 + PostHog/PostHogApi.swift | 176 +++++++++++ PostHog/PostHogConsumerPayload.swift | 3 + PostHog/PostHogQueue.swift | 54 +++- PostHog/PostHogUploadInfo.swift | 7 +- PostHog/QueueEndpoint+Factories.swift | 19 +- PostHog/QueueEndpoint.swift | 5 +- PostHogTests/PostHogCaptureV1Test.swift | 290 ++++++++++++++++++ PostHogTests/PostHogEventSnapshotTests.swift | 18 +- PostHogTests/PostHogQueueTest.swift | 11 +- PostHogTests/PostHogSDKTest.swift | 4 +- .../Resources/event-shapes-batch.json | 32 +- .../TestUtils/MockPostHogServer.swift | 65 +++- 14 files changed, 645 insertions(+), 48 deletions(-) create mode 100644 .changeset/v4-capture-v1.md create mode 100644 PostHogTests/PostHogCaptureV1Test.swift diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md new file mode 100644 index 0000000000..28a6b07830 --- /dev/null +++ b/.changeset/v4-capture-v1.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': major +--- + +**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. Only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 1528a4b624..523e1717f9 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ 6A0C1F2E3B4D5A6B7C8D9E02 /* PostHogOptOutPersistenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C1F2E3B4D5A6B7C8D9E01 /* PostHogOptOutPersistenceTest.swift */; }; A1C081010000000000000001 /* PostHogReplayBatchBoundaryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */; }; 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */; }; + A1C0DE0000000000000000F2 /* PostHogCaptureV1Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */; }; 690FF0EF2AEFF23D00A0B06B /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 690FF0EE2AEFF23D00A0B06B /* OHHTTPStubsSwift */; }; 690FF0F12AEFF24200A0B06B /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = 690FF0F02AEFF24200A0B06B /* OHHTTPStubs */; }; E1A0B0203A0D000100000001 /* CwlCatchException in Frameworks */ = {isa = PBXBuildFile; productRef = E1A0B0213A0D000100000001 /* CwlCatchException */; }; @@ -966,6 +967,7 @@ 6A0C1F2E3B4D5A6B7C8D9E01 /* PostHogOptOutPersistenceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogOptOutPersistenceTest.swift; sourceTree = ""; }; A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReplayBatchBoundaryTest.swift; sourceTree = ""; }; 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogQueueTest.swift; sourceTree = ""; }; + A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCaptureV1Test.swift; sourceTree = ""; }; 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSDKTest.swift; sourceTree = ""; }; 690FF1732AF3CE8A00A0B06B /* PostHogExampleWatchOS.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleWatchOS.xcodeproj; path = PostHogExampleWatchOS/PostHogExampleWatchOS.xcodeproj; sourceTree = ""; }; 69261D122AD5685B00232EC7 /* PostHogRemoteConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfig.swift; sourceTree = ""; }; @@ -1888,6 +1890,7 @@ DAA1B2D02F8C100000112233 /* RageClickDetectorTest.swift */, DAA1B2D22F8C100000112233 /* PostHogRageClickIntegrationTest.swift */, 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */, + A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */, A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */, 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */, 5CA51F0E2B5C9A0100000002 /* PostHogScreenNameTest.swift */, @@ -3806,6 +3809,7 @@ AE3C6A5B6DAB01EF554A0270 /* PostHogPushNotificationTest.swift in Sources */, D36F92424A13D7CE61B6E348 /* PostHogSwizzlerTest.swift in Sources */, 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */, + A1C0DE0000000000000000F2 /* PostHogCaptureV1Test.swift in Sources */, A1C081010000000000000001 /* PostHogReplayBatchBoundaryTest.swift in Sources */, 3AE3FB4B2993A68500AFFC18 /* PostHogStorageTest.swift in Sources */, DAD39DF62D56178D002A1A69 /* UtilsTest.swift in Sources */, diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 1f66010e3c..1db356b0f9 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -65,6 +65,14 @@ class PostHogApi { static let flagsRetryDelay: TimeInterval = 0.3 + /// Guards the capture V1 state below. In memory only: a new request ID + /// after an app restart is fine, since the server only logs it. + private let captureV1Lock = NSLock() + private var captureV1PendingRetry: 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 customRequestHeaders = config.requestHeaders ?? [:] @@ -120,8 +128,11 @@ class PostHogApi { } /// SDK-managed headers that custom values can't override (compared lowercase). + /// `Authorization` stays overridable for proxies on other endpoints; capture + /// V1 sets its Bearer token after custom headers, so it always wins there. private static let reservedHeaderKeys: Set = [ "content-type", "user-agent", "accept-encoding", "content-encoding", + "posthog-sdk-info", "posthog-attempt", "posthog-request-id", "posthog-request-timestamp", ] private func requestAndPayload(url: URL, data: Data, endpointName: String, httpMethod: String = "POST") -> (URLRequest, Data) { @@ -200,6 +211,162 @@ class PostHogApi { }.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. + func captureV1(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { + guard usesCaptureV1 else { + return batch(events: events, completion: completion) + } + + guard let url = getEndpointURL("/i/v1/analytics/events", relativeTo: config.host) else { + hedgeLog("Malformed capture URL error.") + return completion(PostHogUploadInfo(statusCode: nil, error: nil)) + } + + // The server rejects the whole batch with 400 when a UUID repeats. + var seenUuids = Set() + let uniqueEvents = events.filter { seenUuids.insert($0.uuid.postHogUuidString).inserted } + let uuids = uniqueEvents.map(\.uuid.postHogUuidString) + let identity = nextCaptureV1Identity(uuids: Set(uuids)) + + let toSend: [String: Any] = [ + "created_at": identity.createdAt, + "batch": uniqueEvents.map(Self.captureV1JSON), + ] + + guard let data = try? JSONSerialization.data(withJSONObject: toSend) else { + hedgeLog("Error parsing the capture body") + return completion(PostHogUploadInfo(statusCode: nil, error: nil)) + } + + var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(config.projectToken)", forHTTPHeaderField: "Authorization") + request.setValue("\(postHogSdkName)/\(postHogVersion)", forHTTPHeaderField: "PostHog-Sdk-Info") + request.setValue(String(identity.attempt), forHTTPHeaderField: "PostHog-Attempt") + request.setValue(identity.requestId, forHTTPHeaderField: "PostHog-Request-Id") + request.setValue(toISO8601String(Date()), forHTTPHeaderField: "PostHog-Request-Timestamp") + + 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 { + let firstFallback = self.captureV1Lock.withLock { + defer { self.captureV1Unavailable = true } + self.captureV1PendingRetry = 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, data: data, uuids: uuids, identity: identity)) + } + }.resume() + } + + /// Reuses the request ID with the next attempt number when `uuids` is the + /// set of events the previous request left to retry. Otherwise starts a new + /// request at attempt 1. + private func nextCaptureV1Identity(uuids: Set) -> CaptureV1RequestIdentity { + captureV1Lock.withLock { + if let pending = captureV1PendingRetry, pending.uuids == uuids { + return CaptureV1RequestIdentity( + uuids: uuids, requestId: pending.requestId, attempt: pending.attempt + 1, createdAt: pending.createdAt + ) + } + return CaptureV1RequestIdentity( + uuids: uuids, requestId: UUID().postHogUuidString, attempt: 1, createdAt: toISO8601String(Date()) + ) + } + } + + /// Reads the per-event results of a 2xx and records which events, if any, + /// the next request retries under the same identity. + private func captureV1Result( + _ info: PostHogUploadInfo, + data: Data?, + uuids: [String], + identity: CaptureV1RequestIdentity + ) -> PostHogUploadInfo { + var retryUuids: Set = [] + + if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { + // A body without a readable results map counts as delivered, so a + // broken success can't loop forever (matches posthog-python). + let results = data.flatMap { fromJSONData($0) }?["results"] as? [String: Any] + if results == nil { + hedgeLog("Capture returned \(statusCode) without per-event results, treating the batch as delivered.") + } + // Events missing from the results count as delivered (matches posthog-rs). + for uuid in uuids { + guard let entry = results?[uuid] as? [String: Any] else { continue } + switch entry["result"] as? String { + case "retry": + retryUuids.insert(uuid) + case "drop": + let details = entry["details"] as? String ?? "no details" + hedgeLog("Capture dropped event \(uuid): \(details).") + default: + break + } + } + } else if info.statusCode.map(isCaptureV1RetriableStatusCode) ?? true { + // No response or a retriable status: the whole batch is resent. + retryUuids = Set(uuids) + } + + captureV1Lock.withLock { + captureV1PendingRetry = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( + uuids: retryUuids, requestId: identity.requestId, attempt: identity.attempt, createdAt: identity.createdAt + ) + } + + return PostHogUploadInfo( + statusCode: info.statusCode, + error: info.error, + retryAfter: info.retryAfter, + retryRecordIds: info.statusCode.map { 200 ... 299 ~= $0 } == true ? retryUuids : nil + ) + } + + /// 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`. + private static func captureV1JSON(_ event: PostHogEvent) -> [String: Any] { + var properties = event.properties + properties.removeValue(forKey: "$lib") + properties.removeValue(forKey: "$lib_version") + + var json: [String: Any] = [ + "event": event.event, + "uuid": event.uuid.postHogUuidString, + "distinct_id": event.distinctId, + "timestamp": toISO8601String(event.timestamp), + ] + // Always removed from properties; only a string is valid at the root. + if let sessionId = properties.removeValue(forKey: "$session_id") as? String { + json["session_id"] = sessionId + } + if let windowId = properties.removeValue(forKey: "$window_id") as? String { + json["window_id"] = windowId + } + json["properties"] = properties + return json + } + func snapshot(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { guard let url = getEndpointURL(config.snapshotEndpoint, relativeTo: config.host) else { hedgeLog("Malformed snapshot URL error.") @@ -593,3 +760,12 @@ private final class PostHogRedirectHeaderStripper: NSObject, URLSessionTaskDeleg completionHandler(redirected) } } + +/// Identity of a capture V1 request, kept so a retry of the same events reuses +/// `PostHog-Request-Id` and `created_at` with an incremented `PostHog-Attempt`. +private struct CaptureV1RequestIdentity { + let uuids: Set + let requestId: String + let attempt: Int + let createdAt: String +} diff --git a/PostHog/PostHogConsumerPayload.swift b/PostHog/PostHogConsumerPayload.swift index 905206ead9..f94b1c1b2b 100644 --- a/PostHog/PostHogConsumerPayload.swift +++ b/PostHog/PostHogConsumerPayload.swift @@ -13,4 +13,7 @@ import Foundation struct PostHogConsumerPayload { let records: [Record] let completion: (Bool) -> Void + /// Removes every record except those whose `QueueEndpoint.recordId` is in + /// the given set, then ends the flush so the kept records wait for backoff. + let completeKeeping: (Set) -> Void } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 51e5149d9a..cd3d333a31 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -154,15 +154,7 @@ class PostHogQueue { let isRetriable = statusCode == -1 || endpoint.isRetriableStatusCode(statusCode) if isRetriable { - let newCount = nextRetryCount() - let backoffDelay = min(TimeInterval(newCount) * retryDelay, maxRetryDelay) - var retryAfter = result.retryAfter ?? 0 - if let maxRetryAfter = endpoint.maxRetryAfterSeconds { - retryAfter = min(retryAfter, maxRetryAfter) - } - let delay = max(backoffDelay, retryAfter) - pauseFor(seconds: delay) - hedgeLog("Pausing queue consumption for \(delay) seconds due to \(newCount) API failure(s).") + pauseForBackoff(retryAfter: result.retryAfter) payload.completion(false) return } @@ -189,12 +181,32 @@ class PostHogQueue { return } + // A 2xx that asks to retry some records (capture V1) removes the rest + // and backs off before resending the kept ones. + if let retryRecordIds = result.retryRecordIds, !retryRecordIds.isEmpty { + pauseForBackoff(retryAfter: result.retryAfter) + payload.completeKeeping(retryRecordIds) + return + } + // 2xx success or a terminal response removes the exact snapshotted // entries. The adaptive cap stays where it is. resetRetryState() payload.completion(true) } + private func pauseForBackoff(retryAfter: TimeInterval?) { + let newCount = nextRetryCount() + let backoffDelay = min(TimeInterval(newCount) * retryDelay, maxRetryDelay) + var retryAfter = retryAfter ?? 0 + if let maxRetryAfter = endpoint.maxRetryAfterSeconds { + retryAfter = min(retryAfter, maxRetryAfter) + } + let delay = max(backoffDelay, retryAfter) + pauseFor(seconds: delay) + hedgeLog("Pausing queue consumption for \(delay) seconds due to \(newCount) API failure(s).") + } + private func nextRetryCount() -> Int { stateLock.withLock { let maximumBackoffStep = max(1, Int(maxRetryDelay / retryDelay)) @@ -450,6 +462,8 @@ class PostHogQueue { var processing: [Record] = [] var selectedIds: [String] = [] + // Entry ID -> `endpoint.recordId`, for removing all but retried records. + var recordIds: [String: String] = [:] var next = start while next < entries.count { let entry = entries[next] @@ -461,13 +475,19 @@ class PostHogQueue { break } processing.append(record) + recordIds[entry.id] = endpoint.recordId?(record) } selectedIds.append(entry.id) next += 1 } let nextIndex = next - completion(PostHogConsumerPayload(records: processing) { [weak self] success in + let finishFlush: () -> Void = { [weak self] in + guard let self else { return } + self.isFlushingLock.withLock { self.isFlushing = false } + } + + completion(PostHogConsumerPayload(records: processing, completion: { [weak self] success in guard let self else { return } if success, !selectedIds.isEmpty { self.fileQueue.remove(ids: selectedIds) @@ -479,9 +499,19 @@ class PostHogQueue { self?.consume(entries, from: nextIndex, completion: completion) } } else { - self.isFlushingLock.withLock { self.isFlushing = false } + finishFlush() + } + }, completeKeeping: { [weak self] keepRecordIds in + guard let self else { return } + let delivered = selectedIds.filter { id in + recordIds[id].map { !keepRecordIds.contains($0) } ?? true + } + if !delivered.isEmpty { + self.fileQueue.remove(ids: delivered) } - }) + hedgeLog("Completed \(delivered.count) of \(selectedIds.count) records, keeping the rest for retry.") + finishFlush() + })) } #if !os(watchOS) diff --git a/PostHog/PostHogUploadInfo.swift b/PostHog/PostHogUploadInfo.swift index 698df7809c..01b842e799 100644 --- a/PostHog/PostHogUploadInfo.swift +++ b/PostHog/PostHogUploadInfo.swift @@ -11,10 +11,15 @@ struct PostHogUploadInfo { let statusCode: Int? let error: Error? let retryAfter: TimeInterval? + /// Record IDs a 2xx response asked to retry (capture V1 per-event results). + /// The rest of the batch is delivered. `nil` or empty means the whole batch + /// shares `statusCode`'s outcome. + let retryRecordIds: Set? - init(statusCode: Int?, error: Error?, retryAfter: TimeInterval? = nil) { + init(statusCode: Int?, error: Error?, retryAfter: TimeInterval? = nil, retryRecordIds: Set? = nil) { self.statusCode = statusCode self.error = error self.retryAfter = retryAfter + self.retryRecordIds = retryRecordIds } } diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index c1043e5aa6..028e3b16df 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -5,7 +5,7 @@ import Foundation -/// Retry policy shared by `/batch` (events) and `/snapshot` (replay): 408, 429, +/// Retry policy shared by `/batch` (events fallback) and `/snapshot` (replay): 408, 429, /// the listed 5xx, plus 3xx redirects. private func isEventsRetriableStatusCode(_ code: Int) -> Bool { [408, 429, 500, 502, 503, 504].contains(code) @@ -17,8 +17,15 @@ private func isEventsRetriableStatusCode(_ code: Int) -> Bool { /// floors the backoff, so it can't strand the logs queue. Matches posthog-js. let logsMaxRetryAfterSeconds: TimeInterval = 5 * 60 +/// Capture V1 retry policy: 408 and the listed 5xx. Everything else, including +/// 429 and 3xx, is terminal; 413 is handled by the queue's batch halving. +func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { + [408, 500, 502, 503, 504].contains(code) +} + extension QueueEndpoint where Record == PostHogEvent { - /// `/batch` endpoint for analytics events. + /// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`), or + /// `/batch` after V1 returned 404 this session. static func batch(api: PostHogApi) -> QueueEndpoint { QueueEndpoint( storageKey: .queue, @@ -33,10 +40,14 @@ extension QueueEndpoint where Record == PostHogEvent { encode: { event in toJSONData(event.toJSON()) }, decode: { data in PostHogEvent.fromJSON(data) }, describe: { event in "event '\(event.event)'" }, + recordId: { event in event.uuid.postHogUuidString }, send: { events, completion in - api.batch(events: events, completion: completion) + api.captureV1(events: events, completion: completion) }, - isRetriableStatusCode: isEventsRetriableStatusCode + isRetriableStatusCode: { code in + // The /batch fallback keeps the /batch policy. Remove with /batch. + api.usesCaptureV1 ? isCaptureV1RetriableStatusCode(code) : isEventsRetriableStatusCode(code) + } ) } diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index 0587deb847..bf8afee168 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -7,7 +7,7 @@ import Foundation /// Per-wire spec consumed by the generic `PostHogQueue`. /// -/// Encapsulates everything that differs between the events `/batch`, replay +/// Encapsulates everything that differs between the events `/i/v1/analytics/events`, replay /// `/snapshot`, and logs `/i/v1/logs` endpoints — disk codec, payload assembly, /// retriable status set — so the queue itself stays record-type-agnostic. /// Implemented as a struct of closures rather than a protocol with @@ -59,6 +59,9 @@ struct QueueEndpoint { /// Short label for this record used in queue debug logs. Events return /// the event name; snapshots / logs return a generic label. let describe: (Record) -> String + /// Stable ID that a response can use to name a record, so a partial + /// result can keep just that record queued. Events: the event UUID. + var recordId: ((Record) -> String)? /// Whether a later record can share a request with the first record. /// When set, the queue sends only a compatible FIFO prefix. diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift new file mode 100644 index 0000000000..43af9cb14f --- /dev/null +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -0,0 +1,290 @@ +// +// PostHogCaptureV1Test.swift +// PostHogTests +// + +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +@_spi(PostHogInternal) @testable import PostHog +import Testing + +@Suite("Capture V1 transport", .serialized, .resetsGlobalState) +final class PostHogCaptureV1Test { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + private func makeApi(requestHeaders: [String: String]? = nil) -> PostHogApi { + let config = PostHogConfig(projectToken: "phc_capture_v1", host: "http://localhost:9001/proxy") + config.requestHeaders = requestHeaders + return PostHogApi(config) + } + + private func send(_ api: PostHogApi, _ events: [PostHogEvent]) async -> PostHogUploadInfo { + await withCheckedContinuation { continuation in + api.captureV1(events: events) { continuation.resume(returning: $0) } + } + } + + private func body(_ request: URLRequest) throws -> [String: Any] { + try #require(server.parseRequest(request)) + } + + @Test("sends events to /i/v1/analytics/events with Bearer auth and V1 headers") + func sendsV1Request() async throws { + let api = makeApi(requestHeaders: [ + "Authorization": "Bearer custom", "PostHog-Attempt": "9", "PostHog-Request-Id": "custom", + ]) + _ = await send(api, [PostHogEvent(event: "test", distinctId: "user")]) + + let request = try #require(server.batchRequests.first) + #expect(request.url?.path == "/proxy/i/v1/analytics/events") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer phc_capture_v1") + #expect(request.value(forHTTPHeaderField: "PostHog-Sdk-Info") == "\(postHogSdkName)/\(postHogVersion)") + #expect(request.value(forHTTPHeaderField: "PostHog-Attempt") == "1") + let requestId = try #require(request.value(forHTTPHeaderField: "PostHog-Request-Id")) + #expect(UUID(uuidString: requestId) != nil) + let timestamp = try #require(request.value(forHTTPHeaderField: "PostHog-Request-Timestamp")) + #expect(timestamp.hasSuffix("Z")) + #expect(toISO8601Date(timestamp) != nil) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Content-Encoding") == "gzip") + } + + @Test("builds the V1 body: created_at root, promoted session/window IDs, no $lib") + func buildsV1Body() async throws { + let event = PostHogEvent( + event: "test", + distinctId: "user", + properties: [ + "$session_id": "session-1", + "$window_id": "window-1", + "$lib": "posthog-ios", + "$lib_version": "1.0.0", + "$process_person_profile": false, + "custom": "value", + ] + ) + _ = await send(makeApi(), [event]) + + let request = try #require(server.batchRequests.first) + let body = try body(request) + #expect(Set(body.keys) == ["created_at", "batch"]) + let createdAt = try #require(body["created_at"] as? String) + #expect(toISO8601Date(createdAt) != nil) + + let events = try #require(body["batch"] as? [[String: Any]]) + let sent = try #require(events.first) + #expect(sent["event"] as? String == "test") + #expect(sent["uuid"] as? String == event.uuid.postHogUuidString) + #expect(sent["distinct_id"] as? String == "user") + #expect(sent["timestamp"] as? String == toISO8601String(event.timestamp)) + #expect(sent["session_id"] as? String == "session-1") + #expect(sent["window_id"] as? String == "window-1") + #expect(sent["options"] == nil) + + let properties = try #require(sent["properties"] as? [String: Any]) + for key in ["$session_id", "$window_id", "$lib", "$lib_version"] { + #expect(properties[key] == nil, "\(key) should not be in properties") + } + // Options hoisting is not part of this transport change. + #expect(properties["$process_person_profile"] as? Bool == false) + #expect(properties["custom"] as? String == "value") + + // The stored event is not reshaped. + #expect(event.properties["$session_id"] as? String == "session-1") + #expect(event.properties["$lib"] as? String == "posthog-ios") + } + + @Test("sends each UUID once per batch") + func dedupesUuids() async throws { + let first = PostHogEvent(event: "first", distinctId: "user") + let duplicate = PostHogEvent(event: "duplicate", distinctId: "user", uuid: first.uuid) + let other = PostHogEvent(event: "other", distinctId: "user") + _ = await send(makeApi(), [first, duplicate, other]) + + let request = try #require(server.batchRequests.first) + let events = try #require(try body(request)["batch"] as? [[String: Any]]) + #expect(events.map { $0["event"] as? String } == ["first", "other"]) + } + + @Test("reports retry results and treats ok, warning, drop and missing results as delivered") + func reportsRetryResults() async throws { + let events = (0 ..< 5).map { PostHogEvent(event: "e\($0)", distinctId: "user") } + let uuids = events.map(\.uuid.postHogUuidString) + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: ["results": [ + uuids[0]: ["result": "ok"], + uuids[1]: ["result": "warning", "details": "truncated"], + uuids[2]: ["result": "drop", "details": "invalid"], + uuids[3]: ["result": "retry"], + ]], statusCode: 200, headers: ["Retry-After": "2"]) + } + + let result = await send(makeApi(), events) + + #expect(result.statusCode == 200) + #expect(result.retryRecordIds == [uuids[3]]) + #expect(result.retryAfter == 2) + } + + @Test("treats a 200 without a results map as delivered") + func malformedSuccessIsDelivered() async { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(data: Data("not json".utf8), statusCode: 200, headers: nil) + } + + let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + + #expect(result.statusCode == 200) + #expect(result.retryRecordIds?.isEmpty ?? true) + } + + @Test("a retry reuses the request ID and increments the attempt; an independent flush starts over") + func retryIdentity() async throws { + let events = (0 ..< 2).map { PostHogEvent(event: "e\($0)", distinctId: "user") } + let retried = events[1].uuid.postHogUuidString + server.captureV1EventResult = { uuid, index in + index < 3 && uuid == retried ? "retry" : "ok" + } + server.captureV1ResponseHandler = nil + let api = makeApi() + + // 1: both events, server asks to retry the second one. + _ = await send(api, events) + // 2: the retried event alone, still marked retry. + _ = await send(api, [events[1]]) + // 3: retried again (whole batch this time via 503). + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 503, headers: nil) + } + _ = await send(api, [events[1]]) + server.captureV1ResponseHandler = nil + // 4: delivered. + _ = await send(api, [events[1]]) + // 5: an unrelated flush. + _ = await send(api, [PostHogEvent(event: "next", distinctId: "user")]) + + let requests = server.batchRequests + try #require(requests.count == 5) + let ids = requests.map { $0.value(forHTTPHeaderField: "PostHog-Request-Id") } + let attempts = requests.map { $0.value(forHTTPHeaderField: "PostHog-Attempt") } + let createdAt = try requests.map { try body($0)["created_at"] as? String } + #expect(attempts == ["1", "2", "3", "4", "1"]) + #expect(Set(ids[0 ... 3]).count == 1) + #expect(ids[4] != ids[0]) + #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 event = PostHogEvent(event: "first", distinctId: "user") + + let result = await send(api, [event]) + + #expect(result.statusCode == 200) + #expect(!api.usesCaptureV1) + 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("only 408 and 500/502/503/504 are retried", arguments: [ + (408, true), (500, true), (502, true), (503, true), (504, true), + (301, false), (400, false), (401, false), (402, false), (403, false), + (413, false), (415, false), (429, false), (501, false), + ]) + func statusCodeClassification(statusCode: Int, retriable: Bool) { + let endpoint = QueueEndpoint.batch(api: makeApi()) + #expect(endpoint.isRetriableStatusCode(statusCode) == retriable) + } +} + +@Suite("Capture V1 queue partial retry", .serialized, .resetsGlobalState) +final class PostHogCaptureV1QueueTest { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + @Test("removes ok, warning and drop events and keeps retry events for the next attempt") + func keepsOnlyRetryEvents() async throws { + try await withMockedClock { clock in + let config = PostHogConfig(projectToken: "phc_partial_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .batch(api: PostHogApi(config)), nil) + server.batchProjectToken = config.projectToken + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let events = ["ok", "warning", "drop", "retry"].map { PostHogEvent(event: $0, distinctId: "user") } + let results = Dictionary(uniqueKeysWithValues: events.map { ($0.uuid.postHogUuidString, $0.event) }) + server.captureV1EventResult = { uuid, index in index == 1 ? results[uuid] ?? "ok" : "ok" } + for event in events { + queue.add(event) + } + + queue.flush() + await waitUntil { queue.depth == 1 } + #expect(queue.depth == 1) + #expect(queue.currentRetryCountForTesting == 1) + let keptData = try #require(queue.fileQueue.peek(1).first) + let kept = try #require(PostHogEvent.fromJSON(keptData)) + #expect(kept.uuid == events[3].uuid) + + // Paused for backoff until the clock moves past it. + queue.flush() + try await Task.sleep(nanoseconds: 100_000_000) + #expect(server.batchRequests.count == 1) + + clock.date = clock.date.addingTimeInterval(retryDelay + 1) + queue.flush() + await waitUntil { queue.depth == 0 } + #expect(queue.depth == 0) + #expect(queue.currentRetryCountForTesting == 0) + + let requests = server.batchRequests + try #require(requests.count == 2) + #expect(server.parsePostHogEvents(requests[1]).map(\.uuid) == [events[3].uuid]) + #expect(requests[1].value(forHTTPHeaderField: "PostHog-Attempt") == "2") + #expect( + requests[1].value(forHTTPHeaderField: "PostHog-Request-Id") + == requests[0].value(forHTTPHeaderField: "PostHog-Request-Id") + ) + } + } +} diff --git a/PostHogTests/PostHogEventSnapshotTests.swift b/PostHogTests/PostHogEventSnapshotTests.swift index b59d7069a7..664d94ca67 100644 --- a/PostHogTests/PostHogEventSnapshotTests.swift +++ b/PostHogTests/PostHogEventSnapshotTests.swift @@ -164,6 +164,18 @@ final class PostHogEventSnapshotTests { try #require(Int(contentLength) != nil) headers["content-length"] = "" } + if let sdkInfo = headers["posthog-sdk-info"] { + #expect(sdkInfo.hasPrefix("posthog-ios/")) + headers["posthog-sdk-info"] = "posthog-ios/" + } + if let requestId = headers["posthog-request-id"] { + try #require(UUID(uuidString: requestId) != nil) + headers["posthog-request-id"] = "" + } + if let requestTimestamp = headers["posthog-request-timestamp"] { + try #require(toISO8601Date(requestTimestamp) != nil) + headers["posthog-request-timestamp"] = "" + } if let userAgent = headers["user-agent"] { let prefix = "posthog-ios/" #expect(userAgent.hasPrefix(prefix)) @@ -189,9 +201,9 @@ final class PostHogEventSnapshotTests { private func normalizeBatchSnapshot(_ snapshot: inout [String: Any]) throws { var body = try #require(snapshot["body"] as? [String: Any]) - let sentAt = try #require(body["sent_at"] as? String) - try #require(toISO8601Date(sentAt) != nil) - body["sent_at"] = "" + let createdAt = try #require(body["created_at"] as? String) + try #require(toISO8601Date(createdAt) != nil) + body["created_at"] = "" var events = try #require(body["batch"] as? [[String: Any]]) for index in events.indices { diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index c79f8d3644..c4f0095078 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -101,6 +101,7 @@ final class PostHogQueueTest { encode: base.encode, decode: base.decode, describe: base.describe, + recordId: base.recordId, send: { events, completion in let send = sender?.send ?? base.send send(events) { result in @@ -311,8 +312,8 @@ final class PostHogQueueTest { sut.clear() } - @Test("retains batch on HTTP 429 and does not change cap") - func retainsBatchOnHTTP429AndDoesNotChangeCap() async { + @Test("pops batch on HTTP 429 (terminal for capture V1) and does not change cap") + func popsBatchOnHTTP429AndDoesNotChangeCap() async { let sut = getSut(flushAt: 2, maxBatchSize: 4) server.batchResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [], statusCode: 429, headers: nil) @@ -323,7 +324,7 @@ final class PostHogQueueTest { _ = getBatchedEvents(server) - await expectEventually({ sut.depth }, 2) + await expectEventually({ sut.depth }, 0) await expectEventually({ sut.currentBatchCapForTesting }, 4) sut.clear() @@ -649,6 +650,7 @@ struct PostHogQueueUploadDispositionTest { encode: base.encode, decode: base.decode, describe: base.describe, + recordId: base.recordId, canBatchTogether: base.canBatchTogether, send: sender.send, isRetriableStatusCode: base.isRetriableStatusCode @@ -680,7 +682,8 @@ struct PostHogQueueUploadDispositionTest { sender.completeRequest(at: 0, with: $0) } - let retryable = [-1, 408, 429, 503].contains(statusCode) + // 429 is terminal for capture V1 (events) but retried by /snapshot. + let retryable = [-1, 408, 503].contains(statusCode) || (snapshot && statusCode == 429) let expectedIds = retryable ? allIds : allIds.filter { !sentIds.contains($0) } #expect(queue.fileQueue.peekEntries(2).map(\.id) == expectedIds) #expect(queue.currentRetryCountForTesting == (retryable ? 1 : 0)) diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 74b5868534..4a00466e93 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -1191,11 +1191,11 @@ final class PostHogSDKTests { func callerPropertiesWinOverSdkContext() throws { let sut = getSut() - sut.capture("event", properties: ["$os_name": "caller-os", "$lib": "caller-lib"]) + sut.capture("event", properties: ["$os_name": "caller-os", "$app_version": "caller-version"]) let event = try #require(getBatchedEvents(server).first) #expect(event.properties["$os_name"] as? String == "caller-os") - #expect(event.properties["$lib"] as? String == "caller-lib") + #expect(event.properties["$app_version"] as? String == "caller-version") sut.reset() sut.close() diff --git a/PostHogTests/Resources/event-shapes-batch.json b/PostHogTests/Resources/event-shapes-batch.json index 2474ce3a34..3aee5cef35 100644 --- a/PostHogTests/Resources/event-shapes-batch.json +++ b/PostHogTests/Resources/event-shapes-batch.json @@ -1,6 +1,5 @@ { "body" : { - "api_key" : "snapshot-project-token", "batch" : [ { "distinct_id" : "anonymous-123", @@ -26,8 +25,6 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", @@ -36,7 +33,6 @@ "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 0, - "$session_id" : "00000000-0000-7000-8000-000000000001", "$set" : { "plan" : "pro" }, @@ -54,6 +50,7 @@ }, "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" }, @@ -79,8 +76,6 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", @@ -90,7 +85,6 @@ "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 1, "$sdk_debug_session_start" : "", - "$session_id" : "00000000-0000-7000-8000-000000000001", "$set" : { "email" : "person@example.com" }, @@ -101,6 +95,7 @@ "distinct_id" : "user-123", "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" }, @@ -134,8 +129,6 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", @@ -144,10 +137,10 @@ "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 2, - "$session_id" : "00000000-0000-7000-8000-000000000001", "$timezone" : "", "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" }, @@ -175,8 +168,6 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", @@ -185,7 +176,6 @@ "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 3, - "$session_id" : "00000000-0000-7000-8000-000000000001", "$set" : { "$feature_interaction\/checkout-redesign" : "test" }, @@ -194,6 +184,7 @@ "feature_flag_variant" : "test", "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" }, @@ -244,8 +235,6 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", @@ -254,28 +243,33 @@ "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 4, - "$session_id" : "00000000-0000-7000-8000-000000000001", "$timezone" : "", "handled_by" : "snapshot-test", "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" } ], - "sent_at" : "" + "created_at" : "" }, "headers" : { "accept-encoding" : "gzip", + "authorization" : "Bearer snapshot-project-token", "content-encoding" : "gzip", "content-length" : "", - "content-type" : "application\/json; charset=utf-8", + "content-type" : "application\/json", + "posthog-attempt" : "1", + "posthog-request-id" : "", + "posthog-request-timestamp" : "", + "posthog-sdk-info" : "posthog-ios\/", "user-agent" : "posthog-ios\/" }, "method" : "POST", "url" : { "host" : "localhost", - "path" : "\/batch", + "path" : "\/i\/v1\/analytics\/events", "port" : 9001, "query" : [ diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index 44cd35cfc7..b870ac8000 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -86,7 +86,10 @@ class MockPostHogServer { if let batchProjectToken { // Buffer the body: a stream-backed body can only be read once. request.httpBody = request.body() - guard parseRequest(request)?["api_key"] as? String == batchProjectToken else { return } + let token = Self.isCaptureV1(request) + ? request.value(forHTTPHeaderField: "Authorization").map { String($0.dropFirst("Bearer ".count)) } + : parseRequest(request)?["api_key"] as? String + guard token == batchProjectToken else { return } } requestsLock.lock() @@ -146,6 +149,15 @@ class MockPostHogServer { /// 1-based index of that request in the batch sequence so callers can /// vary the response (e.g. 413 then 200). var batchResponseHandler: ((URLRequest, Int) -> HTTPStubsResponse)? + /// Optional override for capture V1 (`/i/v1/analytics/events`) responses, + /// with the same arguments as `batchResponseHandler`. When `nil`, V1 uses + /// `batchResponseHandler`, then `return500`, then a 200 with per-event + /// results from `captureV1EventResult`. + var captureV1ResponseHandler: ((URLRequest, Int) -> HTTPStubsResponse)? + /// Per-event result (`ok`, `warning`, `drop`, `retry`) for the default V1 + /// 200 response, given the event UUID and the 1-based request index. + /// `nil` returns `ok` for every event. + var captureV1EventResult: ((String, Int) -> String)? var returnReplay = false var returnReplayWithVariant = false var returnReplayWithMultiVariant = false @@ -415,6 +427,27 @@ class MockPostHogServer { 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. + var request = request + request.httpBody = request.body() + let index = self.batchRequests.count + 1 + defer { self.trackBatchRequest(request) } + if let handler = self.captureV1ResponseHandler ?? self.batchResponseHandler { + return handler(request, index) + } + if self.return500 { + return HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + } + let uuids = (self.parseRequest(request)?["batch"] as? [[String: Any]] ?? []).compactMap { $0["uuid"] as? String } + var results: [String: Any] = [:] + for uuid in uuids { + results[uuid] = ["result": self.captureV1EventResult?(uuid, index) ?? "ok"] + } + return HTTPStubsResponse(jsonObject: ["results": results], statusCode: 200, headers: nil) + }) + stubDescriptors.append(stub(condition: pathEndsWith("/s")) { _ in if self.return500 { HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) @@ -572,6 +605,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" { @@ -621,6 +655,8 @@ class MockPostHogServer { errorsWhileComputingFlags = false return500 = false batchResponseHandler = nil + captureV1ResponseHandler = nil + captureV1EventResult = nil returnPushSubscription500 = false pushSubscriptionStatusCode = nil pushSubscriptionStatusHandler = nil @@ -635,15 +671,40 @@ class MockPostHogServer { return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] } + static func isCaptureV1(_ request: URLRequest) -> Bool { + request.url?.path.hasSuffix("/i/v1/analytics/events") == true + } + + /// Decodes the events of a `/batch` or capture V1 request. For V1 it puts + /// back what the server injects (`$session_id`, `$window_id`, `$lib`, + /// `$lib_version`), so event assertions don't depend on the transport. func parsePostHogEvents(_ context: URLRequest) -> [PostHogEvent] { let data = parseRequest(context) guard let batchEvents = data?["batch"] as? [[String: Any]] else { return [] } + var sdkInfo: (lib: String, version: String)? + if Self.isCaptureV1(context), + let header = context.value(forHTTPHeaderField: "PostHog-Sdk-Info"), + let slash = header.lastIndex(of: "/") + { + sdkInfo = (String(header[.. Date: Fri, 9 Oct 2026 15:17:10 -0400 Subject: [PATCH 02/67] chore: declare capture_v1 in the compliance adapter Co-Authored-By: Claude Opus 5.5 (1M context) --- sdk_compliance_adapter/Sources/main.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 1f74f01e26..41cd168ed8 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -53,11 +53,10 @@ app.get("health") { req async throws -> Response in "sdk_name": postHogSdkName, "sdk_version": postHogVersion, "adapter_version": "1.0.0", - // Declares which test suites apply. The iOS SDK posts events to /batch - // (capture_v0) with gzip; it does not implement the /i/v1/e capture_v1 - // protocol. Without this, the harness skips the capability-gated capture - // suites entirely. - "capabilities": ["capture_v0", "encoding_gzip"], + // Declares which test suites apply. The iOS SDK posts events to + // /i/v1/analytics/events (capture_v1) with gzip. Without this, the + // harness skips the capability-gated capture suites entirely. + "capabilities": ["capture_v1", "encoding_gzip"], ] print("[ADAPTER] GET /health") From ef924f0e1a1c44ebf04c933ab454ab3f72aa24db Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 15:39:28 -0400 Subject: [PATCH 03/67] chore: document capture auth header, cover fallback retry policy, map adapter init options Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 2 +- PostHog/PostHogConfig.swift | 5 ++++- PostHogTests/PostHogCaptureV1Test.swift | 4 ++++ sdk_compliance_adapter/Sources/main.swift | 8 ++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 28a6b07830..90873eb7d0 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -2,4 +2,4 @@ 'posthog-ios': major --- -**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. Only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. +**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. Only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 928185ce9f..20ec30a365 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -460,7 +460,10 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? @objc public var urlSessionConfiguration: URLSessionConfiguration? /// Custom headers to send with every request to the PostHog API. - /// Useful for reverse-proxy setups that require authentication, e.g. an `Authorization` header. + /// Useful for reverse-proxy setups that require authentication, e.g. an `X-Proxy-Token` header. + /// + /// Event capture requests always send `Authorization: Bearer `, which replaces any + /// `Authorization` header set here, so authenticate your proxy with a different header. /// Read once when the SDK is set up; changes after setup are ignored. @objc public var requestHeaders: [String: String]? diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 43af9cb14f..c951ad5d6b 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -192,12 +192,16 @@ final class PostHogCaptureV1Test { 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) diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 41cd168ed8..ce310f81b3 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -71,6 +71,8 @@ app.post("init") { req async throws -> Response in let flushAt: Int? let flushIntervalMs: Int? let maxRetries: Int? + let enableCompression: Bool? + let disableGeoip: Bool? enum CodingKeys: String, CodingKey { // Wire field name remains api_key, but it carries the PostHog project token. @@ -79,6 +81,8 @@ app.post("init") { req async throws -> Response in case flushAt = "flush_at" case flushIntervalMs = "flush_interval_ms" case maxRetries = "max_retries" + case enableCompression = "enable_compression" + case disableGeoip = "disable_geoip" } } @@ -115,6 +119,10 @@ app.post("init") { req async throws -> Response in let defaultFlushIntervalMs = config.flushAt > 1 ? 5000 : 500 config.flushIntervalSeconds = TimeInterval(initReq.flushIntervalMs ?? defaultFlushIntervalMs) / 1000.0 config.maxRetries = initReq.maxRetries ?? config.maxRetries + if initReq.enableCompression == false { + config.compression = .none + } + config.disableGeoIp = initReq.disableGeoip ?? config.disableGeoIp // Disable features for testing config.captureApplicationLifecycleEvents = false From 32ef0b955f398cbef2b4b03b4e11de8461e3d79d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 15:44:46 -0400 Subject: [PATCH 04/67] chore: log dropped duplicate UUIDs, cover a failing /batch fallback, clarify changeset Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 2 +- PostHog/PostHogApi.swift | 7 +++++-- PostHogTests/PostHogCaptureV1Test.swift | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 90873eb7d0..0398b54644 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -2,4 +2,4 @@ 'posthog-ios': major --- -**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. Only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. +**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 1db356b0f9..6ee4eedd4c 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -234,6 +234,9 @@ class PostHogApi { // The server rejects the whole batch with 400 when a UUID repeats. var seenUuids = Set() let uniqueEvents = events.filter { seenUuids.insert($0.uuid.postHogUuidString).inserted } + if uniqueEvents.count < events.count { + hedgeLog("Dropping \(events.count - uniqueEvents.count) event(s) with a duplicate UUID from the capture batch.") + } let uuids = uniqueEvents.map(\.uuid.postHogUuidString) let identity = nextCaptureV1Identity(uuids: Set(uuids)) @@ -305,12 +308,12 @@ class PostHogApi { if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { // A body without a readable results map counts as delivered, so a - // broken success can't loop forever (matches posthog-python). + // broken success can't loop forever. let results = data.flatMap { fromJSONData($0) }?["results"] as? [String: Any] if results == nil { hedgeLog("Capture returned \(statusCode) without per-event results, treating the batch as delivered.") } - // Events missing from the results count as delivered (matches posthog-rs). + // Events missing from the results count as delivered. for uuid in uuids { guard let entry = results?[uuid] as? [String: Any] else { continue } switch entry["result"] as? String { diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index c951ad5d6b..5e9425704b 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -216,6 +216,30 @@ final class PostHogCaptureV1Test { #expect(makeApi().usesCaptureV1) } + @Test("a failing /batch fallback is retried on /batch") + func failingFallbackIsRetriedOnBatch() 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.retryRecordIds == nil) + + _ = 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"]) + } + @Test("only 408 and 500/502/503/504 are retried", arguments: [ (408, true), (500, true), (502, true), (503, true), (504, true), (301, false), (400, false), (401, false), (402, false), (403, false), From 27469d7e8b6c6441a75ff650b669893350fbfbbf Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 18:04:00 -0400 Subject: [PATCH 05/67] fix: follow capture redirects only to the configured origin 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) --- .changeset/v4-capture-v1.md | 2 +- PostHog/PostHogApi.swift | 115 ++++++++++++++++++------ PostHog/PostHogQueue.swift | 9 +- PostHogTests/PostHogCaptureV1Test.swift | 95 ++++++++++++++++++++ PostHogTests/PostHogLogsQueueTest.swift | 2 +- PostHogTests/PostHogQueueTest.swift | 19 ++++ 6 files changed, 210 insertions(+), 32 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 0398b54644..7fd2dc13fd 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -2,4 +2,4 @@ 'posthog-ios': major --- -**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. +**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. Capture follows only a 307 or 308 redirect to the origin of `host` (same scheme, host and port), at most 5 times; any other redirect drops the events, so point `host` at the final host. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 6ee4eedd4c..d7fc96a8b4 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -94,13 +94,10 @@ class PostHogApi { headers["User-Agent"] = "\(postHogSdkName)/\(postHogVersion)" headers["Accept-Encoding"] = "gzip" sessionConfig.httpAdditionalHeaders = headers - // Strip custom headers on cross-host redirects so they don't leak to another origin. - if customRequestHeaders.isEmpty { - session = URLSession(configuration: sessionConfig) - } else { - let stripper = PostHogRedirectHeaderStripper(allowedHost: config.host.host, headerKeys: Array(customRequestHeaders.keys)) - session = URLSession(configuration: sessionConfig, delegate: stripper, delegateQueue: nil) - } + // Decides each redirect: capture V1 follows only the configured origin, and other + // endpoints strip custom headers on cross-host redirects so they don't leak. + let redirectHandler = PostHogRedirectHandler(host: config.host, headerKeys: Array(customRequestHeaders.keys)) + session = URLSession(configuration: sessionConfig, delegate: redirectHandler, delegateQueue: nil) } /// `gzipped: true` adds `Content-Encoding: gzip` for upload endpoints @@ -234,9 +231,6 @@ class PostHogApi { // The server rejects the whole batch with 400 when a UUID repeats. var seenUuids = Set() let uniqueEvents = events.filter { seenUuids.insert($0.uuid.postHogUuidString).inserted } - if uniqueEvents.count < events.count { - hedgeLog("Dropping \(events.count - uniqueEvents.count) event(s) with a duplicate UUID from the capture batch.") - } let uuids = uniqueEvents.map(\.uuid.postHogUuidString) let identity = nextCaptureV1Identity(uuids: Set(uuids)) @@ -275,7 +269,9 @@ class PostHogApi { return self.batch(events: events, completion: completion) } - completion(self.captureV1Result(info, data: data, uuids: uuids, identity: identity)) + completion(self.captureV1Result( + info, data: data, uuids: uuids, identity: identity, duplicates: events.count - uniqueEvents.count + )) } }.resume() } @@ -302,9 +298,12 @@ class PostHogApi { _ info: PostHogUploadInfo, data: Data?, uuids: [String], - identity: CaptureV1RequestIdentity + identity: CaptureV1RequestIdentity, + duplicates: Int ) -> PostHogUploadInfo { var retryUuids: Set = [] + // Duplicate UUIDs are dropped before sending. + var dropped = duplicates if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { // A body without a readable results map counts as delivered, so a @@ -320,8 +319,7 @@ class PostHogApi { case "retry": retryUuids.insert(uuid) case "drop": - let details = entry["details"] as? String ?? "no details" - hedgeLog("Capture dropped event \(uuid): \(details).") + dropped += 1 default: break } @@ -331,6 +329,11 @@ class PostHogApi { retryUuids = Set(uuids) } + // Counts only: event UUIDs and server details can identify users. + if dropped > 0 || !retryUuids.isEmpty { + hedgeLog("Capture: \(dropped) dropped, \(retryUuids.count) to retry.") + } + captureV1Lock.withLock { captureV1PendingRetry = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( uuids: retryUuids, requestId: identity.requestId, attempt: identity.attempt, createdAt: identity.createdAt @@ -359,11 +362,11 @@ class PostHogApi { "distinct_id": event.distinctId, "timestamp": toISO8601String(event.timestamp), ] - // Always removed from properties; only a string is valid at the root. - if let sessionId = properties.removeValue(forKey: "$session_id") as? String { + // Always removed from properties; only a non-empty string is sent at the root. + if let sessionId = properties.removeValue(forKey: "$session_id") as? String, !sessionId.isEmpty { json["session_id"] = sessionId } - if let windowId = properties.removeValue(forKey: "$window_id") as? String { + if let windowId = properties.removeValue(forKey: "$window_id") as? String, !windowId.isEmpty { json["window_id"] = windowId } json["properties"] = properties @@ -735,24 +738,47 @@ extension PostHogApi { }() } -/// Strips custom headers on redirects that leave the configured host. -private final class PostHogRedirectHeaderStripper: NSObject, URLSessionTaskDelegate { - private let allowedHost: String? +/// Session delegate that decides every redirect. +/// +/// Capture V1 requests follow only a 307 or 308 to the origin of `config.host`, at most +/// `maxCaptureV1Redirects` times, with the original headers re-set. URLSession would otherwise +/// drop `Authorization`, resend the batch to another host, or turn a 302 into a body-less GET. +/// Any other redirect is not followed, so its 3xx is the response. Other requests follow +/// redirects but lose the custom headers when they leave the configured host. +final class PostHogRedirectHandler: NSObject, URLSessionTaskDelegate { + static let maxCaptureV1Redirects = 5 + + private let host: URL private let headerKeys: [String] + private let lock = NSLock() + // Redirects followed per capture task. Weak keys drop finished tasks. + private let captureV1Hops = NSMapTable.weakToStrongObjects() - init(allowedHost: String?, headerKeys: [String]) { - self.allowedHost = allowedHost + init(host: URL, headerKeys: [String]) { + self.host = host self.headerKeys = headerKeys } func urlSession( _: URLSession, - task _: URLSessionTask, - willPerformHTTPRedirection _: HTTPURLResponse, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { - guard request.url?.host != allowedHost else { + if let original = task.originalRequest, Self.isCaptureV1(original) { + let hop = lock.withLock { () -> Int in + let hop = (captureV1Hops.object(forKey: task)?.intValue ?? 0) + 1 + captureV1Hops.setObject(NSNumber(value: hop), forKey: task) + return hop + } + completionHandler(Self.captureV1Redirect( + original: original, statusCode: response.statusCode, newRequest: request, host: host, hop: hop + )) + return + } + + guard request.url?.host != host.host else { completionHandler(request) return } @@ -762,6 +788,45 @@ private final class PostHogRedirectHeaderStripper: NSObject, URLSessionTaskDeleg } completionHandler(redirected) } + + /// Capture V1 requests are the only ones that carry `PostHog-Request-Id`, + /// which custom headers can't set. + static func isCaptureV1(_ request: URLRequest) -> Bool { + request.value(forHTTPHeaderField: "PostHog-Request-Id") != nil + } + + /// The request to follow for the `hop`th redirect of a capture V1 request, or `nil` to stop + /// and return the 3xx. + static func captureV1Redirect( + original: URLRequest, + statusCode: Int, + newRequest: URLRequest, + host: URL, + hop: Int + ) -> URLRequest? { + guard [307, 308].contains(statusCode), + hop <= maxCaptureV1Redirects, + let target = newRequest.url, + let targetOrigin = origin(target), + targetOrigin == origin(host) + else { + hedgeLog("Capture did not follow a \(statusCode) redirect.") + return nil + } + var redirected = newRequest + redirected.httpMethod = original.httpMethod + // Same origin, so every original header is safe to resend. + for (key, value) in original.allHTTPHeaderFields ?? [:] { + redirected.setValue(value, forHTTPHeaderField: key) + } + return redirected + } + + private static func origin(_ url: URL) -> String? { + guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil } + let port = url.port ?? (scheme == "https" ? 443 : 80) + return "\(scheme)://\(host):\(port)" + } } /// Identity of a capture V1 request, kept so a retry of the same events reuses diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index cd3d333a31..d9c8acc611 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -198,11 +198,10 @@ class PostHogQueue { private func pauseForBackoff(retryAfter: TimeInterval?) { let newCount = nextRetryCount() let backoffDelay = min(TimeInterval(newCount) * retryDelay, maxRetryDelay) - var retryAfter = retryAfter ?? 0 - if let maxRetryAfter = endpoint.maxRetryAfterSeconds { - retryAfter = min(retryAfter, maxRetryAfter) - } - let delay = max(backoffDelay, retryAfter) + // A server's Retry-After can't hold the queue longer than the backoff ceiling + // or the endpoint's documented maximum, whichever is lower. + let retryAfterCap = min(maxRetryDelay, endpoint.maxRetryAfterSeconds ?? maxRetryDelay) + let delay = max(backoffDelay, min(retryAfter ?? 0, retryAfterCap)) pauseFor(seconds: delay) hedgeLog("Pausing queue consumption for \(delay) seconds due to \(newCount) API failure(s).") } diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 5e9425704b..67afcdfda4 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -240,6 +240,101 @@ final class PostHogCaptureV1Test { #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/batch", "/proxy/batch"]) } + @Test("omits empty session and window IDs from the root and properties") + func omitsEmptySessionIds() async throws { + let event = PostHogEvent(event: "test", distinctId: "user", properties: ["$session_id": "", "$window_id": ""]) + _ = await send(makeApi(), [event]) + + let request = try #require(server.batchRequests.first) + let sent = try #require((try body(request)["batch"] as? [[String: Any]])?.first) + #expect(sent["session_id"] == nil) + #expect(sent["window_id"] == nil) + let properties = try #require(sent["properties"] as? [String: Any]) + #expect(properties["$session_id"] == nil) + #expect(properties["$window_id"] == nil) + } + + @Test("follows a same-origin 307 or 308 with the same body and headers", arguments: [307, 308]) + func followsSameOriginRedirect(statusCode: Int) async throws { + server.captureV1ResponseHandler = { _, index in + index == 1 + ? HTTPStubsResponse(data: Data(), statusCode: Int32(statusCode), headers: [ + "Location": "http://localhost:9001/moved/i/v1/analytics/events", + ]) + : HTTPStubsResponse(jsonObject: ["results": [:]], statusCode: 200, headers: nil) + } + + let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + + #expect(result.statusCode == 200) + let requests = server.batchRequests + try #require(requests.count == 2) + #expect(requests[1].url?.path == "/moved/i/v1/analytics/events") + #expect(requests[1].httpMethod == "POST") + #expect(requests[1].httpBody == requests[0].httpBody) + for header in ["Authorization", "PostHog-Request-Id", "PostHog-Attempt", "PostHog-Sdk-Info", "Content-Encoding"] { + #expect(requests[1].value(forHTTPHeaderField: header) == requests[0].value(forHTTPHeaderField: header)) + } + } + + @Test("returns other redirects without following them", arguments: [ + (301, "http://localhost:9001/moved/i/v1/analytics/events"), + (302, "http://localhost:9001/moved/i/v1/analytics/events"), + (303, "http://localhost:9001/moved/i/v1/analytics/events"), + (307, "http://other.example.com/i/v1/analytics/events"), + (308, "https://localhost:9001/i/v1/analytics/events"), + (307, "http://localhost:9002/i/v1/analytics/events"), + ]) + func doesNotFollowOtherRedirects(statusCode: Int, location: String) async throws { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(data: Data(), statusCode: Int32(statusCode), headers: ["Location": location]) + } + + let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + + #expect(result.statusCode == statusCode) + #expect(server.batchRequests.count == 1) + #expect(!QueueEndpoint.batch(api: makeApi()).isRetriableStatusCode(statusCode)) + } + + @Test("stops after 5 redirects") + func stopsAfterMaxRedirects() async { + // OHHTTPStubs doesn't resolve a relative `Location`; URLSession does before asking the delegate. + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(data: Data(), statusCode: 307, headers: ["Location": "http://localhost:9001/proxy/i/v1/analytics/events"]) + } + + let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + + #expect(result.statusCode == 307) + #expect(server.batchRequests.count == PostHogRedirectHandler.maxCaptureV1Redirects + 1) + } + + @Test("re-sets the original headers on a followed redirect") + func redirectResetsHeaders() throws { + let host = try #require(URL(string: "https://us.i.posthog.com")) + var original = URLRequest(url: host.appendingPathComponent("i/v1/analytics/events")) + original.httpMethod = "POST" + original.setValue("Bearer phc_test", forHTTPHeaderField: "Authorization") + original.setValue("request-id", forHTTPHeaderField: "PostHog-Request-Id") + original.setValue("2", forHTTPHeaderField: "PostHog-Attempt") + // What URLSession hands the delegate: the new URL without `Authorization`. + let stripped = try URLRequest(url: #require(URL(string: "https://US.i.posthog.com:443/next"))) + + let redirected = try #require(PostHogRedirectHandler.captureV1Redirect( + original: original, statusCode: 308, newRequest: stripped, host: host, hop: 5 + )) + #expect(redirected.url?.path == "/next") + #expect(redirected.httpMethod == "POST") + #expect(redirected.value(forHTTPHeaderField: "Authorization") == "Bearer phc_test") + #expect(redirected.value(forHTTPHeaderField: "PostHog-Request-Id") == "request-id") + #expect(redirected.value(forHTTPHeaderField: "PostHog-Attempt") == "2") + + #expect(PostHogRedirectHandler.captureV1Redirect( + original: original, statusCode: 308, newRequest: stripped, host: host, hop: 6 + ) == nil) + } + @Test("only 408 and 500/502/503/504 are retried", arguments: [ (408, true), (500, true), (502, true), (503, true), (504, true), (301, false), (400, false), (401, false), (402, false), (403, false), diff --git a/PostHogTests/PostHogLogsQueueTest.swift b/PostHogTests/PostHogLogsQueueTest.swift index 60afdf51c5..d36c6f9d5b 100644 --- a/PostHogTests/PostHogLogsQueueTest.swift +++ b/PostHogTests/PostHogLogsQueueTest.swift @@ -328,7 +328,7 @@ final class PostHogLogsQueueTests { } @Test("Retry-After floors the backoff, clamped to the documented maximum", arguments: [ - ("3600", logsMaxRetryAfterSeconds), + ("3600", min(logsMaxRetryAfterSeconds, maxRetryDelay)), ("30", 30.0), ]) func retryAfterIsClampedThenFloored(header: String, expectedWait: TimeInterval) async throws { diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index c4f0095078..e7169507f0 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -312,6 +312,25 @@ final class PostHogQueueTest { sut.clear() } + @Test("caps Retry-After at the maximum retry delay", arguments: [("5", 5.0), ("3600", maxRetryDelay)]) + func capsRetryAfter(retryAfter: String, expectedDelay: TimeInterval) async throws { + try await withMockedClock { clock in + let uploaded = AsyncLatch() + let sut = getSut(flushAt: 100) { uploaded.signal() } + server.batchResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 503, headers: ["Retry-After": retryAfter]) + } + + sut.add(PostHogEvent(event: "event", distinctId: "id")) + sut.flush() + await expectSignaled(uploaded) + + let pausedUntil = try #require(sut.pausedUntilForTesting) + #expect(abs(pausedUntil.timeIntervalSince(clock.date) - expectedDelay) < 0.001) + sut.clear() + } + } + @Test("pops batch on HTTP 429 (terminal for capture V1) and does not change cap") func popsBatchOnHTTP429AndDoesNotChangeCap() async { let sut = getSut(flushAt: 2, maxBatchSize: 4) From 804b1e9e3b4b0afd1cb83fc3a80328076494477b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 18:16:45 -0400 Subject: [PATCH 06/67] feat!: add per-event capture options 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) --- .changeset/v4-capture-v1.md | 2 + PostHog/Models/PostHogEvent.swift | 27 ++- PostHog/PostHogApi.swift | 47 ++++- PostHog/PostHogSDK.swift | 68 ++++++- PostHogTests/PostHogCaptureV1Test.swift | 190 +++++++++++++++++- PostHogTests/PostHogSDKTest.swift | 22 ++ .../Resources/event-shapes-batch.json | 20 +- .../TestUtils/MockPostHogServer.swift | 7 +- api/posthog-ios.public-api.txt | 4 +- sdk_compliance_adapter/Sources/main.swift | 12 +- 10 files changed, 379 insertions(+), 20 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 7fd2dc13fd..98a31fa74a 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -3,3 +3,5 @@ --- **Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. Capture follows only a 307 or 308 redirect to the origin of `host` (same scheme, host and port), at most 5 times; any other redirect drops the events, so point `host` at the final host. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. + +Add per-event capture options: `capture(..., timestamp:options:)` (Objective-C `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`) and `PostHogEvent.options`, which `beforeSend` can read and change. Known keys are `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id`; other keys are sent unchanged. The legacy `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as their options instead of as properties, and an option you set wins over its legacy property. PostHog drops an event whose option value it can't read. diff --git a/PostHog/Models/PostHogEvent.swift b/PostHog/Models/PostHogEvent.swift index c200a8f587..c4d88db0f3 100644 --- a/PostHog/Models/PostHogEvent.swift +++ b/PostHog/Models/PostHogEvent.swift @@ -22,6 +22,15 @@ import Foundation /// Event properties that will be serialized with the event. @objc public var properties: [String: Any] + /// Options that tell PostHog how to process this event, separate from its properties. + /// + /// Holds the options passed to `capture(..., options:)`. Known keys are `process_person_profile`, + /// `cookieless_mode`, `disable_skew_correction` and `product_tour_id`; unknown keys are sent + /// unchanged. When the event is sent, the legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` and `$product_tour_id` properties are removed from `properties` and used + /// for their options, unless that option is set here. + @objc public var options: [String: Any] + /// Event timestamp. The absolute instant is serialized in UTC, regardless of the calendar or /// time zone used to create it. @objc public var timestamp: Date @@ -35,10 +44,19 @@ import Foundation /// Set by the SDK when this event took the throttled replay debug bundle, so it survives `beforeSend` renames or replacement. var carriesReplayDebugBundle = false - init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), projectToken: String? = nil) { + init( + event: String, + distinctId: String, + properties: [String: Any]? = nil, + options: [String: Any]? = nil, + timestamp: Date = Date(), + uuid: UUID = UUID.v7(), + projectToken: String? = nil + ) { self.event = event self.distinctId = distinctId self.properties = properties ?? [:] + self.options = options ?? [:] self.timestamp = timestamp self.uuid = uuid self.projectToken = projectToken @@ -75,6 +93,8 @@ import Foundation event: event, distinctId: distinctId, properties: properties, + // Events queued by older versions have no options. + options: json["options"] as? [String: Any], timestamp: timestampDate, uuid: uuidObj, projectToken: projectToken @@ -90,6 +110,11 @@ import Foundation "uuid": uuid.postHogUuidString, ] + // Stored for the queue only: each endpoint shapes its own wire format. + if !options.isEmpty { + json["options"] = options + } + if let projectToken { // Wire field name remains api_key, but it carries the PostHog project token. json["api_key"] = projectToken diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index d7fc96a8b4..18cf70edcc 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -192,7 +192,7 @@ class PostHogApi { let toSend: [String: Any] = [ // Wire field name remains api_key, but it carries the PostHog project token. "api_key": config.projectToken, - "batch": events.map { $0.toJSON() }, + "batch": events.map(Self.batchJSON), "sent_at": toISO8601String(Date()), ] @@ -348,14 +348,51 @@ class PostHogApi { ) } + /// Legacy properties and the capture V1 option each one fills, in the + /// order posthog-python, posthog-go and posthog-rs use. + static let legacyOptionProperties: [(property: String, option: String)] = [ + ("$cookieless_mode", "cookieless_mode"), + ("$ignore_sent_at", "disable_skew_correction"), + ("$product_tour_id", "product_tour_id"), + ("$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`. + /// + /// Legacy option properties are hoisted here, after `beforeSend`, so events + /// queued by older versions are hoisted too. Each is always removed from + /// properties and fills its option only when the option is missing or null. + /// Values aren't coerced: the server validates them. private static func captureV1JSON(_ event: PostHogEvent) -> [String: Any] { var properties = event.properties properties.removeValue(forKey: "$lib") properties.removeValue(forKey: "$lib_version") + var options = event.options + for (property, option) in legacyOptionProperties { + guard let legacy = properties.removeValue(forKey: property) else { continue } + if options[option] == nil || options[option] is NSNull { + options[option] = legacy + } + } + var json: [String: Any] = [ "event": event.event, "uuid": event.uuid.postHogUuidString, @@ -369,6 +406,7 @@ class PostHogApi { if let windowId = properties.removeValue(forKey: "$window_id") as? String, !windowId.isEmpty { json["window_id"] = windowId } + json["options"] = options json["properties"] = properties return json } @@ -383,7 +421,12 @@ class PostHogApi { event.projectToken = config.projectToken } - let toSend = events.map { $0.toJSON() } + // Options are an analytics capture field; replay doesn't send them. + let toSend = events.map { event -> [String: Any] in + var json = event.toJSON() + json.removeValue(forKey: "options") + return json + } guard let data = try? JSONSerialization.data(withJSONObject: toSend) else { hedgeLog("Error parsing the snapshot body") diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 432916837e..78e2e89d21 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1381,7 +1381,7 @@ let maxRetryDelay = 30.0 timestamp: nil) } - /// Captures a custom event for an explicit distinct ID and timestamp. + /// Captures a custom event for an explicit distinct ID, timestamp and options. /// /// - Parameters: /// - event: Event name to capture. @@ -1392,14 +1392,29 @@ let maxRetryDelay = 30.0 /// - groups: Group type/key pairs to attach to this event. /// - timestamp: Optional event timestamp. Defaults to the current time. The absolute instant /// is serialized in UTC, regardless of the calendar or time zone used to create it. - @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) + /// - options: Optional settings that tell PostHog how to process this event, sent apart from + /// its properties. Known keys: + /// - `process_person_profile` (`Bool`): whether the event creates or updates a person profile. + /// Defaults to the SDK's `personProfiles` setting. + /// - `cookieless_mode` (`Bool`): process the event in cookieless mode. + /// - `disable_skew_correction` (`Bool`): keep `timestamp` as sent, without correcting for + /// device clock skew. + /// - `product_tour_id` (`String`): the product tour the event belongs to. + /// + /// These replace the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and + /// `$product_tour_id` properties. Those properties still work: they are removed from the + /// event's properties and used as the option, unless you set that option here. Other keys + /// are sent unchanged. PostHog drops an event whose option value it can't read. + /// `beforeSend` can change them through `PostHogEvent.options`. + @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) public func capture(_ event: String, distinctId: String? = nil, properties: [String: Any]? = nil, userProperties: [String: Any]? = nil, userPropertiesSetOnce: [String: Any]? = nil, groups: [String: String]? = nil, - timestamp: Date? = nil) + timestamp: Date? = nil, + options: [String: Any]? = nil) { captureInternal( event, @@ -1409,10 +1424,42 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: userPropertiesSetOnce, groups: groups, timestamp: timestamp, + options: options, skipBuildProperties: false ) } + /// Captures a custom event for an explicit distinct ID and timestamp from Objective-C. + /// + /// - Parameters: + /// - event: Event name to capture. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID. + /// - properties: Event properties attached only to this event. + /// - userProperties: Person properties to set. Existing values are overwritten. + /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. + /// - groups: Group type/key pairs to attach to this event. + /// - timestamp: Optional event timestamp. Defaults to the current time. + // A separate Swift name keeps this out of Swift overload resolution. + @available(swift, obsoleted: 1.0, message: "Use capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) instead") + @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) + public func captureObjC(_ event: String, // swiftlint:disable:this function_parameter_count + distinctId: String?, + properties: [String: Any]?, + userProperties: [String: Any]?, + userPropertiesSetOnce: [String: Any]?, + groups: [String: String]?, + timestamp: Date?) + { + capture(event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + options: nil) + } + // MARK: - Logs capture /// Capture a structured log record at `.info` severity. @@ -1565,6 +1612,7 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: [String: Any]? = nil, groups: [String: String]? = nil, timestamp: Date? = nil, + options: [String: Any]? = nil, skipBuildProperties: Bool = false, propertyAllowlist: Set? = nil, deduplicatePersonProperties: Bool = false @@ -1648,6 +1696,7 @@ let maxRetryDelay = 30.0 event: event, distinctId: eventDistinctId, properties: finalProperties, + options: sanitizeDictionary(options), timestamp: eventTimestamp ) @@ -1929,7 +1978,13 @@ let maxRetryDelay = 30.0 } } - func buildEvent(event eventName: String, distinctId: String, properties: [String: Any], timestamp: Date = Date()) -> PostHogEvent? { + func buildEvent( + event eventName: String, + distinctId: String, + properties: [String: Any], + options: [String: Any]? = nil, + timestamp: Date = Date() + ) -> PostHogEvent? { var properties = properties let carriesReplayDebugBundle = properties.removeValue(forKey: Self.replayDebugClaimMarkerKey) != nil @@ -1937,6 +1992,7 @@ let maxRetryDelay = 30.0 event: eventName, distinctId: distinctId, properties: properties, + options: options, timestamp: timestamp ) @@ -2629,6 +2685,10 @@ let maxRetryDelay = 30.0 "$feature_flag_evaluated_at", "$groups", "$process_person_profile", + // Legacy option properties, e.g. registered as super properties, so they still become options. + "$cookieless_mode", + "$ignore_sent_at", + "$product_tour_id", "$session_id", "$lib", "$lib_version", diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 67afcdfda4..128ce58b45 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -91,14 +91,12 @@ final class PostHogCaptureV1Test { #expect(sent["timestamp"] as? String == toISO8601String(event.timestamp)) #expect(sent["session_id"] as? String == "session-1") #expect(sent["window_id"] as? String == "window-1") - #expect(sent["options"] == nil) + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": false]) let properties = try #require(sent["properties"] as? [String: Any]) - for key in ["$session_id", "$window_id", "$lib", "$lib_version"] { + for key in ["$session_id", "$window_id", "$lib", "$lib_version", "$process_person_profile"] { #expect(properties[key] == nil, "\(key) should not be in properties") } - // Options hoisting is not part of this transport change. - #expect(properties["$process_person_profile"] as? Bool == false) #expect(properties["custom"] as? String == "value") // The stored event is not reshaped. @@ -346,6 +344,190 @@ final class PostHogCaptureV1Test { } } +@Suite("Capture V1 event options", .serialized, .resetsGlobalState) +final class PostHogCaptureV1OptionsTest { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + private func makeApi() -> PostHogApi { + PostHogApi(PostHogConfig(projectToken: "phc_options", host: "http://localhost:9001")) + } + + /// Sends `event` through capture V1 and returns its wire JSON. + private func sentEvent(_ event: PostHogEvent, api: PostHogApi? = nil) async throws -> [String: Any] { + let api = api ?? makeApi() + await withCheckedContinuation { continuation in + api.captureV1(events: [event]) { _ in continuation.resume() } + } + let request = try #require(server.batchRequests.last) + return try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.first) + } + + @Test("hoists each legacy property into its option", arguments: [ + ("$cookieless_mode", "cookieless_mode"), + ("$ignore_sent_at", "disable_skew_correction"), + ("$product_tour_id", "product_tour_id"), + ("$process_person_profile", "process_person_profile"), + ]) + func hoistsLegacyProperty(property: String, option: String) async throws { + let sent = try await sentEvent(PostHogEvent(event: "test", distinctId: "user", properties: [property: "value"])) + + #expect(sent["options"] as? [String: String] == [option: "value"]) + #expect((sent["properties"] as? [String: Any])?[property] == nil) + } + + @Test("an option wins over its legacy property, which is still removed; a null option is filled") + func optionWinsOverLegacyProperty() async throws { + let event = PostHogEvent( + event: "test", + distinctId: "user", + properties: ["$cookieless_mode": false, "$product_tour_id": "legacy-tour", "custom": 1], + options: ["cookieless_mode": true, "product_tour_id": NSNull(), "future_option": "kept"] + ) + + let sent = try await sentEvent(event) + + let options = try #require(sent["options"] as? [String: Any]) + #expect(options["cookieless_mode"] as? Bool == true) + #expect(options["product_tour_id"] as? String == "legacy-tour") + #expect(options["future_option"] as? String == "kept") + #expect(options.count == 3) + let properties = try #require(sent["properties"] as? [String: Any]) + #expect(Set(properties.keys) == ["custom"]) + // The stored event is not reshaped. + #expect(event.properties["$cookieless_mode"] as? Bool == false) + } + + @Test("sends empty options as {}") + func sendsEmptyOptions() async throws { + let sent = try await sentEvent(PostHogEvent(event: "test", distinctId: "user")) + + #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]) + await withCheckedContinuation { continuation in + makeApi().snapshot(events: [event]) { _ in continuation.resume() } + } + + let request = try #require(server.snapshotRequests.last) + let body = try #require(request.body().flatMap { try? $0.gunzipped() }) + let sent = try #require((try JSONSerialization.jsonObject(with: body) as? [[String: Any]])?.first) + #expect(sent["options"] == nil) + #expect(sent["properties"] != nil) + } + + @Test("options survive the queue's disk format, and events stored without options still decode") + func persistsOptions() throws { + let event = PostHogEvent(event: "test", distinctId: "user", options: ["cookieless_mode": true]) + let data = try #require(toJSONData(event.toJSON())) + let decoded = try #require(PostHogEvent.fromJSON(data)) + #expect(decoded.options as? [String: Bool] == ["cookieless_mode": true]) + + var legacy = PostHogEvent(event: "old", distinctId: "user", properties: ["$cookieless_mode": true]).toJSON() + #expect(legacy["options"] == nil) + legacy.removeValue(forKey: "options") + let old = try #require(PostHogEvent.fromJSON(legacy)) + #expect(old.options.isEmpty) + #expect(old.properties["$cookieless_mode"] as? Bool == true) + } + + private func makeSDK(personProfiles: PostHogPersonProfiles = .identifiedOnly, beforeSend: BeforeSendBlock? = nil) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_options_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 1 + config.preloadFeatureFlags = false + config.sendFeatureFlagEvent = false + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + config.personProfiles = personProfiles + if let beforeSend { + config.setBeforeSend(beforeSend) + } + server.batchProjectToken = config.projectToken + return PostHogSDK.with(config) + } + + /// Captures one event and returns its wire JSON. + private func captureAndSend(_ sut: PostHogSDK, _ capture: (PostHogSDK) -> Void) async throws -> [String: Any] { + server.reset(batchCount: 1) + capture(sut) + _ = try await getServerEvents(server) + let request = try #require(server.batchRequests.first) + return try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.first) + } + + @Test("the SDK's process_person_profile beats a caller property but not a caller option") + func processPersonProfilePrecedence() async throws { + let sut = makeSDK(personProfiles: .never) + defer { sut.close() } + + var sent = try await captureAndSend(sut) { $0.capture("test", properties: ["$process_person_profile": true]) } + #expect((sent["options"] as? [String: Any])?["process_person_profile"] as? Bool == false) + #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + + sent = try await captureAndSend(sut) { + $0.capture("test", options: ["process_person_profile": true, "cookieless_mode": true]) + } + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": true, "cookieless_mode": true]) + } + + @Test("beforeSend has the final say over options") + func beforeSendEditsOptions() async throws { + let sut = makeSDK(personProfiles: .never) { event in + #expect(event.options["product_tour_id"] as? String == "caller-tour") + event.options["process_person_profile"] = true + event.options["product_tour_id"] = "edited-tour" + return event + } + defer { sut.close() } + + let sent = try await captureAndSend(sut) { $0.capture("test", options: ["product_tour_id": "caller-tour"]) } + + #expect(sent["options"] as? [String: AnyHashable] == ["process_person_profile": true, "product_tour_id": "edited-tour"]) + } +} + @Suite("Capture V1 queue partial retry", .serialized, .resetsGlobalState) final class PostHogCaptureV1QueueTest { private let server: MockPostHogServer diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 4a00466e93..0459a3e315 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -914,6 +914,28 @@ final class PostHogSDKTests { sut.close() } + @Test("keeps legacy option properties on minimal feature flag events") + func keepsLegacyOptionPropertiesOnMinimalFeatureFlagEvents() throws { + server.minimalFlagCalledEvents = true + let sut = getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) + sut.register(["$cookieless_mode": true, "$ignore_sent_at": true, "$product_tour_id": "tour"]) + + waitForFeatureFlagsLoaded(server, sut) + #expect(sut.isFeatureEnabled("string-value") == true) + + let events = getBatchedEvents(server) + + let event = try #require(events.first) + #expect(event.event == "$feature_flag_called") + // They reach capture V1 as options. + #expect(event.options["cookieless_mode"] as? Bool == true) + #expect(event.options["disable_skew_correction"] as? Bool == true) + #expect(event.options["product_tour_id"] as? String == "tour") + + sut.reset() + sut.close() + } + @Test("keeps $groups on minimal feature flag events") func keepsGroupsOnMinimalFeatureFlagEvents() throws { server.minimalFlagCalledEvents = true diff --git a/PostHogTests/Resources/event-shapes-batch.json b/PostHogTests/Resources/event-shapes-batch.json index 3aee5cef35..37c5efda7a 100644 --- a/PostHogTests/Resources/event-shapes-batch.json +++ b/PostHogTests/Resources/event-shapes-batch.json @@ -4,6 +4,9 @@ { "distinct_id" : "anonymous-123", "event" : "order completed", + "options" : { + "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -30,7 +33,6 @@ "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 0, "$set" : { @@ -57,6 +59,9 @@ { "distinct_id" : "user-123", "event" : "$identify", + "options" : { + "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -81,7 +86,6 @@ "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 1, "$sdk_debug_session_start" : "", @@ -102,6 +106,9 @@ { "distinct_id" : "user-123", "event" : "$groupidentify", + "options" : { + "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -134,7 +141,6 @@ "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 2, "$timezone" : "", @@ -147,6 +153,9 @@ { "distinct_id" : "user-123", "event" : "$feature_interaction", + "options" : { + "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -173,7 +182,6 @@ "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 3, "$set" : { @@ -191,6 +199,9 @@ { "distinct_id" : "user-123", "event" : "$exception", + "options" : { + "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -240,7 +251,6 @@ "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 4, "$timezone" : "", diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index b870ac8000..c2c422cec9 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -677,7 +677,8 @@ class MockPostHogServer { /// Decodes the events of a `/batch` or capture V1 request. For V1 it puts /// back what the server injects (`$session_id`, `$window_id`, `$lib`, - /// `$lib_version`), so event assertions don't depend on the transport. + /// `$lib_version` and the legacy property of each known option), so event + /// assertions don't depend on the transport. V1 events keep `options` too. func parsePostHogEvents(_ context: URLRequest) -> [PostHogEvent] { let data = parseRequest(context) guard let batchEvents = data?["batch"] as? [[String: Any]] else { @@ -703,6 +704,10 @@ class MockPostHogServer { properties["$lib"] = sdkInfo.lib properties["$lib_version"] = sdkInfo.version } + let options = event["options"] as? [String: Any] ?? [:] + for (property, option) in PostHogApi.legacyOptionProperties { + properties[property] = options[option] + } event["properties"] = properties } guard let posthogEvent = PostHogEvent.fromJSON(event) else { continue } diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 946179d2e5..73e115d622 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -167,6 +167,7 @@ PostHog | PostHogErrorTrackingConfig.init() | init | override init() | c:@M@Post PostHog | PostHogEvent | class | @objc(PostHogEvent) class PostHogEvent | c:@M@PostHog@objc(cs)PostHogEvent PostHog | PostHogEvent.distinctId | property | @objc var distinctId: String | c:@M@PostHog@objc(cs)PostHogEvent(py)distinctId PostHog | PostHogEvent.event | property | @objc var event: String | c:@M@PostHog@objc(cs)PostHogEvent(py)event +PostHog | PostHogEvent.options | property | @objc var options: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)options PostHog | PostHogEvent.properties | property | @objc var properties: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)properties PostHog | PostHogEvent.timestamp | property | @objc var timestamp: Date | c:@M@PostHog@objc(cs)PostHogEvent(py)timestamp PostHog | PostHogEvent.uuid | property | @objc var uuid: UUID { get } | c:@M@PostHog@objc(cs)PostHogEvent(py)uuid @@ -251,7 +252,7 @@ PostHog | PostHogSDK.addExceptionStep(_:properties:) | method | @objc(addExcepti PostHog | PostHogSDK.alias(_:) | method | @objc func alias(_ alias: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias: PostHog | PostHogSDK.capture(_:) | method | @objc func capture(_ event: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)capture: PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups: -PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: +PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.capture(_:properties:) | method | @objc(captureWithEvent:properties:) func capture(_ event: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties: PostHog | PostHogSDK.capture(_:properties:userProperties:) | method | @objc(captureWithEvent:properties:userProperties:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce: @@ -272,6 +273,7 @@ PostHog | PostHogSDK.captureLog(_:level:) | method | @objc(captureLogWithBody:le PostHog | PostHogSDK.captureLog(_:level:attributes:) | method | @objc(captureLogWithBody:level:attributes:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes: PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | @objc(captureLogWithBody:level:attributes:traceId:spanId:traceFlags:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?, traceId: String?, spanId: String?, traceFlags: NSNumber?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes:traceId:spanId:traceFlags: PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | func captureLog(_ body: String, level: PostHogLogSeverity = .info, attributes: [String : Any]? = nil, traceId: String? = nil, spanId: String? = nil, traceFlags: Int? = nil) | s:7PostHog0aB3SDKC10captureLog_5level10attributes7traceId04spanI00H5FlagsySS_AA0abE8SeverityOSDySSypGSgSSSgANSiSgtF +PostHog | PostHogSDK.captureObjC(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func captureObjC(_ event: String, distinctId: String?, properties: [String : Any]?, userProperties: [String : Any]?, userPropertiesSetOnce: [String : Any]?, groups: [String : String]?, timestamp: Date?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: PostHog | PostHogSDK.capturePushNotificationOpened(response:) | method | @objc func capturePushNotificationOpened(response: UNNotificationResponse) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithResponse: PostHog | PostHogSDK.capturePushNotificationOpened(title:subtitle:body:payload:action:) | method | @objc func capturePushNotificationOpened(title: String? = nil, subtitle: String? = nil, body: String? = nil, payload: [AnyHashable : Any]? = nil, action: String? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithTitle:subtitle:body:payload:action: PostHog | PostHogSDK.close() | method | @objc func close() | c:@M@PostHog@objc(cs)PostHogSDK(im)close diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index ce310f81b3..848312bae9 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -56,7 +56,7 @@ app.get("health") { req async throws -> Response in // Declares which test suites apply. The iOS SDK posts events to // /i/v1/analytics/events (capture_v1) with gzip. Without this, the // harness skips the capability-gated capture suites entirely. - "capabilities": ["capture_v1", "encoding_gzip"], + "capabilities": ["capture_v1", "encoding_gzip", "event_options"], ] print("[ADAPTER] GET /health") @@ -174,12 +174,14 @@ app.post("capture") { req async throws -> Response in let distinctId: String? let properties: [String: AnyCodable]? let timestamp: String? + let options: [String: AnyCodable]? enum CodingKeys: String, CodingKey { case event case distinctId = "distinct_id" case properties case timestamp + case options } } @@ -202,7 +204,13 @@ app.post("capture") { req async throws -> Response in // This ensures the distinct_id is set for THIS event, not globally // The harness may send a non-UTC offset; parsing keeps the instant, and the SDK serializes it as UTC. let timestamp = captureReq.timestamp.flatMap(parseHarnessTimestamp) - sdk.capture(captureReq.event, distinctId: captureReq.distinctId, properties: props, timestamp: timestamp) + sdk.capture( + captureReq.event, + distinctId: captureReq.distinctId, + properties: props, + timestamp: timestamp, + options: captureReq.options?.mapValues(\.value) + ) print("[ADAPTER] Event captured: \(captureReq.event)") From bc852815033ccc786eb51648203ebe3679e4caf7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 18:26:58 -0400 Subject: [PATCH 07/67] docs: clarify capture options precedence and the Retry-After cap Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 4 ++-- PostHog/Models/PostHogEvent.swift | 2 +- PostHog/PostHogSDK.swift | 14 +++++++++----- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 98a31fa74a..c754ef0d1c 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -2,6 +2,6 @@ 'posthog-ios': major --- -**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. Capture follows only a 307 or 308 redirect to the origin of `host` (same scheme, host and port), at most 5 times; any other redirect drops the events, so point `host` at the final host. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. +**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. A server `Retry-After` now pauses sending for at most 30 seconds. Capture V1 follows only a 307 or 308 redirect to the origin of `host` (same scheme, host and port), at most 5 times; any other redirect drops the events, so point `host` at the final host. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. -Add per-event capture options: `capture(..., timestamp:options:)` (Objective-C `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`) and `PostHogEvent.options`, which `beforeSend` can read and change. Known keys are `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id`; other keys are sent unchanged. The legacy `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as their options instead of as properties, and an option you set wins over its legacy property. PostHog drops an event whose option value it can't read. +Add per-event capture options: `capture(..., timestamp:options:)` (Objective-C `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`) and `PostHogEvent.options`, which `beforeSend` can read and change. Known keys are `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id`; other keys are sent unchanged and ignored by PostHog. The legacy `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as their options instead of as properties, and an option you set wins over its legacy property. PostHog drops an event whose option value it can't read. diff --git a/PostHog/Models/PostHogEvent.swift b/PostHog/Models/PostHogEvent.swift index c4d88db0f3..6e1c5f3e6f 100644 --- a/PostHog/Models/PostHogEvent.swift +++ b/PostHog/Models/PostHogEvent.swift @@ -26,7 +26,7 @@ import Foundation /// /// Holds the options passed to `capture(..., options:)`. Known keys are `process_person_profile`, /// `cookieless_mode`, `disable_skew_correction` and `product_tour_id`; unknown keys are sent - /// unchanged. When the event is sent, the legacy `$process_person_profile`, `$cookieless_mode`, + /// unchanged and ignored by PostHog. When the event is sent, the legacy `$process_person_profile`, `$cookieless_mode`, /// `$ignore_sent_at` and `$product_tour_id` properties are removed from `properties` and used /// for their options, unless that option is set here. @objc public var options: [String: Any] diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 78e2e89d21..913b283a29 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1395,7 +1395,8 @@ let maxRetryDelay = 30.0 /// - options: Optional settings that tell PostHog how to process this event, sent apart from /// its properties. Known keys: /// - `process_person_profile` (`Bool`): whether the event creates or updates a person profile. - /// Defaults to the SDK's `personProfiles` setting. + /// Defaults to whether the SDK currently processes person profiles for this user (see + /// `personProfiles`). /// - `cookieless_mode` (`Bool`): process the event in cookieless mode. /// - `disable_skew_correction` (`Bool`): keep `timestamp` as sent, without correcting for /// device clock skew. @@ -1403,8 +1404,10 @@ let maxRetryDelay = 30.0 /// /// These replace the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and /// `$product_tour_id` properties. Those properties still work: they are removed from the - /// event's properties and used as the option, unless you set that option here. Other keys - /// are sent unchanged. PostHog drops an event whose option value it can't read. + /// event's properties and used as the option, unless you set that option here. The SDK + /// always sets `$process_person_profile` itself, so pass the `process_person_profile` option + /// to override it. Other keys are sent unchanged and ignored by PostHog. PostHog drops an + /// event whose option value it can't read. /// `beforeSend` can change them through `PostHogEvent.options`. @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) public func capture(_ event: String, @@ -1429,6 +1432,8 @@ let maxRetryDelay = 30.0 ) } + // A separate Swift name keeps this out of Swift overload resolution. + /// Captures a custom event for an explicit distinct ID and timestamp from Objective-C. /// /// - Parameters: @@ -1439,7 +1444,6 @@ let maxRetryDelay = 30.0 /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. /// - groups: Group type/key pairs to attach to this event. /// - timestamp: Optional event timestamp. Defaults to the current time. - // A separate Swift name keeps this out of Swift overload resolution. @available(swift, obsoleted: 1.0, message: "Use capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) instead") @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) public func captureObjC(_ event: String, // swiftlint:disable:this function_parameter_count @@ -2673,7 +2677,7 @@ let maxRetryDelay = 30.0 /// The strict property allowlist for minimal `$feature_flag_called` events. Everything else — /// other registered super properties, `$active_feature_flags`, the `$feature/` enumeration, /// bootstrap enrichment — is stripped. Kept in sync with the cross-SDK minimal - /// `$feature_flag_called` contract. + /// `$feature_flag_called` contract, plus the legacy option properties that capture V1 sends as options. private static let minimalFeatureFlagCalledProperties: Set = [ "$feature_flag", "$feature_flag_response", From 9007cf67f49921b23ec489cd0ab38210b4c208e7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 18:57:37 -0400 Subject: [PATCH 08/67] chore: track capture V1 requests in the compliance adapter /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) --- sdk_compliance_adapter/Sources/RequestInterceptor.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sdk_compliance_adapter/Sources/RequestInterceptor.swift b/sdk_compliance_adapter/Sources/RequestInterceptor.swift index d012ce687b..fe0cabbd29 100644 --- a/sdk_compliance_adapter/Sources/RequestInterceptor.swift +++ b/sdk_compliance_adapter/Sources/RequestInterceptor.swift @@ -103,11 +103,12 @@ class RequestInterceptor: URLProtocol { // Only intercept requests to the mock server (not to real PostHog endpoints) guard let url = request.url else { return false } - // Intercept /batch and /e/ endpoints, but not /flags/ or /config + // Intercept capture endpoints, but not /flags/ or /config let urlString = url.absoluteString - if urlString.contains("/batch") || urlString.contains("/e/") || urlString.contains("/s/"), - !urlString.contains("/flags"), - !urlString.contains("/config") + if urlString.contains("/i/v1/analytics/events") || urlString.contains("/batch") + || urlString.contains("/e/") || urlString.contains("/s/"), + !urlString.contains("/flags"), + !urlString.contains("/config") { print("[INTERCEPTOR] Can handle: \(urlString)") return true From 1505ffb3aec4a76c533563cb7b2563578dcd16fa Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 20:31:17 -0400 Subject: [PATCH 09/67] docs: note the Retry-After cap applies to every queue and 413 still splits Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index c754ef0d1c..ba5c897d36 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -2,6 +2,6 @@ 'posthog-ios': major --- -**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried; 429 and redirects are no longer retried. A server `Retry-After` now pauses sending for at most 30 seconds. Capture V1 follows only a 307 or 308 redirect to the origin of `host` (same scheme, host and port), at most 5 times; any other redirect drops the events, so point `host` at the final host. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. +**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried, and a 413 still splits the batch; 429 and redirects are no longer retried. A server `Retry-After` now pauses sending for at most 30 seconds, for events, session replay and logs. Capture V1 follows only a 307 or 308 redirect to the origin of `host` (same scheme, host and port), at most 5 times; any other redirect drops the events, so point `host` at the final host. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. Add per-event capture options: `capture(..., timestamp:options:)` (Objective-C `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`) and `PostHogEvent.options`, which `beforeSend` can read and change. Known keys are `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id`; other keys are sent unchanged and ignored by PostHog. The legacy `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as their options instead of as properties, and an option you set wins over its legacy property. PostHog drops an event whose option value it can't read. From 5852d903b6361174e2853c8d4434f473c2193379 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 22:06:14 -0400 Subject: [PATCH 10/67] docs: rewrite the capture V1 changeset as changelog entries Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-options.md | 5 +++++ .changeset/v4-capture-v1.md | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .changeset/v4-capture-options.md diff --git a/.changeset/v4-capture-options.md b/.changeset/v4-capture-options.md new file mode 100644 index 0000000000..6d5378f729 --- /dev/null +++ b/.changeset/v4-capture-options.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': minor +--- + +Add per-event capture options with `capture(..., timestamp:options:)` and `PostHogEvent.options`, which `beforeSend` can read and change. diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index ba5c897d36..849092c176 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -2,6 +2,10 @@ 'posthog-ios': major --- -**Breaking:** send analytics events to `/i/v1/analytics/events` (capture V1) with the project token as an `Authorization: Bearer` header instead of an `api_key` body field. Events the server accepts, warns about or drops are removed from the queue, and only the events it asks to retry are resent. On this endpoint only HTTP 408, 500, 502, 503 and 504 (and network errors) are retried, and a 413 still splits the batch; 429 and redirects are no longer retried. A server `Retry-After` now pauses sending for at most 30 seconds, for events, session replay and logs. Capture V1 follows only a 307 or 308 redirect to the origin of `host` (same scheme, host and port), at most 5 times; any other redirect drops the events, so point `host` at the final host. If the host returns 404 for the new endpoint, the SDK falls back to `/batch` until the app restarts. If you use a reverse proxy, forward `/i/v1/analytics/events`: only a 404 triggers the fallback, and other responses such as 403 or 405 drop the events. Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. +**Breaking:** send events to capture V1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header. -Add per-event capture options: `capture(..., timestamp:options:)` (Objective-C `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`) and `PostHogEvent.options`, which `beforeSend` can read and change. Known keys are `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id`; other keys are sent unchanged and ignored by PostHog. The legacy `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as their options instead of as properties, and an option you set wins over its legacy property. PostHog drops an event whose option value it can't read. +- 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. +- 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. +- The `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as capture options, and PostHog drops an event whose option value it can't read. From fad2204a0cd6940b4fc97ec304355cc47c73dda0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 10 Oct 2026 13:13:44 -0400 Subject: [PATCH 11/67] test: stop the capture redirect tests racing the HTTP stubs 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) --- PostHogTests/PostHogCaptureV1Test.swift | 34 +++++++++++++++++-------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 128ce58b45..3aac4bd433 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -262,9 +262,10 @@ final class PostHogCaptureV1Test { : HTTPStubsResponse(jsonObject: ["results": [:]], statusCode: 200, headers: nil) } - let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + _ = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) - #expect(result.statusCode == 200) + // No check on the result: OHHTTPStubs delivers the 3xx without waiting for the redirect + // decision, so the task can end with either response. The redirected request is what counts. let requests = server.batchRequests try #require(requests.count == 2) #expect(requests[1].url?.path == "/moved/i/v1/analytics/events") @@ -296,22 +297,33 @@ final class PostHogCaptureV1Test { } @Test("stops after 5 redirects") - func stopsAfterMaxRedirects() async { - // OHHTTPStubs doesn't resolve a relative `Location`; URLSession does before asking the delegate. - server.captureV1ResponseHandler = { _, _ in - HTTPStubsResponse(data: Data(), statusCode: 307, headers: ["Location": "http://localhost:9001/proxy/i/v1/analytics/events"]) + func stopsAfterMaxRedirects() throws { + // Drives the delegate directly: a stubbed redirect chain races URLSession and can end + // the task without a response. + let host = try #require(URL(string: "http://localhost:9001")) + let url = host.appendingPathComponent("i/v1/analytics/events") + var original = URLRequest(url: url) + original.setValue("request-id", forHTTPHeaderField: "PostHog-Request-Id") + let task = URLSession.shared.dataTask(with: original) + let redirect = URLRequest(url: host.appendingPathComponent("proxy/i/v1/analytics/events")) + let response = try #require(HTTPURLResponse(url: url, statusCode: 307, httpVersion: nil, headerFields: nil)) + let handler = PostHogRedirectHandler(host: host, headerKeys: []) + + var followed: [Bool] = [] + for _ in 0 ... PostHogRedirectHandler.maxCaptureV1Redirects { + handler.urlSession(URLSession.shared, task: task, willPerformHTTPRedirection: response, newRequest: redirect) { + followed.append($0 != nil) + } } - let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) - - #expect(result.statusCode == 307) - #expect(server.batchRequests.count == PostHogRedirectHandler.maxCaptureV1Redirects + 1) + #expect(followed == Array(repeating: true, count: PostHogRedirectHandler.maxCaptureV1Redirects) + [false]) } @Test("re-sets the original headers on a followed redirect") func redirectResetsHeaders() throws { let host = try #require(URL(string: "https://us.i.posthog.com")) - var original = URLRequest(url: host.appendingPathComponent("i/v1/analytics/events")) + let url = host.appendingPathComponent("i/v1/analytics/events") + var original = URLRequest(url: url) original.httpMethod = "POST" original.setValue("Bearer phc_test", forHTTPHeaderField: "Authorization") original.setValue("request-id", forHTTPHeaderField: "PostHog-Request-Id") From d5fc585a65453bdc4a3ebf91c865fb5fe83f3e01 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 20 Oct 2026 12:09:08 -0400 Subject: [PATCH 12/67] fix: resend a capture V1 batch whose transfer failed after a 2xx A connection that drops after the 2xx header arrives never delivers the per-event results, so the batch now counts as unanswered and is resent instead of being removed. The PostHogApi URLSession is invalidated on deinit so its redirect delegate is released. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogApi.swift | 22 ++++++++++++++++++---- PostHogTests/PostHogCaptureV1Test.swift | 9 +++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 18cf70edcc..f1da9321f3 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -100,6 +100,11 @@ class PostHogApi { session = URLSession(configuration: sessionConfig, delegate: redirectHandler, delegateQueue: nil) } + deinit { + // A session retains its delegate until it's invalidated; in-flight tasks still finish. + session.finishTasksAndInvalidate() + } + /// `gzipped: true` adds `Content-Encoding: gzip` for upload endpoints /// (/batch, /s/, /i/v1/logs) whose bodies are gzipped. private func getURLRequest(_ url: URL, gzipped: Bool = false, httpMethod: String = "POST") -> URLRequest { @@ -304,8 +309,9 @@ class PostHogApi { var retryUuids: Set = [] // Duplicate UUIDs are dropped before sending. var dropped = duplicates + let statusCode = captureV1StatusCode(info) - if let statusCode = info.statusCode, 200 ... 299 ~= statusCode { + if let statusCode, 200 ... 299 ~= statusCode { // A body without a readable results map counts as delivered, so a // broken success can't loop forever. let results = data.flatMap { fromJSONData($0) }?["results"] as? [String: Any] @@ -324,7 +330,7 @@ class PostHogApi { break } } - } else if info.statusCode.map(isCaptureV1RetriableStatusCode) ?? true { + } else if statusCode.map(isCaptureV1RetriableStatusCode) ?? true { // No response or a retriable status: the whole batch is resent. retryUuids = Set(uuids) } @@ -341,10 +347,10 @@ class PostHogApi { } return PostHogUploadInfo( - statusCode: info.statusCode, + statusCode: statusCode, error: info.error, retryAfter: info.retryAfter, - retryRecordIds: info.statusCode.map { 200 ... 299 ~= $0 } == true ? retryUuids : nil + retryRecordIds: statusCode.map { 200 ... 299 ~= $0 } == true ? retryUuids : nil ) } @@ -874,6 +880,14 @@ final class PostHogRedirectHandler: NSObject, URLSessionTaskDelegate { /// Identity of a capture V1 request, kept so a retry of the same events reuses /// `PostHog-Request-Id` and `created_at` with an incremented `PostHog-Attempt`. +/// The status capture V1 acts on. A transfer that failed after a 2xx header +/// never delivered the per-event results, so it counts as no response and the +/// whole batch is resent instead of being removed from the queue. +func captureV1StatusCode(_ info: PostHogUploadInfo) -> Int? { + guard let statusCode = info.statusCode else { return nil } + return info.error != nil && 200 ... 299 ~= statusCode ? nil : statusCode +} + private struct CaptureV1RequestIdentity { let uuids: Set let requestId: String diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 3aac4bd433..6c09dddbf3 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -148,6 +148,15 @@ final class PostHogCaptureV1Test { #expect(result.retryRecordIds?.isEmpty ?? true) } + @Test("a 2xx whose transfer then failed is resent, not treated as delivered") + func failedTransferAfterSuccessIsRetried() { + let error = URLError(.networkConnectionLost) + #expect(captureV1StatusCode(PostHogUploadInfo(statusCode: 200, error: error)) == nil) + #expect(captureV1StatusCode(PostHogUploadInfo(statusCode: 400, error: error)) == 400) + #expect(captureV1StatusCode(PostHogUploadInfo(statusCode: 200, error: nil)) == 200) + #expect(captureV1StatusCode(PostHogUploadInfo(statusCode: nil, error: error)) == nil) + } + @Test("a retry reuses the request ID and increments the attempt; an independent flush starts over") func retryIdentity() async throws { let events = (0 ..< 2).map { PostHogEvent(event: "e\($0)", distinctId: "user") } From fef9de3e353e8a67da88dead61ada047f70136b5 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 15:48:41 -0400 Subject: [PATCH 13/67] refactor: stop deduplicating event UUIDs before capture The SDK generates every event UUID, so a batch can't repeat one. Leave duplicate rejection to the server, like the other capture v1 SDKs. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogApi.swift | 17 +++++------------ PostHogTests/PostHogCaptureV1Test.swift | 12 ------------ 2 files changed, 5 insertions(+), 24 deletions(-) diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index f1da9321f3..fa06336564 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -233,15 +233,12 @@ class PostHogApi { return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - // The server rejects the whole batch with 400 when a UUID repeats. - var seenUuids = Set() - let uniqueEvents = events.filter { seenUuids.insert($0.uuid.postHogUuidString).inserted } - let uuids = uniqueEvents.map(\.uuid.postHogUuidString) + let uuids = events.map(\.uuid.postHogUuidString) let identity = nextCaptureV1Identity(uuids: Set(uuids)) let toSend: [String: Any] = [ "created_at": identity.createdAt, - "batch": uniqueEvents.map(Self.captureV1JSON), + "batch": events.map(Self.captureV1JSON), ] guard let data = try? JSONSerialization.data(withJSONObject: toSend) else { @@ -274,9 +271,7 @@ class PostHogApi { return self.batch(events: events, completion: completion) } - completion(self.captureV1Result( - info, data: data, uuids: uuids, identity: identity, duplicates: events.count - uniqueEvents.count - )) + completion(self.captureV1Result(info, data: data, uuids: uuids, identity: identity)) } }.resume() } @@ -303,12 +298,10 @@ class PostHogApi { _ info: PostHogUploadInfo, data: Data?, uuids: [String], - identity: CaptureV1RequestIdentity, - duplicates: Int + identity: CaptureV1RequestIdentity ) -> PostHogUploadInfo { var retryUuids: Set = [] - // Duplicate UUIDs are dropped before sending. - var dropped = duplicates + var dropped = 0 let statusCode = captureV1StatusCode(info) if let statusCode, 200 ... 299 ~= statusCode { diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 6c09dddbf3..e6696ae18d 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -104,18 +104,6 @@ final class PostHogCaptureV1Test { #expect(event.properties["$lib"] as? String == "posthog-ios") } - @Test("sends each UUID once per batch") - func dedupesUuids() async throws { - let first = PostHogEvent(event: "first", distinctId: "user") - let duplicate = PostHogEvent(event: "duplicate", distinctId: "user", uuid: first.uuid) - let other = PostHogEvent(event: "other", distinctId: "user") - _ = await send(makeApi(), [first, duplicate, other]) - - let request = try #require(server.batchRequests.first) - let events = try #require(try body(request)["batch"] as? [[String: Any]]) - #expect(events.map { $0["event"] as? String } == ["first", "other"]) - } - @Test("reports retry results and treats ok, warning, drop and missing results as delivered") func reportsRetryResults() async throws { let events = (0 ..< 5).map { PostHogEvent(event: "e\($0)", distinctId: "user") } From 29503f37617794bacc9d5a66f6e25613f948f6cf Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 15:58:33 -0400 Subject: [PATCH 14/67] docs: add the 4.0 capture v1 migration guide Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 2 + docs/migration-3.x-to-4.0.md | 118 +++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 docs/migration-3.x-to-4.0.md diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 849092c176..03d151eae4 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -9,3 +9,5 @@ - 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. - The `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as capture options, and PostHog drops an event whose option value it can't read. + +See the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md) for what to check before you upgrade. diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md new file mode 100644 index 0000000000..503b7b1451 --- /dev/null +++ b/docs/migration-3.x-to-4.0.md @@ -0,0 +1,118 @@ +# Migrating from PostHog iOS 3.x to 4.0 + +4.0 sends analytics events to capture v1 (`/i/v1/analytics/events`) instead of `/batch`. +Most apps upgrade without code changes. +Read the checklist first, then the sections that apply to you. + +For the full list of 4.0 changes, see the [changelog](../CHANGELOG.md). + +## Checklist + +You need to change something if your app does any of these: + +- sends events to a self-hosted PostHog or a reverse proxy that doesn't serve `/i/v1/analytics/events` +- authenticates its proxy with an `Authorization` header in `requestHeaders` +- sends events through a proxy that redirects capture requests, other than with a 307 or 308 to the same origin as `host` +- captures events with an empty event name or distinct ID, or with either over 200 bytes +- sets `$lib` or `$lib_version` and expects PostHog to store that value +- reads `$session_id` or `$window_id` from the raw request body, for example in a proxy +- sets `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` +- relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds + +## Endpoints + +| Data | 3.x | 4.0 | +| --- | --- | --- | +| Analytics events | `/batch` | `/i/v1/analytics/events`, or `/batch` after a 404 (see below) | +| Session replay | `/s/` | `/s/`, unchanged | +| Logs | `/i/v1/logs` | `/i/v1/logs`, unchanged | +| Feature flags | `/flags` | `/flags`, unchanged | + +Capture v1 requests send the project token in an `Authorization: Bearer ` header instead of an `api_key` body field. +This header replaces any `Authorization` header you set in `PostHogConfig.requestHeaders`, so authenticate your proxy with a different header, such as `X-Proxy-Token`. +Other requests still send your `Authorization` header. + +### Hosts without capture v1 + +If you use a reverse proxy, forward `/i/v1/analytics/events` 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. + +### Redirects + +The SDK follows a 307 or 308 redirect only to the origin of `host` (same scheme, host and port), at most 5 times, and resends the same body and headers. +Any other redirect is not followed and the events are dropped. +In 3.x, `URLSession` followed redirects to any host. +If a proxy redirects capture requests to another host, point `host` at the final host. + +## Event validation + +Capture v1 checks each event on its own and drops only the invalid ones, so the rest of the batch is delivered. +The SDK doesn't check these before sending. PostHog drops the event, and the SDK removes it from the queue. +With `debug` on, the SDK logs how many events were dropped. + +- An event with an empty event name is dropped, for example `capture("")`. +- An event with an empty or whitespace-only distinct ID is dropped, for example `capture("signed_up", distinctId: "")`. +- An event whose name is over 200 UTF-8 bytes is dropped. Legacy capture had no limit on event name length. +- An event whose distinct ID is over 200 UTF-8 bytes is dropped. Legacy capture shortened the ID to 200 characters and kept the event. + +In 3.x, an empty event name or distinct ID made legacy capture reject the whole batch it was in. + +The distinct ID can come from `identify(_:)`, the `distinctId` argument of `capture` or `beforeSend`. +If you identify users with a long ID, such as a JWT or a long email address, shorten or hash it before you upgrade. Otherwise every event for that user is dropped. + +## SDK identity + +PostHog sets `$lib` and `$lib_version` from the `PostHog-Sdk-Info` request header, for example `posthog-ios/4.0.0`. +The SDK removes `$lib` and `$lib_version` from the properties it sends to capture v1. +A value you set in properties, in a super property or in `beforeSend` doesn't reach PostHog. +`beforeSend` still sees the SDK's value. + +## Session and window IDs + +Capture v1 sends `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields, not as properties. +The SDK moves them out of `properties` for you. +An empty or non-string value is removed and not sent. +In PostHog, the events still have `$session_id` and `$window_id`, so insights, filters and session replay work as before. + +## Event options + +Capture v1 reads processing settings from an `options` object sent next to `properties`. +`capture` takes a new `options` argument, and `beforeSend` can read and change `PostHogEvent.options`. + +```swift +// 3.x +PostHogSDK.shared.capture("signed_up", properties: ["$cookieless_mode": true]) + +// 4.0 +PostHogSDK.shared.capture("signed_up", options: ["cookieless_mode": true]) +``` + +In Objective-C, use `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`. + +The legacy option properties still work. +When the event is sent, the SDK removes each one from `properties` and uses it for its option: + +| Legacy property | Option | +| --- | --- | +| `$process_person_profile` | `process_person_profile` | +| `$cookieless_mode` | `cookieless_mode` | +| `$ignore_sent_at` | `disable_skew_correction` | +| `$product_tour_id` | `product_tour_id` | + +- An option you set in `capture(..., options:)` or in `beforeSend` wins over its legacy property. An option set to `NSNull()` counts as unset, so the legacy property fills it. +- The SDK sets `$process_person_profile` on every event from `personProfiles`, so a `$process_person_profile` property you pass is replaced. To change person processing for one event, pass the `process_person_profile` option. +- 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. + +## Retries + +- The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. +- When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. +- A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x, analytics events and session replay had no cap, and logs allowed up to 5 minutes. +- A 413 still splits the batch into smaller requests. +- After a 404 fallback, `/batch` keeps the 3.x retry rules. From 81fed4c68eba19a87c63f4513cb66946edb7050b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:04:01 -0400 Subject: [PATCH 15/67] fix: give each capture its own UUID when beforeSend returns an earlier event beforeSend can return an event object kept from an earlier call. Its UUID then repeats in the queue, and capture V1 rejects the whole batch. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/Models/PostHogEvent.swift | 2 +- PostHog/PostHogSDK.swift | 3 +++ PostHogTests/PostHogCaptureV1Test.swift | 26 +++++++++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/PostHog/Models/PostHogEvent.swift b/PostHog/Models/PostHogEvent.swift index 6e1c5f3e6f..39a8eecdbd 100644 --- a/PostHog/Models/PostHogEvent.swift +++ b/PostHog/Models/PostHogEvent.swift @@ -36,7 +36,7 @@ import Foundation @objc public var timestamp: Date /// Unique event identifier generated by the SDK. - @objc public private(set) var uuid: UUID + @objc public internal(set) var uuid: UUID // Only used for Replay. // Serialized as api_key on the wire. var projectToken: String? diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 913b283a29..633764c989 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2003,6 +2003,9 @@ let maxRetryDelay = 30.0 let resultEvent = config.runBeforeSend(event) if let resultEvent { + // beforeSend can return an event kept from an earlier call. Capture V1 + // rejects a batch that repeats a UUID, so each capture keeps its own. + resultEvent.uuid = event.uuid resultEvent.carriesReplayDebugBundle = carriesReplayDebugBundle } else { if carriesReplayDebugBundle { diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index e6696ae18d..fdedb12edd 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -480,9 +480,13 @@ final class PostHogCaptureV1OptionsTest { #expect(old.properties["$cookieless_mode"] as? Bool == true) } - private func makeSDK(personProfiles: PostHogPersonProfiles = .identifiedOnly, beforeSend: BeforeSendBlock? = nil) -> PostHogSDK { + private func makeSDK( + personProfiles: PostHogPersonProfiles = .identifiedOnly, + flushAt: Int = 1, + beforeSend: BeforeSendBlock? = nil + ) -> PostHogSDK { let config = PostHogConfig(projectToken: "phc_options_\(UUID().uuidString)", host: "http://localhost:9001") - config.flushAt = 1 + config.flushAt = flushAt config.preloadFeatureFlags = false config.sendFeatureFlagEvent = false config.disableReachabilityForTesting = true @@ -535,6 +539,24 @@ final class PostHogCaptureV1OptionsTest { #expect(sent["options"] as? [String: AnyHashable] == ["process_person_profile": true, "product_tour_id": "edited-tour"]) } + + @Test("an event beforeSend returns again still gets a new UUID") + func beforeSendReturningEarlierEventGetsNewUuid() async throws { + var kept: PostHogEvent? + let sut = makeSDK(flushAt: 2) { event in + if kept == nil { kept = event } + return kept + } + defer { sut.close() } + + server.reset(batchCount: 1) + sut.capture("first") + sut.capture("second") + let events = try await getServerEvents(server) + + #expect(events.count == 2) + #expect(Set(events.map(\.uuid)).count == 2) + } } @Suite("Capture V1 queue partial retry", .serialized, .resetsGlobalState) From f04315e69bfdedc5eb71d3ef94fde18223a9ac49 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 15:58:45 -0400 Subject: [PATCH 16/67] fix(queue): back off exponentially with jitter between failed sends The shared queue grew its wait by 1 second per consecutive failure. It now doubles from 1 second to the 30 second ceiling and scales each wait by a random factor in [0.75, 1.25), capped at the ceiling, matching the capture V1 multiplier, ceiling and jitter of posthog-python, posthog-go and posthog-rs. A clamped Retry-After stays a floor applied after jitter. The 1 second base is kept rather than the server SDKs' 100ms: this queue retries without a count limit and every capture over flushAt is a retry trigger, so a 100ms base would send several requests in a row during an outage. Retries stay unbounded so records are only removed on delivery, a terminal response, or queue capacity. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 1 + PostHog/PostHogQueue.swift | 50 +++++++++++++++++++--- PostHog/QueueEndpoint.swift | 7 ++-- PostHogTests/PostHogQueueTest.swift | 65 ++++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 11 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 03d151eae4..5549257264 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -8,6 +8,7 @@ - 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. +- After a failed send, the wait before the next one now doubles from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. - The `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as capture options, and PostHog drops an event whose option value it can't read. See the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md) for what to check before you upgrade. diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index d9c8acc611..2698d588b7 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -11,6 +11,37 @@ private func clamped(_ value: Int) -> Int { max(1, value) } +/// Largest fraction a retry wait moves either way, so devices refused together +/// don't all come back at the same moment. Matches the capture V1 server SDKs +/// (a factor drawn from `[0.75, 1.25)`). +let retryJitter = 0.25 + +/// Number of consecutive failures after which the exponential backoff reaches +/// `maxRetryDelay`. The retry counter stops there; it only ramps the delay. +let maxRetryBackoffStep = 1 + Int(ceil(log2(maxRetryDelay / retryDelay))) + +/// Wait before the queue sends again after its `attempt`-th consecutive failure. +/// +/// The delay doubles from `retryDelay` and is capped at `maxRetryDelay`, then +/// scaled by a jitter factor in `[1 - retryJitter, 1 + retryJitter)` and capped +/// again. A server `Retry-After`, clamped to `retryAfterCap`, is a floor applied +/// after jitter, so jitter never retries earlier than the server asked. +/// +/// - Parameter jitterUnit: A uniform value in `[0, 1)`. A value outside that +/// range (e.g. no randomness available) falls back to the unjittered delay. +func queueRetryDelay(attempt: Int, + jitterUnit: Double, + retryAfter: TimeInterval?, + retryAfterCap: TimeInterval) -> TimeInterval +{ + let step = min(max(attempt, 1), maxRetryBackoffStep) + let computed = min(retryDelay * pow(2, Double(step - 1)), maxRetryDelay) + let factor = (0 ..< 1).contains(jitterUnit) ? 1 - retryJitter + 2 * retryJitter * jitterUnit : 1 + let jittered = min(computed * factor, maxRetryDelay) + let retryAfterFloor = min(max(retryAfter ?? 0, 0), retryAfterCap) + return max(jittered, retryAfterFloor) +} + /// Adaptive limits stored privately on the queue rather than mutating /// `config.maxBatchSize` / `config.flushAt` so user-supplied config fields /// aren't silently changed. Both `cap` and `flushAt` are halved together on @@ -55,6 +86,8 @@ class PostHogQueue { private let endpoint: QueueEndpoint private let configuredMaxQueueSize: Int private let timerInterval: TimeInterval + /// Source of the backoff jitter: a uniform value in `[0, 1)`. + private let jitterUnit: () -> Double /// Guards `paused`, `pausedUntil`, and `retryCount`. These are touched from /// the URLSession completion queue (handleResult), the timer's main-thread @@ -106,10 +139,13 @@ class PostHogQueue { let fileQueue: PostHogFileBackedQueue #if !os(watchOS) - init(_ config: PostHogConfig, _ storage: PostHogStorage, _ endpoint: QueueEndpoint, _ reachability: Reachability?) { + init(_ config: PostHogConfig, _ storage: PostHogStorage, _ endpoint: QueueEndpoint, _ reachability: Reachability?, + jitterUnit: @escaping () -> Double = { Double.random(in: 0 ..< 1) }) + { self.config = config self.endpoint = endpoint self.reachability = reachability + self.jitterUnit = jitterUnit configuredMaxQueueSize = endpoint.maxQueueSize(config) timerInterval = endpoint.flushIntervalSeconds(config) batchLimits = .initial(cap: endpoint.initialCap(config), flushAt: endpoint.initialFlushAt(config)) @@ -123,9 +159,12 @@ class PostHogQueue { dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } #else - init(_ config: PostHogConfig, _ storage: PostHogStorage, _ endpoint: QueueEndpoint) { + init(_ config: PostHogConfig, _ storage: PostHogStorage, _ endpoint: QueueEndpoint, + jitterUnit: @escaping () -> Double = { Double.random(in: 0 ..< 1) }) + { self.config = config self.endpoint = endpoint + self.jitterUnit = jitterUnit configuredMaxQueueSize = endpoint.maxQueueSize(config) timerInterval = endpoint.flushIntervalSeconds(config) batchLimits = .initial(cap: endpoint.initialCap(config), flushAt: endpoint.initialFlushAt(config)) @@ -197,19 +236,18 @@ class PostHogQueue { private func pauseForBackoff(retryAfter: TimeInterval?) { let newCount = nextRetryCount() - let backoffDelay = min(TimeInterval(newCount) * retryDelay, maxRetryDelay) // A server's Retry-After can't hold the queue longer than the backoff ceiling // or the endpoint's documented maximum, whichever is lower. let retryAfterCap = min(maxRetryDelay, endpoint.maxRetryAfterSeconds ?? maxRetryDelay) - let delay = max(backoffDelay, min(retryAfter ?? 0, retryAfterCap)) + let delay = queueRetryDelay(attempt: newCount, jitterUnit: jitterUnit(), + retryAfter: retryAfter, retryAfterCap: retryAfterCap) pauseFor(seconds: delay) hedgeLog("Pausing queue consumption for \(delay) seconds due to \(newCount) API failure(s).") } private func nextRetryCount() -> Int { stateLock.withLock { - let maximumBackoffStep = max(1, Int(maxRetryDelay / retryDelay)) - if retryCount < maximumBackoffStep { + if retryCount < maxRetryBackoffStep { retryCount += 1 } return retryCount diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index bf8afee168..944e12b65a 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -81,8 +81,9 @@ struct QueueEndpoint { /// since that case is universal. let isRetriableStatusCode: (Int) -> Bool /// Upper bound applied to a parsed `Retry-After` header before it floors - /// the backoff: the wait is `max(backoff, min(retryAfter, maximum))`. - /// `nil` honors the header unbounded; events and replay keep that, logs - /// sets `logsMaxRetryAfterSeconds`. + /// the jittered backoff: the wait is `max(backoff, min(retryAfter, maximum))`. + /// The queue never lets `maximum` exceed `maxRetryDelay`; `nil` means that + /// ceiling alone applies. Events and replay keep `nil`, logs sets + /// `logsMaxRetryAfterSeconds`. var maxRetryAfterSeconds: TimeInterval? } diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index e7169507f0..0fedcce1af 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -78,7 +78,9 @@ final class PostHogQueueTest { #expect(latch.isSignaled, sourceLocation: sourceLocation) } - private func getSut(flushAt: Int = 1, maxQueueSize: Int = 1000, maxBatchSize: Int = 50, maxRetries: Int = 3, sender: ControlledBatchSender? = nil, afterUpload: (() -> Void)? = nil) -> PostHogQueue { + private func getSut(flushAt: Int = 1, maxQueueSize: Int = 1000, maxBatchSize: Int = 50, maxRetries: Int = 3, sender: ControlledBatchSender? = nil, + jitterUnit: @escaping () -> Double = { 0.5 }, afterUpload: (() -> Void)? = nil) -> PostHogQueue + { let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9001") config.flushAt = flushAt config.maxQueueSize = maxQueueSize @@ -111,7 +113,8 @@ final class PostHogQueueTest { }, isRetriableStatusCode: base.isRetriableStatusCode ) - let sut = PostHogQueue(config, storage, endpoint, nil) + // 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 // process-global, so a stray request from another queue/SDK instance would otherwise shift // batchRequests.count and the request number the batchResponseHandler keys on. @@ -331,6 +334,64 @@ final class PostHogQueueTest { } } + @Test("backoff doubles from the base delay up to the ceiling", arguments: [ + (1, 1.0), (2, 2.0), (3, 4.0), (4, 8.0), (5, 16.0), (6, maxRetryDelay), (50, maxRetryDelay), + ]) + func backoffDoublesUpToCeiling(attempt: Int, expected: TimeInterval) { + let delay = queueRetryDelay(attempt: attempt, jitterUnit: 0.5, retryAfter: nil, retryAfterCap: maxRetryDelay) + #expect(delay == expected) + } + + @Test("jitter scales the backoff within 25% and never exceeds the ceiling", arguments: [ + (1, 0.0, 0.75), (1, 0.75, 1.125), (3, 0.0, 3.0), (3, 0.75, 4.5), (6, 0.0, 22.5), (6, 0.999, maxRetryDelay), + ]) + func jitterScalesBackoff(attempt: Int, jitterUnit: Double, expected: TimeInterval) { + let delay = queueRetryDelay(attempt: attempt, jitterUnit: jitterUnit, retryAfter: nil, retryAfterCap: maxRetryDelay) + #expect(abs(delay - expected) < 0.000_001) + } + + @Test("Retry-After is a floor applied after jitter, clamped to its cap", arguments: [ + (10.0, 10.0), (0.5, 0.75), (3600.0, maxRetryDelay), (-5.0, 0.75), + ]) + func retryAfterFloorsJitteredBackoff(retryAfter: TimeInterval, expected: TimeInterval) { + let delay = queueRetryDelay(attempt: 1, jitterUnit: 0, retryAfter: retryAfter, retryAfterCap: maxRetryDelay) + #expect(delay == expected) + } + + @Test("unusable randomness falls back to the unjittered backoff", arguments: [Double.nan, 1.0, -0.1, Double.infinity]) + func unusableJitterFallsBack(jitterUnit: Double) { + let delay = queueRetryDelay(attempt: 3, jitterUnit: jitterUnit, retryAfter: nil, retryAfterCap: maxRetryDelay) + #expect(delay == 4.0) + } + + @Test("pauses for the jittered exponential backoff after consecutive failures") + func pausesForJitteredExponentialBackoff() async throws { + try await withMockedClock { clock in + let uploads = (1 ... 3).map { _ in AsyncLatch() } + var uploadIndex = 0 + let sut = getSut(flushAt: 100, jitterUnit: { 0 }) { + guard uploadIndex < uploads.count else { return } + uploads[uploadIndex].signal() + uploadIndex += 1 + } + server.batchResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 503, headers: nil) + } + + sut.add(PostHogEvent(event: "event", distinctId: "id")) + // Jitter factor 0.75 on 1s, 2s and 4s. + for (index, expectedDelay) in [0.75, 1.5, 3.0].enumerated() { + sut.flush() + await expectSignaled(uploads[index]) + let pausedUntil = try #require(sut.pausedUntilForTesting) + #expect(abs(pausedUntil.timeIntervalSince(clock.date) - expectedDelay) < 0.001) + #expect(sut.depth == 1) + clock.date = pausedUntil.addingTimeInterval(0.001) + } + sut.clear() + } + } + @Test("pops batch on HTTP 429 (terminal for capture V1) and does not change cap") func popsBatchOnHTTP429AndDoesNotChangeCap() async { let sut = getSut(flushAt: 2, maxBatchSize: 4) From 17751d73de2cfbefe79664cc87b1a6dc54a55d71 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 15:59:55 -0400 Subject: [PATCH 17/67] docs: describe the jittered exponential backoff in the 4.0 migration guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 503b7b1451..60afa2ea09 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -114,5 +114,6 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x, analytics events and session replay had no cap, and logs allowed up to 5 minutes. +- After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter. In 3.x it grew by 1 second per failure. This applies to events, session replay and logs. As in 3.x, retries have no limit: queued events stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). - A 413 still splits the batch into smaller requests. - After a 404 fallback, `/batch` keeps the 3.x retry rules. From 564b9df52740eb0539a7d64b52e770909c483c89 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:16:57 -0400 Subject: [PATCH 18/67] feat(config): rename maxRetries to pushNotificationSubscriptionMaxRetries maxRetries reads like an event-queue retry limit, but since 3.73.0 (#788) it has only bounded push-subscription registration retries. Event, replay and log retries stay unlimited so records are kept until delivery. maxRetries remains as a deprecated @objc alias that reads and writes the new property, with the same default of 3. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 1 + PostHog/PostHogConfig.swift | 11 +++++++++-- .../PostHogPushSubscriptionHandler.swift | 6 +++--- PostHogTests/PostHogConfigTest.swift | 14 ++++++++++++++ PostHogTests/PostHogPushNotificationTest.swift | 4 ++-- PostHogTests/PostHogQueueTest.swift | 4 ++-- api/posthog-ios.public-api.txt | 3 ++- docs/migration-3.x-to-4.0.md | 4 +++- sdk_compliance_adapter/Sources/main.swift | 2 +- 9 files changed, 37 insertions(+), 12 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 5549257264..1156817dbb 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -9,6 +9,7 @@ - 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. - After a failed send, the wait before the next one now doubles from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. +- `maxRetries` is deprecated in favor of `pushNotificationSubscriptionMaxRetries`, because it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. - The `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as capture options, and PostHog drops an event whose option value it can't read. See the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md) for what to check before you upgrade. diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 20ec30a365..d235354b1b 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -32,7 +32,7 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? #endif static let maxBatchSize: Int = 50 static let flushIntervalSeconds: TimeInterval = 30 - static let maxRetries: Int = 3 + static let pushNotificationSubscriptionMaxRetries: Int = 3 static let featureFlagRequestMaxRetries: Int = 1 } @@ -86,7 +86,14 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Use `maxQueueSize` for events and replay, and `logs.maxBufferSize` for logs. /// /// Default: `3`. - @objc public var maxRetries: Int = Defaults.maxRetries + @objc public var pushNotificationSubscriptionMaxRetries: Int = Defaults.pushNotificationSubscriptionMaxRetries + + /// Former name of `pushNotificationSubscriptionMaxRetries`. Reads and writes that property. + @available(*, deprecated, message: "Use pushNotificationSubscriptionMaxRetries. maxRetries only ever applied to push-subscription registration in 3.73.0+.") + @objc public var maxRetries: Int { + get { pushNotificationSubscriptionMaxRetries } + set { pushNotificationSubscriptionMaxRetries = newValue } + } /// Maximum number of retries for feature flag requests after transient network errors or retryable HTTP responses. /// Defaults to 1. Set to 0 to disable feature flag request retries. diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index e0bb2748b4..7de15ce4c9 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -824,15 +824,15 @@ final class PostHogPushSubscriptionHandler { return retryCount } - if attempt > config.maxRetries { + if attempt > config.pushNotificationSubscriptionMaxRetries { stateLock.withLock { halted = true } - hedgeLog("Push subscription: max retries (\(config.maxRetries)) exceeded. Keeping record for next launch.") + hedgeLog("Push subscription: max retries (\(config.pushNotificationSubscriptionMaxRetries)) exceeded. Keeping record for next launch.") return } let delay = info.retryAfter ?? retryDelay(forAttempt: attempt) stateLock.withLock { pausedUntil = Date().addingTimeInterval(delay) } - hedgeLog("Push subscription failed (attempt \(attempt)/\(config.maxRetries)). Retrying in \(delay)s.") + hedgeLog("Push subscription failed (attempt \(attempt)/\(config.pushNotificationSubscriptionMaxRetries)). Retrying in \(delay)s.") } /// Keep the delivered record so the token can be re-sent when the distinct id changes, but only if diff --git a/PostHogTests/PostHogConfigTest.swift b/PostHogTests/PostHogConfigTest.swift index f0bf9363dc..f483ae5fd1 100644 --- a/PostHogTests/PostHogConfigTest.swift +++ b/PostHogTests/PostHogConfigTest.swift @@ -36,6 +36,20 @@ struct PostHogConfigTest { #expect(config.pushIdentityProvider == nil) } + @Test("deprecated maxRetries reads and writes pushNotificationSubscriptionMaxRetries") + @available(*, deprecated, message: "Exercises the deprecated maxRetries alias.") + func maxRetriesForwardsToPushNotificationSubscriptionMaxRetries() { + let config = PostHogConfig(projectToken: testProjectToken) + #expect(config.pushNotificationSubscriptionMaxRetries == 3) + #expect(config.maxRetries == 3) + + config.maxRetries = 7 + #expect(config.pushNotificationSubscriptionMaxRetries == 7) + + config.pushNotificationSubscriptionMaxRetries = 1 + #expect(config.maxRetries == 1) + } + @Test("init takes project token") func initTakesProjectToken() { let config = PostHogConfig(projectToken: testProjectToken) diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index 403e715f0f..a71ea87237 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -117,7 +117,7 @@ resetStorage: Bool = true ) -> (handler: PostHogPushSubscriptionHandler, storage: PostHogStorage, config: PostHogConfig) { let config = PostHogConfig(projectToken: projectToken, host: "http://localhost:9001") - config.maxRetries = maxRetries + config.pushNotificationSubscriptionMaxRetries = maxRetries config.disableReachabilityForTesting = true let api = PostHogApi(config) let storage = PostHogStorage(config) @@ -726,7 +726,7 @@ #expect(server.pushSubscriptionRequests.count == 2) } - @Test("gives up after maxRetries, keeps the record, and retries once on relaunch (vector 4)") + @Test("gives up after pushNotificationSubscriptionMaxRetries, keeps the record, and retries once on relaunch (vector 4)") func givesUpKeepsRecordThenRetriesOnRelaunch() async throws { server.pushSubscriptionStatusCode = 500 let maxRetries = 3 diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index 0fedcce1af..c350769054 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -85,7 +85,7 @@ final class PostHogQueueTest { config.flushAt = flushAt config.maxQueueSize = maxQueueSize config.maxBatchSize = maxBatchSize - config.maxRetries = maxRetries + config.pushNotificationSubscriptionMaxRetries = maxRetries config.sendFeatureFlagEvent = false let storage = PostHogStorage(config) let api = PostHogApi(config) @@ -714,7 +714,7 @@ struct PostHogQueueUploadDispositionTest { let config = PostHogConfig(projectToken: "queue_disposition_\(UUID().uuidString)", host: "http://localhost:9001") config.flushAt = 100 config.maxBatchSize = 4 - config.maxRetries = 0 + config.pushNotificationSubscriptionMaxRetries = 0 let api = PostHogApi(config) let base: QueueEndpoint = snapshot ? .snapshot(api: api) : .batch(api: api) let endpoint = QueueEndpoint( diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 73e115d622..b57c83d0f6 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -59,12 +59,13 @@ PostHog | PostHogConfig.init(projectToken:host:) | init | @objc(projectToken:hos PostHog | PostHogConfig.logs | property | @objc let logs: PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)logs PostHog | PostHogConfig.maxBatchSize | property | @objc var maxBatchSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxBatchSize PostHog | PostHogConfig.maxQueueSize | property | @objc var maxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxQueueSize -PostHog | PostHogConfig.maxRetries | property | @objc var maxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxRetries +PostHog | PostHogConfig.maxRetries | property | @objc var maxRetries: Int { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)maxRetries PostHog | PostHogConfig.optOut | property | @objc var optOut: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)optOut PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: PostHogPersonProfiles | c:@M@PostHog@objc(cs)PostHogConfig(py)personProfiles PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider +PostHog | PostHogConfig.pushNotificationSubscriptionMaxRetries | property | @objc var pushNotificationSubscriptionMaxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)pushNotificationSubscriptionMaxRetries PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders PostHog | PostHogConfig.reuseAnonymousId | property | @objc var reuseAnonymousId: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)reuseAnonymousId diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 60afa2ea09..902be0229f 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -114,6 +114,8 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x, analytics events and session replay had no cap, and logs allowed up to 5 minutes. -- After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter. In 3.x it grew by 1 second per failure. This applies to events, session replay and logs. As in 3.x, retries have no limit: queued events stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). +- After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter. In 3.x it grew by 1 second per failure. This applies to events, session replay and logs. +- Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). +- `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`. Since 3.73.0 it has only limited push-notification subscription retries. `maxRetries` still works but is deprecated. - 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/main.swift b/sdk_compliance_adapter/Sources/main.swift index 848312bae9..d3282595ec 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -118,7 +118,7 @@ app.post("init") { req async throws -> Response in config.flushAt = initReq.flushAt ?? 1 let defaultFlushIntervalMs = config.flushAt > 1 ? 5000 : 500 config.flushIntervalSeconds = TimeInterval(initReq.flushIntervalMs ?? defaultFlushIntervalMs) / 1000.0 - config.maxRetries = initReq.maxRetries ?? config.maxRetries + config.pushNotificationSubscriptionMaxRetries = initReq.maxRetries ?? config.pushNotificationSubscriptionMaxRetries if initReq.enableCompression == false { config.compression = .none } From 621bb26c0d44a4a4085c8046ed8591d987349da5 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:26:26 -0400 Subject: [PATCH 19/67] refactor(config)!: drop the deprecated maxRetries alias 4.0 already breaks the API, and maxRetries only limited push registration. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 2 +- PostHog/PostHogConfig.swift | 7 ------- PostHogTests/PostHogConfigTest.swift | 14 -------------- api/posthog-ios.public-api.txt | 1 - docs/migration-3.x-to-4.0.md | 2 +- 5 files changed, 2 insertions(+), 24 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 1156817dbb..94599cca78 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -9,7 +9,7 @@ - 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. - After a failed send, the wait before the next one now doubles from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. -- `maxRetries` is deprecated in favor of `pushNotificationSubscriptionMaxRetries`, because it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. +- `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, because it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. - The `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as capture options, and PostHog drops an event whose option value it can't read. See the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md) for what to check before you upgrade. diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index d235354b1b..6bfa8260ae 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -88,13 +88,6 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Default: `3`. @objc public var pushNotificationSubscriptionMaxRetries: Int = Defaults.pushNotificationSubscriptionMaxRetries - /// Former name of `pushNotificationSubscriptionMaxRetries`. Reads and writes that property. - @available(*, deprecated, message: "Use pushNotificationSubscriptionMaxRetries. maxRetries only ever applied to push-subscription registration in 3.73.0+.") - @objc public var maxRetries: Int { - get { pushNotificationSubscriptionMaxRetries } - set { pushNotificationSubscriptionMaxRetries = newValue } - } - /// Maximum number of retries for feature flag requests after transient network errors or retryable HTTP responses. /// Defaults to 1. Set to 0 to disable feature flag request retries. @objc public var featureFlagRequestMaxRetries: Int = Defaults.featureFlagRequestMaxRetries diff --git a/PostHogTests/PostHogConfigTest.swift b/PostHogTests/PostHogConfigTest.swift index f483ae5fd1..f0bf9363dc 100644 --- a/PostHogTests/PostHogConfigTest.swift +++ b/PostHogTests/PostHogConfigTest.swift @@ -36,20 +36,6 @@ struct PostHogConfigTest { #expect(config.pushIdentityProvider == nil) } - @Test("deprecated maxRetries reads and writes pushNotificationSubscriptionMaxRetries") - @available(*, deprecated, message: "Exercises the deprecated maxRetries alias.") - func maxRetriesForwardsToPushNotificationSubscriptionMaxRetries() { - let config = PostHogConfig(projectToken: testProjectToken) - #expect(config.pushNotificationSubscriptionMaxRetries == 3) - #expect(config.maxRetries == 3) - - config.maxRetries = 7 - #expect(config.pushNotificationSubscriptionMaxRetries == 7) - - config.pushNotificationSubscriptionMaxRetries = 1 - #expect(config.maxRetries == 1) - } - @Test("init takes project token") func initTakesProjectToken() { let config = PostHogConfig(projectToken: testProjectToken) diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index b57c83d0f6..ee26ef2b5f 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -59,7 +59,6 @@ PostHog | PostHogConfig.init(projectToken:host:) | init | @objc(projectToken:hos PostHog | PostHogConfig.logs | property | @objc let logs: PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)logs PostHog | PostHogConfig.maxBatchSize | property | @objc var maxBatchSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxBatchSize PostHog | PostHogConfig.maxQueueSize | property | @objc var maxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxQueueSize -PostHog | PostHogConfig.maxRetries | property | @objc var maxRetries: Int { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)maxRetries PostHog | PostHogConfig.optOut | property | @objc var optOut: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)optOut PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: PostHogPersonProfiles | c:@M@PostHog@objc(cs)PostHogConfig(py)personProfiles PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 902be0229f..e73f3725ee 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -116,6 +116,6 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x, analytics events and session replay had no cap, and logs allowed up to 5 minutes. - After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter. In 3.x it grew by 1 second per failure. This applies to events, session replay and logs. - Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). -- `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`. Since 3.73.0 it has only limited push-notification subscription retries. `maxRetries` still works but is deprecated. +- `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, with no deprecated alias. Since 3.73.0 it has only limited push-notification subscription retries. - A 413 still splits the batch into smaller requests. - After a 404 fallback, `/batch` keeps the 3.x retry rules. From b0636c913a00e5acdf4fdd305fc80c7d35ee3737 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:31:18 -0400 Subject: [PATCH 20/67] fix(capture): send empty session and window IDs and log dropped non-strings Capture V1 now sends an empty or whitespace `$session_id`/`$window_id` at the event root unchanged, matching posthog-go, posthog-rs and posthog-python. A non-string value is still dropped, now with a warning that names the key and its JSON type, never the value. Null drops silently. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogApi.swift | 36 +++++++++++++++++++++-- PostHogTests/PostHogCaptureV1Test.swift | 39 +++++++++++++++++++++++-- docs/migration-3.x-to-4.0.md | 4 ++- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index fa06336564..82ca1a04e0 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -398,11 +398,10 @@ class PostHogApi { "distinct_id": event.distinctId, "timestamp": toISO8601String(event.timestamp), ] - // Always removed from properties; only a non-empty string is sent at the root. - if let sessionId = properties.removeValue(forKey: "$session_id") as? String, !sessionId.isEmpty { + if let sessionId = liftStringProperty("$session_id", from: &properties) { json["session_id"] = sessionId } - if let windowId = properties.removeValue(forKey: "$window_id") as? String, !windowId.isEmpty { + if let windowId = liftStringProperty("$window_id", from: &properties) { json["window_id"] = windowId } json["options"] = options @@ -410,6 +409,37 @@ class PostHogApi { return json } + /// Removes `key` from `properties` and returns its value when it's a string, + /// including an empty one, which is sent unchanged. Capture rejects the whole + /// request when a root `session_id` or `window_id` isn't a string, so any other + /// value is dropped with a warning that names its JSON type, never the value. + /// A null value counts as unset and is dropped without a warning. + static func liftStringProperty( + _ key: String, + from properties: inout [String: Any], + warn: (String) -> Void = hedgeLog + ) -> String? { + guard let value = properties.removeValue(forKey: key) else { return nil } + if let string = value as? String { return string } + if value is NSNull { return nil } + warn("dropping \(key): a \(jsonTypeName(value)) value is not a string") + return nil + } + + /// The JSON type name of a property value, the names posthog-go and posthog-rs log. + private static func jsonTypeName(_ value: Any) -> String { + switch value { + case let number as NSNumber: + return CFGetTypeID(number) == CFBooleanGetTypeID() ? "bool" : "number" + case is [Any]: + return "array" + case is [AnyHashable: Any]: + return "object" + default: + return String(describing: type(of: value)) + } + } + func snapshot(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { guard let url = getEndpointURL(config.snapshotEndpoint, relativeTo: config.host) else { hedgeLog("Malformed snapshot URL error.") diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index fdedb12edd..8de8b750a9 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -235,9 +235,23 @@ final class PostHogCaptureV1Test { #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/batch", "/proxy/batch"]) } - @Test("omits empty session and window IDs from the root and properties") - func omitsEmptySessionIds() async throws { - let event = PostHogEvent(event: "test", distinctId: "user", properties: ["$session_id": "", "$window_id": ""]) + @Test("sends empty and whitespace session and window IDs at the root unchanged", arguments: ["", " "]) + func sendsBlankSessionIds(value: String) async throws { + let event = PostHogEvent(event: "test", distinctId: "user", properties: ["$session_id": value, "$window_id": value]) + _ = await send(makeApi(), [event]) + + let request = try #require(server.batchRequests.first) + let sent = try #require((try body(request)["batch"] as? [[String: Any]])?.first) + #expect(sent["session_id"] as? String == value) + #expect(sent["window_id"] as? String == value) + let properties = try #require(sent["properties"] as? [String: Any]) + #expect(properties["$session_id"] == nil) + #expect(properties["$window_id"] == nil) + } + + @Test("drops non-string session and window IDs from the root and properties") + func dropsNonStringSessionIds() async throws { + let event = PostHogEvent(event: "test", distinctId: "user", properties: ["$session_id": 42, "$window_id": ["w"]]) _ = await send(makeApi(), [event]) let request = try #require(server.batchRequests.first) @@ -249,6 +263,25 @@ final class PostHogCaptureV1Test { #expect(properties["$window_id"] == nil) } + @Test("a dropped non-string ID logs its key and JSON type but not its value; null drops silently", arguments: [ + (42 as Any, "number"), + (3.5 as Any, "number"), + (true as Any, "bool"), + (["secret"] as Any, "array"), + (["k": "secret"] as Any, "object"), + (NSNull() as Any, nil), + ] as [(Any, String?)]) + func warnsOnNonStringId(value: Any, type: String?) { + var properties: [String: Any] = ["$window_id": value, "other": "kept"] + var warnings: [String] = [] + + let lifted = PostHogApi.liftStringProperty("$window_id", from: &properties) { warnings.append($0) } + + #expect(lifted == nil) + #expect(properties.keys.sorted() == ["other"]) + #expect(warnings == type.map { ["dropping $window_id: a \($0) value is not a string"] } ?? []) + } + @Test("follows a same-origin 307 or 308 with the same body and headers", arguments: [307, 308]) func followsSameOriginRedirect(statusCode: Int) async throws { server.captureV1ResponseHandler = { _, index in diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index e73f3725ee..d332f60e93 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -74,7 +74,9 @@ A value you set in properties, in a super property or in `beforeSend` doesn't re Capture v1 sends `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields, not as properties. The SDK moves them out of `properties` for you. -An empty or non-string value is removed and not sent. +A string value, including an empty string, is sent unchanged. +Any other value is removed and not sent, and the SDK logs a warning that names the key and the value's JSON type, never the value. +A null value counts as unset and is removed without a warning. In PostHog, the events still have `$session_id` and `$window_id`, so insights, filters and session replay work as before. ## Event options From ce3ba68bdc34ee7488c8e445c387f03d6e25dce3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:31:55 -0400 Subject: [PATCH 21/67] fix: treat a capture V1 2xx whose results can't be read as delivered A connection that drops after the 2xx status arrives no longer resends the batch. The server already stored the batch's ok events, and capture doesn't dedup a resend by UUID, so resending stored them twice. Such a 2xx is now handled like one with a malformed body: the batch counts as delivered and the SDK logs only the event count. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogApi.swift | 21 +++++++-------------- PostHogTests/PostHogCaptureV1Test.swift | 17 ++++++++++------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 82ca1a04e0..cae13ecb4e 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -294,7 +294,7 @@ class PostHogApi { /// Reads the per-event results of a 2xx and records which events, if any, /// the next request retries under the same identity. - private func captureV1Result( + func captureV1Result( _ info: PostHogUploadInfo, data: Data?, uuids: [String], @@ -302,14 +302,15 @@ class PostHogApi { ) -> PostHogUploadInfo { var retryUuids: Set = [] var dropped = 0 - let statusCode = captureV1StatusCode(info) + let statusCode = info.statusCode if let statusCode, 200 ... 299 ~= statusCode { - // A body without a readable results map counts as delivered, so a - // broken success can't loop forever. + // A 2xx whose results can't be read, including a transfer that failed + // after the status arrived, counts as delivered: the server already + // stored its ok events, and a resend would store them again. let results = data.flatMap { fromJSONData($0) }?["results"] as? [String: Any] if results == nil { - hedgeLog("Capture returned \(statusCode) without per-event results, treating the batch as delivered.") + hedgeLog("Capture returned \(statusCode) without readable per-event results, treating \(uuids.count) events as delivered.") } // Events missing from the results count as delivered. for uuid in uuids { @@ -903,15 +904,7 @@ final class PostHogRedirectHandler: NSObject, URLSessionTaskDelegate { /// Identity of a capture V1 request, kept so a retry of the same events reuses /// `PostHog-Request-Id` and `created_at` with an incremented `PostHog-Attempt`. -/// The status capture V1 acts on. A transfer that failed after a 2xx header -/// never delivered the per-event results, so it counts as no response and the -/// whole batch is resent instead of being removed from the queue. -func captureV1StatusCode(_ info: PostHogUploadInfo) -> Int? { - guard let statusCode = info.statusCode else { return nil } - return info.error != nil && 200 ... 299 ~= statusCode ? nil : statusCode -} - -private struct CaptureV1RequestIdentity { +struct CaptureV1RequestIdentity { let uuids: Set let requestId: String let attempt: Int diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 8de8b750a9..7a80734318 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -136,13 +136,16 @@ final class PostHogCaptureV1Test { #expect(result.retryRecordIds?.isEmpty ?? true) } - @Test("a 2xx whose transfer then failed is resent, not treated as delivered") - func failedTransferAfterSuccessIsRetried() { - let error = URLError(.networkConnectionLost) - #expect(captureV1StatusCode(PostHogUploadInfo(statusCode: 200, error: error)) == nil) - #expect(captureV1StatusCode(PostHogUploadInfo(statusCode: 400, error: error)) == 400) - #expect(captureV1StatusCode(PostHogUploadInfo(statusCode: 200, error: nil)) == 200) - #expect(captureV1StatusCode(PostHogUploadInfo(statusCode: nil, error: error)) == nil) + @Test("treats a 2xx whose transfer then failed as delivered, so its ok events aren't stored twice") + func failedTransferAfterSuccessIsDelivered() { + let uuids = [UUID().postHogUuidString, UUID().postHogUuidString] + let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") + let info = PostHogUploadInfo(statusCode: 200, error: URLError(.networkConnectionLost)) + + let result = makeApi().captureV1Result(info, data: nil, uuids: uuids, identity: identity) + + #expect(result.statusCode == 200) + #expect(result.retryRecordIds?.isEmpty ?? true) } @Test("a retry reuses the request ID and increments the attempt; an independent flush starts over") From b10cabcc92b94f4e47113dbeb0c5bf2891da2004 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:04:28 -0400 Subject: [PATCH 22/67] fix: send process_person_profile only when person processing is off The SDK wrote $process_person_profile (true or false) on every event and relied on hoisting to turn it into an option. It now sets the process_person_profile: false option only when person processing is off, and sends nothing otherwise, which is the server default. A caller's $process_person_profile property is still ignored; the option still overrides it, and beforeSend still has the final say. The crash-context snapshot keeps the property, since it stores only properties. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogPersonProfiles.swift | 4 +- PostHog/PostHogSDK.swift | 40 +++++++++--- PostHogTests/PostHogCaptureV1Test.swift | 33 ++++++++++ .../PostHogSDKPersonProfilesTest.swift | 62 +++++++++---------- PostHogTests/PostHogSDKTest.swift | 8 +-- .../Resources/event-shapes-batch.json | 10 +-- 6 files changed, 107 insertions(+), 50 deletions(-) diff --git a/PostHog/PostHogPersonProfiles.swift b/PostHog/PostHogPersonProfiles.swift index ad1e40c341..3116dc02e5 100644 --- a/PostHog/PostHogPersonProfiles.swift +++ b/PostHog/PostHogPersonProfiles.swift @@ -10,11 +10,11 @@ import Foundation /// Determines the behavior for processing user profiles. /// - `never`: We won't process persons for any event. This means that anonymous users will not be merged once /// they sign up or login, so you lose the ability to create funnels that track users from anonymous to identified. -/// All events (including `$identify`) will be sent with `$process_person_profile: False`. +/// All events (including `$identify`) will be sent with the `process_person_profile: false` option. /// - `always`: We will process persons data for all events. /// - `identifiedOnly`: (default): we will only process persons when you call `identify`, `alias`, and `group`, Anonymous users won't get person profiles. @objc(PostHogPersonProfiles) public enum PostHogPersonProfiles: Int { - /// Never process person profiles. All events are sent with `$process_person_profile: false`. + /// Never process person profiles. All events are sent with the `process_person_profile: false` option. case never /// Always process person profiles for all events. case always diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 633764c989..b4d889fade 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -608,6 +608,18 @@ let maxRetryDelay = 30.0 ) } + /// Adds `process_person_profile: false` when the SDK doesn't process person profiles for this + /// event, unless the caller set that option. Nothing is added otherwise: PostHog processes + /// person profiles by default. + private func personProcessingOptions(_ options: [String: Any]?) -> [String: Any]? { + guard !hasPersonProcessing() else { return options } + var options = options ?? [:] + if options["process_person_profile"] == nil || options["process_person_profile"] is NSNull { + options["process_person_profile"] = false + } + return options + } + @discardableResult private func requirePersonProcessing(functionName: String = #function) -> Bool { if config.personProfiles == .never { @@ -786,7 +798,14 @@ let maxRetryDelay = 30.0 props["$is_identified"] = isIdentified } - props["$process_person_profile"] = hasPersonProcessing() + // Person processing is sent as the `process_person_profile` option, only when it's off + // (see `personProcessingOptions`). The legacy property is dropped so a caller or super + // property can't override the SDK's state with it; the option can. The crash-context + // snapshot stores only properties, so it keeps the property, which the send hoists. + props.removeValue(forKey: "$process_person_profile") + if readOnlySession, !hasPersonProcessing() { + props["$process_person_profile"] = false + } if config.disableGeoIp { props["$geoip_disable"] = true @@ -1075,7 +1094,7 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce) ) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { return } @@ -1405,7 +1424,7 @@ let maxRetryDelay = 30.0 /// These replace the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and /// `$product_tour_id` properties. Those properties still work: they are removed from the /// event's properties and used as the option, unless you set that option here. The SDK - /// always sets `$process_person_profile` itself, so pass the `process_person_profile` option + /// ignores a `$process_person_profile` property, so pass the `process_person_profile` option /// to override it. Other keys are sent unchanged and ignored by PostHog. PostHog drops an /// event whose option value it can't read. /// `beforeSend` can change them through `PostHogEvent.options`. @@ -1695,12 +1714,17 @@ let maxRetryDelay = 30.0 finalProperties[PostHogExceptionStepFields.stepsKey] = steps } + // Snapshots and pre-built crash events don't carry the SDK's person-processing option. + let eventOptions = skipBuildProperties || isSnapshotEvent + ? sanitizeDictionary(options) + : personProcessingOptions(sanitizeDictionary(options)) + // Sanitize is now called in buildEvent let posthogEvent = buildEvent( event: event, distinctId: eventDistinctId, properties: finalProperties, - options: sanitizeDictionary(options), + options: eventOptions, timestamp: eventTimestamp ) @@ -1791,7 +1815,7 @@ let maxRetryDelay = 30.0 let eventName = "$screen" let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { return } @@ -1866,7 +1890,7 @@ let maxRetryDelay = 30.0 let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { return } @@ -1903,7 +1927,7 @@ let maxRetryDelay = 30.0 let eventName = "$create_alias" let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { return } @@ -1969,7 +1993,7 @@ let maxRetryDelay = 30.0 let eventName = "$groupidentify" let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { return } diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 7a80734318..cd20335f42 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -561,6 +561,39 @@ final class PostHogCaptureV1OptionsTest { #expect(sent["options"] as? [String: Bool] == ["process_person_profile": true, "cookieless_mode": true]) } + @Test("the SDK sends process_person_profile only as a false option when person processing is off") + func processPersonProfileOnlyWhenOff() async throws { + let personless = makeSDK(personProfiles: .never) + var sent = try await captureAndSend(personless) { $0.capture("test") } + personless.close() + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": false]) + #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + + let processed = makeSDK(personProfiles: .always) + defer { processed.close() } + sent = try await captureAndSend(processed) { $0.capture("test") } + #expect((sent["options"] as? [String: Any])?.isEmpty == true) + #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + + // A caller property still can't override the SDK's state, so nothing is sent for it. + sent = try await captureAndSend(processed) { $0.capture("test", properties: ["$process_person_profile": false]) } + #expect((sent["options"] as? [String: Any])?.isEmpty == true) + #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + } + + @Test("the crash context keeps $process_person_profile false only when person processing is off") + func crashContextKeepsPersonlessProperty() { + for (personProfiles, expected) in [(PostHogPersonProfiles.never, false as Bool?), (.always, nil)] { + let sut = makeSDK(personProfiles: personProfiles) + var eventProperties: [String: Any]? + let token = sut.onEventContextChanged.subscribe { eventProperties = $0["event_properties"] as? [String: Any] } + sut.notifyContextDidChange() + withExtendedLifetime(token) {} + sut.close() + #expect(eventProperties?["$process_person_profile"] as? Bool == expected) + } + } + @Test("beforeSend has the final say over options") func beforeSendEditsOptions() async throws { let sut = makeSDK(personProfiles: .never) { event in diff --git a/PostHogTests/PostHogSDKPersonProfilesTest.swift b/PostHogTests/PostHogSDKPersonProfilesTest.swift index f28792d7da..eee7b59dcb 100644 --- a/PostHogTests/PostHogSDKPersonProfilesTest.swift +++ b/PostHogTests/PostHogSDKPersonProfilesTest.swift @@ -48,7 +48,7 @@ final class PostHogSDKPersonProfilesTest { return PostHogSDK.with(config) } - @Test("capture sets process person to false if identified only and not identified") + @Test("capture sets the process person option to false if identified only and not identified") func captureSetsProcessPersonFalseIfIdentifiedOnlyAndNotIdentified() throws { let sut = getSut() @@ -60,27 +60,27 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() } - @Test("caller can't override $process_person_profile") + @Test("a caller $process_person_profile property can't override the SDK") func callerCannotOverrideProcessPersonProfile() throws { let sut = getSut(personProfiles: .never) sut.capture("test event", properties: ["$process_person_profile": true]) let event = try #require(getBatchedEvents(server).first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with user props") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithUserProps() throws { + @Test("capture sends no process person option if identified only and with user props") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithUserProps() throws { let sut = getSut() sut.capture("test event", @@ -92,14 +92,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with user set once props") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithUserSetOnceProps() throws { + @Test("capture sends no process person option if identified only and with user set once props") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithUserSetOnceProps() throws { let sut = getSut() sut.capture("test event", @@ -111,14 +111,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with group props") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithGroupProps() throws { + @Test("capture sends no process person option if identified only and with group props") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithGroupProps() throws { let sut = getSut() sut.capture("test event", @@ -130,14 +130,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and identified") - func captureSetsProcessPersonTrueIfIdentifiedOnlyAndIdentified() throws { + @Test("capture sends no process person option if identified only and identified") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyAndIdentified() throws { let sut = getSut(flushAt: 2) sut.identify("distinctId") @@ -150,14 +150,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.last) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with alias") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithAlias() throws { + @Test("capture sends no process person option if identified only and with alias") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithAlias() throws { let sut = getSut(flushAt: 2) sut.alias("distinctId") @@ -170,14 +170,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.last) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with groups") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithGroups() throws { + @Test("capture sends no process person option if identified only and with groups") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithGroups() throws { let sut = getSut(flushAt: 2) sut.group(type: "theType", key: "theKey") @@ -190,14 +190,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.last) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if always") - func captureSetsProcessPersonTrueIfAlways() throws { + @Test("capture sends no process person option if always") + func captureSendsNoProcessPersonOptionIfAlways() throws { let sut = getSut(personProfiles: .always) sut.capture("test event") @@ -208,13 +208,13 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to false if never and identify called") + @Test("capture sets the process person option to false if never and identify called") func captureSetsProcessPersonFalseIfNeverAndIdentifyCalled() throws { let sut = getSut(personProfiles: .never) @@ -229,13 +229,13 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() } - @Test("capture sets process person to false if never and alias called") + @Test("capture sets the process person option to false if never and alias called") func captureSetsProcessPersonFalseIfNeverAndAliasCalled() throws { let sut = getSut(personProfiles: .never) @@ -250,13 +250,13 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() } - @Test("capture sets process person to false if never and group called") + @Test("capture sets the process person option to false if never and group called") func captureSetsProcessPersonFalseIfNeverAndGroupCalled() throws { let sut = getSut(personProfiles: .never) @@ -271,7 +271,7 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 0459a3e315..1971e47713 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -321,7 +321,7 @@ final class PostHogSDKTests { let events = getBatchedEvents(server) #expect(events.count == 1) #expect(events.first?.event == "$set") - #expect(events.first?.properties["$process_person_profile"] as? Bool == true) + #expect(events.first?.options["process_person_profile"] == nil) #expect(config.storageManager?.isIdentified() == true) } @@ -360,7 +360,7 @@ final class PostHogSDKTests { let event = try #require(events.first) #expect(event.event == "$set") - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) let set = event.properties["$set"] as? [String: Any] ?? [:] #expect(set["foo"] as? String == "bar") @@ -955,7 +955,8 @@ final class PostHogSDKTests { let event = try #require(events.last) #expect(event.event == "$feature_flag_called") // $groups is correctness-required (ingestion dedup key + personful routing for group - // flags), so it must survive minimization when groups are registered. + // flags), so it must survive minimization when groups are registered. group() turns person + // processing on, so there's no process_person_profile option or property. #expect(Set(event.properties.keys) == Set([ "$feature_flag", "$feature_flag_response", @@ -966,7 +967,6 @@ final class PostHogSDKTests { "$feature_flag_request_id", "$feature_flag_evaluated_at", "$groups", - "$process_person_profile", "$session_id", "$lib", "$lib_version", diff --git a/PostHogTests/Resources/event-shapes-batch.json b/PostHogTests/Resources/event-shapes-batch.json index 37c5efda7a..983e7afea6 100644 --- a/PostHogTests/Resources/event-shapes-batch.json +++ b/PostHogTests/Resources/event-shapes-batch.json @@ -5,7 +5,7 @@ "distinct_id" : "anonymous-123", "event" : "order completed", "options" : { - "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ @@ -60,7 +60,7 @@ "distinct_id" : "user-123", "event" : "$identify", "options" : { - "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ @@ -107,7 +107,7 @@ "distinct_id" : "user-123", "event" : "$groupidentify", "options" : { - "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ @@ -154,7 +154,7 @@ "distinct_id" : "user-123", "event" : "$feature_interaction", "options" : { - "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ @@ -200,7 +200,7 @@ "distinct_id" : "user-123", "event" : "$exception", "options" : { - "process_person_profile" : true + }, "properties" : { "$active_feature_flags" : [ From 08a631ba1aa17556c5ce49ac5e09b2c81716016e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:29:15 -0400 Subject: [PATCH 23/67] docs: describe how 4.0 sends process_person_profile in the migration guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index d332f60e93..25927b744a 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -16,7 +16,7 @@ You need to change something if your app does any of these: - captures events with an empty event name or distinct ID, or with either over 200 bytes - sets `$lib` or `$lib_version` and expects PostHog to store that value - reads `$session_id` or `$window_id` from the raw request body, for example in a proxy -- sets `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` +- sets `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` - relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds ## Endpoints @@ -105,7 +105,7 @@ When the event is sent, the SDK removes each one from `properties` and uses it f | `$product_tour_id` | `product_tour_id` | - An option you set in `capture(..., options:)` or in `beforeSend` wins over its legacy property. An option set to `NSNull()` counts as unset, so the legacy property fills it. -- The SDK sets `$process_person_profile` on every event from `personProfiles`, so a `$process_person_profile` property you pass is replaced. To change person processing for one event, pass the `process_person_profile` option. +- The SDK ignores a `$process_person_profile` property you pass or register. When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. To change person processing for one event, pass the `process_person_profile` option. - 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. From 02be3d5b391cdad31ea64531ed539254d0e80848 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:48:00 -0400 Subject: [PATCH 24/67] test(compliance): run the adapter with personProfiles = .always The harness models a server SDK: it never identifies and expects person processing on unless an event opts out. Co-Authored-By: Claude Opus 5.5 (1M context) --- sdk_compliance_adapter/Sources/main.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index d3282595ec..6087b5c8bc 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -130,6 +130,9 @@ app.post("init") { req async throws -> Response in config.preloadFeatureFlags = false config.sendFeatureFlagEvent = false config.enableSwizzling = false + // The harness models a server SDK: it never identifies, and expects person + // processing on unless an event opts out. + config.personProfiles = .always #if os(iOS) config.sessionReplay = false From 68d328c040772e1e81cecc9a90342c8beeb719c8 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 17:05:29 -0400 Subject: [PATCH 25/67] feat!: accept options on screen, identify, alias, group and captureException screen, identify, setPersonProperties, alias, group and both captureException overloads take the same per-event options argument as capture. The options go on the event each call sends ($screen, $identify or $set, $create_alias, $groupidentify, $exception), so the legacy-property hoisting, the SDK's process_person_profile default and beforeSend apply exactly as they do for capture. Objective-C gets new selectors ending in options:, and the old selectors keep working through Swift-hidden shims. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-options.md | 2 +- PostHog/PostHogSDK.swift | 237 +++++++++++++++++++++--- PostHogTests/PostHogCaptureV1Test.swift | 79 ++++++++ api/posthog-ios.public-api.txt | 19 +- docs/migration-3.x-to-4.0.md | 6 +- 5 files changed, 309 insertions(+), 34 deletions(-) diff --git a/.changeset/v4-capture-options.md b/.changeset/v4-capture-options.md index 6d5378f729..0e56889425 100644 --- a/.changeset/v4-capture-options.md +++ b/.changeset/v4-capture-options.md @@ -2,4 +2,4 @@ 'posthog-ios': minor --- -Add per-event capture options with `capture(..., timestamp:options:)` and `PostHogEvent.options`, which `beforeSend` can read and change. +Add per-event capture options with `capture(..., timestamp:options:)` and `PostHogEvent.options`, which `beforeSend` can read and change. `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take the same `options` argument. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index b4d889fade..5e3e265321 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1024,10 +1024,19 @@ let maxRetryDelay = 30.0 /// - distinctId: Stable user identifier from your application. /// - userProperties: Properties to set on the person profile. Existing values are overwritten. /// - userPropertiesSetOnce: Properties to set only if they do not already exist. - @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) + /// - options: Optional settings that tell PostHog how to process the `$identify` or `$set` event it sends, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:) public func identify(_ distinctId: String, userProperties: [String: Any]? = nil, - userPropertiesSetOnce: [String: Any]? = nil) + userPropertiesSetOnce: [String: Any]? = nil, + options: [String: Any]? = nil) { if !isEnabled() { return @@ -1094,7 +1103,12 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce) ) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { + guard let event = buildEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: personProcessingOptions(sanitizeDictionary(options)) + ) else { return } @@ -1121,7 +1135,8 @@ let maxRetryDelay = 30.0 capture(PostHogKnownUnsafeEditableEvent.set.rawValue, distinctId: distinctId, userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce) + userPropertiesSetOnce: userPropertiesSetOnce, + options: options) // The identified state itself is not part of the flags request; reload only when the // caller supplied properties that can affect flag evaluation. @@ -1135,6 +1150,7 @@ let maxRetryDelay = 30.0 distinctId: distinctId, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options, deduplicatePersonProperties: true) // Automatically set person properties for feature flags during user property updates @@ -1147,6 +1163,23 @@ let maxRetryDelay = 30.0 } } + // A separate Swift name keeps this out of Swift overload resolution. + + /// Identifies the current user and sets person properties from Objective-C. + /// + /// - Parameters: + /// - distinctId: Stable user identifier from your application. + /// - userProperties: Properties to set on the person profile. Existing values are overwritten. + /// - userPropertiesSetOnce: Properties to set only if they do not already exist. + @available(swift, obsoleted: 1.0, message: "Use identify(_:userProperties:userPropertiesSetOnce:options:) instead") + @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) + public func identifyObjC(_ distinctId: String, + userProperties: [String: Any]?, + userPropertiesSetOnce: [String: Any]?) + { + identify(distinctId, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, options: nil) + } + /// Sets properties on the person profile associated with the current distinct_id. /// /// Updates user properties that are stored with the person profile in PostHog. @@ -1175,10 +1208,19 @@ let maxRetryDelay = 30.0 /// - userPropertiesToSetOnce: Properties to store about the user only if not previously set. /// Note: For feature flag evaluations, if the same key is present in both parameters, /// the value from userPropertiesToSet will take precedence. - @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) + /// - options: Optional settings that tell PostHog how to process the `$set` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:) public func setPersonProperties( userPropertiesToSet: [String: Any]?, - userPropertiesToSetOnce: [String: Any]? + userPropertiesToSetOnce: [String: Any]? = nil, + options: [String: Any]? = nil ) { if !isEnabled() { return @@ -1212,10 +1254,29 @@ let maxRetryDelay = 30.0 distinctId: currentDistinctId, userProperties: userPropertiesToSet, userPropertiesSetOnce: userPropertiesToSetOnce, + options: options, deduplicatePersonProperties: true ) } + // A separate Swift name keeps this out of Swift overload resolution. + + /// Sets properties on the person profile associated with the current distinct_id from Objective-C. + /// + /// This method sends a `$set` event to PostHog. + /// + /// - Parameters: + /// - userPropertiesToSet: Properties to store about the user. These will overwrite existing values. + /// - userPropertiesToSetOnce: Properties to store about the user only if not previously set. + @available(swift, obsoleted: 1.0, message: "Use setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:options:) instead") + @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) + public func setPersonPropertiesObjC( + userPropertiesToSet: [String: Any]?, + userPropertiesToSetOnce: [String: Any]? + ) { + setPersonProperties(userPropertiesToSet: userPropertiesToSet, userPropertiesToSetOnce: userPropertiesToSetOnce, options: nil) + } + /// Computes a hash for deduplicating person properties calls. private func getPersonPropertiesHash( distinctId: String, @@ -1775,8 +1836,16 @@ let maxRetryDelay = 30.0 /// - screenTitle: The screen name to record. It takes precedence over a /// `$screen_name` key in `properties`. /// - properties: Additional properties to attach to this `$screen` event. - @objc(screenWithTitle:properties:) - public func screen(_ screenTitle: String, properties: [String: Any]? = nil) { + /// - options: Optional settings that tell PostHog how to process this `$screen` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(screenWithTitle:properties:options:) + public func screen(_ screenTitle: String, properties: [String: Any]? = nil, options: [String: Any]? = nil) { if !isEnabled() { return } @@ -1815,7 +1884,12 @@ let maxRetryDelay = 30.0 let eventName = "$screen" let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { + guard let event = buildEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: personProcessingOptions(sanitizeDictionary(options)) + ) else { return } @@ -1837,6 +1911,20 @@ let maxRetryDelay = 30.0 } } + // A separate Swift name keeps this out of Swift overload resolution. + + /// Records a screen view by capturing a `$screen` event from Objective-C. + /// + /// - Parameters: + /// - screenTitle: The screen name to record. It takes precedence over a + /// `$screen_name` key in `properties`. + /// - properties: Additional properties to attach to this `$screen` event. + @available(swift, obsoleted: 1.0, message: "Use screen(_:properties:options:) instead") + @objc(screenWithTitle:properties:) + public func screenObjC(_ screenTitle: String, properties: [String: Any]?) { + screen(screenTitle, properties: properties, options: nil) + } + func autocapture( eventType: String, elementsChain: String, @@ -1904,6 +1992,26 @@ let maxRetryDelay = 30.0 /// /// - Parameter alias: The additional distinct ID to associate with the current user. @objc public func alias(_ alias: String) { + self.alias(alias, options: nil) + } + + /// Assigns an additional distinct ID to the current user. + /// + /// Use alias when a user should be connected to another identifier that was previously used + /// to track them. This sends a `$create_alias` event. + /// + /// - Parameters: + /// - alias: The additional distinct ID to associate with the current user. + /// - options: Optional settings that tell PostHog how to process the `$create_alias` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(alias:options:) + public func alias(_ alias: String, options: [String: Any]? = nil) { if !isEnabled() { return } @@ -1927,7 +2035,12 @@ let maxRetryDelay = 30.0 let eventName = "$create_alias" let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { + guard let event = buildEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: personProcessingOptions(sanitizeDictionary(options)) + ) else { return } @@ -1962,7 +2075,7 @@ let maxRetryDelay = 30.0 return mergedGroups ?? [:] } - private func groupIdentify(type: String, key: String, groupProperties: [String: Any]? = nil) { + private func groupIdentify(type: String, key: String, groupProperties: [String: Any]? = nil, options: [String: Any]? = nil) { if !isEnabled() { return } @@ -1993,7 +2106,12 @@ let maxRetryDelay = 30.0 let eventName = "$groupidentify" let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { + guard let event = buildEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: personProcessingOptions(sanitizeDictionary(options)) + ) else { return } @@ -2112,8 +2230,16 @@ let maxRetryDelay = 30.0 /// - type: Group type, such as `"company"` or `"organization"`. /// - key: Group key from your application. /// - groupProperties: Optional properties to set on the group profile. - @objc(groupWithType:key:groupProperties:) - public func group(type: String, key: String, groupProperties: [String: Any]? = nil) { + /// - options: Optional settings that tell PostHog how to process the `$groupidentify` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(groupWithType:key:groupProperties:options:) + public func group(type: String, key: String, groupProperties: [String: Any]? = nil, options: [String: Any]? = nil) { if !isEnabled() { return } @@ -2133,12 +2259,26 @@ let maxRetryDelay = 30.0 _ = groups([type: key]) - groupIdentify(type: type, key: key, groupProperties: sanitizeDictionary(groupProperties)) + groupIdentify(type: type, key: key, groupProperties: sanitizeDictionary(groupProperties), options: options) // Notify integrations of context change (e.g., for crash reporting) notifyContextDidChange() } + // A separate Swift name keeps this out of Swift overload resolution. + + /// Associates subsequent events with a group and optionally updates group properties from Objective-C. + /// + /// - Parameters: + /// - type: Group type, such as `"company"` or `"organization"`. + /// - key: Group key from your application. + /// - groupProperties: Optional properties to set on the group profile. + @available(swift, obsoleted: 1.0, message: "Use group(type:key:groupProperties:options:) instead") + @objc(groupWithType:key:groupProperties:) + public func groupObjC(type: String, key: String, groupProperties: [String: Any]?) { + group(type: type, key: key, groupProperties: groupProperties, options: nil) + } + // FEATURE FLAGS /// Sets person properties that will be included in feature flag evaluation requests. @@ -3237,11 +3377,20 @@ let maxRetryDelay = 30.0 /// - Parameters: /// - error: The error to capture (can be any Error or NSError) /// - properties: Optional additional properties to attach to the event - /// - @objc(captureExceptionWithError:properties:) + /// - options: Optional settings that tell PostHog how to process the `$exception` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + /// + @objc(captureExceptionWithError:properties:options:) public func captureException( _ error: Error, - properties: [String: Any]? = nil + properties: [String: Any]? = nil, + options: [String: Any]? = nil ) { guard isEnabled() else { return } @@ -3251,7 +3400,21 @@ let maxRetryDelay = 30.0 config: config.errorTrackingConfig ) - captureExceptionEvent(errorProperties, additionalProperties: properties) + captureExceptionEvent(errorProperties, additionalProperties: properties, options: options) + } + + // A separate Swift name keeps this out of Swift overload resolution. + + /// Capture a Swift Error or NSError from Objective-C + /// + /// - Parameters: + /// - error: The error to capture (can be any Error or NSError) + /// - properties: Optional additional properties to attach to the event + /// + @available(swift, obsoleted: 1.0, message: "Use captureException(_:properties:options:) instead") + @objc(captureExceptionWithError:properties:) + public func captureExceptionObjC(_ error: Error, properties: [String: Any]?) { + captureException(error, properties: properties, options: nil) } /// Capture a Swift Error or NSError without additional properties @@ -3284,11 +3447,20 @@ let maxRetryDelay = 30.0 /// - Parameters: /// - exception: The NSException to capture /// - properties: Optional additional properties to attach to the event - /// - @objc(captureExceptionWithNSException:properties:) + /// - options: Optional settings that tell PostHog how to process the `$exception` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + /// + @objc(captureExceptionWithNSException:properties:options:) public func captureException( _ exception: NSException, - properties: [String: Any]? = nil + properties: [String: Any]? = nil, + options: [String: Any]? = nil ) { guard isEnabled() else { return } @@ -3298,7 +3470,21 @@ let maxRetryDelay = 30.0 config: config.errorTrackingConfig ) - captureExceptionEvent(exceptionProperties, additionalProperties: properties) + captureExceptionEvent(exceptionProperties, additionalProperties: properties, options: options) + } + + // A separate Swift name keeps this out of Swift overload resolution. + + /// Capture an NSException from Objective-C + /// + /// - Parameters: + /// - exception: The NSException to capture + /// - properties: Optional additional properties to attach to the event + /// + @available(swift, obsoleted: 1.0, message: "Use captureException(_:properties:options:) instead") + @objc(captureExceptionWithNSException:properties:) + public func captureExceptionObjC(_ exception: NSException, properties: [String: Any]?) { + captureException(exception, properties: properties, options: nil) } /// Capture an NSException without additional properties @@ -3411,7 +3597,8 @@ let maxRetryDelay = 30.0 private func captureExceptionEvent( _ exceptionProperties: [String: Any], - additionalProperties: [String: Any]? + additionalProperties: [String: Any]?, + options: [String: Any]? ) { var mergedProperties = exceptionProperties additionalProperties?.forEach { key, value in @@ -3421,7 +3608,7 @@ let maxRetryDelay = 30.0 } // ignoredExceptionTypes is enforced in captureInternal, the chokepoint for every $exception path - capture("$exception", properties: mergedProperties) + capture("$exception", properties: mergedProperties, options: options) } private func installIntegrations() { diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index cd20335f42..c9d01deb28 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -626,6 +626,85 @@ final class PostHogCaptureV1OptionsTest { #expect(events.count == 2) #expect(Set(events.map(\.uuid)).count == 2) } + + @Test("the other event-sending methods apply options like capture does", arguments: [ + "screen", "identify", "identify existing user", "setPersonProperties", "alias", "group", + "captureException Error", "captureException NSException", + ]) + func eventSendingMethodsApplyOptions(method: String) async throws { + let options: [String: Any] = ["process_person_profile": true, "cookieless_mode": true, "product_tour_id": "caller-tour"] + // The setup identify and the call under test go out in one batch. + let sut = makeSDK(flushAt: method == "identify existing user" ? 2 : 1) { event in + if event.options["product_tour_id"] as? String == "caller-tour" { + event.options["product_tour_id"] = "edited-tour" + } + return event + } + defer { sut.close() } + // A legacy property the option must beat. + sut.register(["$cookieless_mode": false]) + + server.reset(batchCount: 1) + let expectedEvent: String + switch method { + case "screen": + sut.screen("Home", properties: ["$screen_name": "Other"], options: options) + expectedEvent = "$screen" + case "identify": + sut.identify("user", options: options) + expectedEvent = "$identify" + case "identify existing user": + sut.identify("user") + sut.identify("user", userProperties: ["plan": "pro"], options: options) + expectedEvent = "$set" + case "setPersonProperties": + sut.setPersonProperties(userPropertiesToSet: ["plan": "pro"], options: options) + expectedEvent = "$set" + case "alias": + sut.alias("other-id", options: options) + expectedEvent = "$create_alias" + case "group": + sut.group(type: "company", key: "acme", options: options) + expectedEvent = "$groupidentify" + case "captureException Error": + sut.captureException(NSError(domain: "test", code: 1), options: options) + expectedEvent = "$exception" + default: + sut.captureException(NSException(name: .genericException, reason: "boom"), options: options) + expectedEvent = "$exception" + } + _ = try await getServerEvents(server) + let request = try #require(server.batchRequests.first) + let sent = try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.last) + + #expect(sent["event"] as? String == expectedEvent) + #expect(sent["options"] as? [String: AnyHashable] == [ + "process_person_profile": true, "cookieless_mode": true, "product_tour_id": "edited-tour", + ]) + let properties = try #require(sent["properties"] as? [String: Any]) + #expect(properties["$cookieless_mode"] == nil) + if method == "screen" { + #expect(properties["$screen_name"] as? String == "Home") + } + } + + @Test("Objective-C keeps the old selectors and gains ones that take options") + func objcSelectors() { + let selectors = [ + "screenWithTitle:properties:", "screenWithTitle:properties:options:", + "identifyWithDistinctId:userProperties:userPropertiesSetOnce:", + "identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:", + "setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:", + "setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:", + "alias:", "alias:options:", + "groupWithType:key:groupProperties:", "groupWithType:key:groupProperties:options:", + "captureExceptionWithError:properties:", "captureExceptionWithError:properties:options:", + "captureExceptionWithNSException:properties:", "captureExceptionWithNSException:properties:options:", + ] + for selector in selectors { + #expect(PostHogSDK.instancesRespond(to: NSSelectorFromString(selector)), "\(selector)") + } + } } @Suite("Capture V1 queue partial retry", .serialized, .resetsGlobalState) diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index ee26ef2b5f..fb3eda0f7a 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -250,6 +250,7 @@ PostHog | PostHogSDK | class | @objc class PostHogSDK | c:@M@PostHog@objc(cs)Pos PostHog | PostHogSDK.addExceptionStep(_:) | method | @objc(addExceptionStep:) func addExceptionStep(_ message: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep: PostHog | PostHogSDK.addExceptionStep(_:properties:) | method | @objc(addExceptionStep:properties:) func addExceptionStep(_ message: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep:properties: PostHog | PostHogSDK.alias(_:) | method | @objc func alias(_ alias: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias: +PostHog | PostHogSDK.alias(_:options:) | method | @objc(alias:options:) func alias(_ alias: String, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias:options: PostHog | PostHogSDK.capture(_:) | method | @objc func capture(_ event: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)capture: PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups: PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: @@ -264,8 +265,10 @@ PostHog | PostHogSDK.captureDeepLink(urls:) | method | @objc func captureDeepLin PostHog | PostHogSDK.captureDeepLink(userActivity:) | method | @objc func captureDeepLink(userActivity: NSUserActivity) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUserActivity: PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithError:) func captureException(_ error: any Error) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError: PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithNSException:) func captureException(_ exception: NSException) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException: -PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureException(_ error: any Error, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: -PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureException(_ exception: NSException, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: +PostHog | PostHogSDK.captureException(_:properties:options:) | method | @objc(captureExceptionWithError:properties:options:) func captureException(_ error: any Error, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties:options: +PostHog | PostHogSDK.captureException(_:properties:options:) | method | @objc(captureExceptionWithNSException:properties:options:) func captureException(_ exception: NSException, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties:options: +PostHog | PostHogSDK.captureExceptionObjC(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureExceptionObjC(_ error: any Error, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: +PostHog | PostHogSDK.captureExceptionObjC(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureExceptionObjC(_ exception: NSException, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: PostHog | PostHogSDK.captureFeatureInteraction(flag:flagVariant:) | method | @objc func captureFeatureInteraction(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureInteractionWithFlag:flagVariant: PostHog | PostHogSDK.captureFeatureView(flag:flagVariant:) | method | @objc func captureFeatureView(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureViewWithFlag:flagVariant: PostHog | PostHogSDK.captureLog(_:) | method | @objc(captureLogWithBody:) func captureLog(_ body: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody: @@ -292,10 +295,12 @@ PostHog | PostHogSDK.getFeatureFlagResult(_:) | method | @objc func getFeatureFl PostHog | PostHogSDK.getFeatureFlagResult(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagResultWithKey:sendFeatureFlagEvent:) func getFeatureFlagResult(_ key: String, sendFeatureFlagEvent: Bool) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResultWithKey:sendFeatureFlagEvent: PostHog | PostHogSDK.getSessionId() | method | @objc func getSessionId() -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)getSessionId PostHog | PostHogSDK.group(type:key:) | method | @objc(groupWithType:key:) func group(type: String, key: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key: -PostHog | PostHogSDK.group(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func group(type: String, key: String, groupProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: +PostHog | PostHogSDK.group(type:key:groupProperties:options:) | method | @objc(groupWithType:key:groupProperties:options:) func group(type: String, key: String, groupProperties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties:options: +PostHog | PostHogSDK.groupObjC(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func groupObjC(type: String, key: String, groupProperties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: PostHog | PostHogSDK.identify(_:) | method | @objc func identify(_ distinctId: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)identify: PostHog | PostHogSDK.identify(_:userProperties:) | method | @objc(identifyWithDistinctId:userProperties:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties: -PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: +PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:options:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce:options: +PostHog | PostHogSDK.identifyObjC(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identifyObjC(_ distinctId: String, userProperties: [String : Any]?, userPropertiesSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.isAutocaptureActive() | method | @objc func isAutocaptureActive() -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isAutocaptureActive PostHog | PostHogSDK.isFeatureEnabled(_:) | method | @objc func isFeatureEnabled(_ key: String) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabled: PostHog | PostHogSDK.isFeatureEnabled(_:sendFeatureFlagEvent:) | method | @objc(isFeatureEnabledWithKey:sendFeatureFlagEvent:) func isFeatureEnabled(_ key: String, sendFeatureFlagEvent: Bool) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabledWithKey:sendFeatureFlagEvent: @@ -320,13 +325,15 @@ PostHog | PostHogSDK.resetGroupPropertiesForFlags(reloadFeatureFlags:) | method PostHog | PostHogSDK.resetPersonPropertiesForFlags() | method | @objc func resetPersonPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlags PostHog | PostHogSDK.resetPersonPropertiesForFlags(reloadFeatureFlags:) | method | @objc(resetPersonPropertiesForFlagsWithReloadFeatureFlags:) func resetPersonPropertiesForFlags(reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlagsWithReloadFeatureFlags: PostHog | PostHogSDK.screen(_:) | method | @objc func screen(_ screenTitle: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)screen: -PostHog | PostHogSDK.screen(_:properties:) | method | @objc(screenWithTitle:properties:) func screen(_ screenTitle: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: +PostHog | PostHogSDK.screen(_:properties:options:) | method | @objc(screenWithTitle:properties:options:) func screen(_ screenTitle: String, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties:options: +PostHog | PostHogSDK.screenObjC(_:properties:) | method | @objc(screenWithTitle:properties:) func screenObjC(_ screenTitle: String, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:) | method | @objc(setGroupPropertiesForFlags:properties:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties: PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:reloadFeatureFlags:) | method | @objc(setGroupPropertiesForFlags:properties:reloadFeatureFlags:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties:reloadFeatureFlags: PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:) func setPersonProperties(userPropertiesToSet: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet: -PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: +PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:options:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options: PostHog | PostHogSDK.setPersonPropertiesForFlags(_:) | method | @objc func setPersonPropertiesForFlags(_ properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlags: PostHog | PostHogSDK.setPersonPropertiesForFlags(_:reloadFeatureFlags:) | method | @objc(setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags:) func setPersonPropertiesForFlags(_ properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags: +PostHog | PostHogSDK.setPersonPropertiesObjC(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonPropertiesObjC(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: PostHog | PostHogSDK.setup(_:) | method | @objc func setup(_ config: PostHogConfig) | c:@M@PostHog@objc(cs)PostHogSDK(im)setup: PostHog | PostHogSDK.shared | type.property | @objc nonisolated(unsafe) static let shared: PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK(cpy)shared PostHog | PostHogSDK.startSession() | method | @objc func startSession() | c:@M@PostHog@objc(cs)PostHogSDK(im)startSession diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 25927b744a..ab81431af0 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -83,6 +83,8 @@ In PostHog, the events still have `$session_id` and `$window_id`, so insights, f Capture v1 reads processing settings from an `options` object sent next to `properties`. `capture` takes a new `options` argument, and `beforeSend` can read and change `PostHogEvent.options`. +`screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take the same argument. +For `identify`, the options apply to the `$identify` or `$set` event the call sends. ```swift // 3.x @@ -92,7 +94,7 @@ PostHogSDK.shared.capture("signed_up", properties: ["$cookieless_mode": true]) PostHogSDK.shared.capture("signed_up", options: ["cookieless_mode": true]) ``` -In Objective-C, use `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`. +In Objective-C, use the selectors that end in `options:`, such as `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`, `screenWithTitle:properties:options:`, `identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:`, `setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:`, `alias:options:`, `groupWithType:key:groupProperties:options:`, `captureExceptionWithError:properties:options:` and `captureExceptionWithNSException:properties:options:`. The selectors without `options:` still work. The legacy option properties still work. When the event is sent, the SDK removes each one from `properties` and uses it for its option: @@ -104,7 +106,7 @@ When the event is sent, the SDK removes each one from `properties` and uses it f | `$ignore_sent_at` | `disable_skew_correction` | | `$product_tour_id` | `product_tour_id` | -- An option you set in `capture(..., options:)` or in `beforeSend` wins over its legacy property. An option set to `NSNull()` counts as unset, so the legacy property fills it. +- An option you pass to any of these methods or set in `beforeSend` wins over its legacy property. An option set to `NSNull()` counts as unset, so the legacy property fills it. - The SDK ignores a `$process_person_profile` property you pass or register. When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. To change person processing for one event, pass the `process_person_profile` option. - 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. From 14910016e5e33d031fe0a161b58396491cd5fbe0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 12:45:32 -0400 Subject: [PATCH 26/67] fix: don't deduplicate person properties sent without person processing A $set sent with process_person_profile: false stored its hash, so the same properties set later with person processing were suppressed and never reached the person. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogSDK.swift | 2 ++ PostHogTests/PostHogCaptureV1Test.swift | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 5e3e265321..ce19d0dc88 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -2170,8 +2170,10 @@ let maxRetryDelay = 30.0 let userPropertiesSetOnce = event.properties["$set_once"] as? [String: Any] // Presence, not content: sanitizing can empty a non-empty input (a `UUID` or `Data` value // is dropped), and that empty `$set` is still queued, so it needs a marker too. + // A personless event doesn't update the person, so it neither marks nor matches a hash. if event.event == PostHogKnownUnsafeEditableEvent.set.rawValue || event.event == PostHogKnownUnsafeEditableEvent.identify.rawValue, userProperties != nil || userPropertiesSetOnce != nil, + event.options["process_person_profile"] as? Bool != false, let storageManager = config.storageManager { let hash = getPersonPropertiesHash( diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index c9d01deb28..5fd001b4fc 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -609,6 +609,20 @@ final class PostHogCaptureV1OptionsTest { #expect(sent["options"] as? [String: AnyHashable] == ["process_person_profile": true, "product_tour_id": "edited-tour"]) } + @Test("a personless $set doesn't suppress the same properties set later with person processing") + func personlessSetDoesNotDeduplicate() async throws { + let sut = makeSDK(personProfiles: .always, flushAt: 2) + defer { sut.close() } + + server.reset(batchCount: 1) + sut.setPersonProperties(userPropertiesToSet: ["plan": "pro"], options: ["process_person_profile": false]) + sut.setPersonProperties(userPropertiesToSet: ["plan": "pro"]) + let events = try await getServerEvents(server) + + #expect(events.map(\.event) == ["$set", "$set"]) + #expect(events.last?.options["process_person_profile"] == nil) + } + @Test("an event beforeSend returns again still gets a new UUID") func beforeSendReturningEarlierEventGetsNewUuid() async throws { var kept: PostHogEvent? From a9c30651c43136cbbfb8de930877f9965a0221e0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 12:45:32 -0400 Subject: [PATCH 27/67] fix: use the current distinct ID for a blank distinctId argument An empty or whitespace-only distinctId now falls back like nil, instead of capture v1 dropping the event. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogSDK.swift | 9 +++++---- PostHogTests/PostHogCaptureV1Test.swift | 10 ++++++++++ docs/migration-3.x-to-4.0.md | 6 ++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index ce19d0dc88..a13179114d 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1439,7 +1439,7 @@ let maxRetryDelay = 30.0 /// /// - Parameters: /// - event: Event name to capture. - /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID when nil, empty or whitespace-only. /// - properties: Event properties attached only to this event. /// - userProperties: Person properties to set. Existing values are overwritten. /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. @@ -1465,7 +1465,7 @@ let maxRetryDelay = 30.0 /// /// - Parameters: /// - event: Event name to capture. - /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID when nil, empty or whitespace-only. /// - properties: Event properties attached only to this event. /// - userProperties: Person properties to set. Existing values are overwritten. /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. @@ -1518,7 +1518,7 @@ let maxRetryDelay = 30.0 /// /// - Parameters: /// - event: Event name to capture. - /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID when nil, empty or whitespace-only. /// - properties: Event properties attached only to this event. /// - userProperties: Person properties to set. Existing values are overwritten. /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. @@ -1726,7 +1726,8 @@ let maxRetryDelay = 30.0 var isSnapshotEvent = event == "$snapshot" let eventTimestamp = timestamp ?? now() - let eventDistinctId = distinctId ?? getDistinctId() + // A blank distinct ID falls back to the current one, like nil, instead of PostHog dropping the event. + let eventDistinctId = distinctId.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } ?? getDistinctId() // if the user isn't identified but passed userProperties, userPropertiesSetOnce or groups, // we should still enable person processing since this is intentional diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 5fd001b4fc..17d02098fa 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -623,6 +623,16 @@ final class PostHogCaptureV1OptionsTest { #expect(events.last?.options["process_person_profile"] == nil) } + @Test("a blank distinctId argument uses the current distinct ID", arguments: ["", " "]) + func blankDistinctIdFallsBack(distinctId: String) async throws { + let sut = makeSDK() + defer { sut.close() } + + let sent = try await captureAndSend(sut) { $0.capture("test", distinctId: distinctId) } + + #expect(sent["distinct_id"] as? String == sut.getDistinctId()) + } + @Test("an event beforeSend returns again still gets a new UUID") func beforeSendReturningEarlierEventGetsNewUuid() async throws { var kept: PostHogEvent? diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index ab81431af0..a587fef668 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -13,7 +13,8 @@ You need to change something if your app does any of these: - sends events to a self-hosted PostHog or a reverse proxy that doesn't serve `/i/v1/analytics/events` - authenticates its proxy with an `Authorization` header in `requestHeaders` - sends events through a proxy that redirects capture requests, other than with a 307 or 308 to the same origin as `host` -- captures events with an empty event name or distinct ID, or with either over 200 bytes +- captures events with an empty event name, or with an event name or distinct ID over 200 bytes +- sets an empty distinct ID in `beforeSend` - sets `$lib` or `$lib_version` and expects PostHog to store that value - reads `$session_id` or `$window_id` from the raw request body, for example in a proxy - sets `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` @@ -54,7 +55,8 @@ The SDK doesn't check these before sending. PostHog drops the event, and the SDK With `debug` on, the SDK logs how many events were dropped. - An event with an empty event name is dropped, for example `capture("")`. -- An event with an empty or whitespace-only distinct ID is dropped, for example `capture("signed_up", distinctId: "")`. +- An event with an empty or whitespace-only distinct ID is dropped, for example one `beforeSend` sets to `""`. + An empty or whitespace-only `distinctId` argument isn't dropped: like `nil`, it uses the current distinct ID. - An event whose name is over 200 UTF-8 bytes is dropped. Legacy capture had no limit on event name length. - An event whose distinct ID is over 200 UTF-8 bytes is dropped. Legacy capture shortened the ID to 200 characters and kept the event. From 25fbf005fdbd9ebd6cf7c827794b8e280e82e0b3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 12:45:33 -0400 Subject: [PATCH 28/67] fix: disable the SDK when projectToken is a personal API key PostHog rejects a phx_ key for capture with a 401, so every event was dropped. Setup now disables the SDK, as for an empty token. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogSDK.swift | 6 ++++++ PostHogTests/PostHogCaptureV1Test.swift | 9 +++++++++ docs/migration-3.x-to-4.0.md | 4 ++++ 3 files changed, 19 insertions(+) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index a13179114d..6a57d9cff6 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -181,6 +181,12 @@ let maxRetryDelay = 30.0 return } + // PostHog rejects a personal API key for capture with a 401, and every event would be dropped. + if config.projectToken.hasPrefix("phx_") { + hedgeLog("PostHog SDK will be disabled because projectToken is a personal API key (phx_...), not a project token (phc_...).") + return + } + if PostHogSDK.projectTokens.contains(config.projectToken) { hedgeLog("Project token: \(config.projectToken) already has a PostHog instance.") } else { diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 17d02098fa..2e2db30e28 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -633,6 +633,15 @@ final class PostHogCaptureV1OptionsTest { #expect(sent["distinct_id"] as? String == sut.getDistinctId()) } + @Test("a personal API key disables the SDK at setup") + func personalApiKeyDisablesSdk() { + let sut = PostHogSDK.with(PostHogConfig(projectToken: "phx_personal", host: "http://localhost:9001")) + defer { sut.close() } + + // A disabled SDK has no storage, so it has no distinct ID. + #expect(sut.getDistinctId() == "") + } + @Test("an event beforeSend returns again still gets a new UUID") func beforeSendReturningEarlierEventGetsNewUuid() async throws { var kept: PostHogEvent? diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index a587fef668..ba6b1ab677 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -15,6 +15,7 @@ You need to change something if your app does any of these: - sends events through a proxy that redirects capture requests, other than with a 307 or 308 to the same origin as `host` - captures events with an empty event name, or with an event name or distinct ID over 200 bytes - sets an empty distinct ID in `beforeSend` +- sets `projectToken` to a personal API key (`phx_...`) - sets `$lib` or `$lib_version` and expects PostHog to store that value - reads `$session_id` or `$window_id` from the raw request body, for example in a proxy - sets `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` @@ -33,6 +34,9 @@ Capture v1 requests send the project token in an `Authorization: Bearer Date: Fri, 23 Oct 2026 13:37:28 -0400 Subject: [PATCH 29/67] docs: mark the migration guide to link the public capture v1 guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index ba6b1ab677..f02fb1eb28 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -4,6 +4,8 @@ Most apps upgrade without code changes. Read the checklist first, then the sections that apply to you. + + For the full list of 4.0 changes, see the [changelog](../CHANGELOG.md). ## Checklist From 31a80cb35545a47ec95ef5e1dfdcdc23e1f53619 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 13:50:31 -0400 Subject: [PATCH 30/67] feat!: let event properties override the SDK's own values Caller event properties now win over $is_identified, $process_person_profile, $geoip_disable and the session replay debug values, which become defaults. Registered super properties still can't override them, and beforeSend still has the final say: super properties < SDK context and values < event properties < beforeSend. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 1 + PostHog/PostHogSDK.swift | 67 ++++++++++--------- PostHogTests/PostHogCaptureV1Test.swift | 13 ++-- PostHogTests/PostHogGeoIpTest.swift | 17 +++++ .../PostHogSDKPersonProfilesTest.swift | 6 +- PostHogTests/PostHogSDKTest.swift | 37 +++++----- docs/migration-3.x-to-4.0.md | 17 ++++- 7 files changed, 103 insertions(+), 55 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 94599cca78..4959d9b3a7 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -11,5 +11,6 @@ - After a failed send, the wait before the next one now doubles from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. - `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, because it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. - The `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as capture options, and PostHog drops an event whose option value it can't read. +- Properties you pass on an event now also override the SDK's `$is_identified`, `$process_person_profile`, `$geoip_disable` and session replay debug values. Registered super properties still can't, and `beforeSend` still has the final say. See the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md) for what to check before you upgrade. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 6a57d9cff6..4026fe47e1 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -615,10 +615,13 @@ let maxRetryDelay = 30.0 } /// Adds `process_person_profile: false` when the SDK doesn't process person profiles for this - /// event, unless the caller set that option. Nothing is added otherwise: PostHog processes - /// person profiles by default. - private func personProcessingOptions(_ options: [String: Any]?) -> [String: Any]? { + /// event, unless the caller set that option or the legacy `$process_person_profile` property. + /// Nothing is added otherwise: PostHog processes person profiles by default. + private func personProcessingOptions(_ options: [String: Any]?, properties: [String: Any]) -> [String: Any]? { guard !hasPersonProcessing() else { return options } + if let legacy = properties["$process_person_profile"], !(legacy is NSNull) { + return options + } var options = options ?? [:] if options["process_person_profile"] == nil || options["process_person_profile"] is NSNull { options["process_person_profile"] = false @@ -782,32 +785,17 @@ let maxRetryDelay = 30.0 props["distinct_id"] = distinctId } - // Caller-supplied properties win over registered super properties and SDK context. - // A blank caller value for a key the SDK filled in above is treated as absent. - var callerProps = properties ?? [:] - for key in ["$session_id", "$screen_name", "distinct_id"] where props[key] != nil { - if let value = callerProps[key] as? String, !Self.isBlank(value) { - continue - } - callerProps.removeValue(forKey: key) - } - // A caller-supplied $groups adds to the registered and per-call groups instead of replacing them. - if let callerGroups = callerProps["$groups"] as? [String: String], let currentGroups = props["$groups"] as? [String: String] { - callerProps["$groups"] = currentGroups.merging(callerGroups) { _, new in new } - } - props.merge(callerProps) { _, new in new } - - // After the caller merge so the SDK's person-processing state, geoip opt-out and debug - // values can't be overridden per event, matching posthog-js and posthog-android. + // SDK-owned values are defaults: they overwrite registered super properties and SDK context, + // and the caller's properties below override them for one event. if appendSharedProps { if let isIdentified = config.storageManager?.isIdentified() { props["$is_identified"] = isIdentified } // Person processing is sent as the `process_person_profile` option, only when it's off - // (see `personProcessingOptions`). The legacy property is dropped so a caller or super - // property can't override the SDK's state with it; the option can. The crash-context - // snapshot stores only properties, so it keeps the property, which the send hoists. + // (see `personProcessingOptions`). A registered legacy property is dropped; a caller's is + // kept and wins over the SDK's option when the send hoists it. The crash-context snapshot + // stores only properties, so it keeps the property, which the send hoists. props.removeValue(forKey: "$process_person_profile") if readOnlySession, !hasPersonProcessing() { props["$process_person_profile"] = false @@ -849,6 +837,21 @@ let maxRetryDelay = 30.0 } } + // Caller-supplied properties win over registered super properties, SDK context and SDK-owned values. + // A blank caller value for a key the SDK filled in above is treated as absent. + var callerProps = properties ?? [:] + for key in ["$session_id", "$screen_name", "distinct_id"] where props[key] != nil { + if let value = callerProps[key] as? String, !Self.isBlank(value) { + continue + } + callerProps.removeValue(forKey: key) + } + // A caller-supplied $groups adds to the registered and per-call groups instead of replacing them. + if let callerGroups = callerProps["$groups"] as? [String: String], let currentGroups = props["$groups"] as? [String: String] { + callerProps["$groups"] = currentGroups.merging(callerGroups) { _, new in new } + } + props.merge(callerProps) { _, new in new } + return props } @@ -1113,7 +1116,7 @@ let maxRetryDelay = 30.0 event: eventName, distinctId: distinctId, properties: properties, - options: personProcessingOptions(sanitizeDictionary(options)) + options: personProcessingOptions(sanitizeDictionary(options), properties: properties) ) else { return } @@ -1490,9 +1493,9 @@ let maxRetryDelay = 30.0 /// /// These replace the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and /// `$product_tour_id` properties. Those properties still work: they are removed from the - /// event's properties and used as the option, unless you set that option here. The SDK - /// ignores a `$process_person_profile` property, so pass the `process_person_profile` option - /// to override it. Other keys are sent unchanged and ignored by PostHog. PostHog drops an + /// event's properties and used as the option, unless you set that option here. A + /// `$process_person_profile` property you pass here overrides the SDK's default, but a + /// registered one is ignored. Other keys are sent unchanged and ignored by PostHog. PostHog drops an /// event whose option value it can't read. /// `beforeSend` can change them through `PostHogEvent.options`. @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) @@ -1785,7 +1788,7 @@ let maxRetryDelay = 30.0 // Snapshots and pre-built crash events don't carry the SDK's person-processing option. let eventOptions = skipBuildProperties || isSnapshotEvent ? sanitizeDictionary(options) - : personProcessingOptions(sanitizeDictionary(options)) + : personProcessingOptions(sanitizeDictionary(options), properties: finalProperties) // Sanitize is now called in buildEvent let posthogEvent = buildEvent( @@ -1895,7 +1898,7 @@ let maxRetryDelay = 30.0 event: eventName, distinctId: distinctId, properties: properties, - options: personProcessingOptions(sanitizeDictionary(options)) + options: personProcessingOptions(sanitizeDictionary(options), properties: properties) ) else { return } @@ -1985,7 +1988,7 @@ let maxRetryDelay = 30.0 let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil)) else { + guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil, properties: properties)) else { return } @@ -2046,7 +2049,7 @@ let maxRetryDelay = 30.0 event: eventName, distinctId: distinctId, properties: properties, - options: personProcessingOptions(sanitizeDictionary(options)) + options: personProcessingOptions(sanitizeDictionary(options), properties: properties) ) else { return } @@ -2117,7 +2120,7 @@ let maxRetryDelay = 30.0 event: eventName, distinctId: distinctId, properties: properties, - options: personProcessingOptions(sanitizeDictionary(options)) + options: personProcessingOptions(sanitizeDictionary(options), properties: properties) ) else { return } diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 2e2db30e28..13a5c0c0e4 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -546,15 +546,20 @@ final class PostHogCaptureV1OptionsTest { return try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.first) } - @Test("the SDK's process_person_profile beats a caller property but not a caller option") + @Test("a caller's process_person_profile property or option beats the SDK's, a registered one doesn't") func processPersonProfilePrecedence() async throws { let sut = makeSDK(personProfiles: .never) defer { sut.close() } var sent = try await captureAndSend(sut) { $0.capture("test", properties: ["$process_person_profile": true]) } - #expect((sent["options"] as? [String: Any])?["process_person_profile"] as? Bool == false) + #expect((sent["options"] as? [String: Any])?["process_person_profile"] as? Bool == true) #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + sut.register(["$process_person_profile": true]) + sent = try await captureAndSend(sut) { $0.capture("test") } + sut.unregister("$process_person_profile") + #expect((sent["options"] as? [String: Any])?["process_person_profile"] as? Bool == false) + sent = try await captureAndSend(sut) { $0.capture("test", options: ["process_person_profile": true, "cookieless_mode": true]) } @@ -575,9 +580,9 @@ final class PostHogCaptureV1OptionsTest { #expect((sent["options"] as? [String: Any])?.isEmpty == true) #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) - // A caller property still can't override the SDK's state, so nothing is sent for it. + // A caller property overrides the SDK's state for that event, sent as the option. sent = try await captureAndSend(processed) { $0.capture("test", properties: ["$process_person_profile": false]) } - #expect((sent["options"] as? [String: Any])?.isEmpty == true) + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": false]) #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) } diff --git a/PostHogTests/PostHogGeoIpTest.swift b/PostHogTests/PostHogGeoIpTest.swift index bcbaa1a35c..7455e66b28 100644 --- a/PostHogTests/PostHogGeoIpTest.swift +++ b/PostHogTests/PostHogGeoIpTest.swift @@ -64,6 +64,23 @@ class PostHogGeoIpTests { sut.close() } + @Test("a caller $geoip_disable overrides disableGeoIp, a registered one doesn't") + func callerOverridesGeoipDisable() { + let sut = getSut(disableGeoIp: true) + server.reset(batchCount: 2) + + sut.capture("caller", properties: ["$geoip_disable": false]) + sut.register(["$geoip_disable": false]) + sut.capture("registered") + + let events = getBatchedEvents(server) + #expect(events.first { $0.event == "caller" }?.properties["$geoip_disable"] as? Bool == false) + #expect(events.first { $0.event == "registered" }?.properties["$geoip_disable"] as? Bool == true) + + sut.reset() + sut.close() + } + @Test("flags requests send geoip_disable false by default") func flagsRequestsSendGeoipDisableFalseByDefault() async throws { let sut = getSut() diff --git a/PostHogTests/PostHogSDKPersonProfilesTest.swift b/PostHogTests/PostHogSDKPersonProfilesTest.swift index eee7b59dcb..2307e137be 100644 --- a/PostHogTests/PostHogSDKPersonProfilesTest.swift +++ b/PostHogTests/PostHogSDKPersonProfilesTest.swift @@ -66,14 +66,14 @@ final class PostHogSDKPersonProfilesTest { sut.close() } - @Test("a caller $process_person_profile property can't override the SDK") - func callerCannotOverrideProcessPersonProfile() throws { + @Test("a caller $process_person_profile property overrides the SDK") + func callerOverridesProcessPersonProfile() throws { let sut = getSut(personProfiles: .never) sut.capture("test event", properties: ["$process_person_profile": true]) let event = try #require(getBatchedEvents(server).first) - #expect(event.options["process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == true) sut.reset() sut.close() diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 1971e47713..76898227a4 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -1267,31 +1267,38 @@ final class PostHogSDKTests { sut.close() } - @Test("caller can't override $is_identified") - func callerCannotOverrideIsIdentified() throws { - let sut = getSut(flushAt: 2) + @Test("a caller $is_identified overrides the SDK's, a registered one doesn't") + func callerOverridesIsIdentified() throws { + let sut = getSut(flushAt: 3) sut.identify("user") - sut.capture("event", properties: ["$is_identified": false]) + sut.capture("caller", properties: ["$is_identified": false]) + sut.register(["$is_identified": false]) + sut.capture("registered") - let event = try #require(getBatchedEvents(server).first { $0.event == "event" }) - #expect(event.properties["$is_identified"] as? Bool == true) + let events = getBatchedEvents(server) + #expect(events.first { $0.event == "caller" }?.properties["$is_identified"] as? Bool == false) + #expect(events.first { $0.event == "registered" }?.properties["$is_identified"] as? Bool == true) sut.reset() sut.close() } - @Test("caller can't override SDK debug properties") - func callerCannotOverrideSdkDebugProperties() throws { - let sut = getSut() + @Test("caller debug properties override the SDK's, registered ones don't") + func callerOverridesSdkDebugProperties() throws { + let sut = getSut(flushAt: 2) - sut.capture("event", properties: ["$recording_status": "bogus", "$sdk_debug_pending_queue_size": -1]) + sut.capture("caller", properties: ["$recording_status": "bogus", "$sdk_debug_pending_queue_size": -1]) + sut.register(["$recording_status": "bogus", "$sdk_debug_pending_queue_size": -1]) + sut.capture("registered") - let event = try #require(getBatchedEvents(server).first) - let recordingStatus = try #require(event.properties["$recording_status"] as? String) - let pendingQueueSize = try #require(event.properties["$sdk_debug_pending_queue_size"] as? Int) - #expect(recordingStatus != "bogus") - #expect(pendingQueueSize != -1) + let events = getBatchedEvents(server) + let caller = try #require(events.first { $0.event == "caller" }) + #expect(caller.properties["$recording_status"] as? String == "bogus") + #expect(caller.properties["$sdk_debug_pending_queue_size"] as? Int == -1) + let registered = try #require(events.first { $0.event == "registered" }) + #expect(registered.properties["$recording_status"] as? String != "bogus") + #expect(registered.properties["$sdk_debug_pending_queue_size"] as? Int != -1) sut.reset() sut.close() diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index f02fb1eb28..1f263dd69e 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -20,6 +20,7 @@ You need to change something if your app does any of these: - sets `projectToken` to a personal API key (`phx_...`) - sets `$lib` or `$lib_version` and expects PostHog to store that value - reads `$session_id` or `$window_id` from the raw request body, for example in a proxy +- passes `$is_identified`, `$geoip_disable`, `$process_person_profile` or `$recording_status` in event properties and expects the SDK's value to win - sets `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` - relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds @@ -78,6 +79,20 @@ The SDK removes `$lib` and `$lib_version` from the properties it sends to captur A value you set in properties, in a super property or in `beforeSend` doesn't reach PostHog. `beforeSend` still sees the SDK's value. +## Property precedence + +An event's properties are merged in this order, and later values win: + +1. registered super properties (`register`) +2. SDK context, such as `$os`, `$app_version` and `$screen_name` +3. SDK-owned values: `$is_identified`, `$geoip_disable` when `disableGeoIp` is on, and the session replay debug properties (`$recording_status` and the `$sdk_debug_*` keys) +4. the properties you pass to `capture` or another event-sending method +5. `beforeSend` + +In 3.x, the SDK-owned values in step 3 were set after your properties, so you couldn't override them for one event. +In 4.0 a value you pass wins, for example `capture("signed_up", properties: ["$geoip_disable": false])` sends that event with GeoIP enabled even when `disableGeoIp` is on. +A registered super property still can't override them. + ## Session and window IDs Capture v1 sends `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields, not as properties. @@ -115,7 +130,7 @@ When the event is sent, the SDK removes each one from `properties` and uses it f | `$product_tour_id` | `product_tour_id` | - An option you pass to any of these methods or set in `beforeSend` wins over its legacy property. An option set to `NSNull()` counts as unset, so the legacy property fills it. -- The SDK ignores a `$process_person_profile` property you pass or register. When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. To change person processing for one event, pass the `process_person_profile` option. +- When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. To change person processing for one event, pass the `process_person_profile` option or the `$process_person_profile` property. The SDK ignores a registered `$process_person_profile`. - 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. From 74d8c21ee5814213a39acc156cf19364319181be Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 14:31:02 -0400 Subject: [PATCH 31/67] feat!: let super properties override properties the SDK sets Event properties merge as: SDK-set properties, then registered super properties, then the caller's properties, then beforeSend. A registered key now wins over SDK context and over $is_identified, $process_person_profile, $geoip_disable and the replay debug values. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 2 +- PostHog/PostHogSDK.swift | 26 +++++++------- PostHogTests/PostHogCaptureV1Test.swift | 4 +-- PostHogTests/PostHogGeoIpTest.swift | 10 +++--- PostHogTests/PostHogSDKTest.swift | 46 ++++++++----------------- docs/migration-3.x-to-4.0.md | 18 +++++----- 6 files changed, 44 insertions(+), 62 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 4959d9b3a7..df983dcc60 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -11,6 +11,6 @@ - After a failed send, the wait before the next one now doubles from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. - `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, because it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. - The `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as capture options, and PostHog drops an event whose option value it can't read. -- Properties you pass on an event now also override the SDK's `$is_identified`, `$process_person_profile`, `$geoip_disable` and session replay debug values. Registered super properties still can't, and `beforeSend` still has the final say. +- Event properties are merged as: properties the SDK sets itself, then registered super properties, then the properties you pass, then `beforeSend`. Super properties now override SDK properties such as `$os`, and both super and event properties override `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug values. See the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md) for what to check before you upgrade. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 4026fe47e1..452ed88207 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -565,7 +565,7 @@ let maxRetryDelay = 30.0 // EVENT CAPTURE private func dynamicContext() -> [String: Any] { - var properties = getRegisteredProperties() + var properties: [String: Any] = [:] var groups: [String: String]? groupsLock.withLock { @@ -785,18 +785,17 @@ let maxRetryDelay = 30.0 props["distinct_id"] = distinctId } - // SDK-owned values are defaults: they overwrite registered super properties and SDK context, - // and the caller's properties below override them for one event. + // Values the SDK sets itself. Registered super properties and then the caller's properties + // override them below, and `beforeSend` has the final say. if appendSharedProps { if let isIdentified = config.storageManager?.isIdentified() { props["$is_identified"] = isIdentified } // Person processing is sent as the `process_person_profile` option, only when it's off - // (see `personProcessingOptions`). A registered legacy property is dropped; a caller's is - // kept and wins over the SDK's option when the send hoists it. The crash-context snapshot - // stores only properties, so it keeps the property, which the send hoists. - props.removeValue(forKey: "$process_person_profile") + // (see `personProcessingOptions`). A registered or caller `$process_person_profile` wins + // over it when the send hoists the property. The crash-context snapshot stores only + // properties, so it keeps the property, which the send hoists. if readOnlySession, !hasPersonProcessing() { props["$process_person_profile"] = false } @@ -805,8 +804,6 @@ let maxRetryDelay = 30.0 props["$geoip_disable"] = true } - // SDK-computed debug keys overwrite a same-named registered super property (js: `extend` - // after super properties), so a stale `register()` can't shadow the live status. #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) var replayDebugProperties = replayIntegration?.debugProperties() ?? [ "$recording_status": "disabled", @@ -837,7 +834,12 @@ let maxRetryDelay = 30.0 } } - // Caller-supplied properties win over registered super properties, SDK context and SDK-owned values. + // Registered super properties win over everything the SDK set above. + if appendSharedProps { + props.merge(getRegisteredProperties()) { _, new in new } + } + + // Caller-supplied properties win over registered super properties and SDK-set values. // A blank caller value for a key the SDK filled in above is treated as absent. var callerProps = properties ?? [:] for key in ["$session_id", "$screen_name", "distinct_id"] where props[key] != nil { @@ -1494,8 +1496,8 @@ let maxRetryDelay = 30.0 /// These replace the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and /// `$product_tour_id` properties. Those properties still work: they are removed from the /// event's properties and used as the option, unless you set that option here. A - /// `$process_person_profile` property you pass here overrides the SDK's default, but a - /// registered one is ignored. Other keys are sent unchanged and ignored by PostHog. PostHog drops an + /// `$process_person_profile` property you pass or register overrides the SDK's default. + /// Other keys are sent unchanged and ignored by PostHog. PostHog drops an /// event whose option value it can't read. /// `beforeSend` can change them through `PostHogEvent.options`. @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 13a5c0c0e4..95c0bcbae8 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -546,7 +546,7 @@ final class PostHogCaptureV1OptionsTest { return try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.first) } - @Test("a caller's process_person_profile property or option beats the SDK's, a registered one doesn't") + @Test("registered and caller process_person_profile properties and options beat the SDK's") func processPersonProfilePrecedence() async throws { let sut = makeSDK(personProfiles: .never) defer { sut.close() } @@ -558,7 +558,7 @@ final class PostHogCaptureV1OptionsTest { sut.register(["$process_person_profile": true]) sent = try await captureAndSend(sut) { $0.capture("test") } sut.unregister("$process_person_profile") - #expect((sent["options"] as? [String: Any])?["process_person_profile"] as? Bool == false) + #expect((sent["options"] as? [String: Any])?["process_person_profile"] as? Bool == true) sent = try await captureAndSend(sut) { $0.capture("test", options: ["process_person_profile": true, "cookieless_mode": true]) diff --git a/PostHogTests/PostHogGeoIpTest.swift b/PostHogTests/PostHogGeoIpTest.swift index 7455e66b28..467885d1b1 100644 --- a/PostHogTests/PostHogGeoIpTest.swift +++ b/PostHogTests/PostHogGeoIpTest.swift @@ -64,18 +64,18 @@ class PostHogGeoIpTests { sut.close() } - @Test("a caller $geoip_disable overrides disableGeoIp, a registered one doesn't") - func callerOverridesGeoipDisable() { + @Test("registered and caller $geoip_disable values override disableGeoIp") + func registeredAndCallerOverrideGeoipDisable() { let sut = getSut(disableGeoIp: true) server.reset(batchCount: 2) - sut.capture("caller", properties: ["$geoip_disable": false]) sut.register(["$geoip_disable": false]) sut.capture("registered") + sut.capture("caller", properties: ["$geoip_disable": true]) let events = getBatchedEvents(server) - #expect(events.first { $0.event == "caller" }?.properties["$geoip_disable"] as? Bool == false) - #expect(events.first { $0.event == "registered" }?.properties["$geoip_disable"] as? Bool == true) + #expect(events.first { $0.event == "registered" }?.properties["$geoip_disable"] as? Bool == false) + #expect(events.first { $0.event == "caller" }?.properties["$geoip_disable"] as? Bool == true) sut.reset() sut.close() diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 76898227a4..266aafb3a9 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -525,24 +525,6 @@ final class PostHogSDKTests { sut.close() } - @Test("SDK-computed debug keys win over a same-named registered super property") - func sdkComputedDebugKeysWinOverRegisteredSuperProperty() throws { - server.reset(batchCount: 1) - let sut = getSut() - sut.register(["$recording_status": "bogus", "$sdk_debug_pending_queue_size": -1]) - - sut.capture("test event") - - let events = getBatchedEvents(server) - let props = try #require(events.first).properties - #expect(props["$recording_status"] as? String == "disabled") - let pendingQueueSize = try #require(props["$sdk_debug_pending_queue_size"] as? Int) - #expect(pendingQueueSize != -1) - - sut.reset() - sut.close() - } - #else @Test("reports disabled recording status with no replay keys on non-iOS platforms") func reportsDisabledRecordingStatusOnNonIOSPlatforms() throws { @@ -1267,38 +1249,38 @@ final class PostHogSDKTests { sut.close() } - @Test("a caller $is_identified overrides the SDK's, a registered one doesn't") - func callerOverridesIsIdentified() throws { + @Test("a registered $is_identified overrides the SDK's, and a caller one overrides both") + func registeredAndCallerOverrideIsIdentified() throws { let sut = getSut(flushAt: 3) sut.identify("user") - sut.capture("caller", properties: ["$is_identified": false]) sut.register(["$is_identified": false]) sut.capture("registered") + sut.capture("caller", properties: ["$is_identified": true]) let events = getBatchedEvents(server) - #expect(events.first { $0.event == "caller" }?.properties["$is_identified"] as? Bool == false) - #expect(events.first { $0.event == "registered" }?.properties["$is_identified"] as? Bool == true) + #expect(events.first { $0.event == "registered" }?.properties["$is_identified"] as? Bool == false) + #expect(events.first { $0.event == "caller" }?.properties["$is_identified"] as? Bool == true) sut.reset() sut.close() } - @Test("caller debug properties override the SDK's, registered ones don't") - func callerOverridesSdkDebugProperties() throws { + @Test("registered debug properties override the SDK's, and caller ones override both") + func registeredAndCallerOverrideSdkDebugProperties() throws { let sut = getSut(flushAt: 2) - sut.capture("caller", properties: ["$recording_status": "bogus", "$sdk_debug_pending_queue_size": -1]) - sut.register(["$recording_status": "bogus", "$sdk_debug_pending_queue_size": -1]) + sut.register(["$recording_status": "registered", "$sdk_debug_pending_queue_size": -1]) sut.capture("registered") + sut.capture("caller", properties: ["$recording_status": "caller", "$sdk_debug_pending_queue_size": -2]) let events = getBatchedEvents(server) - let caller = try #require(events.first { $0.event == "caller" }) - #expect(caller.properties["$recording_status"] as? String == "bogus") - #expect(caller.properties["$sdk_debug_pending_queue_size"] as? Int == -1) let registered = try #require(events.first { $0.event == "registered" }) - #expect(registered.properties["$recording_status"] as? String != "bogus") - #expect(registered.properties["$sdk_debug_pending_queue_size"] as? Int != -1) + #expect(registered.properties["$recording_status"] as? String == "registered") + #expect(registered.properties["$sdk_debug_pending_queue_size"] as? Int == -1) + let caller = try #require(events.first { $0.event == "caller" }) + #expect(caller.properties["$recording_status"] as? String == "caller") + #expect(caller.properties["$sdk_debug_pending_queue_size"] as? Int == -2) sut.reset() sut.close() diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 1f263dd69e..f1e0db9ae5 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -20,7 +20,7 @@ You need to change something if your app does any of these: - sets `projectToken` to a personal API key (`phx_...`) - sets `$lib` or `$lib_version` and expects PostHog to store that value - reads `$session_id` or `$window_id` from the raw request body, for example in a proxy -- passes `$is_identified`, `$geoip_disable`, `$process_person_profile` or `$recording_status` in event properties and expects the SDK's value to win +- registers a super property, or passes an event property, with the same key as one the SDK sets, such as `$os`, `$is_identified` or `$geoip_disable`, and expects the SDK's value to win - sets `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` - relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds @@ -83,15 +83,13 @@ A value you set in properties, in a super property or in `beforeSend` doesn't re An event's properties are merged in this order, and later values win: -1. registered super properties (`register`) -2. SDK context, such as `$os`, `$app_version` and `$screen_name` -3. SDK-owned values: `$is_identified`, `$geoip_disable` when `disableGeoIp` is on, and the session replay debug properties (`$recording_status` and the `$sdk_debug_*` keys) -4. the properties you pass to `capture` or another event-sending method -5. `beforeSend` +1. properties the SDK sets itself, such as `$os`, `$app_version`, `$screen_name`, `$is_identified`, `$geoip_disable` and the session replay debug properties +2. registered super properties (`register`) +3. the properties you pass to `capture` or another event-sending method +4. `beforeSend` -In 3.x, the SDK-owned values in step 3 were set after your properties, so you couldn't override them for one event. -In 4.0 a value you pass wins, for example `capture("signed_up", properties: ["$geoip_disable": false])` sends that event with GeoIP enabled even when `disableGeoIp` is on. -A registered super property still can't override them. +In 3.x, the SDK's own values won over super properties, and `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug properties also won over the properties you pass. +In 4.0 a value you register or pass wins, for example `capture("signed_up", properties: ["$geoip_disable": false])` sends that event with GeoIP enabled even when `disableGeoIp` is on. ## Session and window IDs @@ -130,7 +128,7 @@ When the event is sent, the SDK removes each one from `properties` and uses it f | `$product_tour_id` | `product_tour_id` | - An option you pass to any of these methods or set in `beforeSend` wins over its legacy property. An option set to `NSNull()` counts as unset, so the legacy property fills it. -- When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. To change person processing for one event, pass the `process_person_profile` option or the `$process_person_profile` property. The SDK ignores a registered `$process_person_profile`. +- When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. To change person processing for one event, pass the `process_person_profile` option or the `$process_person_profile` property. A registered `$process_person_profile` changes it for every event. - 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. From 44cf2753a4698af17626d9d6fc34f05a7011fc25 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 14:36:31 -0400 Subject: [PATCH 32/67] docs: fix what beforeSend sees for $lib and note registered $session_id Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index f1e0db9ae5..857ad00295 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -77,7 +77,7 @@ If you identify users with a long ID, such as a JWT or a long email address, sho PostHog sets `$lib` and `$lib_version` from the `PostHog-Sdk-Info` request header, for example `posthog-ios/4.0.0`. The SDK removes `$lib` and `$lib_version` from the properties it sends to capture v1. A value you set in properties, in a super property or in `beforeSend` doesn't reach PostHog. -`beforeSend` still sees the SDK's value. +The SDK removes them when it sends the event, so `beforeSend` still sees them. ## Property precedence @@ -99,6 +99,7 @@ A string value, including an empty string, is sent unchanged. Any other value is removed and not sent, and the SDK logs a warning that names the key and the value's JSON type, never the value. A null value counts as unset and is removed without a warning. In PostHog, the events still have `$session_id` and `$window_id`, so insights, filters and session replay work as before. +A registered `$session_id` now overrides the SDK's session ID (see [Property precedence](#property-precedence)), which can detach events from their session replay. ## Event options From b50af501e59ed082d9c805b5263d005c319599e3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 18:23:43 -0400 Subject: [PATCH 33/67] test: send both GeoIP override events in one batch With flushAt 1 and no queue timer, the second event could be queued while the first request was in flight and never sent on slow runners. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHogTests/PostHogGeoIpTest.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PostHogTests/PostHogGeoIpTest.swift b/PostHogTests/PostHogGeoIpTest.swift index 467885d1b1..5edb215a9c 100644 --- a/PostHogTests/PostHogGeoIpTest.swift +++ b/PostHogTests/PostHogGeoIpTest.swift @@ -15,10 +15,10 @@ class PostHogGeoIpTests { server.stop() } - func getSut(disableGeoIp: Bool = false) -> PostHogSDK { + func getSut(disableGeoIp: Bool = false, flushAt: Int = 1) -> PostHogSDK { // A token per test keeps its storage and requests apart from other suites' SDK instances let config = PostHogConfig(projectToken: "geoip_\(UUID().uuidString)", host: "http://localhost:9001") - config.flushAt = 1 + config.flushAt = flushAt config.captureApplicationLifecycleEvents = false config.disableReachabilityForTesting = true config.disableQueueTimerForTesting = true @@ -66,8 +66,8 @@ class PostHogGeoIpTests { @Test("registered and caller $geoip_disable values override disableGeoIp") func registeredAndCallerOverrideGeoipDisable() { - let sut = getSut(disableGeoIp: true) - server.reset(batchCount: 2) + // flushAt 2 so both events share one batch + let sut = getSut(disableGeoIp: true, flushAt: 2) sut.register(["$geoip_disable": false]) sut.capture("registered") From 042b7bf5f93a6f6e4781f303fd1eaf0aeed2139f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 22:44:38 -0400 Subject: [PATCH 34/67] feat: add captureAi on the capture V1 AI endpoint captureAi(...) takes capture's arguments, runs the same enrichment and beforeSend, and queues the event on its own disk queue for /i/v1/ai/events. It reuses the capture V1 transport (headers, per-event results, retry identity per endpoint, redirects, Retry-After), with no /batch fallback. The AI queue batches by bytes (5 MiB) as well as count, keeps at most 100 events, and drops events over 8 MiB + 64 KiB logging only the name and size. capture() never reroutes $ai_ events. The compliance adapter gains /capture_ai and the capture_ai_v1 capability. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 5 + PostHog/PostHogApi.swift | 33 +++-- PostHog/PostHogQueue.swift | 7 + PostHog/PostHogSDK.swift | 68 +++++++++- PostHog/PostHogStorage.swift | 3 +- PostHog/QueueEndpoint+Factories.swift | 43 ++++++ PostHog/QueueEndpoint.swift | 5 + PostHogTests/PostHogCaptureV1Test.swift | 126 +++++++++++++++++- .../TestUtils/MockPostHogServer.swift | 28 +++- api/posthog-ios.public-api.txt | 1 + .../Sources/RequestInterceptor.swift | 3 +- sdk_compliance_adapter/Sources/main.swift | 36 ++++- 12 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 .changeset/v4-capture-ai.md diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md new file mode 100644 index 0000000000..92e292bdb1 --- /dev/null +++ b/.changeset/v4-capture-ai.md @@ -0,0 +1,5 @@ +--- +'posthog-ios': minor +--- + +Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index cae13ecb4e..aa47c8cfa8 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -68,7 +68,8 @@ class PostHogApi { /// Guards the capture V1 state below. In memory only: a new request ID /// after an app restart is fine, since the server only logs it. private let captureV1Lock = NSLock() - private var captureV1PendingRetry: CaptureV1RequestIdentity? + // 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 @@ -227,14 +228,27 @@ class PostHogApi { 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`. + func captureAi(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { + postCaptureV1(path: Self.captureV1AiPath, events: events, completion: completion) + } + + static let captureV1AnalyticsPath = "/i/v1/analytics/events" + static let captureV1AiPath = "/i/v1/ai/events" - guard let url = getEndpointURL("/i/v1/analytics/events", relativeTo: config.host) else { + private func postCaptureV1(path: String, events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { + guard let url = getEndpointURL(path, relativeTo: config.host) else { hedgeLog("Malformed capture URL error.") return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } let uuids = events.map(\.uuid.postHogUuidString) - let identity = nextCaptureV1Identity(uuids: Set(uuids)) + let identity = nextCaptureV1Identity(path: path, uuids: Set(uuids)) let toSend: [String: Any] = [ "created_at": identity.createdAt, @@ -259,10 +273,10 @@ class PostHogApi { guard let self else { return completion(info) } // iOS-only fallback for hosts without capture V1. Remove with /batch. - if info.statusCode == 404 { + if info.statusCode == 404, path == Self.captureV1AnalyticsPath { let firstFallback = self.captureV1Lock.withLock { defer { self.captureV1Unavailable = true } - self.captureV1PendingRetry = nil + self.captureV1PendingRetry[path] = nil return !self.captureV1Unavailable } if firstFallback { @@ -271,7 +285,7 @@ class PostHogApi { return self.batch(events: events, completion: completion) } - completion(self.captureV1Result(info, data: data, uuids: uuids, identity: identity)) + completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity)) } }.resume() } @@ -279,9 +293,9 @@ class PostHogApi { /// Reuses the request ID with the next attempt number when `uuids` is the /// set of events the previous request left to retry. Otherwise starts a new /// request at attempt 1. - private func nextCaptureV1Identity(uuids: Set) -> CaptureV1RequestIdentity { + private func nextCaptureV1Identity(path: String, uuids: Set) -> CaptureV1RequestIdentity { captureV1Lock.withLock { - if let pending = captureV1PendingRetry, pending.uuids == uuids { + if let pending = captureV1PendingRetry[path], pending.uuids == uuids { return CaptureV1RequestIdentity( uuids: uuids, requestId: pending.requestId, attempt: pending.attempt + 1, createdAt: pending.createdAt ) @@ -296,6 +310,7 @@ class PostHogApi { /// the next request retries under the same identity. func captureV1Result( _ info: PostHogUploadInfo, + path: String, data: Data?, uuids: [String], identity: CaptureV1RequestIdentity @@ -335,7 +350,7 @@ class PostHogApi { } captureV1Lock.withLock { - captureV1PendingRetry = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( + captureV1PendingRetry[path] = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( uuids: retryUuids, requestId: identity.requestId, attempt: identity.attempt, createdAt: identity.createdAt ) } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 2698d588b7..1989e141f2 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -501,6 +501,7 @@ class PostHogQueue { var selectedIds: [String] = [] // Entry ID -> `endpoint.recordId`, for removing all but retried records. var recordIds: [String: String] = [:] + var batchBytes = 0 var next = start while next < entries.count { let entry = entries[next] @@ -511,6 +512,12 @@ class PostHogQueue { { break } + if !processing.isEmpty, let maxBatchBytes = endpoint.maxBatchBytes, + batchBytes + entry.data.count > maxBatchBytes + { + break + } + batchBytes += entry.data.count processing.append(record) recordIds[entry.id] = endpoint.recordId?(record) } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 8a0b8f4453..55b2054967 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -85,6 +85,7 @@ let maxRetryDelay = 30.0 } private(set) var logsQueue: PostHogQueue? + private(set) var aiQueue: PostHogQueue? private(set) var storage: PostHogStorage? private var surveyIdentityGeneration: String? #if !os(watchOS) @@ -288,10 +289,12 @@ let maxRetryDelay = 30.0 queue = PostHogQueue(config, theStorage, .batch(api: api), reachability) replayQueue = PostHogReplayQueue(config, theStorage, api, reachability) logsQueue = PostHogQueue(config, theStorage, logsEndpoint, reachability) + aiQueue = PostHogQueue(config, theStorage, .ai(api: api), reachability) #else queue = PostHogQueue(config, theStorage, .batch(api: api)) replayQueue = PostHogReplayQueue(config, theStorage, api) logsQueue = PostHogQueue(config, theStorage, logsEndpoint) + aiQueue = PostHogQueue(config, theStorage, .ai(api: api)) #endif queue?.start(disableReachabilityForTesting: config.disableReachabilityForTesting, @@ -303,6 +306,9 @@ let maxRetryDelay = 30.0 logsQueue?.start(disableReachabilityForTesting: config.disableReachabilityForTesting, disableQueueTimerForTesting: config.disableQueueTimerForTesting) + aiQueue?.start(disableReachabilityForTesting: config.disableReachabilityForTesting, + disableQueueTimerForTesting: config.disableQueueTimerForTesting) + // Create session manager instance for this PostHogSDK instance sessionManager.setup(config: config) sessionManager.startSession() @@ -853,8 +859,8 @@ let maxRetryDelay = 30.0 return props } - /// Trigger an immediate flush of every queue — events, session-replay - /// snapshots, and structured logs. + /// Trigger an immediate flush of every queue — events, AI events, + /// session-replay snapshots, and structured logs. @objc public func flush() { if !isEnabled() { return @@ -863,6 +869,7 @@ let maxRetryDelay = 30.0 queue?.flush() replayQueue?.flush() logsQueue?.flush() + aiQueue?.flush() pushSubscriptionHandler?.retryIfNeeded() } @@ -1519,6 +1526,49 @@ let maxRetryDelay = 30.0 ) } + /// Captures an AI observability event, such as `$ai_generation`, `$ai_span`, `$ai_trace` or + /// `$ai_embedding`, and sends it to PostHog's AI endpoint, which accepts much larger events than + /// `capture`. + /// + /// Takes the same arguments as `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)` + /// and goes through the same `beforeSend` hooks. The event is queued on disk separately from other + /// events and sent unchanged: the SDK doesn't redact or truncate prompts, outputs or media. + /// An event over 8 MiB is dropped. PostHog drops an event sent here whose name doesn't start + /// with `$ai_`. `capture` never sends events here, even ones named `$ai_*`. + /// + /// - Parameters: + /// - event: AI event name, such as `$ai_generation`. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID. + /// - properties: Event properties attached only to this event. + /// - userProperties: Person properties to set. Existing values are overwritten. + /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. + /// - groups: Group type/key pairs to attach to this event. + /// - timestamp: Optional event timestamp. Defaults to the current time. + /// - options: Optional settings that tell PostHog how to process this event. Same keys as + /// `capture`'s `options`. + @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) + public func captureAi(_ event: String, + distinctId: String? = nil, + properties: [String: Any]? = nil, + userProperties: [String: Any]? = nil, + userPropertiesSetOnce: [String: Any]? = nil, + groups: [String: String]? = nil, + timestamp: Date? = nil, + options: [String: Any]? = nil) + { + captureInternal( + event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + options: options, + aiLane: true + ) + } + // A separate Swift name keeps this out of Swift overload resolution. /// Captures a custom event for an explicit distinct ID and timestamp from Objective-C. @@ -1706,7 +1756,8 @@ let maxRetryDelay = 30.0 options: [String: Any]? = nil, skipBuildProperties: Bool = false, propertyAllowlist: Set? = nil, - deduplicatePersonProperties: Bool = false + deduplicatePersonProperties: Bool = false, + aiLane: Bool = false ) { if !isEnabled() { return @@ -1716,7 +1767,7 @@ let maxRetryDelay = 30.0 return } - guard let queue else { + guard let queue = aiLane ? aiQueue : queue else { return } @@ -1731,7 +1782,7 @@ let maxRetryDelay = 30.0 } } - var isSnapshotEvent = event == "$snapshot" + var isSnapshotEvent = !aiLane && event == "$snapshot" let eventTimestamp = timestamp ?? now() // A blank distinct ID falls back to the current one, like nil, instead of PostHog dropping the event. let eventDistinctId = distinctId.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } ?? getDistinctId() @@ -1801,8 +1852,9 @@ let maxRetryDelay = 30.0 return } - // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook - isSnapshotEvent = posthogEvent.event == "$snapshot" + // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook. + // The AI lane always uses its own queue. + isSnapshotEvent = !aiLane && posthogEvent.event == "$snapshot" // if this is a $snapshot event and $session_id is missing, don't process then event if isSnapshotEvent, posthogEvent.properties["$session_id"] == nil { @@ -3123,10 +3175,12 @@ let maxRetryDelay = 30.0 queue?.stop() replayQueue?.stop() logsQueue?.stop() + aiQueue?.stop() queue = nil replayQueue = nil logsQueue = nil + aiQueue = nil pushSubscriptionHandler = nil #if os(iOS) startSessionRecordingOnOptIn = true diff --git a/PostHog/PostHogStorage.swift b/PostHog/PostHogStorage.swift index 26ae002038..386e54135e 100644 --- a/PostHog/PostHogStorage.swift +++ b/PostHog/PostHogStorage.swift @@ -236,6 +236,7 @@ class PostHogStorage { case oldReplayQueue = "posthog.replayFolder" // replay queue with legacy timestamp filenames case replayBufferQueue = "posthog.replayBufferFolder" case logsQueue = "posthog.logsFolder" + case aiQueue = "posthog.aiFolder" case enabledFeatureFlags = "posthog.enabledFeatureFlags" case enabledFeatureFlagPayloads = "posthog.enabledFeatureFlagPayloads" case flags = "posthog.flags" @@ -497,7 +498,7 @@ class PostHogStorage { if !keepAnonymousId { deleteSafely(url(forKey: .anonymousId)) } - // .queue, .replayQeueue, .logsQueue not deleted here — each queue manages its own + // .queue, .replayQeueue, .logsQueue, .aiQueue not deleted here — each queue manages its own // disk state via clear() and the per-record distinctId captured at enqueue time // (see PostHogLogRecord) lets in-flight telemetry survive an identity change. deleteSafely(url(forKey: .oldQueueFolder)) diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 028e3b16df..8304c2de61 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -23,6 +23,15 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { [408, 500, 502, 503, 504].contains(code) } +/// PostHog's AI endpoint drops events whose properties exceed 8 MiB. The +/// stored event also holds its envelope, so allow 64 KiB on top, as +/// posthog-python and posthog-go do. +let aiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024 +/// Soft request size for the AI endpoint, matching the other PostHog SDKs. +let aiMaxBatchBytes = 5 * 1024 * 1024 +/// AI events can be megabytes, so keep fewer of them on disk than analytics events. +let aiMaxQueueSize = 100 + extension QueueEndpoint where Record == PostHogEvent { /// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`), or /// `/batch` after V1 returned 404 this session. @@ -51,6 +60,40 @@ extension QueueEndpoint where Record == PostHogEvent { ) } + /// Capture V1 AI endpoint (`/i/v1/ai/events`) for events sent with + /// `captureAi`. Same retry policy as capture V1, with no `/batch` fallback. + /// Batches by bytes as well as count, since AI events can be megabytes. + static func ai(api: PostHogApi) -> QueueEndpoint { + QueueEndpoint( + storageKey: .aiQueue, + oldStorageKeys: [], + dispatchQueueLabel: "com.posthog.AiQueue", + initialCap: { $0.maxBatchSize }, + initialFlushAt: { $0.flushAt }, + maxQueueSize: { _ in aiMaxQueueSize }, + flushIntervalSeconds: { $0.flushIntervalSeconds }, + rateCapMax: { _ in 0 }, + rateCapWindowSeconds: { _ in 0 }, + encode: { event in + guard let data = toJSONData(event.toJSON()) else { return nil } + // Name and size only: AI properties can hold prompts and media. + if data.count > aiMaxEventBytes { + hedgeLog("Dropping AI event '\(event.event)' of \(data.count) bytes, over the \(aiMaxEventBytes) byte limit.") + return nil + } + return data + }, + decode: { data in PostHogEvent.fromJSON(data) }, + describe: { event in "AI event '\(event.event)'" }, + recordId: { event in event.uuid.postHogUuidString }, + maxBatchBytes: aiMaxBatchBytes, + send: { events, completion in + api.captureAi(events: events, completion: completion) + }, + isRetriableStatusCode: isCaptureV1RetriableStatusCode + ) + } + /// `/snapshot` endpoint for session-replay snapshots. Shares its retry /// policy with `/batch`. static func snapshot(api: PostHogApi) -> QueueEndpoint { diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index 944e12b65a..567283d429 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -67,6 +67,11 @@ struct QueueEndpoint { /// When set, the queue sends only a compatible FIFO prefix. var canBatchTogether: ((Record, Record) -> Bool)? + /// Soft byte limit for one request, measured on the encoded records. A + /// record that doesn't fit starts the next request; a record larger than + /// the limit goes alone. `nil` batches by count only. + var maxBatchBytes: Int? + // MARK: Send /// Build the wire payload from a list of records and POST it. The queue diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 95c0bcbae8..ce8236229e 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -142,7 +142,7 @@ final class PostHogCaptureV1Test { let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") let info = PostHogUploadInfo(statusCode: 200, error: URLError(.networkConnectionLost)) - let result = makeApi().captureV1Result(info, data: nil, uuids: uuids, identity: identity) + let result = makeApi().captureV1Result(info, path: PostHogApi.captureV1AnalyticsPath, data: nil, uuids: uuids, identity: identity) #expect(result.statusCode == 200) #expect(result.retryRecordIds?.isEmpty ?? true) @@ -810,3 +810,127 @@ final class PostHogCaptureV1QueueTest { } } } + +@Suite("Capture V1 AI lane", .serialized, .resetsGlobalState) +final class PostHogCaptureAiTest { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + private func makeSDK(beforeSend: BeforeSendBlock? = nil) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_ai_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 1 + config.preloadFeatureFlags = false + config.sendFeatureFlagEvent = false + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + if let beforeSend { + config.setBeforeSend(beforeSend) + } + server.batchProjectToken = config.projectToken + return PostHogSDK.with(config) + } + + @Test("captureAi sends to /i/v1/ai/events through beforeSend, and capture keeps $ai_ events on analytics") + func routesByMethod() async throws { + let sut = makeSDK { event in + event.properties["edited"] = true + return event + } + defer { sut.close() } + + sut.captureAi("$ai_generation", distinctId: "user", properties: ["$ai_model": "gpt"], options: ["cookieless_mode": true]) + await waitUntil { self.server.aiRequests.count == 1 } + + let request = try #require(server.aiRequests.first) + #expect(request.url?.path == "/i/v1/ai/events") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(sut.config.projectToken)") + #expect(request.value(forHTTPHeaderField: "PostHog-Attempt") == "1") + #expect(request.value(forHTTPHeaderField: "PostHog-Request-Id") != nil) + let event = try #require(server.parsePostHogEvents(request).first) + #expect(event.event == "$ai_generation") + #expect(event.distinctId == "user") + #expect(event.properties["$ai_model"] as? String == "gpt") + #expect(event.properties["edited"] as? Bool == true) + #expect(event.options["cookieless_mode"] as? Bool == true) + #expect(server.batchRequests.isEmpty) + + sut.capture("$ai_generation") + await waitUntil { self.server.batchRequests.count == 1 } + #expect(server.parsePostHogEvents(try #require(server.batchRequests.first)).map(\.event) == ["$ai_generation"]) + #expect(server.aiRequests.count == 1) + } + + @Test("a 404 from the AI endpoint drops the batch without falling back to /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") + let storage = PostHogStorage(config) + let api = PostHogApi(config) + let queue = PostHogQueue(config, storage, .ai(api: api), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user")) + queue.flush() + await waitUntil { queue.depth == 0 } + + #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") + func dropsOversizedEvent() { + let config = PostHogConfig(projectToken: "phc_ai_big", host: "http://localhost:9001") + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let big = String(repeating: "a", count: aiMaxEventBytes) + #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": big]))) + #expect(queue.depth == 0) + } + + @Test("splits AI requests at the batch byte limit") + func batchesByBytes() async throws { + let config = PostHogConfig(projectToken: "phc_ai_bytes", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + // Each event is a little over 2 MiB, so two fit under 5 MiB and the third goes next. + let input = String(repeating: "a", count: 2 * 1024 * 1024) + for _ in 0 ..< 3 { + queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": input])) + } + queue.flush() + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(server.aiRequests.map { server.parsePostHogEvents($0).count } == [2, 1]) + } +} diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index c2c422cec9..63988a2779 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -17,6 +17,7 @@ class MockPostHogServer { private var recordedSnapshotRequests = [URLRequest]() private var recordedLogsRequests = [URLRequest]() private var recordedFlagsRequests = [URLRequest]() + private var recordedAiRequests = [URLRequest]() var batchRequests: [URLRequest] { get { requestsLock.withLock { recordedBatchRequests } } @@ -34,6 +35,14 @@ class MockPostHogServer { get { requestsLock.withLock { recordedFlagsRequests } } set { requestsLock.withLock { recordedFlagsRequests = newValue } } } + /// Requests to the capture V1 AI endpoint (`/i/v1/ai/events`), with buffered bodies. + var aiRequests: [URLRequest] { + get { requestsLock.withLock { recordedAiRequests } } + set { requestsLock.withLock { recordedAiRequests = newValue } } + } + /// Optional override for `/i/v1/ai/events` responses, given the request and its + /// 1-based number. When `nil`, every event gets an `ok` result. + var aiResponseHandler: ((URLRequest, Int) -> HTTPStubsResponse)? var batchExpectation: XCTestExpectation? var snapshotExpectation: XCTestExpectation? var logsExpectation: XCTestExpectation? @@ -448,6 +457,21 @@ class MockPostHogServer { return HTTPStubsResponse(jsonObject: ["results": results], statusCode: 200, headers: nil) }) + stubDescriptors.append(stub(condition: pathEndsWith("/i/v1/ai/events")) { request in + var request = request + request.httpBody = request.body() + let index = self.requestsLock.withLock { () -> Int in + self.recordedAiRequests.append(request) + return self.recordedAiRequests.count + } + if let handler = self.aiResponseHandler { + return handler(request, index) + } + let uuids = (self.parseRequest(request)?["batch"] as? [[String: Any]] ?? []).compactMap { $0["uuid"] as? String } + let results = Dictionary(uniqueKeysWithValues: uuids.map { ($0, ["result": "ok"]) }) + return HTTPStubsResponse(jsonObject: ["results": results], statusCode: 200, headers: nil) + }) + stubDescriptors.append(stub(condition: pathEndsWith("/s")) { _ in if self.return500 { HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) @@ -640,6 +664,8 @@ class MockPostHogServer { snapshotRequests = [] logsRequests = [] flagsRequests = [] + aiRequests = [] + aiResponseHandler = nil pushSubscriptionRequests = [] batchExpectation = XCTestExpectation(description: "\(batchCount) batch requests to occur") snapshotExpectation = XCTestExpectation(description: "\(snapshotCount) snapshot requests to occur") @@ -672,7 +698,7 @@ class MockPostHogServer { } static func isCaptureV1(_ request: URLRequest) -> Bool { - request.url?.path.hasSuffix("/i/v1/analytics/events") == true + 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 diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index fb3eda0f7a..98647893c8 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -258,6 +258,7 @@ PostHog | PostHogSDK.capture(_:properties:) | method | @objc(captureWithEvent:pr PostHog | PostHogSDK.capture(_:properties:userProperties:) | method | @objc(captureWithEvent:properties:userProperties:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups: +PostHog | PostHogSDK.captureAi(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func captureAi(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.captureDeepLink(openURLContexts:) | method | @objc func captureDeepLink(openURLContexts: Set) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithOpenURLContexts: PostHog | PostHogSDK.captureDeepLink(url:) | method | @objc func captureDeepLink(url: URL) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl: PostHog | PostHogSDK.captureDeepLink(url:options:) | method | @objc func captureDeepLink(url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl:options: diff --git a/sdk_compliance_adapter/Sources/RequestInterceptor.swift b/sdk_compliance_adapter/Sources/RequestInterceptor.swift index fe0cabbd29..416a9cea34 100644 --- a/sdk_compliance_adapter/Sources/RequestInterceptor.swift +++ b/sdk_compliance_adapter/Sources/RequestInterceptor.swift @@ -105,7 +105,8 @@ class RequestInterceptor: URLProtocol { // Intercept capture endpoints, but not /flags/ or /config let urlString = url.absoluteString - if urlString.contains("/i/v1/analytics/events") || urlString.contains("/batch") + 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") diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 6087b5c8bc..a14e029056 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -56,7 +56,7 @@ app.get("health") { req async throws -> Response in // Declares which test suites apply. The iOS SDK posts events to // /i/v1/analytics/events (capture_v1) with gzip. Without this, the // harness skips the capability-gated capture suites entirely. - "capabilities": ["capture_v1", "encoding_gzip", "event_options"], + "capabilities": ["capture_v1", "capture_ai_v1", "encoding_gzip", "event_options"], ] print("[ADAPTER] GET /health") @@ -221,6 +221,40 @@ app.post("capture") { req async throws -> Response in return try await result.encodeResponse(for: req) } +// AI capture endpoint: same request as /capture, sent with captureAi to /i/v1/ai/events. +app.post("capture_ai") { req async throws -> Response in + struct CaptureAiRequest: Content { + let event: String + let distinctId: String? + let properties: [String: AnyCodable]? + let options: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case event + case distinctId = "distinct_id" + case properties + case options + } + } + + let captureReq = try req.content.decode(CaptureAiRequest.self) + print("[ADAPTER] POST /capture_ai - event: \(captureReq.event), distinct_id: \(captureReq.distinctId ?? "nil")") + + guard let sdk = state.posthogSDK else { + throw Abort(.badRequest, reason: "SDK not initialized. Call /init first.") + } + + sdk.captureAi( + captureReq.event, + distinctId: captureReq.distinctId, + properties: captureReq.properties?.mapValues(\.value), + options: captureReq.options?.mapValues(\.value) + ) + + let result = ["status": "ok"] + return try await result.encodeResponse(for: req) +} + func fetchFlagsWithRetry(flagsURL: URL, payload: [String: Any]) async throws -> Data { let body = try JSONSerialization.data(withJSONObject: payload) var lastStatus = 0 From 2b7cbd7a4d7eaa13e2764081f4241cb94c347931 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sun, 25 Oct 2026 10:58:03 -0400 Subject: [PATCH 35/67] fix: let per-call $set, $set_once and groups win over registered ones Registered super properties were merged after the typed per-call values, so a registered $set, $set_once or $groups overrode userProperties, userPropertiesSetOnce and per-call groups. Apply the typed values and the groups set with group() after the registered merge, as in 3.x and posthog-android 4.0. Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogSDK.swift | 36 ++++++++++++++----------------- PostHogTests/PostHogSDKTest.swift | 24 +++++++++++++++++++++ docs/migration-3.x-to-4.0.md | 3 +++ 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 452ed88207..8a0b8f4453 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -567,14 +567,6 @@ let maxRetryDelay = 30.0 private func dynamicContext() -> [String: Any] { var properties: [String: Any] = [:] - var groups: [String: String]? - groupsLock.withLock { - groups = getGroups() - } - if groups != nil, !groups!.isEmpty { - properties["$groups"] = groups! - } - guard let flags = remoteConfig?.getFeatureFlags() as? [String: Any] else { return properties } @@ -738,18 +730,6 @@ let maxRetryDelay = 30.0 props = props.merging(dynamicCtx ?? [:]) { current, _ in current } } props = props.merging(localDynamicCtx) { current, _ in current } - if userProperties != nil { - props["$set"] = (userProperties ?? [:]) - } - if userPropertiesSetOnce != nil { - props["$set_once"] = (userPropertiesSetOnce ?? [:]) - } - if groups != nil { - // $groups are also set via the dynamicContext - let currentGroups = props["$groups"] as? [String: String] ?? [:] - let mergedGroups = currentGroups.merging(groups ?? [:]) { _, new in new } - props["$groups"] = mergedGroups - } // Only stamp if the caller didn't supply a non-empty value. Whitespace-only // caller values are treated as absent (almost always accidental). @@ -837,6 +817,22 @@ let maxRetryDelay = 30.0 // Registered super properties win over everything the SDK set above. if appendSharedProps { props.merge(getRegisteredProperties()) { _, new in new } + + // Per-call values, and groups set with `group()`, win over registered ones. + if userProperties != nil { + props["$set"] = (userProperties ?? [:]) + } + if userPropertiesSetOnce != nil { + props["$set_once"] = (userPropertiesSetOnce ?? [:]) + } + var mergedGroups: [String: String] = [:] + groupsLock.withLock { + mergedGroups = getGroups() + } + mergedGroups.merge(groups ?? [:]) { _, new in new } + if !mergedGroups.isEmpty { + props["$groups"] = mergedGroups + } } // Caller-supplied properties win over registered super properties and SDK-set values. diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 266aafb3a9..b348d0dd8a 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -1235,6 +1235,30 @@ final class PostHogSDKTests { sut.close() } + @Test("per-call userProperties, userPropertiesSetOnce and groups, and group() groups, win over registered ones") + func perCallTypedValuesWinOverRegistered() throws { + let sut = getSut(flushAt: 2) + + sut.group(type: "project", key: "stored") + sut.register([ + "$set": ["plan": "registered"], + "$set_once": ["source": "registered"], + "$groups": ["company": "registered", "team": "registered"], + ]) + sut.capture("event", + userProperties: ["plan": "caller"], + userPropertiesSetOnce: ["source": "caller"], + groups: ["company": "caller"]) + + let event = try #require(getBatchedEvents(server).first { $0.event == "event" }) + #expect(event.properties["$set"] as? [String: String] == ["plan": "caller"]) + #expect(event.properties["$set_once"] as? [String: String] == ["source": "caller"]) + #expect(event.properties["$groups"] as? [String: String] == ["project": "stored", "company": "caller"]) + + sut.reset() + sut.close() + } + @Test("blank or non-string caller $session_id keeps the SDK session", arguments: ["", " ", 1] as [AnyHashable]) func invalidCallerSessionIdKeepsSdkSession(sessionId: AnyHashable) throws { let sut = getSut() diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 857ad00295..3b69a4ed27 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -88,6 +88,9 @@ An event's properties are merged in this order, and later values win: 3. the properties you pass to `capture` or another event-sending method 4. `beforeSend` +Per-call `userProperties` and `userPropertiesSetOnce` are applied between steps 2 and 3, so they win over a registered `$set` or `$set_once`, as in 3.x. +Groups set with `group()` and per-call `groups` are applied there too, and replace a registered `$groups`. + In 3.x, the SDK's own values won over super properties, and `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug properties also won over the properties you pass. In 4.0 a value you register or pass wins, for example `capture("signed_up", properties: ["$geoip_disable": false])` sends that event with GeoIP enabled even when `disableGeoIp` is on. From 3d8bec73c32a07726ba5213fe379a1d15be4fbff Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 10 Oct 2026 11:35:06 -0400 Subject: [PATCH 36/67] feat: cap AI queue disk use and return the event UUID from captureAi The AI queue now drops its oldest events once they take more than 50 MiB on disk, on top of the 100-event limit. captureAi returns the queued event's UUID, or nil when the event isn't queued. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 2 +- PostHog/PostHogFileBackedQueue.swift | 74 +++++++++++++++---- PostHog/PostHogQueue.swift | 10 ++- PostHog/PostHogSDK.swift | 34 ++++++--- PostHog/QueueEndpoint+Factories.swift | 3 + PostHog/QueueEndpoint.swift | 4 + PostHogTests/PostHogCaptureV1Test.swift | 15 +++- .../PostHogFileBackedQueueAlignmentTest.swift | 53 ++++++++++++- api/posthog-ios.public-api.txt | 2 +- sdk_compliance_adapter/Sources/main.swift | 5 +- 10 files changed, 165 insertions(+), 37 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index 92e292bdb1..cc73f66f76 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -2,4 +2,4 @@ 'posthog-ios': minor --- -Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. +Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. It returns the event UUID. diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index 9206ca0a00..4a4d4ee3bf 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -15,16 +15,23 @@ class PostHogFileBackedQueue { let queue: URL private let maxSize: Int? + /// FIFO limit on the total size of the stored files, on top of `maxSize`. + /// The newest entry is always kept, even when it's larger on its own. + private let maxBytes: Int? private var items = [String]() + /// File sizes by id, tracked only when `maxBytes` is set. Guarded by `itemsLock`. + private var sizes = [String: Int]() + private var totalBytes = 0 private let itemsLock = NSLock() var depth: Int { itemsLock.withLock { items.count } } - init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil) { + init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil, maxBytes: Int? = nil) { self.queue = queue self.maxSize = maxSize.map { max(1, $0) } + self.maxBytes = maxBytes.map { max(1, $0) } setup(oldQueues: oldQueues) } @@ -67,7 +74,7 @@ class PostHogFileBackedQueue { func delete(index: Int) { let removed: String? = itemsLock.withLock { guard index < items.count else { return nil } - return items.remove(at: index) + return untrack(items.remove(at: index)) } if let removed { @@ -84,6 +91,7 @@ class PostHogFileBackedQueue { let removed: [String] = itemsLock.withLock { let removed = items.filter { ids.contains($0) } items.removeAll { ids.contains($0) } + removed.forEach { untrack($0) } return removed } @@ -93,32 +101,34 @@ class PostHogFileBackedQueue { } /// Persists one entry and optionally enforces a FIFO capacity in the same - /// critical section. Returning an evicted id lets the queue report + /// critical section. Returning the evicted ids lets the queue report /// backpressure without racing a separate depth check against other adds. @discardableResult - func add(_ contents: Data, maxSize: Int? = nil) -> (success: Bool, evicted: String?) { + func add(_ contents: Data, maxSize: Int? = nil) -> (success: Bool, evicted: [String]) { do { let filename = UUID.v7String() let effectiveMaxSize = maxSize.map { max(1, $0) } ?? self.maxSize - var evicted: String? + var evicted = [String]() try itemsLock.withLock { try contents.write(to: queue.appendingPathComponent(filename)) if let effectiveMaxSize, items.count >= effectiveMaxSize { - evicted = items.removeFirst() - if let evicted { - deleteSafely(queue.appendingPathComponent(evicted)) - } + evicted.append(untrack(items.removeFirst())) } items.append(filename) + track(filename, size: contents.count) + evicted += evictOverByteLimit() } + for id in evicted { + deleteSafely(queue.appendingPathComponent(id)) + } return (true, evicted) } catch { hedgeLog("Could not write file \(error)") - return (false, nil) + return (false, []) } } @@ -148,7 +158,15 @@ class PostHogFileBackedQueue { let sortedItems = try FileManager.default.contentsOfDirectory(at: queue, sortedBy: .creationDateKey) let overflow = maxSize.map { max(0, sortedItems.count - $0) } ?? 0 items = Array(sortedItems.dropFirst(overflow)) - return Array(sortedItems.prefix(overflow)) + sizes = [:] + totalBytes = 0 + if maxBytes != nil { + for item in items { + let attributes = try? FileManager.default.attributesOfItem(atPath: queue.appendingPathComponent(item).path) + track(item, size: (attributes?[.size] as? NSNumber)?.intValue ?? 0) + } + } + return Array(sortedItems.prefix(overflow)) + evictOverByteLimit() } for item in dropped { @@ -194,7 +212,10 @@ class PostHogFileBackedQueue { } if !skipped.isEmpty { - itemsLock.withLock { items.removeAll { skipped.contains($0) } } + itemsLock.withLock { + items.removeAll { skipped.contains($0) } + skipped.forEach { untrack($0) } + } } return results @@ -216,11 +237,38 @@ class PostHogFileBackedQueue { return false } + /// Caller must hold `itemsLock`. + private func track(_ id: String, size: Int) { + guard maxBytes != nil else { return } + sizes[id] = size + totalBytes += size + } + + /// Caller must hold `itemsLock`. Returns `id` for chaining. + @discardableResult + private func untrack(_ id: String) -> String { + if let size = sizes.removeValue(forKey: id) { + totalBytes -= size + } + return id + } + + /// Drops the oldest items until the total fits `maxBytes`, keeping the + /// newest. Caller must hold `itemsLock` and delete the returned files. + private func evictOverByteLimit() -> [String] { + guard let maxBytes else { return [] } + var evicted = [String]() + while totalBytes > maxBytes, items.count > 1 { + evicted.append(untrack(items.removeFirst())) + } + return evicted + } + private func deleteFiles(_ count: Int) { for _ in 0 ..< count { let removed: String? = itemsLock.withLock { guard !items.isEmpty else { return nil } - return items.remove(at: 0) // We always remove from the top of the queue + return untrack(items.remove(at: 0)) // We always remove from the top of the queue } guard let removed else { return } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 1989e141f2..3e755ff0ea 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -154,7 +154,8 @@ class PostHogQueue { fileQueue = PostHogFileBackedQueue( queue: storage.url(forKey: endpoint.storageKey), oldQueues: endpoint.oldStorageKeys.map { storage.url(forKey: $0) }, - maxSize: configuredMaxQueueSize + maxSize: configuredMaxQueueSize, + maxBytes: endpoint.maxQueueBytes ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } @@ -173,7 +174,8 @@ class PostHogQueue { fileQueue = PostHogFileBackedQueue( queue: storage.url(forKey: endpoint.storageKey), oldQueues: endpoint.oldStorageKeys.map { storage.url(forKey: $0) }, - maxSize: configuredMaxQueueSize + maxSize: configuredMaxQueueSize, + maxBytes: endpoint.maxQueueBytes ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } @@ -403,8 +405,8 @@ class PostHogQueue { let result = fileQueue.add(data, maxSize: configuredMaxQueueSize) guard result.success else { return false } - if result.evicted != nil { - hedgeLog("Queue is full, dropping oldest record") + if !result.evicted.isEmpty { + hedgeLog("Queue is full, dropped \(result.evicted.count) oldest record(s)") } hedgeLog("Queued \(endpoint.describe(record)). Depth: \(fileQueue.depth)") flushIfOverThreshold() diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 55b2054967..0af984a8f1 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1546,6 +1546,10 @@ let maxRetryDelay = 30.0 /// - timestamp: Optional event timestamp. Defaults to the current time. /// - options: Optional settings that tell PostHog how to process this event. Same keys as /// `capture`'s `options`. + /// + /// - Returns: The event's UUID, or `nil` if the event wasn't queued: for example, the SDK is + /// opted out or disabled, `beforeSend` dropped it, or it's over 8 MiB. + @discardableResult @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) public func captureAi(_ event: String, distinctId: String? = nil, @@ -1554,7 +1558,7 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: [String: Any]? = nil, groups: [String: String]? = nil, timestamp: Date? = nil, - options: [String: Any]? = nil) + options: [String: Any]? = nil) -> String? { captureInternal( event, @@ -1745,6 +1749,8 @@ let maxRetryDelay = 30.0 /// - Parameters: /// - skipBuildProperties: When true, skips buildProperties call and uses properties as-is. /// Used by crash reporting to capture events with pre-built crash-time context. + /// - Returns: The queued event's UUID, or `nil` when the event isn't queued. + @discardableResult func captureInternal( _ event: String, distinctId: String? = nil, @@ -1758,17 +1764,17 @@ let maxRetryDelay = 30.0 propertyAllowlist: Set? = nil, deduplicatePersonProperties: Bool = false, aiLane: Bool = false - ) { + ) -> String? { if !isEnabled() { - return + return nil } if isOptOutState() { - return + return nil } guard let queue = aiLane ? aiQueue : queue else { - return + return nil } // $exception_list only ever comes from the caller, so raw properties are sufficient here @@ -1778,7 +1784,7 @@ let maxRetryDelay = 30.0 PostHogErrorTrackingAutoCaptureIntegration.exceptionListMatchesIgnoredTypes(properties ?? [:], ignoredTypes: ignored) { hedgeLog("$exception skipped: exception type is in errorTrackingConfig.ignoredExceptionTypes") - return + return nil } } @@ -1849,7 +1855,7 @@ let maxRetryDelay = 30.0 ) guard let posthogEvent else { - return + return nil } // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook. @@ -1859,7 +1865,7 @@ let maxRetryDelay = 30.0 // if this is a $snapshot event and $session_id is missing, don't process then event if isSnapshotEvent, posthogEvent.properties["$session_id"] == nil { releaseClaimIfCarried(posthogEvent) - return + return nil } // Automatically set person properties for feature flags during capture event @@ -1872,9 +1878,10 @@ let maxRetryDelay = 30.0 releaseClaimIfCarried(posthogEvent) replayQueue?.add(posthogEvent) onEventCaptured.invoke(posthogEvent) - } else { - queueEvent(posthogEvent, queue: queue, deduplicatePersonProperties: deduplicatePersonProperties) + return replayQueue == nil ? nil : posthogEvent.uuid.postHogUuidString } + let stored = queueEvent(posthogEvent, queue: queue, deduplicatePersonProperties: deduplicatePersonProperties) + return stored ? posthogEvent.uuid.postHogUuidString : nil } /// Records a screen view by capturing a `$screen` event. @@ -2224,7 +2231,9 @@ let maxRetryDelay = 30.0 return resultEvent } - private func queueEvent(_ event: PostHogEvent, queue: PostHogQueue, deduplicatePersonProperties: Bool = false) { + /// Returns whether the event was stored in `queue`. + @discardableResult + private func queueEvent(_ event: PostHogEvent, queue: PostHogQueue, deduplicatePersonProperties: Bool = false) -> Bool { let storedInQueue: Bool let userProperties = event.properties["$set"] as? [String: Any] let userPropertiesSetOnce = event.properties["$set_once"] as? [String: Any] @@ -2257,7 +2266,7 @@ let maxRetryDelay = 30.0 releaseClaimIfCarried(event) // Only a suppressed duplicate skips the callback below: subscribers such as replay // triggers and event-activated surveys don't depend on this queue reaching disk. - return + return false } storedInQueue = stored } else { @@ -2271,6 +2280,7 @@ let maxRetryDelay = 30.0 } } onEventCaptured.invoke(event) + return storedInQueue } /// Associates subsequent events with a group. diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 8304c2de61..31bb458159 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -31,6 +31,8 @@ let aiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024 let aiMaxBatchBytes = 5 * 1024 * 1024 /// AI events can be megabytes, so keep fewer of them on disk than analytics events. let aiMaxQueueSize = 100 +/// Disk budget for queued AI events; the oldest are dropped first. +let aiMaxQueueBytes = 50 * 1024 * 1024 extension QueueEndpoint where Record == PostHogEvent { /// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`), or @@ -87,6 +89,7 @@ extension QueueEndpoint where Record == PostHogEvent { describe: { event in "AI event '\(event.event)'" }, recordId: { event in event.uuid.postHogUuidString }, maxBatchBytes: aiMaxBatchBytes, + maxQueueBytes: aiMaxQueueBytes, send: { events, completion in api.captureAi(events: events, completion: completion) }, diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index 567283d429..e64c7f31e3 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -72,6 +72,10 @@ struct QueueEndpoint { /// the limit goes alone. `nil` batches by count only. var maxBatchBytes: Int? + /// FIFO limit on the total size of queued records on disk, on top of + /// `maxQueueSize`. `nil` limits by count only. + var maxQueueBytes: Int? + // MARK: Send /// Build the wire payload from a list of records and POST it. The queue diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index ce8236229e..25434cd455 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -849,7 +849,7 @@ final class PostHogCaptureAiTest { } defer { sut.close() } - sut.captureAi("$ai_generation", distinctId: "user", properties: ["$ai_model": "gpt"], options: ["cookieless_mode": true]) + let uuid = sut.captureAi("$ai_generation", distinctId: "user", properties: ["$ai_model": "gpt"], options: ["cookieless_mode": true]) await waitUntil { self.server.aiRequests.count == 1 } let request = try #require(server.aiRequests.first) @@ -859,6 +859,7 @@ final class PostHogCaptureAiTest { #expect(request.value(forHTTPHeaderField: "PostHog-Request-Id") != nil) let event = try #require(server.parsePostHogEvents(request).first) #expect(event.event == "$ai_generation") + #expect(event.uuid.postHogUuidString == uuid) #expect(event.distinctId == "user") #expect(event.properties["$ai_model"] as? String == "gpt") #expect(event.properties["edited"] as? Bool == true) @@ -871,6 +872,17 @@ final class PostHogCaptureAiTest { #expect(server.aiRequests.count == 1) } + @Test("captureAi returns nil when the event isn't queued") + func returnsNilWhenNotQueued() { + let sut = makeSDK { event in event.event == "$ai_span" ? nil : event } + defer { sut.close() } + + #expect(sut.captureAi("$ai_span") == nil) + #expect(sut.captureAi("$ai_generation", properties: ["$ai_input": String(repeating: "a", count: aiMaxEventBytes)]) == nil) + sut.optOut() + #expect(sut.captureAi("$ai_generation") == nil) + } + @Test("a 404 from the AI endpoint drops the batch without falling back to /batch") func notFoundIsTerminal() async throws { server.aiResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [:], statusCode: 404, headers: nil) } @@ -905,6 +917,7 @@ final class PostHogCaptureAiTest { deleteSafely(storage.appFolderUrl) } + #expect(QueueEndpoint.ai(api: PostHogApi(config)).maxQueueBytes == aiMaxQueueBytes) let big = String(repeating: "a", count: aiMaxEventBytes) #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": big]))) #expect(queue.depth == 0) diff --git a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index a6b2629576..27a60fa2c6 100644 --- a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift +++ b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift @@ -57,7 +57,7 @@ struct PostHogFileBackedQueueAlignmentTest { let first = queue.add(Data("A".utf8), maxSize: 2) #expect(first.success) - #expect(first.evicted == nil) + #expect(first.evicted.isEmpty) queue.add(Data("B".utf8), maxSize: 2) let originalIds = queue.peekEntries(2).map(\.id) try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: dir.path) @@ -67,7 +67,7 @@ struct PostHogFileBackedQueueAlignmentTest { let failed = queue.add(Data("C".utf8), maxSize: 2) #expect(!failed.success) - #expect(failed.evicted == nil) + #expect(failed.evicted.isEmpty) #expect(queue.depth == 2) #expect(queue.peekEntries(2).map(\.id) == originalIds) #expect(decode(queue.peek(2)) == ["A", "B"]) @@ -76,7 +76,7 @@ struct PostHogFileBackedQueueAlignmentTest { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dir.path) let retried = queue.add(Data("C".utf8), maxSize: 2) #expect(retried.success) - #expect(retried.evicted == originalIds.first) + #expect(retried.evicted == [originalIds[0]]) #expect(queue.depth == 2) #expect(decode(queue.peek(2)) == ["B", "C"]) let reloaded = PostHogFileBackedQueue(queue: dir, maxSize: 2) @@ -84,6 +84,53 @@ struct PostHogFileBackedQueueAlignmentTest { #expect(reloaded.depth == 2) } + @Test("evicts the oldest records to stay under the byte limit") + func evictsOldestOverByteLimit() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-bytes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let queue = PostHogFileBackedQueue(queue: dir, maxBytes: 10) + + queue.add(Data("AAAA".utf8)) + let first = queue.peekEntries(1).map(\.id) + queue.add(Data("BBBB".utf8)) + let added = queue.add(Data("CCCC".utf8)) + + #expect(added.evicted == first) + #expect(decode(queue.peek(10)) == ["BBBB", "CCCC"]) + #expect(try FileManager.default.contentsOfDirectory(atPath: dir.path).count == 2) + + queue.pop(1) + queue.add(Data("DDDD".utf8)) + #expect(decode(queue.peek(10)) == ["CCCC", "DDDD"]) + } + + @Test("keeps the newest record even when it is over the byte limit alone") + func keepsOversizedNewestRecord() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-bytes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let queue = PostHogFileBackedQueue(queue: dir, maxBytes: 4) + + queue.add(Data("AA".utf8)) + let added = queue.add(Data("BBBBBB".utf8)) + + #expect(added.evicted.count == 1) + #expect(decode(queue.peek(10)) == ["BBBBBB"]) + } + + @Test("trims the oldest records over the byte limit when reloading from disk") + func trimsByteLimitOnLoad() throws { + let (queue, dir) = makeQueue() + defer { try? FileManager.default.removeItem(at: dir) } + enqueue(["AAAA", "BBBB", "CCCC"], into: queue) + + let reloaded = PostHogFileBackedQueue(queue: dir, maxBytes: 8) + + #expect(decode(reloaded.peek(10)) == ["BBBB", "CCCC"]) + #expect(try FileManager.default.contentsOfDirectory(atPath: dir.path).count == 2) + } + @Test("delivers every record once in FIFO order on the happy path") func happyPath() throws { let (queue, dir) = makeQueue() diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 98647893c8..96c6bc0acb 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -258,7 +258,7 @@ PostHog | PostHogSDK.capture(_:properties:) | method | @objc(captureWithEvent:pr PostHog | PostHogSDK.capture(_:properties:userProperties:) | method | @objc(captureWithEvent:properties:userProperties:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups: -PostHog | PostHogSDK.captureAi(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func captureAi(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: +PostHog | PostHogSDK.captureAi(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @discardableResult @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func captureAi(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.captureDeepLink(openURLContexts:) | method | @objc func captureDeepLink(openURLContexts: Set) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithOpenURLContexts: PostHog | PostHogSDK.captureDeepLink(url:) | method | @objc func captureDeepLink(url: URL) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl: PostHog | PostHogSDK.captureDeepLink(url:options:) | method | @objc func captureDeepLink(url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl:options: diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index a14e029056..34187a1fcc 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -244,14 +244,15 @@ app.post("capture_ai") { req async throws -> Response in throw Abort(.badRequest, reason: "SDK not initialized. Call /init first.") } - sdk.captureAi( + let uuid = sdk.captureAi( captureReq.event, distinctId: captureReq.distinctId, properties: captureReq.properties?.mapValues(\.value), options: captureReq.options?.mapValues(\.value) ) - let result = ["status": "ok"] + var result = ["status": "ok"] + result["uuid"] = uuid return try await result.encodeResponse(for: req) } From af822d8f24e157baf86924fbfffc092fef93b710 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 20 Oct 2026 12:04:16 -0400 Subject: [PATCH 37/67] fix: flush and bound AI queue reads by batch bytes, and flush it on close() - close() flushes the AI queue along with the others, and aiQueue is guarded by setupLock like the other queues. - The AI queue flushes once a batch's worth of bytes (5 MiB) is queued, so multi-MB events are sent before the 50 MiB cap evicts them. - A flush reads about one byte-sized batch from disk instead of the whole window, so large AI events aren't all held in memory at once. - The compliance adapter's /capture_ai accepts bodies up to 9 MB and passes the harness timestamp. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogFileBackedQueue.swift | 17 +++++++++++---- PostHog/PostHogQueue.swift | 8 +++++-- PostHog/PostHogSDK.swift | 9 ++++++-- PostHogTests/PostHogCaptureV1Test.swift | 21 +++++++++++++++++++ .../PostHogFileBackedQueueAlignmentTest.swift | 11 ++++++++++ sdk_compliance_adapter/Sources/main.swift | 6 +++++- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index 4a4d4ee3bf..ed0367801a 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -28,6 +28,11 @@ class PostHogFileBackedQueue { itemsLock.withLock { items.count } } + /// Total size of the stored files. Tracked only when `maxBytes` is set, 0 otherwise. + var byteCount: Int { + itemsLock.withLock { totalBytes } + } + init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil, maxBytes: Int? = nil) { self.queue = queue self.maxSize = maxSize.map { max(1, $0) } @@ -67,8 +72,10 @@ class PostHogFileBackedQueue { peekEntries(count).map(\.data) } - func peekEntries(_ count: Int) -> [Entry] { - loadEntries(count) + /// Reads up to `count` entries, oldest first. With `maxBytes`, stops after the + /// entry that reaches it, so large entries aren't all held in memory at once. + func peekEntries(_ count: Int, maxBytes: Int? = nil) -> [Entry] { + loadEntries(count, maxBytes: maxBytes) } func delete(index: Int) { @@ -177,9 +184,10 @@ class PostHogFileBackedQueue { } } - private func loadEntries(_ count: Int) -> [Entry] { + private func loadEntries(_ count: Int, maxBytes: Int? = nil) -> [Entry] { var results = [Entry]() var skipped = Set() + var loadedBytes = 0 let itemsCopy = itemsLock.withLock { items } @@ -194,6 +202,7 @@ class PostHogFileBackedQueue { let contents = try Data(contentsOf: itemURL) results.append(Entry(id: item, data: contents)) + loadedBytes += contents.count } catch { if isTemporarilyUnavailable(error) { hedgeLog("File \(itemURL) is temporarily unavailable, will retry \(error)") @@ -206,7 +215,7 @@ class PostHogFileBackedQueue { skipped.insert(item) } - if results.count == count { + if results.count == count || maxBytes.map({ loadedBytes >= $0 }) == true { break } } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 3e755ff0ea..722e62377a 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -376,7 +376,10 @@ class PostHogQueue { func flushIfOverThreshold() { let threshold = batchLimitsLock.withLock { batchLimits.flushAt } - if fileQueue.depth >= threshold { + // A full byte-sized batch also flushes, so large records go out before + // the queue's byte cap starts evicting them. + let overByteThreshold = endpoint.maxBatchBytes.map { fileQueue.byteCount >= $0 } == true + if fileQueue.depth >= threshold || overByteThreshold { flush() } } @@ -482,7 +485,8 @@ class PostHogQueue { // Bound this flush to its original FIFO window, including across // replay boundaries. Concurrent appends belong to a later flush. - let entries = self.fileQueue.peekEntries(count) + // A byte-limited endpoint reads about one batch, not the whole window. + let entries = self.fileQueue.peekEntries(count, maxBytes: self.endpoint.maxBatchBytes) self.consume(entries, from: 0, completion: completion) } } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 0af984a8f1..599860f51b 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -85,7 +85,11 @@ let maxRetryDelay = 30.0 } private(set) var logsQueue: PostHogQueue? - private(set) var aiQueue: PostHogQueue? + private var _aiQueue: PostHogQueue? + private(set) var aiQueue: PostHogQueue? { + get { setupLock.withLock { _aiQueue } } + set { setupLock.withLock { _aiQueue = newValue } } + } private(set) var storage: PostHogStorage? private var surveyIdentityGeneration: String? #if !os(watchOS) @@ -3166,7 +3170,7 @@ let maxRetryDelay = 30.0 /// Shuts down this SDK instance and clears its in-memory state. /// - /// Starts sending queued events, session replay snapshots, and logs (up to one batch each) and returns without + /// Starts sending queued events, AI events, session replay snapshots, and logs (up to one batch each) and returns without /// waiting for delivery. Anything not sent, for example while offline, stays on disk and is sent after the next /// setup. Integrations are uninstalled, and this instance must be set up again before reuse. @objc public func close() { @@ -3179,6 +3183,7 @@ let maxRetryDelay = 30.0 PostHogSDK.projectTokens.remove(config.projectToken) queue?.flush() + aiQueue?.flush() replayQueue?.flush() logsQueue?.flush() diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 25434cd455..87c3d91fe6 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -946,4 +946,25 @@ final class PostHogCaptureAiTest { #expect(queue.depth == 0) #expect(server.aiRequests.map { server.parsePostHogEvents($0).count } == [2, 1]) } + + @Test("flushes once a batch's worth of bytes is queued, before flushAt is reached") + func flushesAtBatchBytes() async throws { + let config = PostHogConfig(projectToken: "phc_ai_byte_flush", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let input = String(repeating: "a", count: 3 * 1024 * 1024) + queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": input])) + queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": input])) + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(server.aiRequests.map { server.parsePostHogEvents($0).count } == [1, 1]) + } } diff --git a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index 27a60fa2c6..07ec910c68 100644 --- a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift +++ b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift @@ -131,6 +131,17 @@ struct PostHogFileBackedQueueAlignmentTest { #expect(try FileManager.default.contentsOfDirectory(atPath: dir.path).count == 2) } + @Test("a byte-bounded peek stops after the record that reaches the budget") + func peekStopsAtByteBudget() throws { + let (queue, dir) = makeQueue() + defer { try? FileManager.default.removeItem(at: dir) } + enqueue(["AAAA", "BBBB", "CCCC"], into: queue) + + #expect(queue.peekEntries(10, maxBytes: 5).map { String(decoding: $0.data, as: UTF8.self) } == ["AAAA", "BBBB"]) + #expect(queue.peekEntries(10, maxBytes: 1).count == 1) + #expect(queue.peekEntries(10).count == 3) + } + @Test("delivers every record once in FIFO order on the happy path") func happyPath() throws { let (queue, dir) = makeQueue() diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 34187a1fcc..8db1ddfbb8 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -222,17 +222,20 @@ app.post("capture") { req async throws -> Response in } // AI capture endpoint: same request as /capture, sent with captureAi to /i/v1/ai/events. -app.post("capture_ai") { req async throws -> Response in +// AI events can be up to 8 MiB, over Vapor's default 16 KB body limit. +app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws -> Response in struct CaptureAiRequest: Content { let event: String let distinctId: String? let properties: [String: AnyCodable]? + let timestamp: String? let options: [String: AnyCodable]? enum CodingKeys: String, CodingKey { case event case distinctId = "distinct_id" case properties + case timestamp case options } } @@ -248,6 +251,7 @@ app.post("capture_ai") { req async throws -> Response in captureReq.event, distinctId: captureReq.distinctId, properties: captureReq.properties?.mapValues(\.value), + timestamp: captureReq.timestamp.flatMap(parseHarnessTimestamp), options: captureReq.options?.mapValues(\.value) ) From d3842094f73f4ad7cdbf23a40e5f3b3a28c14b43 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 20 Oct 2026 13:01:18 -0400 Subject: [PATCH 38/67] chore: return success from the compliance adapter's /capture_ai The harness reads the action result's success field, as posthog-python's adapter returns it. Co-Authored-By: Claude Opus 5.5 (1M context) --- sdk_compliance_adapter/Sources/main.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 8db1ddfbb8..11df198fa9 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -255,7 +255,7 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - options: captureReq.options?.mapValues(\.value) ) - var result = ["status": "ok"] + var result: [String: Any] = ["success": true] result["uuid"] = uuid return try await result.encodeResponse(for: req) } From 27ec3072a557f9f1ba399aa35efa0c90c564accf Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:37:03 -0400 Subject: [PATCH 39/67] feat(queue): cap analytics batches at 5 MiB Capture V1 rejects requests over 10 MiB, and analytics batches were capped by count only. Give the analytics endpoint the same 5 MiB soft byte limit as the AI lane (renamed to captureV1MaxBatchBytes). An event over the limit is still sent alone, and 413 halving is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 2 ++ PostHog/QueueEndpoint+Factories.swift | 11 ++++++---- PostHogTests/PostHogCaptureV1Test.swift | 27 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index cc73f66f76..0b1d1316e3 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -3,3 +3,5 @@ --- Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. It returns the event UUID. + +Analytics batches are now also capped at 5 MiB per request, so a batch of large events stays under capture V1's request limit; an event larger than that is sent on its own. diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 31bb458159..4ce0424f80 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -27,8 +27,9 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { /// stored event also holds its envelope, so allow 64 KiB on top, as /// posthog-python and posthog-go do. let aiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024 -/// Soft request size for the AI endpoint, matching the other PostHog SDKs. -let aiMaxBatchBytes = 5 * 1024 * 1024 +/// Soft request size for the capture V1 analytics and AI endpoints, matching +/// the other PostHog SDKs and well under capture V1's 10 MiB request limit. +let captureV1MaxBatchBytes = 5 * 1024 * 1024 /// AI events can be megabytes, so keep fewer of them on disk than analytics events. let aiMaxQueueSize = 100 /// Disk budget for queued AI events; the oldest are dropped first. @@ -36,7 +37,8 @@ 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. + /// `/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. static func batch(api: PostHogApi) -> QueueEndpoint { QueueEndpoint( storageKey: .queue, @@ -52,6 +54,7 @@ extension QueueEndpoint where Record == PostHogEvent { decode: { data in PostHogEvent.fromJSON(data) }, describe: { event in "event '\(event.event)'" }, recordId: { event in event.uuid.postHogUuidString }, + maxBatchBytes: captureV1MaxBatchBytes, send: { events, completion in api.captureV1(events: events, completion: completion) }, @@ -88,7 +91,7 @@ extension QueueEndpoint where Record == PostHogEvent { decode: { data in PostHogEvent.fromJSON(data) }, describe: { event in "AI event '\(event.event)'" }, recordId: { event in event.uuid.postHogUuidString }, - maxBatchBytes: aiMaxBatchBytes, + maxBatchBytes: captureV1MaxBatchBytes, maxQueueBytes: aiMaxQueueBytes, send: { events, completion in api.captureAi(events: events, completion: completion) diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 87c3d91fe6..fa35761d5f 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -809,6 +809,33 @@ final class PostHogCaptureV1QueueTest { ) } } + + @Test("splits analytics batches at the byte limit and sends an event over it alone") + func batchesByBytes() async throws { + let config = PostHogConfig(projectToken: "phc_bytes_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .batch(api: PostHogApi(config)), nil) + server.batchProjectToken = config.projectToken + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + // Two ~2 MiB events fit under 5 MiB; the ~6 MiB one doesn't fit with them and + // is over the limit on its own. + let small = String(repeating: "a", count: 2 * 1024 * 1024) + let big = String(repeating: "b", count: 6 * 1024 * 1024) + for blob in [small, small, big] { + queue.add(PostHogEvent(event: "large", distinctId: "user", properties: ["blob": blob])) + } + queue.flush() + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(server.batchRequests.map { server.parsePostHogEvents($0).count } == [2, 1]) + } } @Suite("Capture V1 AI lane", .serialized, .resetsGlobalState) From 800e9204936752ba6efd060f981ee01ee9fc057f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 17:03:45 -0400 Subject: [PATCH 40/67] feat: give AI requests a 60 s timeout and log non-$ai_ captureAi names Document the AI lane's fixed limits on captureAi. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 2 ++ PostHog/PostHogApi.swift | 19 ++++++++++++------- PostHog/PostHogSDK.swift | 15 +++++++++++++-- PostHogTests/PostHogCaptureV1Test.swift | 5 ++++- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index 0b1d1316e3..d561a607ae 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -5,3 +5,5 @@ Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. It returns the event UUID. Analytics batches are now also capped at 5 MiB per request, so a batch of large events stays under capture V1's request limit; an event larger than that is sent on its own. + +AI requests time out after 60 seconds without network activity, instead of 10, since they can carry several MB. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index aa47c8cfa8..772db9d176 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -58,6 +58,8 @@ class PostHogApi { // default is 60s but we do 10s private let defaultTimeout: TimeInterval = 10 + /// AI batches can be several MB, so give them longer than other requests. + static let aiTimeout: TimeInterval = 60 /// Shared so connection pool, TLS state, and HTTP/2 streams survive /// between calls instead of being torn down per request. @@ -108,10 +110,10 @@ class PostHogApi { /// `gzipped: true` adds `Content-Encoding: gzip` for upload endpoints /// (/batch, /s/, /i/v1/logs) whose bodies are gzipped. - private func getURLRequest(_ url: URL, gzipped: Bool = false, httpMethod: String = "POST") -> URLRequest { + private func getURLRequest(_ url: URL, gzipped: Bool = false, httpMethod: String = "POST", timeout: TimeInterval? = nil) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = httpMethod - request.timeoutInterval = defaultTimeout + request.timeoutInterval = timeout ?? defaultTimeout applyCustomHeaders(&request) if gzipped { request.setValue("gzip", forHTTPHeaderField: "Content-Encoding") @@ -138,15 +140,17 @@ class PostHogApi { "posthog-sdk-info", "posthog-attempt", "posthog-request-id", "posthog-request-timestamp", ] - private func requestAndPayload(url: URL, data: Data, endpointName: String, httpMethod: String = "POST") -> (URLRequest, Data) { + private func requestAndPayload( + url: URL, data: Data, endpointName: String, httpMethod: String = "POST", timeout: TimeInterval? = nil + ) -> (URLRequest, Data) { guard config.compression == .gzip else { - return (getURLRequest(url, httpMethod: httpMethod), data) + return (getURLRequest(url, httpMethod: httpMethod, timeout: timeout), data) } do { - return (getURLRequest(url, gzipped: true, httpMethod: httpMethod), try Self.gzipData(data)) + return (getURLRequest(url, gzipped: true, httpMethod: httpMethod, timeout: timeout), try Self.gzipData(data)) } catch { hedgeLog("Error gzipping the \(endpointName) body, sending it uncompressed: \(error).") - return (getURLRequest(url, httpMethod: httpMethod), data) + return (getURLRequest(url, httpMethod: httpMethod, timeout: timeout), data) } } @@ -260,7 +264,8 @@ class PostHogApi { return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture") + let timeout = path == Self.captureV1AiPath ? Self.aiTimeout : nil + var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture", timeout: timeout) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(config.projectToken)", forHTTPHeaderField: "Authorization") request.setValue("\(postHogSdkName)/\(postHogVersion)", forHTTPHeaderField: "PostHog-Sdk-Info") diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 599860f51b..2391e2d642 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1538,7 +1538,14 @@ let maxRetryDelay = 30.0 /// and goes through the same `beforeSend` hooks. The event is queued on disk separately from other /// events and sent unchanged: the SDK doesn't redact or truncate prompts, outputs or media. /// An event over 8 MiB is dropped. PostHog drops an event sent here whose name doesn't start - /// with `$ai_`. `capture` never sends events here, even ones named `$ai_*`. + /// with `$ai_`, and the SDK logs a debug message when it's given one. `capture` never sends events + /// here, even ones named `$ai_*`. + /// + /// AI events use `flushAt`, `maxBatchSize`, `flushIntervalSeconds` and `compression` from + /// `PostHogConfig`, like other events. These limits are fixed and can't be configured: + /// - Requests carry up to about 5 MiB of events, and the SDK sends as soon as that much is queued. + /// - Requests time out after 60 seconds without network activity, instead of 10. + /// - Up to 100 events and 50 MiB are kept on disk. Beyond that, the oldest are dropped. /// /// - Parameters: /// - event: AI event name, such as `$ai_generation`. @@ -1564,7 +1571,11 @@ let maxRetryDelay = 30.0 timestamp: Date? = nil, options: [String: Any]? = nil) -> String? { - captureInternal( + if !event.hasPrefix("$ai_") { + hedgeLog("captureAi event '\(event)' doesn't start with '$ai_', so PostHog will drop it. Use capture for non-AI events.") + } + + return captureInternal( event, distinctId: distinctId, properties: properties, diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index fa35761d5f..cbcf374e4f 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -884,6 +884,7 @@ final class PostHogCaptureAiTest { #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(sut.config.projectToken)") #expect(request.value(forHTTPHeaderField: "PostHog-Attempt") == "1") #expect(request.value(forHTTPHeaderField: "PostHog-Request-Id") != nil) + #expect(request.timeoutInterval == PostHogApi.aiTimeout) let event = try #require(server.parsePostHogEvents(request).first) #expect(event.event == "$ai_generation") #expect(event.uuid.postHogUuidString == uuid) @@ -895,7 +896,9 @@ final class PostHogCaptureAiTest { sut.capture("$ai_generation") await waitUntil { self.server.batchRequests.count == 1 } - #expect(server.parsePostHogEvents(try #require(server.batchRequests.first)).map(\.event) == ["$ai_generation"]) + let batchRequest = try #require(server.batchRequests.first) + #expect(server.parsePostHogEvents(batchRequest).map(\.event) == ["$ai_generation"]) + #expect(batchRequest.timeoutInterval < PostHogApi.aiTimeout) #expect(server.aiRequests.count == 1) } From a0a729ced5a44300781d9a65c0a4e1635029a459 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 14:36:49 -0400 Subject: [PATCH 41/67] docs: note the 5 MiB analytics batch cap in the migration guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 3b69a4ed27..3d30234da8 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -146,5 +146,6 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter. In 3.x it grew by 1 second per failure. This applies to events, session replay and logs. - Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). - `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. From cf6c2ae4694cac8a274479f256d0aa4efa76757e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 15:40:29 -0400 Subject: [PATCH 42/67] docs: add an AI events section to the migration guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 3d30234da8..5fefd76bd1 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -30,6 +30,7 @@ You need to change something if your app does any of these: | --- | --- | --- | | Analytics events | `/batch` | `/i/v1/analytics/events`, or `/batch` after a 404 (see below) | | 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 | | Feature flags | `/flags` | `/flags`, unchanged | @@ -138,6 +139,24 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - 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 + +4.0 adds `captureAi`, which sends AI events, such as `$ai_generation`, to PostHog's AI endpoint (`/i/v1/ai/events`) instead of the analytics endpoint. +It takes the same arguments as `capture`, and the events go through the same `beforeSend` hooks. + +```swift +PostHogSDK.shared.captureAi("$ai_generation", properties: ["$ai_model": "gpt-5", "$ai_trace_id": traceId]) +``` + +In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`. + +- An AI event can be up to 8 MiB. The SDK drops a larger event, and `captureAi` returns `nil`. +- PostHog drops an event sent with `captureAi` whose name doesn't start with `$ai_`. `capture` never sends events to the AI endpoint, even ones named `$ai_*`. +- AI requests time out after 60 seconds without network activity. Other requests still time out after 10 seconds. +- AI events are queued on disk separately from other events. Up to 100 events and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. +- AI events use `flushAt`, `maxBatchSize`, `flushIntervalSeconds` and `compression`, like other events. A request carries up to about 5 MiB, as for analytics events (see [Retries](#retries)). +- The SDK sends AI events unchanged. It doesn't redact or truncate prompts, outputs or media, so use `beforeSend` to remove anything you don't want to send. + ## Retries - The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. From a58da3cfbd086cc39e3b60346c1d75b46e5fda0a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 15:53:52 -0400 Subject: [PATCH 43/67] feat: add captureAi settings to PostHogConfig Add captureAiCompression, captureAiMaxQueueSize, captureAiTimeout and captureAiMaxEventBytes, matching the AI lane settings in the backend v1 SDKs. Defaults keep the current behavior: gzip, 100 events, 60 s, 8 MiB. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 2 + PostHog/PostHogApi.swift | 18 +++--- PostHog/PostHogConfig.swift | 29 ++++++++++ PostHog/PostHogSDK.swift | 16 +++--- PostHog/QueueEndpoint+Factories.swift | 40 ++++++++++---- PostHogTests/PostHogCaptureV1Test.swift | 73 ++++++++++++++++++++++++- api/posthog-ios.public-api.txt | 4 ++ docs/migration-3.x-to-4.0.md | 9 +-- 8 files changed, 159 insertions(+), 32 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index d561a607ae..b46089d20b 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -7,3 +7,5 @@ Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI Analytics batches are now also capped at 5 MiB per request, so a batch of large events stays under capture V1's request limit; an event larger than that is sent on its own. AI requests time out after 60 seconds without network activity, instead of 10, since they can carry several MB. + +Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults keep the behavior above: gzip, 100 queued events, 60 seconds and 8 MiB. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 772db9d176..f17a9fd872 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -51,15 +51,13 @@ func processUploadResponse( class PostHogApi { static var gzipData: (Data) throws -> Data = { try $0.gzipped() } - private let config: PostHogConfig + let config: PostHogConfig /// Snapshot at init; treated as immutable after setup. private let customRequestHeaders: [String: String] // default is 60s but we do 10s private let defaultTimeout: TimeInterval = 10 - /// AI batches can be several MB, so give them longer than other requests. - static let aiTimeout: TimeInterval = 60 /// Shared so connection pool, TLS state, and HTTP/2 streams survive /// between calls instead of being torn down per request. @@ -141,9 +139,10 @@ class PostHogApi { ] private func requestAndPayload( - url: URL, data: Data, endpointName: String, httpMethod: String = "POST", timeout: TimeInterval? = nil + url: URL, data: Data, endpointName: String, httpMethod: String = "POST", timeout: TimeInterval? = nil, + compression: PostHogCompression? = nil ) -> (URLRequest, Data) { - guard config.compression == .gzip else { + guard (compression ?? config.compression) == .gzip else { return (getURLRequest(url, httpMethod: httpMethod, timeout: timeout), data) } do { @@ -264,8 +263,13 @@ class PostHogApi { return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - let timeout = path == Self.captureV1AiPath ? Self.aiTimeout : nil - var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture", timeout: timeout) + // AI batches can be several MB, so they get their own timeout and compression. + let isAi = path == Self.captureV1AiPath + var (request, payload) = requestAndPayload( + url: url, data: data, endpointName: "capture", + timeout: isAi ? config.effectiveCaptureAiTimeout : nil, + compression: isAi ? config.captureAiCompression : nil + ) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(config.projectToken)", forHTTPHeaderField: "Authorization") request.setValue("\(postHogSdkName)/\(postHogVersion)", forHTTPHeaderField: "PostHog-Sdk-Info") diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 6bfa8260ae..d7d0aaedb2 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -34,6 +34,9 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? static let flushIntervalSeconds: TimeInterval = 30 static let pushNotificationSubscriptionMaxRetries: Int = 3 static let featureFlagRequestMaxRetries: Int = 1 + static let captureAiMaxQueueSize: Int = 100 + static let captureAiTimeout: TimeInterval = 60 + static let captureAiMaxEventBytes: Int = 8 * 1024 * 1024 } /// Network connectivity mode required before queued data may be flushed. @@ -475,6 +478,32 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Default: `.gzip`. @objc public var compression: PostHogCompression = .gzip + /// How the SDK compresses `captureAi` request bodies, separately from `compression`. + /// + /// Default: `.gzip`. + @objc public var captureAiCompression: PostHogCompression = .gzip + + /// Maximum number of `captureAi` events kept in their on-disk queue before older events are dropped. + /// + /// AI events can be megabytes, so this is lower than `maxQueueSize`. The queue also keeps at most + /// 50 MiB of AI events, whatever this is set to. A value of `0` or less uses the default. + /// Default: `100`. + @objc public var captureAiMaxQueueSize: Int = Defaults.captureAiMaxQueueSize + + /// How long, in seconds, a `captureAi` request can go without network activity before it times out. + /// + /// AI requests can carry several MB, so this is longer than for other requests, which time out + /// after 10 seconds. A value of `0` or less uses the default. + /// Default: `60`. + @objc public var captureAiTimeout: TimeInterval = Defaults.captureAiTimeout + + /// Maximum size, in bytes, of a `captureAi` event's properties. The SDK drops a larger event. + /// + /// Use it to lower the limit. PostHog drops AI events over 8 MiB, so a larger value, or a value of + /// `0` or less, uses 8 MiB. + /// Default: `8388608` (8 MiB). + @objc public var captureAiMaxEventBytes: Int = Defaults.captureAiMaxEventBytes + // only internal var disableReachabilityForTesting: Bool = false var disableQueueTimerForTesting: Bool = false diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2391e2d642..6b85a57d67 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1537,15 +1537,15 @@ let maxRetryDelay = 30.0 /// Takes the same arguments as `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)` /// and goes through the same `beforeSend` hooks. The event is queued on disk separately from other /// events and sent unchanged: the SDK doesn't redact or truncate prompts, outputs or media. - /// An event over 8 MiB is dropped. PostHog drops an event sent here whose name doesn't start - /// with `$ai_`, and the SDK logs a debug message when it's given one. `capture` never sends events - /// here, even ones named `$ai_*`. + /// An event over `captureAiMaxEventBytes` (8 MiB by default) is dropped. PostHog drops an event + /// sent here whose name doesn't start with `$ai_`, and the SDK logs a debug message when it's + /// given one. `capture` never sends events here, even ones named `$ai_*`. /// - /// AI events use `flushAt`, `maxBatchSize`, `flushIntervalSeconds` and `compression` from - /// `PostHogConfig`, like other events. These limits are fixed and can't be configured: + /// AI events use `flushAt`, `maxBatchSize` and `flushIntervalSeconds` from `PostHogConfig`, like + /// other events, and `captureAiCompression`, `captureAiMaxQueueSize` and `captureAiTimeout` instead + /// of `compression`, `maxQueueSize` and the 10-second timeout. These limits are fixed: /// - Requests carry up to about 5 MiB of events, and the SDK sends as soon as that much is queued. - /// - Requests time out after 60 seconds without network activity, instead of 10. - /// - Up to 100 events and 50 MiB are kept on disk. Beyond that, the oldest are dropped. + /// - Up to 50 MiB of AI events are kept on disk. Beyond that, the oldest are dropped. /// /// - Parameters: /// - event: AI event name, such as `$ai_generation`. @@ -1559,7 +1559,7 @@ let maxRetryDelay = 30.0 /// `capture`'s `options`. /// /// - Returns: The event's UUID, or `nil` if the event wasn't queued: for example, the SDK is - /// opted out or disabled, `beforeSend` dropped it, or it's over 8 MiB. + /// opted out or disabled, `beforeSend` dropped it, or it's over `captureAiMaxEventBytes`. @discardableResult @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) public func captureAi(_ event: String, diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 4ce0424f80..7b50de1bdf 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -23,15 +23,14 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { [408, 500, 502, 503, 504].contains(code) } -/// PostHog's AI endpoint drops events whose properties exceed 8 MiB. The -/// stored event also holds its envelope, so allow 64 KiB on top, as -/// posthog-python and posthog-go do. -let aiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024 +/// The stored event also holds its envelope, so allow 64 KiB on top of the +/// properties limit, as posthog-python and posthog-go do. +let aiEventEnvelopeBytes = 64 * 1024 +/// Stored-size limit for an AI event at the default `captureAiMaxEventBytes`. +let aiMaxEventBytes = PostHogConfig.Defaults.captureAiMaxEventBytes + aiEventEnvelopeBytes /// Soft request size for the capture V1 analytics and AI endpoints, matching /// the other PostHog SDKs and well under capture V1's 10 MiB request limit. let captureV1MaxBatchBytes = 5 * 1024 * 1024 -/// AI events can be megabytes, so keep fewer of them on disk than analytics events. -let aiMaxQueueSize = 100 /// Disk budget for queued AI events; the oldest are dropped first. let aiMaxQueueBytes = 50 * 1024 * 1024 @@ -69,21 +68,22 @@ extension QueueEndpoint where Record == PostHogEvent { /// `captureAi`. Same retry policy as capture V1, with no `/batch` fallback. /// Batches by bytes as well as count, since AI events can be megabytes. static func ai(api: PostHogApi) -> QueueEndpoint { - QueueEndpoint( + let maxEventBytes = api.config.captureAiMaxStoredEventBytes + return QueueEndpoint( storageKey: .aiQueue, oldStorageKeys: [], dispatchQueueLabel: "com.posthog.AiQueue", initialCap: { $0.maxBatchSize }, initialFlushAt: { $0.flushAt }, - maxQueueSize: { _ in aiMaxQueueSize }, + maxQueueSize: { $0.effectiveCaptureAiMaxQueueSize }, flushIntervalSeconds: { $0.flushIntervalSeconds }, rateCapMax: { _ in 0 }, rateCapWindowSeconds: { _ in 0 }, encode: { event in guard let data = toJSONData(event.toJSON()) else { return nil } // Name and size only: AI properties can hold prompts and media. - if data.count > aiMaxEventBytes { - hedgeLog("Dropping AI event '\(event.event)' of \(data.count) bytes, over the \(aiMaxEventBytes) byte limit.") + if data.count > maxEventBytes { + hedgeLog("Dropping AI event '\(event.event)' of \(data.count) bytes, over the \(maxEventBytes) byte limit.") return nil } return data @@ -177,3 +177,23 @@ extension QueueEndpoint where Record == PostHogLogRecord { ) } } + +extension PostHogConfig { + /// `captureAiMaxQueueSize`, or its default when it isn't positive. + var effectiveCaptureAiMaxQueueSize: Int { + captureAiMaxQueueSize > 0 ? captureAiMaxQueueSize : Defaults.captureAiMaxQueueSize + } + + /// `captureAiTimeout`, or its default when it isn't positive. + var effectiveCaptureAiTimeout: TimeInterval { + captureAiTimeout > 0 ? captureAiTimeout : Defaults.captureAiTimeout + } + + /// Stored-size limit for an AI event: `captureAiMaxEventBytes`, capped at + /// PostHog's 8 MiB, plus the envelope allowance. + var captureAiMaxStoredEventBytes: Int { + let limit = Defaults.captureAiMaxEventBytes + let bytes = captureAiMaxEventBytes > 0 ? min(captureAiMaxEventBytes, limit) : limit + return bytes + aiEventEnvelopeBytes + } +} diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index cbcf374e4f..f455534028 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -852,7 +852,7 @@ final class PostHogCaptureAiTest { server.stop() } - private func makeSDK(beforeSend: BeforeSendBlock? = nil) -> PostHogSDK { + private func makeSDK(configure: (PostHogConfig) -> Void = { _ in }, beforeSend: BeforeSendBlock? = nil) -> PostHogSDK { let config = PostHogConfig(projectToken: "phc_ai_\(UUID().uuidString)", host: "http://localhost:9001") config.flushAt = 1 config.preloadFeatureFlags = false @@ -865,6 +865,7 @@ final class PostHogCaptureAiTest { config.setBeforeSend(beforeSend) } server.batchProjectToken = config.projectToken + configure(config) return PostHogSDK.with(config) } @@ -884,7 +885,7 @@ final class PostHogCaptureAiTest { #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(sut.config.projectToken)") #expect(request.value(forHTTPHeaderField: "PostHog-Attempt") == "1") #expect(request.value(forHTTPHeaderField: "PostHog-Request-Id") != nil) - #expect(request.timeoutInterval == PostHogApi.aiTimeout) + #expect(request.timeoutInterval == 60) let event = try #require(server.parsePostHogEvents(request).first) #expect(event.event == "$ai_generation") #expect(event.uuid.postHogUuidString == uuid) @@ -898,7 +899,7 @@ final class PostHogCaptureAiTest { await waitUntil { self.server.batchRequests.count == 1 } let batchRequest = try #require(server.batchRequests.first) #expect(server.parsePostHogEvents(batchRequest).map(\.event) == ["$ai_generation"]) - #expect(batchRequest.timeoutInterval < PostHogApi.aiTimeout) + #expect(batchRequest.timeoutInterval < 60) #expect(server.aiRequests.count == 1) } @@ -913,6 +914,72 @@ final class PostHogCaptureAiTest { #expect(sut.captureAi("$ai_generation") == nil) } + @Test("AI requests use captureAiTimeout and captureAiCompression, and analytics requests don't") + func usesAiRequestSettings() async throws { + let sut = makeSDK { config in + config.captureAiTimeout = 90 + config.captureAiCompression = .none + } + defer { sut.close() } + + sut.captureAi("$ai_generation") + await waitUntil { self.server.aiRequests.count == 1 } + let request = try #require(server.aiRequests.first) + #expect(request.timeoutInterval == 90) + #expect(request.value(forHTTPHeaderField: "Content-Encoding") == nil) + let batch = server.parseRequest(request, gzip: false)?["batch"] as? [[String: Any]] + #expect(batch?.map { $0["event"] as? String } == ["$ai_generation"]) + + sut.capture("signed_up") + await waitUntil { self.server.batchRequests.count == 1 } + let batchRequest = try #require(server.batchRequests.first) + #expect(batchRequest.timeoutInterval < 90) + #expect(batchRequest.value(forHTTPHeaderField: "Content-Encoding") == "gzip") + } + + @Test("AI settings of 0 or less use their defaults, and captureAiMaxEventBytes can't exceed 8 MiB") + func aiSettingLimits() { + let config = PostHogConfig(projectToken: "phc_ai_settings") + #expect(config.effectiveCaptureAiMaxQueueSize == 100) + #expect(config.effectiveCaptureAiTimeout == 60) + #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + + config.captureAiMaxQueueSize = 3 + config.captureAiTimeout = 5 + config.captureAiMaxEventBytes = 1024 + #expect(QueueEndpoint.ai(api: PostHogApi(config)).maxQueueSize(config) == 3) + #expect(config.effectiveCaptureAiTimeout == 5) + #expect(config.captureAiMaxStoredEventBytes == 1024 + aiEventEnvelopeBytes) + + config.captureAiMaxQueueSize = 0 + config.captureAiTimeout = -1 + config.captureAiMaxEventBytes = 0 + #expect(config.effectiveCaptureAiMaxQueueSize == 100) + #expect(config.effectiveCaptureAiTimeout == 60) + #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + + config.captureAiMaxEventBytes = 100 * 1024 * 1024 + #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + } + + @Test("drops an AI event over a lowered captureAiMaxEventBytes") + func dropsEventOverConfiguredLimit() { + let config = PostHogConfig(projectToken: "phc_ai_lowered", host: "http://localhost:9001") + config.captureAiMaxEventBytes = 1024 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let overLimit = String(repeating: "a", count: 1024 + aiEventEnvelopeBytes) + #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": overLimit]))) + #expect(queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": "short"]))) + #expect(queue.depth == 1) + } + @Test("a 404 from the AI endpoint drops the batch without falling back to /batch") func notFoundIsTerminal() async throws { server.aiResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [:], statusCode: 404, headers: nil) } diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 96c6bc0acb..00cf413019 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -34,6 +34,10 @@ PostHog | PostHogConfig.PostHogDataMode.init(rawValue:) | init | init?(rawValue: PostHog | PostHogConfig.PostHogDataMode.wifi | enum.case | case wifi | s:7PostHog0aB6ConfigC0aB8DataModeO4wifiyA2EmF PostHog | PostHogConfig.appGroupIdentifier | property | @objc var appGroupIdentifier: String? | c:@M@PostHog@objc(cs)PostHogConfig(py)appGroupIdentifier PostHog | PostHogConfig.bootstrap | property | @objc var bootstrap: PostHogBootstrapConfig? | c:@M@PostHog@objc(cs)PostHogConfig(py)bootstrap +PostHog | PostHogConfig.captureAiCompression | property | @objc var captureAiCompression: PostHogCompression | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiCompression +PostHog | PostHogConfig.captureAiMaxEventBytes | property | @objc var captureAiMaxEventBytes: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiMaxEventBytes +PostHog | PostHogConfig.captureAiMaxQueueSize | property | @objc var captureAiMaxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiMaxQueueSize +PostHog | PostHogConfig.captureAiTimeout | property | @objc var captureAiTimeout: TimeInterval | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiTimeout PostHog | PostHogConfig.captureApplicationLifecycleEvents | property | @objc var captureApplicationLifecycleEvents: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureApplicationLifecycleEvents PostHog | PostHogConfig.captureAutocaptureElementText | property | @objc var captureAutocaptureElementText: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAutocaptureElementText PostHog | PostHogConfig.captureElementInteractions | property | @objc var captureElementInteractions: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureElementInteractions diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 5fefd76bd1..3e4d44d4ba 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -150,11 +150,12 @@ PostHogSDK.shared.captureAi("$ai_generation", properties: ["$ai_model": "gpt-5", In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`. -- An AI event can be up to 8 MiB. The SDK drops a larger event, and `captureAi` returns `nil`. +- An AI event can be up to 8 MiB. The SDK drops a larger event, and `captureAi` returns `nil`. Set `captureAiMaxEventBytes` to lower the limit. - PostHog drops an event sent with `captureAi` whose name doesn't start with `$ai_`. `capture` never sends events to the AI endpoint, even ones named `$ai_*`. -- AI requests time out after 60 seconds without network activity. Other requests still time out after 10 seconds. -- AI events are queued on disk separately from other events. Up to 100 events and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. -- AI events use `flushAt`, `maxBatchSize`, `flushIntervalSeconds` and `compression`, like other events. A request carries up to about 5 MiB, as for analytics events (see [Retries](#retries)). +- AI requests time out after 60 seconds without network activity, or `captureAiTimeout`. Other requests still time out after 10 seconds. +- AI events are queued on disk separately from other events. Up to 100 events (`captureAiMaxQueueSize`) and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. +- AI requests are gzipped unless you set `captureAiCompression` to `.none`. `compression` doesn't apply. +- AI events use `flushAt`, `maxBatchSize` and `flushIntervalSeconds`, like other events. A request carries up to about 5 MiB, as for analytics events (see [Retries](#retries)). - The SDK sends AI events unchanged. It doesn't redact or truncate prompts, outputs or media, so use `beforeSend` to remove anything you don't want to send. ## Retries From ee92eab7c2ceb32aa4e4c86b6d86d74c92294c3f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 14:54:16 -0400 Subject: [PATCH 44/67] fix: make captureAiMaxEventBytes limit the whole serialized event It capped properties but the SDK added 64 KiB on top of any value, so a lowered limit let much larger events through. Like posthog-python's capture_ai_max_event_bytes, it now limits the whole serialized event and defaults to 8 MiB plus 64 KiB. Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-capture-ai.md | 2 +- PostHog/PostHogConfig.swift | 12 +++++++----- PostHog/PostHogSDK.swift | 2 +- PostHog/QueueEndpoint+Factories.swift | 17 ++++++----------- PostHogTests/PostHogCaptureV1Test.swift | 12 ++++++------ docs/migration-3.x-to-4.0.md | 2 +- 6 files changed, 22 insertions(+), 25 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index b46089d20b..4a24f8be0c 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -8,4 +8,4 @@ Analytics batches are now also capped at 5 MiB per request, so a batch of large AI requests time out after 60 seconds without network activity, instead of 10, since they can carry several MB. -Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults keep the behavior above: gzip, 100 queued events, 60 seconds and 8 MiB. +Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults keep the behavior above: gzip, 100 queued events, 60 seconds and 8 MiB plus 64 KiB per serialized event. diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index d7d0aaedb2..957a58d490 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -36,7 +36,8 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? static let featureFlagRequestMaxRetries: Int = 1 static let captureAiMaxQueueSize: Int = 100 static let captureAiTimeout: TimeInterval = 60 - static let captureAiMaxEventBytes: Int = 8 * 1024 * 1024 + /// PostHog's 8 MiB AI properties limit, plus 64 KiB for the rest of the event. + static let captureAiMaxEventBytes: Int = 8 * 1024 * 1024 + 64 * 1024 } /// Network connectivity mode required before queued data may be flushed. @@ -497,11 +498,12 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Default: `60`. @objc public var captureAiTimeout: TimeInterval = Defaults.captureAiTimeout - /// Maximum size, in bytes, of a `captureAi` event's properties. The SDK drops a larger event. + /// Maximum size, in bytes, of a serialized `captureAi` event. The SDK drops a larger event. /// - /// Use it to lower the limit. PostHog drops AI events over 8 MiB, so a larger value, or a value of - /// `0` or less, uses 8 MiB. - /// Default: `8388608` (8 MiB). + /// Use it to lower the limit. PostHog drops AI events whose properties are over 8 MiB, so the default + /// allows 8 MiB plus 64 KiB for the rest of the event. A larger value, or a value of `0` or less, + /// uses the default. + /// Default: `8454144` (8 MiB + 64 KiB). @objc public var captureAiMaxEventBytes: Int = Defaults.captureAiMaxEventBytes // only internal diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 6b85a57d67..936fea4a27 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1537,7 +1537,7 @@ let maxRetryDelay = 30.0 /// Takes the same arguments as `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)` /// and goes through the same `beforeSend` hooks. The event is queued on disk separately from other /// events and sent unchanged: the SDK doesn't redact or truncate prompts, outputs or media. - /// An event over `captureAiMaxEventBytes` (8 MiB by default) is dropped. PostHog drops an event + /// An event over `captureAiMaxEventBytes` (8 MiB plus 64 KiB by default) is dropped. PostHog drops an event /// sent here whose name doesn't start with `$ai_`, and the SDK logs a debug message when it's /// given one. `capture` never sends events here, even ones named `$ai_*`. /// diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 7b50de1bdf..41a67d7412 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -23,11 +23,8 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { [408, 500, 502, 503, 504].contains(code) } -/// The stored event also holds its envelope, so allow 64 KiB on top of the -/// properties limit, as posthog-python and posthog-go do. -let aiEventEnvelopeBytes = 64 * 1024 -/// Stored-size limit for an AI event at the default `captureAiMaxEventBytes`. -let aiMaxEventBytes = PostHogConfig.Defaults.captureAiMaxEventBytes + aiEventEnvelopeBytes +/// Size limit for a whole serialized AI event at the default `captureAiMaxEventBytes`. +let aiMaxEventBytes = PostHogConfig.Defaults.captureAiMaxEventBytes /// Soft request size for the capture V1 analytics and AI endpoints, matching /// the other PostHog SDKs and well under capture V1's 10 MiB request limit. let captureV1MaxBatchBytes = 5 * 1024 * 1024 @@ -68,7 +65,7 @@ extension QueueEndpoint where Record == PostHogEvent { /// `captureAi`. Same retry policy as capture V1, with no `/batch` fallback. /// Batches by bytes as well as count, since AI events can be megabytes. static func ai(api: PostHogApi) -> QueueEndpoint { - let maxEventBytes = api.config.captureAiMaxStoredEventBytes + let maxEventBytes = api.config.effectiveCaptureAiMaxEventBytes return QueueEndpoint( storageKey: .aiQueue, oldStorageKeys: [], @@ -189,11 +186,9 @@ extension PostHogConfig { captureAiTimeout > 0 ? captureAiTimeout : Defaults.captureAiTimeout } - /// Stored-size limit for an AI event: `captureAiMaxEventBytes`, capped at - /// PostHog's 8 MiB, plus the envelope allowance. - var captureAiMaxStoredEventBytes: Int { + /// `captureAiMaxEventBytes`, capped at its default, or the default when it isn't positive. + var effectiveCaptureAiMaxEventBytes: Int { let limit = Defaults.captureAiMaxEventBytes - let bytes = captureAiMaxEventBytes > 0 ? min(captureAiMaxEventBytes, limit) : limit - return bytes + aiEventEnvelopeBytes + return captureAiMaxEventBytes > 0 ? min(captureAiMaxEventBytes, limit) : limit } } diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index f455534028..5a0d5d13c7 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -937,29 +937,29 @@ final class PostHogCaptureAiTest { #expect(batchRequest.value(forHTTPHeaderField: "Content-Encoding") == "gzip") } - @Test("AI settings of 0 or less use their defaults, and captureAiMaxEventBytes can't exceed 8 MiB") + @Test("AI settings of 0 or less use their defaults, and captureAiMaxEventBytes can't exceed its default") func aiSettingLimits() { let config = PostHogConfig(projectToken: "phc_ai_settings") #expect(config.effectiveCaptureAiMaxQueueSize == 100) #expect(config.effectiveCaptureAiTimeout == 60) - #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + #expect(config.effectiveCaptureAiMaxEventBytes == aiMaxEventBytes) config.captureAiMaxQueueSize = 3 config.captureAiTimeout = 5 config.captureAiMaxEventBytes = 1024 #expect(QueueEndpoint.ai(api: PostHogApi(config)).maxQueueSize(config) == 3) #expect(config.effectiveCaptureAiTimeout == 5) - #expect(config.captureAiMaxStoredEventBytes == 1024 + aiEventEnvelopeBytes) + #expect(config.effectiveCaptureAiMaxEventBytes == 1024) config.captureAiMaxQueueSize = 0 config.captureAiTimeout = -1 config.captureAiMaxEventBytes = 0 #expect(config.effectiveCaptureAiMaxQueueSize == 100) #expect(config.effectiveCaptureAiTimeout == 60) - #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + #expect(config.effectiveCaptureAiMaxEventBytes == aiMaxEventBytes) config.captureAiMaxEventBytes = 100 * 1024 * 1024 - #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + #expect(config.effectiveCaptureAiMaxEventBytes == aiMaxEventBytes) } @Test("drops an AI event over a lowered captureAiMaxEventBytes") @@ -974,7 +974,7 @@ final class PostHogCaptureAiTest { deleteSafely(storage.appFolderUrl) } - let overLimit = String(repeating: "a", count: 1024 + aiEventEnvelopeBytes) + let overLimit = String(repeating: "a", count: 1024) #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": overLimit]))) #expect(queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": "short"]))) #expect(queue.depth == 1) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 3e4d44d4ba..8ce87d88bf 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -150,7 +150,7 @@ PostHogSDK.shared.captureAi("$ai_generation", properties: ["$ai_model": "gpt-5", In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`. -- An AI event can be up to 8 MiB. The SDK drops a larger event, and `captureAi` returns `nil`. Set `captureAiMaxEventBytes` to lower the limit. +- An AI event's properties can be up to 8 MiB. The SDK drops a serialized event over 8 MiB + 64 KiB, and `captureAi` returns `nil`. Set `captureAiMaxEventBytes` to lower the limit; it measures the whole serialized event. - PostHog drops an event sent with `captureAi` whose name doesn't start with `$ai_`. `capture` never sends events to the AI endpoint, even ones named `$ai_*`. - AI requests time out after 60 seconds without network activity, or `captureAiTimeout`. Other requests still time out after 10 seconds. - AI events are queued on disk separately from other events. Up to 100 events (`captureAiMaxQueueSize`) and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. From 8ac03d030f70bdaff8a08c11bb7d41cce31e9480 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 22:15:10 -0400 Subject: [PATCH 45/67] 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 | 23 +++--- 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, 62 insertions(+), 212 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index df983dcc60..121a537f3b 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 f17a9fd872..eb92a8551f 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 41a67d7412..83205c4e07 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) } @@ -32,9 +31,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, @@ -54,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 { let maxEventBytes = api.config.effectiveCaptureAiMaxEventBytes @@ -97,8 +93,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 +116,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 6a5497d759..29f59d8401 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 5a0d5d13c7..c1f5b662e2 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 c350769054..1d2f2f8561 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 b348d0dd8a..3c74092795 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/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index 63988a2779..d4a64a31d9 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 416a9cea34..778b829c8e 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 a35f65cc879c39511957d60556afce04f9f82192 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 20 Oct 2026 22:49:14 -0400 Subject: [PATCH 46/67] 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 3934c29f90..477c4fa010 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 148c3f0497..943e497673 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 From 9c7b166053799fca58b61cf7798b5cd99264406d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 13:37:54 -0400 Subject: [PATCH 47/67] docs: drop the /batch fallback from the migration guide A 404 from capture v1 now drops the events, as the changeset says. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 8ce87d88bf..543c4ef6a5 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 @@ -137,7 +136,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 @@ -168,4 +166,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. From 65b2cc38ce5e8f7614c0d4d2065cc6b43b214a7d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 14:52:20 -0400 Subject: [PATCH 48/67] fix: keep ignoring /batch in replay network capture React Native apps record replay through posthog-ios while their JS SDK still posts to /batch, so those requests would show up in recordings. Co-Authored-By: Claude Opus 5.5 --- .../PostHogSessionReplayNetworkPlugin.swift | 4 +++- PostHogTests/PostHogSessionReplayPluginTest.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift index 477c4fa010..58f21bd386 100644 --- a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift +++ b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift @@ -101,7 +101,9 @@ } let path = strippingAPIHostPathPrefix(from: url.path, apiHostPath: apiHost.path) - let ignoredPaths = [snapshotEndpoint, "/e/", "/i/"] + // The SDK no longer sends to /batch, but React Native apps record replay through + // this plugin while their JS SDK still posts there. + let ignoredPaths = [snapshotEndpoint, "/batch", "/e/", "/i/"] return ignoredPaths.contains { ignoredPath in pathMatches(path, ignoredPath: ignoredPath) diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index 943e497673..55b493b36e 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -159,6 +159,13 @@ snapshotEndpoint: "/s/", expected: true ), + IgnoredNetworkRequestCase( + name: "ignores batch ingestion on the configured host", + requestURL: "https://us.i.posthog.com/batch", + apiHost: "https://us.i.posthog.com", + snapshotEndpoint: "/s/", + expected: true + ), IgnoredNetworkRequestCase( name: "ignores replay ingestion behind a reverse proxy path", requestURL: "https://app.posthog.com/ingest/s/?ip=1", @@ -173,6 +180,13 @@ snapshotEndpoint: "/s/", expected: true ), + IgnoredNetworkRequestCase( + name: "ignores batch ingestion behind a reverse proxy path", + requestURL: "https://app.posthog.com/ingest/batch", + apiHost: "https://app.posthog.com/ingest", + snapshotEndpoint: "/s/", + expected: true + ), IgnoredNetworkRequestCase( name: "ignores a remotely configured snapshot endpoint", requestURL: "https://us.i.posthog.com/newS/?ip=1", From 34791640f8c8a61659278d66baaa16fe310a3ad1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 09:49:46 -0400 Subject: [PATCH 49/67] docs: list the reverse proxy requirements for capture v1 Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 543c4ef6a5..3a71f5bb7a 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -43,8 +43,16 @@ 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 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. +PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves both capture v1 endpoints. +If you self-host PostHog, check that it serves `/i/v1/analytics/events` and `/i/v1/ai/events` before you upgrade. + +If you send events through your own reverse proxy, update it before you upgrade: + +- Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. +- Pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged. PostHog answers `401` without `Authorization` and `400` without the others, and the batch is dropped. +- Accept request bodies up to 20 MB. AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. On a `413` from the proxy, the SDK splits the batch and drops any event that is still too large on its own. + +PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). 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. From 949225e494cef977391ed169b4faf541d1258e72 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 09:55:38 -0400 Subject: [PATCH 50/67] test(compliance): disable AI compression when enable_compression is false Co-Authored-By: Claude Opus 5.5 --- sdk_compliance_adapter/Sources/main.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 11df198fa9..ccbbd65e3e 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -121,6 +121,7 @@ app.post("init") { req async throws -> Response in config.pushNotificationSubscriptionMaxRetries = initReq.maxRetries ?? config.pushNotificationSubscriptionMaxRetries if initReq.enableCompression == false { config.compression = .none + config.captureAiCompression = .none } config.disableGeoIp = initReq.disableGeoip ?? config.disableGeoIp From b6c4c9114e15d73792ddedf4688ba756c132696d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:47:52 -0400 Subject: [PATCH 51/67] fix: log capture V1 drop reasons and the captureAiMaxEventBytes clamp - Log each event a 2xx drops at debug level with its name, UUID and the server's details (e.g. ai_event_too_big), never its properties. - Check the AI event size limit in the queue via maxRecordBytes, so an oversized event logs only the size line, not "unserialisable record". - Warn once at setup when captureAiMaxEventBytes is over the ceiling. Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-capture-ai.md | 2 + PostHog/PostHogApi.swift | 14 +++-- PostHog/PostHogQueue.swift | 5 ++ PostHog/QueueEndpoint+Factories.swift | 15 ++--- PostHog/QueueEndpoint.swift | 4 ++ .../ApplicationViewLayoutPublisherTest.swift | 20 ------ PostHogTests/PostHogCaptureV1Test.swift | 62 ++++++++++++++++++- PostHogTests/TestUtils/TestPostHog.swift | 22 +++++++ 8 files changed, 108 insertions(+), 36 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index 4a24f8be0c..ab38dbb58c 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -9,3 +9,5 @@ Analytics batches are now also capped at 5 MiB per request, so a batch of large AI requests time out after 60 seconds without network activity, instead of 10, since they can carry several MB. Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults keep the behavior above: gzip, 100 queued events, 60 seconds and 8 MiB plus 64 KiB per serialized event. + +With `debug` on, the SDK logs the name, UUID and reason of each event PostHog drops, and warns when `captureAiMaxEventBytes` is set above its 8 MiB plus 64 KiB ceiling, which it uses instead. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index f17a9fd872..3443234f7c 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -251,6 +251,7 @@ class PostHogApi { } let uuids = events.map(\.uuid.postHogUuidString) + let eventNames = Dictionary(events.map { ($0.uuid.postHogUuidString, $0.event) }, uniquingKeysWith: { first, _ in first }) let identity = nextCaptureV1Identity(path: path, uuids: Set(uuids)) let toSend: [String: Any] = [ @@ -294,7 +295,7 @@ class PostHogApi { return self.batch(events: events, completion: completion) } - completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity)) + completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity, eventNames: eventNames)) } }.resume() } @@ -316,13 +317,15 @@ class PostHogApi { } /// Reads the per-event results of a 2xx and records which events, if any, - /// the next request retries under the same identity. + /// the next request retries under the same identity. `eventNames` maps + /// UUIDs to event names for the per-event drop log. func captureV1Result( _ info: PostHogUploadInfo, path: String, data: Data?, uuids: [String], - identity: CaptureV1RequestIdentity + identity: CaptureV1RequestIdentity, + eventNames: [String: String] = [:] ) -> PostHogUploadInfo { var retryUuids: Set = [] var dropped = 0 @@ -344,6 +347,10 @@ class PostHogApi { retryUuids.insert(uuid) case "drop": dropped += 1 + // Debug only. Name, UUID, and the server's reason; never content or properties. + let name = eventNames[uuid].map { "'\($0)'" } ?? "event" + let details = entry["details"] as? String ?? "no details" + hedgeLog("Capture dropped \(name) (\(uuid)): \(details).") default: break } @@ -353,7 +360,6 @@ class PostHogApi { retryUuids = Set(uuids) } - // Counts only: event UUIDs and server details can identify users. if dropped > 0 || !retryUuids.isEmpty { hedgeLog("Capture: \(dropped) dropped, \(retryUuids.count) to retry.") } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 722e62377a..4b3343ffbc 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -405,6 +405,11 @@ class PostHogQueue { hedgeLog("Tried to queue unserialisable record") return false } + // Name and size only: AI properties can hold prompts and media. + if let maxRecordBytes = endpoint.maxRecordBytes, data.count > maxRecordBytes { + hedgeLog("Dropping \(endpoint.describe(record)) of \(data.count) bytes, over the \(maxRecordBytes) byte limit.") + return false + } let result = fileQueue.add(data, maxSize: configuredMaxQueueSize) guard result.success else { return false } diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 41a67d7412..7776fa3d9e 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -66,6 +66,10 @@ extension QueueEndpoint where Record == PostHogEvent { /// Batches by bytes as well as count, since AI events can be megabytes. static func ai(api: PostHogApi) -> QueueEndpoint { let maxEventBytes = api.config.effectiveCaptureAiMaxEventBytes + // Read once per SDK setup, so this warns once. + if api.config.captureAiMaxEventBytes > maxEventBytes { + hedgeLog("Warning: captureAiMaxEventBytes \(api.config.captureAiMaxEventBytes) is over the \(maxEventBytes) byte ceiling, using \(maxEventBytes).") + } return QueueEndpoint( storageKey: .aiQueue, oldStorageKeys: [], @@ -76,19 +80,12 @@ extension QueueEndpoint where Record == PostHogEvent { flushIntervalSeconds: { $0.flushIntervalSeconds }, rateCapMax: { _ in 0 }, rateCapWindowSeconds: { _ in 0 }, - encode: { event in - guard let data = toJSONData(event.toJSON()) else { return nil } - // Name and size only: AI properties can hold prompts and media. - if data.count > maxEventBytes { - hedgeLog("Dropping AI event '\(event.event)' of \(data.count) bytes, over the \(maxEventBytes) byte limit.") - return nil - } - return data - }, + encode: { event in toJSONData(event.toJSON()) }, decode: { data in PostHogEvent.fromJSON(data) }, describe: { event in "AI event '\(event.event)'" }, recordId: { event in event.uuid.postHogUuidString }, maxBatchBytes: captureV1MaxBatchBytes, + maxRecordBytes: maxEventBytes, maxQueueBytes: aiMaxQueueBytes, send: { events, completion in api.captureAi(events: events, completion: completion) diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index e64c7f31e3..3886d84cad 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -72,6 +72,10 @@ struct QueueEndpoint { /// the limit goes alone. `nil` batches by count only. var maxBatchBytes: Int? + /// Largest encoded record `add()` accepts; a larger one is dropped with a + /// size log. `nil` accepts any size. + var maxRecordBytes: Int? + /// FIFO limit on the total size of queued records on disk, on top of /// `maxQueueSize`. `nil` limits by count only. var maxQueueBytes: Int? diff --git a/PostHogTests/ApplicationViewLayoutPublisherTest.swift b/PostHogTests/ApplicationViewLayoutPublisherTest.swift index 6266f02bcb..8b1379270d 100644 --- a/PostHogTests/ApplicationViewLayoutPublisherTest.swift +++ b/PostHogTests/ApplicationViewLayoutPublisherTest.swift @@ -379,26 +379,6 @@ try #require(finished.wait(timeout: .now() + 5) == .success) } - private func captureStdout(_ body: () throws -> Void) throws -> String { - let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - FileManager.default.createFile(atPath: url.path, contents: nil) - defer { try? FileManager.default.removeItem(at: url) } - let file = try FileHandle(forWritingTo: url) - defer { file.closeFile() } - fflush(stdout) - let saved = dup(STDOUT_FILENO) - try #require(saved >= 0) - defer { close(saved) } - try #require(dup2(file.fileDescriptor, STDOUT_FILENO) >= 0) - defer { - fflush(stdout) - dup2(saved, STDOUT_FILENO) - } - try body() - fflush(stdout) - return try String(contentsOf: url, encoding: .utf8) - } - @MainActor @Test("forwards layout synchronously on the calling thread and notifies on main", arguments: [false, true]) func forwardsLayout(background: Bool) async throws { diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 5a0d5d13c7..aec4c1f551 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -136,6 +136,31 @@ final class PostHogCaptureV1Test { #expect(result.retryRecordIds?.isEmpty ?? true) } + @Test("logs each dropped event's name, UUID and reason, without its properties") + func logsDropReasons() throws { + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let uuids = [UUID().postHogUuidString, UUID().postHogUuidString, UUID().postHogUuidString] + let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") + let body = try JSONSerialization.data(withJSONObject: ["results": [ + uuids[0]: ["result": "drop", "details": "ai_event_too_big"], + uuids[1]: ["result": "drop", "details": "misrouted_event"], + uuids[2]: ["result": "ok"], + ]]) + + let output = try captureStdout { + _ = makeApi().captureV1Result( + PostHogUploadInfo(statusCode: 200, error: nil), path: PostHogApi.captureV1AiPath, data: body, + uuids: uuids, identity: identity, eventNames: [uuids[0]: "$ai_generation", uuids[1]: "signup"] + ) + } + + #expect(output.contains("Capture dropped '$ai_generation' (\(uuids[0])): ai_event_too_big.")) + #expect(output.contains("Capture dropped 'signup' (\(uuids[1])): misrouted_event.")) + #expect(!output.contains(uuids[2])) + } + @Test("treats a 2xx whose transfer then failed as delivered, so its ok events aren't stored twice") func failedTransferAfterSuccessIsDelivered() { let uuids = [UUID().postHogUuidString, UUID().postHogUuidString] @@ -1003,8 +1028,8 @@ final class PostHogCaptureAiTest { #expect(api.usesCaptureV1) } - @Test("drops an AI event over the size limit when it is queued") - func dropsOversizedEvent() { + @Test("drops an AI event over the size limit when it is queued, logging only its size") + func dropsOversizedEvent() throws { let config = PostHogConfig(projectToken: "phc_ai_big", host: "http://localhost:9001") let storage = PostHogStorage(config) let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) @@ -1015,9 +1040,40 @@ final class PostHogCaptureAiTest { } #expect(QueueEndpoint.ai(api: PostHogApi(config)).maxQueueBytes == aiMaxQueueBytes) + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } let big = String(repeating: "a", count: aiMaxEventBytes) - #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": big]))) + var added = true + let output = try captureStdout { + added = queue.add(PostHogEvent(event: "$ai_oversized", distinctId: "user", properties: ["$ai_input": big])) + } + #expect(!added) #expect(queue.depth == 0) + // One size line, not a second "unserialisable" line. + #expect(output.contains("Dropping AI event '$ai_oversized' of ")) + #expect(output.contains("over the \(aiMaxEventBytes) byte limit.")) + #expect(!output.contains("unserialisable")) + } + + @Test("clamps captureAiMaxEventBytes above the ceiling and warns naming both values") + func warnsWhenMaxEventBytesClamped() throws { + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let config = PostHogConfig(projectToken: "phc_ai_clamp", host: "http://localhost:9001") + config.captureAiMaxEventBytes = aiMaxEventBytes + 1 + + var endpoint: QueueEndpoint? + let output = try captureStdout { endpoint = .ai(api: PostHogApi(config)) } + + #expect(endpoint?.maxRecordBytes == aiMaxEventBytes) + #expect(output.contains("captureAiMaxEventBytes \(aiMaxEventBytes + 1) is over the \(aiMaxEventBytes) byte ceiling")) + + config.captureAiMaxEventBytes = 1024 + let quiet = try captureStdout { endpoint = .ai(api: PostHogApi(config)) } + #expect(endpoint?.maxRecordBytes == 1024) + #expect(!quiet.contains("captureAiMaxEventBytes")) } @Test("splits AI requests at the batch byte limit") diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 1cebef4df1..40d4e4f4b2 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -159,6 +159,28 @@ extension Trait where Self == ResetGlobalStateTrait { static var resetsGlobalState: ResetGlobalStateTrait { ResetGlobalStateTrait() } } +/// Returns what `body` printed to stdout, such as `hedgeLog` lines. Stdout is process-wide, so +/// match on something unique to the test rather than asserting the whole output. +func captureStdout(_ body: () throws -> Void) throws -> String { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + FileManager.default.createFile(atPath: url.path, contents: nil) + defer { try? FileManager.default.removeItem(at: url) } + let file = try FileHandle(forWritingTo: url) + defer { file.closeFile() } + fflush(stdout) + let saved = dup(STDOUT_FILENO) + try #require(saved >= 0) + defer { close(saved) } + try #require(dup2(file.fileDescriptor, STDOUT_FILENO) >= 0) + defer { + fflush(stdout) + dup2(saved, STDOUT_FILENO) + } + try body() + fflush(stdout) + return try String(contentsOf: url, encoding: .utf8) +} + /// Adaptive poll that returns the instant `condition` holds (or after `timeout`). Yields the thread /// between checks instead of spinning. Use this only when the state you're asserting isn't tied to an /// awaitable signal (e.g. an async storage write with no completion callback) — when a callback exists, From 5a584cb80509082b1ba29c3a7a66f37ef6f594db Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:48:26 -0400 Subject: [PATCH 52/67] docs: note the captureAiMaxEventBytes clamp warning Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogConfig.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 957a58d490..ae324128d9 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -501,8 +501,8 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Maximum size, in bytes, of a serialized `captureAi` event. The SDK drops a larger event. /// /// Use it to lower the limit. PostHog drops AI events whose properties are over 8 MiB, so the default - /// allows 8 MiB plus 64 KiB for the rest of the event. A larger value, or a value of `0` or less, - /// uses the default. + /// allows 8 MiB plus 64 KiB for the rest of the event. A larger value uses the default and logs a + /// warning when `debug` is on. A value of `0` or less uses the default. /// Default: `8454144` (8 MiB + 64 KiB). @objc public var captureAiMaxEventBytes: Int = Defaults.captureAiMaxEventBytes From 078c6e477e057887c477e3deb79e3e3c3ffdaf74 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 10:56:14 -0400 Subject: [PATCH 53/67] chore: combine the capture V1, options and captureAi changesets into one major entry Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-capture-ai.md | 13 ------------- .changeset/v4-capture-options.md | 5 ----- .changeset/v4-capture-v1.md | 29 +++++++++++++++++++---------- 3 files changed, 19 insertions(+), 28 deletions(-) delete mode 100644 .changeset/v4-capture-ai.md delete mode 100644 .changeset/v4-capture-options.md diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md deleted file mode 100644 index ab38dbb58c..0000000000 --- a/.changeset/v4-capture-ai.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'posthog-ios': minor ---- - -Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. It returns the event UUID. - -Analytics batches are now also capped at 5 MiB per request, so a batch of large events stays under capture V1's request limit; an event larger than that is sent on its own. - -AI requests time out after 60 seconds without network activity, instead of 10, since they can carry several MB. - -Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults keep the behavior above: gzip, 100 queued events, 60 seconds and 8 MiB plus 64 KiB per serialized event. - -With `debug` on, the SDK logs the name, UUID and reason of each event PostHog drops, and warns when `captureAiMaxEventBytes` is set above its 8 MiB plus 64 KiB ceiling, which it uses instead. diff --git a/.changeset/v4-capture-options.md b/.changeset/v4-capture-options.md deleted file mode 100644 index 0e56889425..0000000000 --- a/.changeset/v4-capture-options.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'posthog-ios': minor ---- - -Add per-event capture options with `capture(..., timestamp:options:)` and `PostHogEvent.options`, which `beforeSend` can read and change. `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take the same `options` argument. diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 121a537f3b..5dc8761158 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -2,15 +2,24 @@ 'posthog-ios': major --- -**Breaking:** send events to capture V1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header. +Send events through capture V1 and add `captureAi(...)` for AI events. See the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md) for what to check before you upgrade. -- 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. -- After a failed send, the wait before the next one now doubles from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. -- `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, because it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. -- The `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties are sent as capture options, and PostHog drops an event whose option value it can't read. -- Event properties are merged as: properties the SDK sets itself, then registered super properties, then the properties you pass, then `beforeSend`. Super properties now override SDK properties such as `$os`, and both super and event properties override `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug values. +**Changed** -See the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/docs/migration-3.x-to-4.0.md) for what to check before you upgrade. +- **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 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. +- **Breaking:** stop retrying 429 responses, and pause sending for at most 30 seconds on `Retry-After`, including session replay and logs. +- **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options. PostHog drops an event whose option value it can't read. +- **Breaking:** merge event properties as: properties the SDK sets itself, then registered super properties, then the properties you pass, then `beforeSend`. Super properties now override SDK properties such as `$os`, and both super and event properties override `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug values. +- **Breaking:** rename `maxRetries` to `pushNotificationSubscriptionMaxRetries`, since it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. +- Double the wait after a failed send from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing it by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. +- Cap analytics batches at 5 MiB per request; an event larger than that is sent on its own. + +**Added** + +- Add per-event capture options with `capture(..., timestamp:options:)` and `PostHogEvent.options`, which `beforeSend` can read and change. `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take the same `options` argument. +- Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. It returns the event UUID, and its requests time out after 60 seconds without network activity. + - Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults are gzip, 100 queued events, 60 seconds and 8 MiB plus 64 KiB per serialized event; a larger `captureAiMaxEventBytes` is clamped to that ceiling with a debug warning. +- Log the name, UUID and reason of each event PostHog drops when `debug` is on. From 9c1565b484d7acc5ef6876c64343c41c54c4bb81 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:59:30 -0400 Subject: [PATCH 54/67] fix: flush analytics events at the batch byte limit The analytics queue sets maxBatchBytes but no maxQueueBytes, and the file queue only tracked byteCount when maxQueueBytes was set, so byteCount stayed 0 and the byte threshold never triggered a flush. Track bytes whenever the endpoint batches by bytes. Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogFileBackedQueue.swift | 13 ++++++++----- PostHog/PostHogQueue.swift | 8 ++++++-- PostHogTests/PostHogQueueTest.swift | 17 ++++++++++++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index ed0367801a..19d035f51b 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -18,8 +18,10 @@ class PostHogFileBackedQueue { /// FIFO limit on the total size of the stored files, on top of `maxSize`. /// The newest entry is always kept, even when it's larger on its own. private let maxBytes: Int? + /// Whether file sizes are tracked: when `maxBytes` is set or the caller asked for `byteCount`. + private let tracksBytes: Bool private var items = [String]() - /// File sizes by id, tracked only when `maxBytes` is set. Guarded by `itemsLock`. + /// File sizes by id, tracked only when `tracksBytes`. Guarded by `itemsLock`. private var sizes = [String: Int]() private var totalBytes = 0 private let itemsLock = NSLock() @@ -28,15 +30,16 @@ class PostHogFileBackedQueue { itemsLock.withLock { items.count } } - /// Total size of the stored files. Tracked only when `maxBytes` is set, 0 otherwise. + /// Total size of the stored files. Tracked only when `maxBytes` or `trackBytes` is set, 0 otherwise. var byteCount: Int { itemsLock.withLock { totalBytes } } - init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil, maxBytes: Int? = nil) { + init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil, maxBytes: Int? = nil, trackBytes: Bool = false) { self.queue = queue self.maxSize = maxSize.map { max(1, $0) } self.maxBytes = maxBytes.map { max(1, $0) } + tracksBytes = trackBytes || maxBytes != nil setup(oldQueues: oldQueues) } @@ -167,7 +170,7 @@ class PostHogFileBackedQueue { items = Array(sortedItems.dropFirst(overflow)) sizes = [:] totalBytes = 0 - if maxBytes != nil { + if tracksBytes { for item in items { let attributes = try? FileManager.default.attributesOfItem(atPath: queue.appendingPathComponent(item).path) track(item, size: (attributes?[.size] as? NSNumber)?.intValue ?? 0) @@ -248,7 +251,7 @@ class PostHogFileBackedQueue { /// Caller must hold `itemsLock`. private func track(_ id: String, size: Int) { - guard maxBytes != nil else { return } + guard tracksBytes else { return } sizes[id] = size totalBytes += size } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 4b3343ffbc..fb4ea06a76 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -155,7 +155,9 @@ class PostHogQueue { queue: storage.url(forKey: endpoint.storageKey), oldQueues: endpoint.oldStorageKeys.map { storage.url(forKey: $0) }, maxSize: configuredMaxQueueSize, - maxBytes: endpoint.maxQueueBytes + maxBytes: endpoint.maxQueueBytes, + // `flushIfOverThreshold` reads `byteCount` for the batch byte threshold. + trackBytes: endpoint.maxBatchBytes != nil ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } @@ -175,7 +177,9 @@ class PostHogQueue { queue: storage.url(forKey: endpoint.storageKey), oldQueues: endpoint.oldStorageKeys.map { storage.url(forKey: $0) }, maxSize: configuredMaxQueueSize, - maxBytes: endpoint.maxQueueBytes + maxBytes: endpoint.maxQueueBytes, + // `flushIfOverThreshold` reads `byteCount` for the batch byte threshold. + trackBytes: endpoint.maxBatchBytes != nil ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index 1d2f2f8561..1f8fd1cf51 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -79,7 +79,7 @@ final class PostHogQueueTest { } private func getSut(flushAt: Int = 1, maxQueueSize: Int = 1000, maxBatchSize: Int = 50, maxRetries: Int = 3, sender: ControlledBatchSender? = nil, - jitterUnit: @escaping () -> Double = { 0.5 }, afterUpload: (() -> Void)? = nil) -> PostHogQueue + maxBatchBytes: Int? = nil, jitterUnit: @escaping () -> Double = { 0.5 }, afterUpload: (() -> Void)? = nil) -> PostHogQueue { let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9001") config.flushAt = flushAt @@ -104,6 +104,7 @@ final class PostHogQueueTest { decode: base.decode, describe: base.describe, recordId: base.recordId, + maxBatchBytes: maxBatchBytes, send: { events, completion in let send = sender?.send ?? base.send send(events) { result in @@ -186,6 +187,20 @@ final class PostHogQueueTest { sut.clear() } + @Test("flushes below flushAt once queued bytes reach the batch byte limit") + func flushesOnBatchByteThresholdWithoutQueueByteCap() async { + let sender = ControlledBatchSender() + let sut = getSut(flushAt: 100, sender: sender, maxBatchBytes: 1000) + + sut.add(PostHogEvent(event: "small", distinctId: "distinctId")) + #expect(sender.requestCount == 0) + + sut.add(PostHogEvent(event: "large", distinctId: "distinctId", properties: ["blob": String(repeating: "x", count: 2000)])) + await expectEventually({ sender.requestCount }, 1) + + sut.clear() + } + @Test("add item to queue and rotate queue") func addItemToQueueAndRotateQueue() throws { let sut = getSut(flushAt: 3, maxQueueSize: 2) From 1e0cc55f2645c29283f29bff8fc7752c71bfab9f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:59:31 -0400 Subject: [PATCH 55/67] fix: keep a caller-supplied UUID in the /capture_ai adapter route Adds an @_spi(PostHogInternal) captureAi overload that takes the event UUID, and the compliance adapter passes the harness's uuid through it, so capture_ai_keeps_supplied_uuid gets the same UUID back and on the wire. Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogSDK.swift | 35 +++++++++++++++++++++-- PostHogTests/PostHogCaptureV1Test.swift | 13 +++++++++ sdk_compliance_adapter/Sources/main.swift | 20 ++++++++----- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2a20bf1280..923e24826a 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1591,6 +1591,31 @@ let maxRetryDelay = 30.0 ) } + /// `captureAi` that sends the event with a caller-supplied `uuid` instead of a generated one. + /// For hosts that already assigned the event's UUID, such as the SDK compliance adapter. + @_spi(PostHogInternal) @discardableResult + public func captureAi(_ event: String, + uuid: UUID, + distinctId: String? = nil, + properties: [String: Any]? = nil, + timestamp: Date? = nil, + options: [String: Any]? = nil) -> String? + { + if !event.hasPrefix("$ai_") { + hedgeLog("captureAi event '\(event)' doesn't start with '$ai_', so PostHog will drop it. Use capture for non-AI events.") + } + + return captureInternal( + event, + distinctId: distinctId, + properties: properties, + timestamp: timestamp, + options: options, + uuid: uuid, + aiLane: true + ) + } + // A separate Swift name keeps this out of Swift overload resolution. /// Captures a custom event for an explicit distinct ID and timestamp from Objective-C. @@ -1778,6 +1803,7 @@ let maxRetryDelay = 30.0 groups: [String: String]? = nil, timestamp: Date? = nil, options: [String: Any]? = nil, + uuid: UUID? = nil, skipBuildProperties: Bool = false, propertyAllowlist: Set? = nil, deduplicatePersonProperties: Bool = false, @@ -1869,7 +1895,8 @@ let maxRetryDelay = 30.0 distinctId: eventDistinctId, properties: finalProperties, options: eventOptions, - timestamp: eventTimestamp + timestamp: eventTimestamp, + uuid: uuid ) guard let posthogEvent else { @@ -2214,7 +2241,8 @@ let maxRetryDelay = 30.0 distinctId: String, properties: [String: Any], options: [String: Any]? = nil, - timestamp: Date = Date() + timestamp: Date = Date(), + uuid: UUID? = nil ) -> PostHogEvent? { var properties = properties let carriesReplayDebugBundle = properties.removeValue(forKey: Self.replayDebugClaimMarkerKey) != nil @@ -2224,7 +2252,8 @@ let maxRetryDelay = 30.0 distinctId: distinctId, properties: properties, options: options, - timestamp: timestamp + timestamp: timestamp, + uuid: uuid ?? UUID.v7() ) let resultEvent = config.runBeforeSend(event) diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index ee1bd66250..257d55a4ff 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -865,6 +865,19 @@ final class PostHogCaptureAiTest { #expect(server.aiRequests.count == 1) } + @Test("captureAi with a supplied uuid returns and sends that uuid") + func keepsSuppliedUuid() async throws { + let sut = makeSDK() + defer { sut.close() } + let supplied = try #require(UUID(uuidString: "0198c0de-0000-7000-8000-000000000abc")) + + #expect(sut.captureAi("$ai_generation", uuid: supplied, distinctId: "user") == "0198c0de-0000-7000-8000-000000000abc") + await waitUntil { self.server.aiRequests.count == 1 } + + let event = try #require(server.aiRequests.first.flatMap { server.parsePostHogEvents($0).first }) + #expect(event.uuid == supplied) + } + @Test("captureAi returns nil when the event isn't queued") func returnsNilWhenNotQueued() { let sut = makeSDK { event in event.event == "$ai_span" ? nil : event } diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index ccbbd65e3e..bb0b786bbd 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -231,6 +231,7 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - let properties: [String: AnyCodable]? let timestamp: String? let options: [String: AnyCodable]? + let uuid: String? enum CodingKeys: String, CodingKey { case event @@ -238,6 +239,7 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - case properties case timestamp case options + case uuid } } @@ -248,13 +250,17 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - throw Abort(.badRequest, reason: "SDK not initialized. Call /init first.") } - let uuid = sdk.captureAi( - captureReq.event, - distinctId: captureReq.distinctId, - properties: captureReq.properties?.mapValues(\.value), - timestamp: captureReq.timestamp.flatMap(parseHarnessTimestamp), - options: captureReq.options?.mapValues(\.value) - ) + let properties = captureReq.properties?.mapValues(\.value) + let timestamp = captureReq.timestamp.flatMap(parseHarnessTimestamp) + let options = captureReq.options?.mapValues(\.value) + // A supplied UUID is sent as-is; an invalid one is replaced by a generated UUID. + let uuid = if let suppliedUuid = captureReq.uuid.flatMap(UUID.init(uuidString:)) { + sdk.captureAi(captureReq.event, uuid: suppliedUuid, distinctId: captureReq.distinctId, + properties: properties, timestamp: timestamp, options: options) + } else { + sdk.captureAi(captureReq.event, distinctId: captureReq.distinctId, + properties: properties, timestamp: timestamp, options: options) + } var result: [String: Any] = ["success": true] result["uuid"] = uuid From 30a9645d405ce6ca7692a18dd9118ff2a5a71634 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:59:31 -0400 Subject: [PATCH 56/67] chore: update the no-traits API snapshot for the capture V1 API changes Co-Authored-By: Claude Opus 5.5 --- api/posthog-ios.public-api.no-traits.txt | 33 +++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/api/posthog-ios.public-api.no-traits.txt b/api/posthog-ios.public-api.no-traits.txt index aea274a331..b7e932b9be 100644 --- a/api/posthog-ios.public-api.no-traits.txt +++ b/api/posthog-ios.public-api.no-traits.txt @@ -31,6 +31,10 @@ PostHog | PostHogConfig.PostHogDataMode.init(rawValue:) | init | init?(rawValue: PostHog | PostHogConfig.PostHogDataMode.wifi | enum.case | case wifi | s:7PostHog0aB6ConfigC0aB8DataModeO4wifiyA2EmF PostHog | PostHogConfig.appGroupIdentifier | property | @objc var appGroupIdentifier: String? | c:@M@PostHog@objc(cs)PostHogConfig(py)appGroupIdentifier PostHog | PostHogConfig.bootstrap | property | @objc var bootstrap: PostHogBootstrapConfig? | c:@M@PostHog@objc(cs)PostHogConfig(py)bootstrap +PostHog | PostHogConfig.captureAiCompression | property | @objc var captureAiCompression: PostHogCompression | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiCompression +PostHog | PostHogConfig.captureAiMaxEventBytes | property | @objc var captureAiMaxEventBytes: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiMaxEventBytes +PostHog | PostHogConfig.captureAiMaxQueueSize | property | @objc var captureAiMaxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiMaxQueueSize +PostHog | PostHogConfig.captureAiTimeout | property | @objc var captureAiTimeout: TimeInterval | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiTimeout PostHog | PostHogConfig.captureApplicationLifecycleEvents | property | @objc var captureApplicationLifecycleEvents: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureApplicationLifecycleEvents PostHog | PostHogConfig.captureAutocaptureElementText | property | @objc var captureAutocaptureElementText: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAutocaptureElementText PostHog | PostHogConfig.captureElementInteractions | property | @objc var captureElementInteractions: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureElementInteractions @@ -56,18 +60,19 @@ PostHog | PostHogConfig.init(projectToken:host:) | init | @objc(projectToken:hos PostHog | PostHogConfig.logs | property | @objc let logs: PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)logs PostHog | PostHogConfig.maxBatchSize | property | @objc var maxBatchSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxBatchSize PostHog | PostHogConfig.maxQueueSize | property | @objc var maxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxQueueSize -PostHog | PostHogConfig.maxRetries | property | @objc var maxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxRetries PostHog | PostHogConfig.optOut | property | @objc var optOut: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)optOut PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: PostHogPersonProfiles | c:@M@PostHog@objc(cs)PostHogConfig(py)personProfiles PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider +PostHog | PostHogConfig.pushNotificationSubscriptionMaxRetries | property | @objc var pushNotificationSubscriptionMaxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)pushNotificationSubscriptionMaxRetries PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders PostHog | PostHogConfig.reuseAnonymousId | property | @objc var reuseAnonymousId: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)reuseAnonymousId PostHog | PostHogConfig.sendFeatureFlagEvent | property | @objc var sendFeatureFlagEvent: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sendFeatureFlagEvent PostHog | PostHogConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) | c:@M@PostHog@objc(cs)PostHogConfig(im)setBeforeSend: PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFYbcd_tF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [(PostHogEvent) -> PostHogEvent?]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFcGF PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFYbcGF PostHog | PostHogConfig.setDefaultPersonProperties | property | @objc var setDefaultPersonProperties: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)setDefaultPersonProperties PostHog | PostHogConfig.tracingHeaders | property | @objc var tracingHeaders: [String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)tracingHeaders @@ -82,6 +87,7 @@ PostHog | PostHogErrorTrackingConfig.init() | init | override init() | c:@M@Post PostHog | PostHogEvent | class | @objc(PostHogEvent) class PostHogEvent | c:@M@PostHog@objc(cs)PostHogEvent PostHog | PostHogEvent.distinctId | property | @objc var distinctId: String | c:@M@PostHog@objc(cs)PostHogEvent(py)distinctId PostHog | PostHogEvent.event | property | @objc var event: String | c:@M@PostHog@objc(cs)PostHogEvent(py)event +PostHog | PostHogEvent.options | property | @objc var options: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)options PostHog | PostHogEvent.properties | property | @objc var properties: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)properties PostHog | PostHogEvent.timestamp | property | @objc var timestamp: Date | c:@M@PostHog@objc(cs)PostHogEvent(py)timestamp PostHog | PostHogEvent.uuid | property | @objc var uuid: UUID { get } | c:@M@PostHog@objc(cs)PostHogEvent(py)uuid @@ -146,6 +152,7 @@ PostHog | PostHogLogsConfig.serviceName | property | @objc var serviceName: Stri PostHog | PostHogLogsConfig.serviceVersion | property | @objc var serviceVersion: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceVersion PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) | c:@M@PostHog@objc(cs)PostHogLogsConfig(im)setBeforeSend: PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFYbcd_tF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [(PostHogLogRecord) -> PostHogLogRecord?]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFcGF PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFYbcGF PostHog | PostHogPersonProfiles | enum | @objc(PostHogPersonProfiles) enum PostHogPersonProfiles | c:@M@PostHog@E@PostHogPersonProfiles PostHog | PostHogPersonProfiles.always | enum.case | case always | c:@M@PostHog@E@PostHogPersonProfiles@PostHogPersonProfilesAlways @@ -161,13 +168,15 @@ PostHog | PostHogSDK | class | @objc class PostHogSDK | c:@M@PostHog@objc(cs)Pos PostHog | PostHogSDK.addExceptionStep(_:) | method | @objc(addExceptionStep:) func addExceptionStep(_ message: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep: PostHog | PostHogSDK.addExceptionStep(_:properties:) | method | @objc(addExceptionStep:properties:) func addExceptionStep(_ message: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep:properties: PostHog | PostHogSDK.alias(_:) | method | @objc func alias(_ alias: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias: +PostHog | PostHogSDK.alias(_:options:) | method | @objc(alias:options:) func alias(_ alias: String, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias:options: PostHog | PostHogSDK.capture(_:) | method | @objc func capture(_ event: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)capture: PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups: -PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: +PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.capture(_:properties:) | method | @objc(captureWithEvent:properties:) func capture(_ event: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties: PostHog | PostHogSDK.capture(_:properties:userProperties:) | method | @objc(captureWithEvent:properties:userProperties:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups: +PostHog | PostHogSDK.captureAi(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @discardableResult @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func captureAi(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.captureDeepLink(openURLContexts:) | method | @objc func captureDeepLink(openURLContexts: Set) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithOpenURLContexts: PostHog | PostHogSDK.captureDeepLink(url:) | method | @objc func captureDeepLink(url: URL) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl: PostHog | PostHogSDK.captureDeepLink(url:options:) | method | @objc func captureDeepLink(url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl:options: @@ -175,8 +184,10 @@ PostHog | PostHogSDK.captureDeepLink(urls:) | method | @objc func captureDeepLin PostHog | PostHogSDK.captureDeepLink(userActivity:) | method | @objc func captureDeepLink(userActivity: NSUserActivity) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUserActivity: PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithError:) func captureException(_ error: any Error) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError: PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithNSException:) func captureException(_ exception: NSException) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException: -PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureException(_ error: any Error, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: -PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureException(_ exception: NSException, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: +PostHog | PostHogSDK.captureException(_:properties:options:) | method | @objc(captureExceptionWithError:properties:options:) func captureException(_ error: any Error, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties:options: +PostHog | PostHogSDK.captureException(_:properties:options:) | method | @objc(captureExceptionWithNSException:properties:options:) func captureException(_ exception: NSException, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties:options: +PostHog | PostHogSDK.captureExceptionObjC(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureExceptionObjC(_ error: any Error, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: +PostHog | PostHogSDK.captureExceptionObjC(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureExceptionObjC(_ exception: NSException, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: PostHog | PostHogSDK.captureFeatureInteraction(flag:flagVariant:) | method | @objc func captureFeatureInteraction(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureInteractionWithFlag:flagVariant: PostHog | PostHogSDK.captureFeatureView(flag:flagVariant:) | method | @objc func captureFeatureView(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureViewWithFlag:flagVariant: PostHog | PostHogSDK.captureLog(_:) | method | @objc(captureLogWithBody:) func captureLog(_ body: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody: @@ -184,11 +195,11 @@ PostHog | PostHogSDK.captureLog(_:level:) | method | @objc(captureLogWithBody:le PostHog | PostHogSDK.captureLog(_:level:attributes:) | method | @objc(captureLogWithBody:level:attributes:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes: PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | @objc(captureLogWithBody:level:attributes:traceId:spanId:traceFlags:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?, traceId: String?, spanId: String?, traceFlags: NSNumber?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes:traceId:spanId:traceFlags: PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | func captureLog(_ body: String, level: PostHogLogSeverity = .info, attributes: [String : Any]? = nil, traceId: String? = nil, spanId: String? = nil, traceFlags: Int? = nil) | s:7PostHog0aB3SDKC10captureLog_5level10attributes7traceId04spanI00H5FlagsySS_AA0abE8SeverityOSDySSypGSgSSSgANSiSgtF +PostHog | PostHogSDK.captureObjC(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func captureObjC(_ event: String, distinctId: String?, properties: [String : Any]?, userProperties: [String : Any]?, userPropertiesSetOnce: [String : Any]?, groups: [String : String]?, timestamp: Date?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: PostHog | PostHogSDK.capturePushNotificationOpened(response:) | method | @objc func capturePushNotificationOpened(response: UNNotificationResponse) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithResponse: PostHog | PostHogSDK.capturePushNotificationOpened(title:subtitle:body:payload:action:) | method | @objc func capturePushNotificationOpened(title: String? = nil, subtitle: String? = nil, body: String? = nil, payload: [AnyHashable : Any]? = nil, action: String? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithTitle:subtitle:body:payload:action: PostHog | PostHogSDK.close() | method | @objc func close() | c:@M@PostHog@objc(cs)PostHogSDK(im)close PostHog | PostHogSDK.debug(_:) | method | @objc func debug(_ enabled: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)debug: -PostHog | PostHogSDK.didReceiveFeatureFlags | type.property | @objc static let didReceiveFeatureFlags: Notification.Name | c:@CM@PostHog@objc(cs)PostHogSDK(cpy)didReceiveFeatureFlags PostHog | PostHogSDK.didStartNotification | type.property | @objc static let didStartNotification: Notification.Name | c:@CM@PostHog@objc(cs)PostHogSDK(cpy)didStartNotification PostHog | PostHogSDK.endSession() | method | @objc func endSession() | c:@M@PostHog@objc(cs)PostHogSDK(im)endSession PostHog | PostHogSDK.flush() | method | @objc func flush() | c:@M@PostHog@objc(cs)PostHogSDK(im)flush @@ -202,10 +213,12 @@ PostHog | PostHogSDK.getFeatureFlagResult(_:) | method | @objc func getFeatureFl PostHog | PostHogSDK.getFeatureFlagResult(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagResultWithKey:sendFeatureFlagEvent:) func getFeatureFlagResult(_ key: String, sendFeatureFlagEvent: Bool) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResultWithKey:sendFeatureFlagEvent: PostHog | PostHogSDK.getSessionId() | method | @objc func getSessionId() -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)getSessionId PostHog | PostHogSDK.group(type:key:) | method | @objc(groupWithType:key:) func group(type: String, key: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key: -PostHog | PostHogSDK.group(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func group(type: String, key: String, groupProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: +PostHog | PostHogSDK.group(type:key:groupProperties:options:) | method | @objc(groupWithType:key:groupProperties:options:) func group(type: String, key: String, groupProperties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties:options: +PostHog | PostHogSDK.groupObjC(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func groupObjC(type: String, key: String, groupProperties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: PostHog | PostHogSDK.identify(_:) | method | @objc func identify(_ distinctId: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)identify: PostHog | PostHogSDK.identify(_:userProperties:) | method | @objc(identifyWithDistinctId:userProperties:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties: -PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: +PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:options:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce:options: +PostHog | PostHogSDK.identifyObjC(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identifyObjC(_ distinctId: String, userProperties: [String : Any]?, userPropertiesSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.isAutocaptureActive() | method | @objc func isAutocaptureActive() -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isAutocaptureActive PostHog | PostHogSDK.isFeatureEnabled(_:) | method | @objc func isFeatureEnabled(_ key: String) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabled: PostHog | PostHogSDK.isFeatureEnabled(_:sendFeatureFlagEvent:) | method | @objc(isFeatureEnabledWithKey:sendFeatureFlagEvent:) func isFeatureEnabled(_ key: String, sendFeatureFlagEvent: Bool) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabledWithKey:sendFeatureFlagEvent: @@ -229,13 +242,15 @@ PostHog | PostHogSDK.resetGroupPropertiesForFlags(reloadFeatureFlags:) | method PostHog | PostHogSDK.resetPersonPropertiesForFlags() | method | @objc func resetPersonPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlags PostHog | PostHogSDK.resetPersonPropertiesForFlags(reloadFeatureFlags:) | method | @objc(resetPersonPropertiesForFlagsWithReloadFeatureFlags:) func resetPersonPropertiesForFlags(reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlagsWithReloadFeatureFlags: PostHog | PostHogSDK.screen(_:) | method | @objc func screen(_ screenTitle: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)screen: -PostHog | PostHogSDK.screen(_:properties:) | method | @objc(screenWithTitle:properties:) func screen(_ screenTitle: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: +PostHog | PostHogSDK.screen(_:properties:options:) | method | @objc(screenWithTitle:properties:options:) func screen(_ screenTitle: String, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties:options: +PostHog | PostHogSDK.screenObjC(_:properties:) | method | @objc(screenWithTitle:properties:) func screenObjC(_ screenTitle: String, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:) | method | @objc(setGroupPropertiesForFlags:properties:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties: PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:reloadFeatureFlags:) | method | @objc(setGroupPropertiesForFlags:properties:reloadFeatureFlags:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties:reloadFeatureFlags: PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:) func setPersonProperties(userPropertiesToSet: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet: -PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: +PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:options:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options: PostHog | PostHogSDK.setPersonPropertiesForFlags(_:) | method | @objc func setPersonPropertiesForFlags(_ properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlags: PostHog | PostHogSDK.setPersonPropertiesForFlags(_:reloadFeatureFlags:) | method | @objc(setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags:) func setPersonPropertiesForFlags(_ properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags: +PostHog | PostHogSDK.setPersonPropertiesObjC(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonPropertiesObjC(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: PostHog | PostHogSDK.setup(_:) | method | @objc func setup(_ config: PostHogConfig) | c:@M@PostHog@objc(cs)PostHogSDK(im)setup: PostHog | PostHogSDK.shared | type.property | @objc nonisolated(unsafe) static let shared: PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK(cpy)shared PostHog | PostHogSDK.startSession() | method | @objc func startSession() | c:@M@PostHog@objc(cs)PostHogSDK(im)startSession From 13062804cbefa067384f82faf8a54c2a6a60e310 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:30:37 -0400 Subject: [PATCH 57/67] chore: show capture options and captureAi in the example apps (#985) Co-authored-by: Claude Opus 5.5 --- PostHogExample/ContentView.swift | 25 +++++++++++++++++++++++++ PostHogObjCExample/AppDelegate.m | 18 ++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 16df487da7..d0911c2047 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -313,6 +313,31 @@ struct ContentView: View { } } + // Events go to /i/v1/analytics/events; captureAi events go to /i/v1/ai/events. + Section("Capture v1") { + Button("Capture with options") { + PostHogSDK.shared.capture("example_event_with_options", properties: [ + "source": "example_app", + ], options: [ + "process_person_profile": false, + ]) + } + + Button("Capture $ai_generation") { + let uuid = PostHogSDK.shared.captureAi("$ai_generation", properties: [ + "$ai_trace_id": UUID().uuidString, + "$ai_provider": "openai", + "$ai_model": "gpt-4o-mini", + "$ai_input": [["role": "user", "content": "Tell me a fun fact about hedgehogs"]], + "$ai_output_choices": [["role": "assistant", "content": "Hedgehogs have about 5,000 spines."]], + "$ai_input_tokens": 12, + "$ai_output_tokens": 9, + "$ai_latency": 0.42, + ]) + print("captureAi queued $ai_generation: \(uuid ?? "not queued")") + } + } + // Manual testing of exception steps. Section("Exception steps") { Button("Add 3 exception steps") { diff --git a/PostHogObjCExample/AppDelegate.m b/PostHogObjCExample/AppDelegate.m index 3df93236df..f21b61b742 100644 --- a/PostHogObjCExample/AppDelegate.m +++ b/PostHogObjCExample/AppDelegate.m @@ -127,6 +127,24 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( groups:groupProps timestamp:[NSDate date]]; + [[PostHogSDK shared] captureWithEvent:@"theEvent" + distinctId:nil + properties:props + userProperties:nil + userPropertiesSetOnce:nil + groups:nil + timestamp:nil + options:@{@"process_person_profile": @NO}]; + + NSString *aiEventUuid = [[PostHogSDK shared] captureAiWithEvent:@"$ai_generation" + distinctId:nil + properties:@{@"$ai_model": @"gpt-4o-mini", @"$ai_provider": @"openai"} + userProperties:nil + userPropertiesSetOnce:nil + groups:nil + timestamp:nil + options:nil]; + NSLog(@"captureAi uuid: %@", aiEventUuid); [[PostHogSDK shared] groupWithType:@"theType" key:@"theKey"]; [[PostHogSDK shared] groupWithType:@"theType" key:@"theKey" groupProperties:groupProps]; From b53ff66b7589bb3f1983a652e18adfd85a42c2f4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 13:43:16 -0400 Subject: [PATCH 58/67] revert: drop the adapter-only captureAi UUID overload This reverts commit 1e0cc55f2645c29283f29bff8fc7752c71bfab9f. captureAi takes no UUID, like capture, so capture_ai_keeps_supplied_uuid tests a server-SDK feature iOS does not offer. Passing it through an internal overload only the adapter can call made the harness test code apps never run. The test is an expected failure again, as on Android. Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogSDK.swift | 35 ++--------------------- PostHogTests/PostHogCaptureV1Test.swift | 13 --------- sdk_compliance_adapter/Sources/main.swift | 20 +++++-------- 3 files changed, 10 insertions(+), 58 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 923e24826a..2a20bf1280 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1591,31 +1591,6 @@ let maxRetryDelay = 30.0 ) } - /// `captureAi` that sends the event with a caller-supplied `uuid` instead of a generated one. - /// For hosts that already assigned the event's UUID, such as the SDK compliance adapter. - @_spi(PostHogInternal) @discardableResult - public func captureAi(_ event: String, - uuid: UUID, - distinctId: String? = nil, - properties: [String: Any]? = nil, - timestamp: Date? = nil, - options: [String: Any]? = nil) -> String? - { - if !event.hasPrefix("$ai_") { - hedgeLog("captureAi event '\(event)' doesn't start with '$ai_', so PostHog will drop it. Use capture for non-AI events.") - } - - return captureInternal( - event, - distinctId: distinctId, - properties: properties, - timestamp: timestamp, - options: options, - uuid: uuid, - aiLane: true - ) - } - // A separate Swift name keeps this out of Swift overload resolution. /// Captures a custom event for an explicit distinct ID and timestamp from Objective-C. @@ -1803,7 +1778,6 @@ let maxRetryDelay = 30.0 groups: [String: String]? = nil, timestamp: Date? = nil, options: [String: Any]? = nil, - uuid: UUID? = nil, skipBuildProperties: Bool = false, propertyAllowlist: Set? = nil, deduplicatePersonProperties: Bool = false, @@ -1895,8 +1869,7 @@ let maxRetryDelay = 30.0 distinctId: eventDistinctId, properties: finalProperties, options: eventOptions, - timestamp: eventTimestamp, - uuid: uuid + timestamp: eventTimestamp ) guard let posthogEvent else { @@ -2241,8 +2214,7 @@ let maxRetryDelay = 30.0 distinctId: String, properties: [String: Any], options: [String: Any]? = nil, - timestamp: Date = Date(), - uuid: UUID? = nil + timestamp: Date = Date() ) -> PostHogEvent? { var properties = properties let carriesReplayDebugBundle = properties.removeValue(forKey: Self.replayDebugClaimMarkerKey) != nil @@ -2252,8 +2224,7 @@ let maxRetryDelay = 30.0 distinctId: distinctId, properties: properties, options: options, - timestamp: timestamp, - uuid: uuid ?? UUID.v7() + timestamp: timestamp ) let resultEvent = config.runBeforeSend(event) diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 257d55a4ff..ee1bd66250 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -865,19 +865,6 @@ final class PostHogCaptureAiTest { #expect(server.aiRequests.count == 1) } - @Test("captureAi with a supplied uuid returns and sends that uuid") - func keepsSuppliedUuid() async throws { - let sut = makeSDK() - defer { sut.close() } - let supplied = try #require(UUID(uuidString: "0198c0de-0000-7000-8000-000000000abc")) - - #expect(sut.captureAi("$ai_generation", uuid: supplied, distinctId: "user") == "0198c0de-0000-7000-8000-000000000abc") - await waitUntil { self.server.aiRequests.count == 1 } - - let event = try #require(server.aiRequests.first.flatMap { server.parsePostHogEvents($0).first }) - #expect(event.uuid == supplied) - } - @Test("captureAi returns nil when the event isn't queued") func returnsNilWhenNotQueued() { let sut = makeSDK { event in event.event == "$ai_span" ? nil : event } diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index bb0b786bbd..ccbbd65e3e 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -231,7 +231,6 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - let properties: [String: AnyCodable]? let timestamp: String? let options: [String: AnyCodable]? - let uuid: String? enum CodingKeys: String, CodingKey { case event @@ -239,7 +238,6 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - case properties case timestamp case options - case uuid } } @@ -250,17 +248,13 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - throw Abort(.badRequest, reason: "SDK not initialized. Call /init first.") } - let properties = captureReq.properties?.mapValues(\.value) - let timestamp = captureReq.timestamp.flatMap(parseHarnessTimestamp) - let options = captureReq.options?.mapValues(\.value) - // A supplied UUID is sent as-is; an invalid one is replaced by a generated UUID. - let uuid = if let suppliedUuid = captureReq.uuid.flatMap(UUID.init(uuidString:)) { - sdk.captureAi(captureReq.event, uuid: suppliedUuid, distinctId: captureReq.distinctId, - properties: properties, timestamp: timestamp, options: options) - } else { - sdk.captureAi(captureReq.event, distinctId: captureReq.distinctId, - properties: properties, timestamp: timestamp, options: options) - } + let uuid = sdk.captureAi( + captureReq.event, + distinctId: captureReq.distinctId, + properties: captureReq.properties?.mapValues(\.value), + timestamp: captureReq.timestamp.flatMap(parseHarnessTimestamp), + options: captureReq.options?.mapValues(\.value) + ) var result: [String: Any] = ["success": true] result["uuid"] = uuid From 0fd48c0cd70d9908ba3b3873cb2d6b035325089c Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:04:59 -0400 Subject: [PATCH 59/67] fix: retry capture v1 requests that get 404 or 429 (#979) * fix: retry capture v1 requests that get 404 or 429 A 404 usually means a misconfigured proxy and a 429 now only comes from an overloaded Envoy, so neither should drop events. Matches posthog-go#395. * fix: warn once when capture v1 returns 404 A 404 now keeps events queued, so nothing tells the app that the route is missing. Print one warning per client, even without debug, naming the URL and the path a reverse proxy or self-hosted instance must serve. * fix: leave host credentials out of the capture v1 404 warning Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changeset/v4-capture-v1.md | 4 +- PostHog/PostHogApi.swift | 25 ++++++++++ PostHog/QueueEndpoint+Factories.swift | 6 +-- PostHog/Utils/Hedgelog.swift | 5 ++ PostHogTests/PostHogCaptureV1Test.swift | 63 +++++++++++++++++++------ PostHogTests/PostHogQueueTest.swift | 29 ++---------- docs/migration-3.x-to-4.0.md | 6 +-- 7 files changed, 92 insertions(+), 46 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 5dc8761158..6797f2898a 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -7,10 +7,10 @@ Send events through capture V1 and add `captureAi(...)` for AI events. See the [ **Changed** - **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 or self-host PostHog, make sure `/i/v1/analytics/events` is served: a 404 or other non-retriable response drops the events. + - If you use a reverse proxy or self-host PostHog, make sure `/i/v1/analytics/events` is served: a 404 is retried, so events stay queued until the route exists, and the SDK prints a warning once, also without `debug`. - 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. -- **Breaking:** stop retrying 429 responses, and pause sending for at most 30 seconds on `Retry-After`, including session replay and logs. +- **Breaking:** pause sending for at most 30 seconds on `Retry-After`, including session replay and logs. - **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options. PostHog drops an event whose option value it can't read. - **Breaking:** merge event properties as: properties the SDK sets itself, then registered super properties, then the properties you pass, then `beforeSend`. Super properties now override SDK properties such as `$os`, and both super and event properties override `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug values. - **Breaking:** rename `maxRetries` to `pushNotificationSubscriptionMaxRetries`, since it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 314dc73acc..7a5e81ae5c 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -70,6 +70,9 @@ class PostHogApi { private let captureV1Lock = NSLock() // Keyed by endpoint path, so analytics and AI retries keep separate identities. private var captureV1PendingRetry: [String: CaptureV1RequestIdentity] = [:] + // A 404 keeps the events queued, so the warning is printed once instead of on every retry. + private var warnedCaptureNotFound = false + var printWarning: (String) -> Void = hedgeLogWarning init(_ config: PostHogConfig) { self.config = config @@ -243,11 +246,33 @@ 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) } + if info.statusCode == 404 { + self.warnCaptureNotFoundOnce(url: url, path: path) + } completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity, eventNames: eventNames)) } }.resume() } + private func warnCaptureNotFoundOnce(url: URL, path: String) { + let shouldWarn = captureV1Lock.withLock { + defer { warnedCaptureNotFound = true } + return !warnedCaptureNotFound + } + guard shouldWarn else { return } + // Warnings print even without debug, so drop any credentials in the host. Clearing the + // query also drops the trailing "?" that getEndpointURL's empty query prints as. + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.user = nil + components?.password = nil + components?.query = nil + components?.fragment = nil + printWarning( + "\(components?.url?.absoluteString ?? path) returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves \(path)." + ) + } + /// Reuses the request ID with the next attempt number when `uuids` is the /// set of events the previous request left to retry. Otherwise starts a new /// request at attempt 1. diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index a9b0b7b238..25d93d7979 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -16,10 +16,10 @@ private func isSnapshotRetriableStatusCode(_ code: Int) -> Bool { /// floors the backoff, so it can't strand the logs queue. Matches posthog-js. let logsMaxRetryAfterSeconds: TimeInterval = 5 * 60 -/// Capture V1 retry policy: 408 and the listed 5xx. Everything else, including -/// 429 and 3xx, is terminal; 413 is handled by the queue's batch halving. +/// Capture V1 retry policy: 404, 408, 429 and the listed 5xx. Everything else, +/// including 3xx, is terminal; 413 is handled by the queue's batch halving. func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { - [408, 500, 502, 503, 504].contains(code) + [404, 408, 429, 500, 502, 503, 504].contains(code) } /// Size limit for a whole serialized AI event at the default `captureAiMaxEventBytes`. diff --git a/PostHog/Utils/Hedgelog.swift b/PostHog/Utils/Hedgelog.swift index f53ad8694b..ce8318f1ed 100644 --- a/PostHog/Utils/Hedgelog.swift +++ b/PostHog/Utils/Hedgelog.swift @@ -18,3 +18,8 @@ func hedgeLog(_ message: String) { if !hedgeLogEnabled { return } print("[PostHog] \(message)") } + +/// Prints even when debug is off, for a problem the app can't see any other way. +func hedgeLogWarning(_ message: String) { + print("[PostHog] \(message)") +} diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index ee1bd66250..26f3474e26 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -209,23 +209,58 @@ final class PostHogCaptureV1Test { #expect(Set(createdAt[0 ... 3]).count == 1) } - @Test("a 404 is terminal and doesn't fall back to /batch") - func notFoundIsTerminal() async throws { + @Test("a 404 is retried under the same request ID and doesn't fall back to /batch") + func notFoundIsRetried() async throws { server.captureV1ResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) } let api = makeApi() + let event = PostHogEvent(event: "first", distinctId: "user") - let result = await send(api, [PostHogEvent(event: "first", distinctId: "user")]) + let result = await send(api, [event]) + _ = await send(api, [event]) #expect(result.statusCode == 404) - #expect(result.retryRecordIds == nil) - #expect(!QueueEndpoint.batch(api: api).isRetriableStatusCode(404)) + #expect(QueueEndpoint.batch(api: api).isRetriableStatusCode(404)) + let requests = server.batchRequests + #expect(requests.map { $0.url?.path } == ["/proxy/i/v1/analytics/events", "/proxy/i/v1/analytics/events"]) + #expect(requests.map { $0.value(forHTTPHeaderField: "PostHog-Attempt") } == ["1", "2"]) + #expect(Set(requests.map { $0.value(forHTTPHeaderField: "PostHog-Request-Id") }).count == 1) + } + + @Test("a 404 warns once with the capture URL") + func notFoundWarnsOnce() async throws { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) + } + let api = makeApi() + var warnings: [String] = [] + api.printWarning = { warnings.append($0) } + _ = await send(api, [PostHogEvent(event: "first", distinctId: "user")]) _ = await send(api, [PostHogEvent(event: "second", distinctId: "user")]) - let paths = server.batchRequests.map { $0.url?.path } - #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/i/v1/analytics/events"]) + #expect(warnings == [ + "http://localhost:9001/proxy/i/v1/analytics/events returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves /i/v1/analytics/events.", + ]) + } + + @Test("the 404 warning leaves out credentials in the host") + func notFoundWarningRedactsCredentials() async throws { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) + } + let api = PostHogApi(PostHogConfig(projectToken: "phc_capture_v1", host: "http://proxy-user:proxy-password@localhost:9001/proxy")) + var warnings: [String] = [] + api.printWarning = { warnings.append($0) } + + _ = await send(api, [PostHogEvent(event: "first", distinctId: "user")]) + + let warning = try #require(warnings.first) + #expect(warning.hasPrefix("http://localhost:9001/proxy/i/v1/analytics/events returned 404.")) + #expect(!warning.contains("proxy-user")) + #expect(!warning.contains("proxy-password")) } @Test("sends empty and whitespace session and window IDs at the root unchanged", arguments: ["", " "]) @@ -368,10 +403,10 @@ final class PostHogCaptureV1Test { ) == nil) } - @Test("only 408 and 500/502/503/504 are retried", arguments: [ - (408, true), (500, true), (502, true), (503, true), (504, true), + @Test("only 404, 408, 429 and 500/502/503/504 are retried", arguments: [ + (404, true), (408, true), (429, true), (500, true), (502, true), (503, true), (504, true), (301, false), (400, false), (401, false), (402, false), (403, false), - (413, false), (415, false), (429, false), (501, false), + (413, false), (415, false), (501, false), ]) func statusCodeClassification(statusCode: Int, retriable: Bool) { let endpoint = QueueEndpoint.batch(api: makeApi()) @@ -942,8 +977,8 @@ final class PostHogCaptureAiTest { #expect(queue.depth == 1) } - @Test("a 404 from the AI endpoint drops the batch") - func notFoundIsTerminal() async throws { + @Test("a 404 from the AI endpoint keeps the batch for retry") + func notFoundIsRetried() async throws { server.aiResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [:], statusCode: 404, headers: nil) } let config = PostHogConfig(projectToken: "phc_ai_404", host: "http://localhost:9001") let storage = PostHogStorage(config) @@ -957,9 +992,9 @@ final class PostHogCaptureAiTest { queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user")) queue.flush() - await waitUntil { queue.depth == 0 } + await waitUntil { queue.currentRetryCountForTesting == 1 } - #expect(queue.depth == 0) + #expect(queue.depth == 1) #expect(server.aiRequests.count == 1) #expect(server.batchRequests.isEmpty) } diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index 1f8fd1cf51..02bfb02ba6 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -407,29 +407,11 @@ final class PostHogQueueTest { } } - @Test("pops batch on HTTP 429 (terminal for capture V1) and does not change cap") - func popsBatchOnHTTP429AndDoesNotChangeCap() async { + @Test("retains batch on a retriable HTTP status and does not change cap", arguments: [404, 408, 429]) + func retainsBatchOnRetriableStatusAndDoesNotChangeCap(statusCode: Int32) async { let sut = getSut(flushAt: 2, maxBatchSize: 4) server.batchResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 429, headers: nil) - } - - sut.add(PostHogEvent(event: "event1", distinctId: "id1")) - sut.add(PostHogEvent(event: "event2", distinctId: "id2")) - - _ = getBatchedEvents(server) - - await expectEventually({ sut.depth }, 0) - await expectEventually({ sut.currentBatchCapForTesting }, 4) - - sut.clear() - } - - @Test("retains batch on HTTP 408 (request timeout is retriable) and does not change cap") - func retainsBatchOnHTTP408AndDoesNotChangeCap() async { - let sut = getSut(flushAt: 2, maxBatchSize: 4) - server.batchResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 408, headers: nil) + HTTPStubsResponse(jsonObject: [], statusCode: statusCode, headers: nil) } sut.add(PostHogEvent(event: "event1", distinctId: "id1")) @@ -758,7 +740,7 @@ struct PostHogQueueUploadDispositionTest { try #require(sender.requestCount == count) } - @Test("received HTTP disposition wins over an accompanying transport error", arguments: [-1, 200, 400, 408, 429, 503], [false, true]) + @Test("received HTTP disposition wins over an accompanying transport error", arguments: [-1, 200, 400, 404, 408, 429, 503], [false, true]) func receivedHTTPDisposition(statusCode: Int, snapshot: Bool) async throws { let sender = ControlledBatchSender() let queue = makeQueue(snapshot: snapshot, sender: sender) @@ -777,8 +759,7 @@ struct PostHogQueueUploadDispositionTest { sender.completeRequest(at: 0, with: $0) } - // 429 is terminal for capture V1 (events) but retried by /snapshot. - let retryable = [-1, 408, 503].contains(statusCode) || (snapshot && statusCode == 429) + let retryable = [-1, 408, 429, 503].contains(statusCode) || (!snapshot && statusCode == 404) let expectedIds = retryable ? allIds : allIds.filter { !sentIds.contains($0) } #expect(queue.fileQueue.peekEntries(2).map(\.id) == expectedIds) #expect(queue.currentRetryCountForTesting == (retryable ? 1 : 0)) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 3a71f5bb7a..35d53853ae 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -22,7 +22,7 @@ You need to change something if your app does any of these: - reads `$session_id` or `$window_id` from the raw request body, for example in a proxy - registers a super property, or passes an event property, with the same key as one the SDK sets, such as `$os`, `$is_identified` or `$geoip_disable`, and expects the SDK's value to win - sets `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` -- relies on 429 responses being retried, or on a `Retry-After` longer than 30 seconds +- relies on a `Retry-After` longer than 30 seconds ## Endpoints @@ -54,7 +54,7 @@ If you send events through your own reverse proxy, update it before you upgrade: PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). -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. +The SDK doesn't fall back to `/batch`. A 404 is retried, so events stay queued until the route is served. Other error statuses that aren't retried, such as 403 or 405, drop the events. ### Redirects @@ -166,7 +166,7 @@ In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:use ## Retries -- The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. +- The SDK retries network errors and 404, 408, 429, 500, 502, 503 and 504 responses. Other statuses drop the events. 3.x did not retry 404, and retried 3xx responses. - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x, analytics events and session replay had no cap, and logs allowed up to 5 minutes. - After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter. In 3.x it grew by 1 second per failure. This applies to events, session replay and logs. From 68c96cbb3f97b1364cc7a0c99e9a923f5103b971 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:10:26 -0400 Subject: [PATCH 60/67] fix: flush analytics once a batch's worth of bytes is queued (#986) The early flush at maxBatchBytes read the file queue's byteCount, which was only tracked when a disk byte cap (maxQueueBytes) was set. Analytics has a batch byte limit but no disk cap, so byteCount stayed 0 and large events waited for flushAt or the timer. Track file sizes whenever the endpoint has a batch byte limit, without adding eviction. Co-authored-by: Claude Opus 5.5 --- PostHogTests/PostHogCaptureV1Test.swift | 24 +++++++++++++++++++ .../PostHogFileBackedQueueAlignmentTest.swift | 17 +++++++++++++ 2 files changed, 41 insertions(+) diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 26f3474e26..f86adb4ace 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -833,6 +833,30 @@ final class PostHogCaptureV1QueueTest { #expect(queue.depth == 0) #expect(server.batchRequests.map { server.parsePostHogEvents($0).count } == [2, 1]) } + + @Test("flushes analytics once a batch's worth of bytes is queued, before flushAt") + func flushesAtByteLimit() async throws { + let config = PostHogConfig(projectToken: "phc_bytes_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + // Not started, so no timer: only the byte threshold can trigger a send. + let queue = PostHogQueue(config, storage, .batch(api: PostHogApi(config)), nil) + server.batchProjectToken = config.projectToken + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let blob = String(repeating: "a", count: 2 * 1024 * 1024) + for _ in 0 ..< 3 { + queue.add(PostHogEvent(event: "large", distinctId: "user", properties: ["blob": blob])) + } + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(!server.batchRequests.isEmpty) + } } @Suite("Capture V1 AI lane", .serialized, .resetsGlobalState) diff --git a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index 07ec910c68..01a12fc685 100644 --- a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift +++ b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift @@ -105,6 +105,23 @@ struct PostHogFileBackedQueueAlignmentTest { #expect(decode(queue.peek(10)) == ["CCCC", "DDDD"]) } + @Test("tracks the byte count without a byte limit and evicts nothing") + func tracksBytesWithoutLimit() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-bytes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let queue = PostHogFileBackedQueue(queue: dir, trackBytes: true) + + #expect(queue.add(Data("AAAA".utf8)).evicted.isEmpty) + #expect(queue.add(Data("BBBBBB".utf8)).evicted.isEmpty) + #expect(queue.byteCount == 10) + #expect(PostHogFileBackedQueue(queue: dir, trackBytes: true).byteCount == 10) + + queue.pop(1) + #expect(queue.byteCount == 6) + #expect(decode(queue.peek(10)) == ["BBBBBB"]) + } + @Test("keeps the newest record even when it is over the byte limit alone") func keepsOversizedNewestRecord() throws { let dir = FileManager.default.temporaryDirectory From f5e139d4f7144c5dd8d047816df97d27b6842a6c Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:46:46 -0400 Subject: [PATCH 61/67] chore(compliance): bump the SDK test harness to 1.26.0 (#988) * chore(compliance): bump the test harness to 1.25.0 Declare disable_geoip, generated_payloads, property_layers and fail_open, and add the adapter fields they need. Co-Authored-By: Claude Opus 5.5 * chore(compliance): cap generated payload size before allocating padding * chore(compliance): bump the test harness to 1.26.0 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- sdk_compliance_adapter/Sources/main.swift | 55 ++++++++++++++++++++--- sdk_compliance_adapter/docker-compose.yml | 2 +- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index ccbbd65e3e..6e2fa99865 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -56,7 +56,10 @@ app.get("health") { req async throws -> Response in // Declares which test suites apply. The iOS SDK posts events to // /i/v1/analytics/events (capture_v1) with gzip. Without this, the // harness skips the capability-gated capture suites entirely. - "capabilities": ["capture_v1", "capture_ai_v1", "encoding_gzip", "event_options"], + "capabilities": [ + "capture_v1", "capture_ai_v1", "encoding_gzip", "event_options", + "disable_geoip", "generated_payloads", "property_layers", "fail_open", + ], ] print("[ADAPTER] GET /health") @@ -73,6 +76,9 @@ app.post("init") { req async throws -> Response in let maxRetries: Int? let enableCompression: Bool? let disableGeoip: Bool? + let superProperties: [String: AnyCodable]? + // Declarative hook: {"set_properties": {...}} sets those keys on every event. + let beforeSend: [String: AnyCodable]? enum CodingKeys: String, CodingKey { // Wire field name remains api_key, but it carries the PostHog project token. @@ -83,16 +89,15 @@ app.post("init") { req async throws -> Response in case maxRetries = "max_retries" case enableCompression = "enable_compression" case disableGeoip = "disable_geoip" + case superProperties = "super_properties" + case beforeSend = "before_send" } } let initReq = try req.content.decode(InitRequest.self) - // Empty token: setup() stays disabled but the singleton is non-nil, hanging a later - // reloadFeatureFlags() (callback never fires when disabled). Fail fast. - guard !initReq.apiKey.isEmpty else { - throw Abort(.badRequest, reason: "Empty project token; SDK would not enable.") - } + // An empty or personal (phx_) token is passed through as given: setup() leaves the SDK + // disabled, so captures send nothing (fail_open). // Rewrite host.docker.internal to localhost since adapter runs on macOS host let host = initReq.host.replacingOccurrences(of: "host.docker.internal", with: "localhost") @@ -124,6 +129,12 @@ app.post("init") { req async throws -> Response in config.captureAiCompression = .none } config.disableGeoIp = initReq.disableGeoip ?? config.disableGeoIp + if let setProperties = initReq.beforeSend?["set_properties"]?.value as? [String: Any] { + config.setBeforeSend { event in + event.properties.merge(setProperties) { _, new in new } + return event + } + } // Disable features for testing config.captureApplicationLifecycleEvents = false @@ -150,6 +161,9 @@ app.post("init") { req async throws -> Response in // Initialize PostHog SDK PostHogSDK.shared.setup(config) + if let superProperties = initReq.superProperties { + PostHogSDK.shared.register(superProperties.mapValues(\.value)) + } state.posthogSDK = PostHogSDK.shared state.apiKey = initReq.apiKey state.host = host @@ -231,6 +245,8 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - let properties: [String: AnyCodable]? let timestamp: String? let options: [String: AnyCodable]? + let padPropertiesToBytes: Int? + let marker: String? enum CodingKeys: String, CodingKey { case event @@ -238,6 +254,8 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - case properties case timestamp case options + case padPropertiesToBytes = "pad_properties_to_bytes" + case marker } } @@ -248,10 +266,15 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - throw Abort(.badRequest, reason: "SDK not initialized. Call /init first.") } + var properties = captureReq.properties?.mapValues(\.value) + if let targetBytes = captureReq.padPropertiesToBytes { + properties = try paddedProperties(properties ?? [:], toBytes: targetBytes, marker: captureReq.marker ?? "") + } + let uuid = sdk.captureAi( captureReq.event, distinctId: captureReq.distinctId, - properties: captureReq.properties?.mapValues(\.value), + properties: properties, timestamp: captureReq.timestamp.flatMap(parseHarnessTimestamp), options: captureReq.options?.mapValues(\.value) ) @@ -261,6 +284,24 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - return try await result.encodeResponse(for: req) } +/// Adds a `$test_padding` string so `properties`, as compact UTF-8 JSON, is exactly `targetBytes` +/// long (generated_payloads). The padding starts with `marker`. +func paddedProperties(_ properties: [String: Any], toBytes targetBytes: Int, marker: String) throws -> [String: Any] { + // The harness asks for at most ~9 MiB; refuse anything far beyond that instead of allocating it. + let maxTargetBytes = 64 * 1024 * 1024 + guard targetBytes <= maxTargetBytes else { + throw Abort(.badRequest, reason: "pad_properties_to_bytes \(targetBytes) is over the \(maxTargetBytes)-byte limit.") + } + var padded = properties + padded["$test_padding"] = marker + let baseBytes = try JSONSerialization.data(withJSONObject: padded).count + guard baseBytes <= targetBytes else { + throw Abort(.badRequest, reason: "Properties are already \(baseBytes) bytes, over \(targetBytes).") + } + padded["$test_padding"] = marker + String(repeating: "x", count: targetBytes - baseBytes) + return padded +} + func fetchFlagsWithRetry(flagsURL: URL, payload: [String: Any]) async throws -> Data { let body = try JSONSerialization.data(withJSONObject: payload) var lastStatus = 0 diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index 4a5b520ca0..6f0a6e80ec 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -15,7 +15,7 @@ version: '3.8' services: test-harness: - image: ${TEST_HARNESS_IMAGE:-ghcr.io/posthog/sdk-test-harness:1.14.0} + image: ${TEST_HARNESS_IMAGE:-ghcr.io/posthog/sdk-test-harness:1.26.0} command: ["run", "--adapter-url", "http://host.docker.internal:8080", "--mock-url", "http://host.docker.internal:8081", "--sdk-type", "server", "--output", "text", "--report", "/report/sdk-compliance-report.md", "--debug"] ports: - "8081:8081" From 48e0e42e18b4ba51cb75116fb13cf7e827e9579a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 12:12:41 -0400 Subject: [PATCH 62/67] fix: don't log the response body of failed capture requests Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogApi.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 7a5e81ae5c..2e53fdda48 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -244,7 +244,8 @@ class PostHogApi { request.setValue(toISO8601String(Date()), forHTTPHeaderField: "PostHog-Request-Timestamp") session.uploadTask(with: request, from: payload) { [weak self] data, response, error in - processUploadResponse(endpointName: "capture", data: data, response: response, error: error) { info in + // No body for the error log: a proxy's error body can echo request content. + processUploadResponse(endpointName: "capture", data: nil, response: response, error: error) { info in guard let self else { return completion(info) } if info.statusCode == 404 { self.warnCaptureNotFoundOnce(url: url, path: path) From f9a271685d0d833d352792bec9c1e77bebec0061 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 12:12:41 -0400 Subject: [PATCH 63/67] docs: summarize capture v1 in MIGRATION.md and link the guide Co-Authored-By: Claude Opus 5.5 --- MIGRATION.md | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 0eb3665a57..73263255f1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -100,17 +100,9 @@ If an app updates from a 2.x SDK straight to 4.0, events still queued on disk by ### Capture v1 -4.0 sends analytics events to `/i/v1/analytics/events` instead of `/batch`. Session replay (`/s/`), logs (`/i/v1/logs`) and feature flags (`/flags`) keep their endpoints. Check these if they apply to you: - -- **Reverse proxies and self-hosted instances:** forward `/i/v1/analytics/events` before you upgrade. PostHog Cloud already serves it. A 404 makes the SDK resend to `/batch` until the app restarts, with the 3.x retry rules. On `/i/v1/analytics/events`, 408, 500, 502, 503 and 504 keep the events and retry them, and 413 retries in smaller batches until a single event is rejected, which is dropped. Other errors, such as 403, 405 or 429, drop the events. -- **`Authorization` in `requestHeaders`:** capture requests now send `Authorization: Bearer `, which replaces yours. Authenticate your proxy with another header, such as `X-Proxy-Token`. -- **Redirects:** only 307 and 308 redirects to the same origin as `host` are followed, at most 5 times. Other redirects drop the events, so point `host` at the final host. -- **Event validation:** PostHog drops an event with an empty event name, an empty distinct ID, or an event name or distinct ID over 200 UTF-8 bytes. The rest of the batch is still delivered. Shorten or hash long distinct IDs, such as JWTs, before you upgrade. -- **`projectToken`:** a personal API key (`phx_...`) now disables the SDK at setup. Use your project token (`phc_...`). -- **`$lib` and `$lib_version`:** PostHog sets these from the SDK. A value you set in properties, a super property or `beforeSend` is ignored. -- **Property precedence:** super properties now override SDK properties such as `$os`, and the properties you pass override `$is_identified`, `$process_person_profile` and `$geoip_disable`. -- **`$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at`, `$product_tour_id`:** still work, but are sent as capture options. PostHog drops an event whose option value it can't read, such as `"maybe"`. New code can pass `options:` to `capture` instead. -- **Retries:** 429 and 3xx responses are no longer retried, `Retry-After` pauses sending for at most 30 seconds, and the wait between failed sends doubles from 1 second up to 30 seconds. Queued events are still kept until they're delivered. +4.0 sends analytics events to `/i/v1/analytics/events` instead of `/batch`. Session replay (`/s/`), logs (`/i/v1/logs`) and feature flags (`/flags`) keep their endpoints. + +Most apps need no code changes. Check the [capture v1 guide](docs/migration-3.x-to-4.0.md) if you use a reverse proxy or self-host PostHog, set `Authorization` in `requestHeaders`, send long distinct IDs such as JWTs, or override SDK properties. It covers endpoints, event validation, property precedence, capture options and retries. ## Compile errors and how to fix them From da388b85337b95f8bb77c64b40f6dd9a05a8d31c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 12:12:42 -0400 Subject: [PATCH 64/67] docs: add long distinct IDs and phx_ keys to the 4.0 changelog Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-capture-v1.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 6797f2898a..0acba9d881 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -10,6 +10,8 @@ Send events through capture V1 and add `captureAi(...)` for AI events. See the [ - If you use a reverse proxy or self-host PostHog, make sure `/i/v1/analytics/events` is served: a 404 is retried, so events stay queued until the route exists, and the SDK prints a warning once, also without `debug`. - 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. +- **Breaking:** drop an event whose distinct ID is over 200 UTF-8 bytes, instead of shortening the ID and keeping the event. Shorten or hash long IDs, such as JWTs, before you upgrade. +- **Breaking:** disable the SDK at setup when `projectToken` is a personal API key (`phx_...`). Use your project token (`phc_...`). - **Breaking:** pause sending for at most 30 seconds on `Retry-After`, including session replay and logs. - **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options. PostHog drops an event whose option value it can't read. - **Breaking:** merge event properties as: properties the SDK sets itself, then registered super properties, then the properties you pass, then `beforeSend`. Super properties now override SDK properties such as `$os`, and both super and event properties override `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug values. From 70e38fe558193c9663e31193650d406c74718d23 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 12:12:42 -0400 Subject: [PATCH 65/67] docs: add the proxy Authorization rule to the migration guide Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 35d53853ae..88d6ae1eb6 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -50,6 +50,7 @@ If you send events through your own reverse proxy, update it before you upgrade: - Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. - Pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged. PostHog answers `401` without `Authorization` and `400` without the others, and the batch is dropped. +- If your proxy strips other credentials, keep `Authorization` values that start with `Bearer phc_`, or the exact `Bearer ` if your project token doesn't start with `phc_`. - Accept request bodies up to 20 MB. AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. On a `413` from the proxy, the SDK splits the batch and drops any event that is still too large on its own. PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). From 29bc86cc195e26dae15880949cda5c77b65a70a0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 12:12:42 -0400 Subject: [PATCH 66/67] docs: note non-JSON values, 402, AI flushes and registered options in the guide Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 88d6ae1eb6..ef7b76122f 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -76,6 +76,8 @@ With `debug` on, the SDK logs how many events were dropped. - An event whose name is over 200 UTF-8 bytes is dropped. Legacy capture had no limit on event name length. - An event whose distinct ID is over 200 UTF-8 bytes is dropped. Legacy capture shortened the ID to 200 characters and kept the event. +A `NaN`, infinite or other non-JSON property value is removed before sending, and the event is still sent. + In 3.x, an empty event name or distinct ID made legacy capture reject the whole batch it was in. The distinct ID can come from `identify(_:)`, the `distinctId` argument of `capture` or `beforeSend`. @@ -144,6 +146,7 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. To change person processing for one event, pass the `process_person_profile` option or the `$process_person_profile` property. A registered `$process_person_profile` changes it for every event. - 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. +- There are no global options. To set one for every event, register its legacy property, for example `register(["$cookieless_mode": true])`. - Events still queued on disk by 3.x are sent to capture v1 after the update, with their legacy properties moved to options. ## AI events @@ -163,11 +166,12 @@ In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:use - AI events are queued on disk separately from other events. Up to 100 events (`captureAiMaxQueueSize`) and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. - AI requests are gzipped unless you set `captureAiCompression` to `.none`. `compression` doesn't apply. - AI events use `flushAt`, `maxBatchSize` and `flushIntervalSeconds`, like other events. A request carries up to about 5 MiB, as for analytics events (see [Retries](#retries)). +- `flush()` and `close()` also send AI events, and AI events left on disk are sent after the next launch. - The SDK sends AI events unchanged. It doesn't redact or truncate prompts, outputs or media, so use `beforeSend` to remove anything you don't want to send. ## Retries -- The SDK retries network errors and 404, 408, 429, 500, 502, 503 and 504 responses. Other statuses drop the events. 3.x did not retry 404, and retried 3xx responses. +- The SDK retries network errors and 404, 408, 429, 500, 502, 503 and 504 responses. Other statuses, such as a 402 when you're over your billing limit, drop the whole request. 3.x did not retry 404, and retried 3xx responses. - When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. - A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x, analytics events and session replay had no cap, and logs allowed up to 5 minutes. - After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter. In 3.x it grew by 1 second per failure. This applies to events, session replay and logs. From 8caa38d4d7231a3a7787aa59de2e874a8ab02ef1 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:45:37 -0400 Subject: [PATCH 67/67] feat: add build and send steps for wrapper SDKs' beforeSend (#991) * feat: add build and send steps for wrapper SDKs' beforeSend Wrapper SDKs such as posthog-flutter run their own beforeSend in another runtime. buildEventForWrapper and buildScreenEventForWrapper return the event capture would queue, with SDK, registered and call properties merged, and sendBuiltEventForWrapper runs native beforeSend and queues it without filling properties again. capture and screen now go through the same two steps. The methods are @_spi(PostHogInternal), not public API. * feat: build wrapper $screen events and claim the replay debug bundle at send buildEventForWrapper now builds a $screen event like screen(), from its $screen_name property, so wrappers use one entry point. The throttled replay debug bundle is claimed when a wrapper event is sent rather than built, so a wrapper that never sends an event can't hold it. * fix: record a wrapper's $screen at send and use the send's lane A wrapper $screen is now cached as the current screen once it's sent, from the final event, so a wrapper hook that renames or drops it is respected. sendBuiltEventForWrapper uses its own ai value when it differs from the build's, with a log. * fix: take a wrapper event's flag person properties from the sent event, send it once under a lock, and keep its replay removals - Feature flags get the $set/$set_once left on the event after the wrapper's hook, and none when reset() or identify() changed the identity since the build. - The send-once marker is read and cleared under a lock. - Send adds only the deferred replay debug properties, so a required key the wrapper removed stays removed. * fix: defer a wrapper build's person processing to send and keep its replay eligibility from the built name A build the wrapper drops no longer turns person processing on; the event's options are computed as though it were on, and send turns it on. Replay debug eligibility comes from the name the event was built with, so a hook that renames it doesn't change it. * fix: drop a wrapper event built before close/setup or on another instance, and keep identity and replay values from its build A send now rejects a built event from another SDK instance or an earlier setup, turns on person processing only for the identity it was built for, and adds the replay debug values snapshotted at build. * fix: treat a reset that keeps the distinct ID as a new identity for wrapper sends * fix: don't restore a wrapper screen name after a reset since the build * fix: add the replay debug bundle to wrapper AI events, like captureAi --- PostHog.xcodeproj/project.pbxproj | 4 + PostHog/Models/PostHogEvent.swift | 22 ++ PostHog/PostHogSDK.swift | 403 ++++++++++++++++++--- PostHogTests/PostHogWrapperEventTest.swift | 353 ++++++++++++++++++ 4 files changed, 732 insertions(+), 50 deletions(-) create mode 100644 PostHogTests/PostHogWrapperEventTest.swift diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 4ca8a8515f..0b95a9becd 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ A1C081010000000000000001 /* PostHogReplayBatchBoundaryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */; }; 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */; }; A1C0DE0000000000000000F2 /* PostHogCaptureV1Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */; }; + A1C0DE00000000000000FAB2 /* PostHogWrapperEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE00000000000000FAB1 /* PostHogWrapperEventTest.swift */; }; 690FF0EF2AEFF23D00A0B06B /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 690FF0EE2AEFF23D00A0B06B /* OHHTTPStubsSwift */; }; 690FF0F12AEFF24200A0B06B /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = 690FF0F02AEFF24200A0B06B /* OHHTTPStubs */; }; E1A0B0203A0D000100000001 /* CwlCatchException in Frameworks */ = {isa = PBXBuildFile; productRef = E1A0B0213A0D000100000001 /* CwlCatchException */; }; @@ -960,6 +961,7 @@ A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReplayBatchBoundaryTest.swift; sourceTree = ""; }; 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogQueueTest.swift; sourceTree = ""; }; A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCaptureV1Test.swift; sourceTree = ""; }; + A1C0DE00000000000000FAB1 /* PostHogWrapperEventTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogWrapperEventTest.swift; sourceTree = ""; }; 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSDKTest.swift; sourceTree = ""; }; 690FF1732AF3CE8A00A0B06B /* PostHogExampleWatchOS.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleWatchOS.xcodeproj; path = PostHogExampleWatchOS/PostHogExampleWatchOS.xcodeproj; sourceTree = ""; }; 69261D122AD5685B00232EC7 /* PostHogRemoteConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfig.swift; sourceTree = ""; }; @@ -1882,6 +1884,7 @@ DAA1B2D22F8C100000112233 /* PostHogRageClickIntegrationTest.swift */, 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */, A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */, + A1C0DE00000000000000FAB1 /* PostHogWrapperEventTest.swift */, A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */, 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */, 5CA51F0E2B5C9A0100000002 /* PostHogScreenNameTest.swift */, @@ -3782,6 +3785,7 @@ D36F92424A13D7CE61B6E348 /* PostHogSwizzlerTest.swift in Sources */, 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */, A1C0DE0000000000000000F2 /* PostHogCaptureV1Test.swift in Sources */, + A1C0DE00000000000000FAB2 /* PostHogWrapperEventTest.swift in Sources */, A1C081010000000000000001 /* PostHogReplayBatchBoundaryTest.swift in Sources */, 3AE3FB4B2993A68500AFFC18 /* PostHogStorageTest.swift in Sources */, DAD39DF62D56178D002A1A69 /* UtilsTest.swift in Sources */, diff --git a/PostHog/Models/PostHogEvent.swift b/PostHog/Models/PostHogEvent.swift index 39a8eecdbd..f6d6d1b0a2 100644 --- a/PostHog/Models/PostHogEvent.swift +++ b/PostHog/Models/PostHogEvent.swift @@ -44,6 +44,28 @@ import Foundation /// Set by the SDK when this event took the throttled replay debug bundle, so it survives `beforeSend` renames or replacement. var carriesReplayDebugBundle = false + /// The caller's person properties, kept from build to send so feature flags see them once the event is accepted. + var flagUserProperties: [String: Any]? + var flagUserPropertiesSetOnce: [String: Any]? + + /// Set when a wrapper build returns this event: true for the AI lane. Cleared once it's sent, so it's sent once. + var wrapperAiLane: Bool? + /// The SDK's distinct ID when a wrapper built this event, so a send after `reset()` or `identify()` + /// doesn't give the new user this event's person properties for feature flags. + var wrapperBuildDistinctId: String? + /// The SDK's reset count when a wrapper built this event, so a `reset()` that keeps the distinct ID + /// (`reuseAnonymousId`) still counts as a new identity at send. + var wrapperBuildResetGeneration: Int? + /// The SDK instance and setup generation that built this wrapper event, so a send after `close()`/`setup()` + /// or on another instance is dropped instead of sent with the new configuration. + var wrapperBuildSDK: ObjectIdentifier? + var wrapperBuildSetupGeneration: Int? + /// The optional replay debug values snapshotted when a wrapper built this event, set only when its built name + /// is eligible. Send adds them if it claims the throttled window. + var wrapperDeferredReplayDebug: [String: Any]? + /// True when a wrapper build passed person properties or groups. Person processing turns on at send. + var wrapperRequiresPersonProcessing = false + init( event: String, distinctId: String, diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2a20bf1280..99e3aa2646 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -55,6 +55,11 @@ let maxRetryDelay = 30.0 private let lastScreenLock = NSLock() private let replayDebugPropertiesLock = NSLock() + private let wrapperSendLock = NSLock() + /// Bumped by `setup` and `close`, so a wrapper event built before a reconfigure isn't sent through the new one. + private var wrapperSetupGeneration = 0 + /// Bumped by `reset()`, so a wrapper send treats a reset that keeps the distinct ID as a new identity. + private var wrapperResetGeneration = 0 private var _lastScreenName: String? var lastScreenName: String? { lastScreenLock.withLock { _lastScreenName } @@ -203,6 +208,7 @@ let maxRetryDelay = 30.0 enabled = true self.config = config + wrapperSendLock.withLock { wrapperSetupGeneration += 1 } let theStorage = PostHogStorage(config) storage = theStorage let api = PostHogApi(config) @@ -621,9 +627,10 @@ let maxRetryDelay = 30.0 /// Adds `process_person_profile: false` when the SDK doesn't process person profiles for this /// event, unless the caller set that option or the legacy `$process_person_profile` property. - /// Nothing is added otherwise: PostHog processes person profiles by default. - private func personProcessingOptions(_ options: [String: Any]?, properties: [String: Any]) -> [String: Any]? { - guard !hasPersonProcessing() else { return options } + /// Nothing is added otherwise: PostHog processes person profiles by default. `personProcessingPending` + /// counts a person-processing change a wrapper build defers to its send. + private func personProcessingOptions(_ options: [String: Any]?, properties: [String: Any], personProcessingPending: Bool = false) -> [String: Any]? { + guard !hasPersonProcessing(), !(personProcessingPending && config.personProfiles != .never) else { return options } if let legacy = properties["$process_person_profile"], !(legacy is NSNull) { return options } @@ -708,6 +715,21 @@ let maxRetryDelay = 30.0 } } + private func replayDebugProperties() -> [String: Any] { + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) + var replayDebugProperties = replayIntegration?.debugProperties() ?? [ + "$recording_status": "disabled", + "$sdk_debug_replay_capture_mode": PostHogReplayIntegration.captureMode, + ] + #else + var replayDebugProperties: [String: Any] = ["$recording_status": "disabled"] + #endif + if let sessionStart = sessionManager.sessionStartTimestampSnapshot { + replayDebugProperties["$sdk_debug_session_start"] = Int64(sessionStart * 1000) + } + return replayDebugProperties + } + private func buildProperties(event: String?, distinctId: String, properties: [String: Any]?, @@ -718,7 +740,10 @@ let maxRetryDelay = 30.0 timestamp: Date? = nil, // Snapshot callers pass true: resolving the session read-only keeps a // background crash-context refresh from rotating an idle session. - readOnlySession: Bool = false) -> [String: Any] + readOnlySession: Bool = false, + // Wrapper builds pass false and claim at send instead: the claim never expires, so it + // can't wait on a wrapper round trip. + claimReplayDebugBundle: Bool = true) -> [String: Any] { var props: [String: Any] = [:] @@ -797,19 +822,10 @@ let maxRetryDelay = 30.0 props["$geoip_disable"] = true } - #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) - var replayDebugProperties = replayIntegration?.debugProperties() ?? [ - "$recording_status": "disabled", - "$sdk_debug_replay_capture_mode": PostHogReplayIntegration.captureMode, - ] - #else - var replayDebugProperties: [String: Any] = ["$recording_status": "disabled"] - #endif - if let sessionStart = sessionManager.sessionStartTimestampSnapshot { - replayDebugProperties["$sdk_debug_session_start"] = Int64(sessionStart * 1000) - } + let replayDebugProperties = replayDebugProperties() // Read-only builds are the crash-context snapshot, refreshed on every status transition. - let claimedReplayDebugWindow = !readOnlySession && Self.isReplayDebugEvent(event) && claimReplayDebugPropertiesWindow() + let claimedReplayDebugWindow = claimReplayDebugBundle && !readOnlySession && Self.isReplayDebugEvent(event) + && claimReplayDebugPropertiesWindow() if readOnlySession || claimedReplayDebugWindow { props.merge(replayDebugProperties) { _, new in new } if claimedReplayDebugWindow { @@ -930,6 +946,7 @@ let maxRetryDelay = 30.0 flagCallReportedLock.withLock { flagCallReported.removeAll() } + wrapperSendLock.withLock { wrapperResetGeneration += 1 } sessionManager.reset() // Clear all in-memory caches (feature flags, session replay state, etc.) @@ -1574,9 +1591,7 @@ let maxRetryDelay = 30.0 timestamp: Date? = nil, options: [String: Any]? = nil) -> String? { - if !event.hasPrefix("$ai_") { - hedgeLog("captureAi event '\(event)' doesn't start with '$ai_', so PostHog will drop it. Use capture for non-AI events.") - } + warnIfNotAiEvent(event) return captureInternal( event, @@ -1783,6 +1798,41 @@ let maxRetryDelay = 30.0 deduplicatePersonProperties: Bool = false, aiLane: Bool = false ) -> String? { + guard let prepared = prepareCaptureEvent( + event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + options: options, + skipBuildProperties: skipBuildProperties, + propertyAllowlist: propertyAllowlist, + aiLane: aiLane + ) else { + return nil + } + return finishCaptureEvent(prepared, aiLane: aiLane, deduplicatePersonProperties: deduplicatePersonProperties)? + .uuid.postHogUuidString + } + + /// Builds the event a capture would queue, without running `beforeSend` or queueing it. + /// Returns `nil` when the capture is skipped. + private func prepareCaptureEvent( // swiftlint:disable:this function_parameter_count + _ event: String, + distinctId: String?, + properties: [String: Any]?, + userProperties: [String: Any]?, + userPropertiesSetOnce: [String: Any]?, + groups: [String: String]?, + timestamp: Date?, + options: [String: Any]?, + skipBuildProperties: Bool, + propertyAllowlist: Set?, + aiLane: Bool, + forWrapper: Bool = false + ) -> PostHogEvent? { if !isEnabled() { return nil } @@ -1791,7 +1841,7 @@ let maxRetryDelay = 30.0 return nil } - guard let queue = aiLane ? aiQueue : queue else { + guard (aiLane ? aiQueue : queue) != nil else { return nil } @@ -1806,7 +1856,7 @@ let maxRetryDelay = 30.0 } } - var isSnapshotEvent = !aiLane && event == "$snapshot" + let isSnapshotEvent = !aiLane && event == "$snapshot" let eventTimestamp = timestamp ?? now() // A blank distinct ID falls back to the current one, like nil, instead of PostHog dropping the event. let eventDistinctId = distinctId.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } ?? getDistinctId() @@ -1817,7 +1867,8 @@ let maxRetryDelay = 30.0 || userPropertiesSetOnce?.isEmpty == false || groups?.isEmpty == false - if !skipBuildProperties, hasPersonData { + // A wrapper build defers this to its send, so a build the wrapper drops changes no state. + if !skipBuildProperties, hasPersonData, !forWrapper { requirePersonProcessing() } @@ -1834,7 +1885,8 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce), groups: groups, appendSharedProps: !isSnapshotEvent, - timestamp: timestamp + timestamp: timestamp, + claimReplayDebugBundle: !forWrapper ) } @@ -1861,24 +1913,40 @@ let maxRetryDelay = 30.0 // Snapshots and pre-built crash events don't carry the SDK's person-processing option. let eventOptions = skipBuildProperties || isSnapshotEvent ? sanitizeDictionary(options) - : personProcessingOptions(sanitizeDictionary(options), properties: finalProperties) + : personProcessingOptions(sanitizeDictionary(options), properties: finalProperties, personProcessingPending: forWrapper && hasPersonData) - // Sanitize is now called in buildEvent - let posthogEvent = buildEvent( + let posthogEvent = makeEvent( event: event, distinctId: eventDistinctId, properties: finalProperties, options: eventOptions, timestamp: eventTimestamp ) + if !skipBuildProperties { + posthogEvent.flagUserProperties = userProperties + posthogEvent.flagUserPropertiesSetOnce = userPropertiesSetOnce + } + if forWrapper { + posthogEvent.wrapperRequiresPersonProcessing = hasPersonData + } + return posthogEvent + } - guard let posthogEvent else { + /// Runs `beforeSend` on an event from `prepareCaptureEvent` and queues the result. + /// - Returns: The queued event, as `beforeSend` returned it, or `nil` when the event isn't queued. + private func finishCaptureEvent(_ prepared: PostHogEvent, aiLane: Bool, deduplicatePersonProperties: Bool = false) -> PostHogEvent? { + guard let queue = aiLane ? aiQueue : queue else { + releaseClaimIfCarried(prepared) + return nil + } + + guard let posthogEvent = applyBeforeSend(prepared) else { return nil } // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook. // The AI lane always uses its own queue. - isSnapshotEvent = !aiLane && posthogEvent.event == "$snapshot" + let isSnapshotEvent = !aiLane && posthogEvent.event == "$snapshot" // if this is a $snapshot event and $session_id is missing, don't process then event if isSnapshotEvent, posthogEvent.properties["$session_id"] == nil { @@ -1887,19 +1955,198 @@ let maxRetryDelay = 30.0 } // Automatically set person properties for feature flags during capture event - if !skipBuildProperties { - setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce: userPropertiesSetOnce) - } + setPersonPropertiesForFlagsIfNeeded(prepared.flagUserProperties, userPropertiesSetOnce: prepared.flagUserPropertiesSetOnce) // Session Replay has its own queue if isSnapshotEvent { releaseClaimIfCarried(posthogEvent) replayQueue?.add(posthogEvent) onEventCaptured.invoke(posthogEvent) - return replayQueue == nil ? nil : posthogEvent.uuid.postHogUuidString + return replayQueue == nil ? nil : posthogEvent } let stored = queueEvent(posthogEvent, queue: queue, deduplicatePersonProperties: deduplicatePersonProperties) - return stored ? posthogEvent.uuid.postHogUuidString : nil + return stored ? posthogEvent : nil + } + + private func warnIfNotAiEvent(_ event: String) { + if !event.hasPrefix("$ai_") { + hedgeLog("captureAi event '\(event)' doesn't start with '$ai_', so PostHog will drop it. Use capture for non-AI events.") + } + } + + /// For wrapper SDKs only (such as posthog-flutter); not for app code. + /// + /// Builds the event `capture` (or `captureAi` when `ai` is true) would queue, with SDK, registered and + /// caller properties already merged, but doesn't run `beforeSend` or queue it. A `$screen` event is built + /// like `screen(_:properties:options:)` instead: it needs a non-blank `$screen_name` in `properties`, and + /// `distinctId`, person properties, groups and `timestamp` are ignored. It becomes the current screen once sent. This lets a wrapper run + /// its own `beforeSend` on the final event, then hand it back with `sendBuiltEventForWrapper(_:ai:)`. + /// Edit the returned event in place; its `uuid` and `timestamp` are kept. An event the wrapper drops needs no + /// cleanup. Distinct ID, session and person processing are read here, at build time. + /// + /// - Parameters: + /// - event: Event name. + /// - distinctId: Optional distinct ID. Defaults to the current one when nil, empty or whitespace-only. + /// - properties: The call's event properties. + /// - userProperties: Person properties to `$set`. + /// - userPropertiesSetOnce: Person properties to `$set_once`. + /// - groups: Groups for the event, keyed by group type. + /// - options: Event options, the same keys as `capture`'s `options`. + /// - timestamp: Optional event timestamp. Defaults to the current time. + /// - ai: True for an AI event, sent on the AI lane. + /// - Returns: The built event, or `nil` when the capture is skipped: the SDK isn't set up, is disabled or + /// opted out, the event is an ignored exception type, or a `$screen` event has no `$screen_name`. + /// + /// ```swift + /// @_spi(PostHogInternal) import PostHog + /// + /// if let event = PostHogSDK.shared.buildEventForWrapper(event: "signed_up", distinctId: nil, properties: ["plan": "pro"], + /// userProperties: nil, userPropertiesSetOnce: nil, groups: nil, + /// options: nil, ai: false) { + /// event.properties["source"] = "flutter" + /// PostHogSDK.shared.sendBuiltEventForWrapper(event, ai: false) + /// } + /// ``` + @_spi(PostHogInternal) public func buildEventForWrapper( // swiftlint:disable:this function_parameter_count + event: String, + distinctId: String?, + properties: [String: Any]?, + userProperties: [String: Any]?, + userPropertiesSetOnce: [String: Any]?, + groups: [String: String]?, + options: [String: Any]?, + timestamp: Date? = nil, + ai: Bool // swiftlint:disable:this identifier_name + ) -> PostHogEvent? { + if ai { + warnIfNotAiEvent(event) + } else if event == "$screen" { + let screen = buildScreenEventForWrapper(properties: properties, options: options) + stampWrapperBuild(screen, event: event) + return screen + } + let built = prepareCaptureEvent( + event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + options: options, + skipBuildProperties: false, + propertyAllowlist: nil, + aiLane: ai, + forWrapper: true + ) + built?.wrapperAiLane = ai + stampWrapperBuild(built, event: event) + return built + } + + /// Records the build-time state a wrapper send checks: this SDK and its setup, the identity, and the + /// replay debug values, so a `close()`/`setup()`, `reset()` or new session during the round trip doesn't + /// leak into the event. + private func stampWrapperBuild(_ built: PostHogEvent?, event: String) { + guard let built else { return } + built.wrapperBuildSDK = ObjectIdentifier(self) + built.wrapperBuildSetupGeneration = wrapperSendLock.withLock { wrapperSetupGeneration } + built.wrapperBuildDistinctId = getDistinctId() + built.wrapperBuildResetGeneration = wrapperSendLock.withLock { wrapperResetGeneration } + if Self.isReplayDebugEvent(event) { + built.wrapperDeferredReplayDebug = replayDebugProperties().filter { !Self.requiredReplayDebugPropertyKeys.contains($0.key) } + } + } + + /// Builds a wrapper's `$screen` event like `screen(_:properties:options:)`, from its required `$screen_name`. + /// The screen is recorded at send, from the final event, so a wrapper hook that renames or drops it is respected. + private func buildScreenEventForWrapper(properties: [String: Any]?, options: [String: Any]?) -> PostHogEvent? { + guard let screenName = properties?["$screen_name"] as? String, !Self.isBlank(screenName) else { + hedgeLog("PostHog $screen event without a $screen_name was dropped") + return nil + } + guard let prepared = prepareScreenEvent(screenName, properties: properties, options: options, forWrapper: true) else { + return nil + } + prepared.event.wrapperAiLane = false + return prepared.event + } + + /// For wrapper SDKs only (such as posthog-flutter); not for app code. + /// + /// Sends an event from `buildEventForWrapper`: runs the native + /// `beforeSend` chain on it, then queues it on the analytics lane, or the AI lane when `ai` is true. + /// The SDK adds only the throttled replay debug properties a normal capture would, and keeps the event's + /// `uuid` and `timestamp`. A sent `$screen` event becomes the current screen. + /// + /// - Parameters: + /// - event: The built event, as edited by the wrapper. + /// - ai: True to send on the AI lane. Pass the same value given to `buildEventForWrapper`; this one wins, + /// with a log, if they differ. + /// - Returns: The queued event's UUID, or `nil` when it isn't queued: the SDK was opted out since the build, + /// `beforeSend` dropped it, the event was already sent or wasn't built for a wrapper, or it was built by + /// another instance or before `close()`/`setup()`. + /// + /// ```swift + /// let uuid = PostHogSDK.shared.sendBuiltEventForWrapper(event, ai: true) + /// ``` + @_spi(PostHogInternal) @discardableResult public func sendBuiltEventForWrapper( + _ event: PostHogEvent, + ai: Bool // swiftlint:disable:this identifier_name + ) -> String? { + let builtLane: Bool? = wrapperSendLock.withLock { + defer { event.wrapperAiLane = nil } + return event.wrapperAiLane + } + guard let builtLane else { + hedgeLog("PostHog event \(event.event) wasn't built by buildEventForWrapper, or was already sent, so it was dropped") + return nil + } + let currentSetup = wrapperSendLock.withLock { wrapperSetupGeneration } + guard event.wrapperBuildSDK == ObjectIdentifier(self), event.wrapperBuildSetupGeneration == currentSetup else { + hedgeLog("PostHog event \(event.event) was built before the SDK was closed or set up again, so it was dropped") + return nil + } + if ai != builtLane { + hedgeLog("PostHog event \(event.event) was built for the \(builtLane ? "AI" : "analytics") lane, but is sent on the lane given here") + } + if !isEnabled() || isOptOutState() { + return nil + } + // Person processing and flag person properties apply only while the SDK still has the identity + // the event was built for, so a `reset()` or `identify()` during the round trip doesn't move them. + let sameIdentity = event.wrapperBuildDistinctId == getDistinctId() + && event.wrapperBuildResetGeneration == wrapperSendLock.withLock { wrapperResetGeneration } + if sameIdentity, event.wrapperRequiresPersonProcessing { + requirePersonProcessing() + } + // Eligibility and values come from the build, since the hook can rename the event and the session can change. + if let deferred = event.wrapperDeferredReplayDebug, claimReplayDebugPropertiesWindow() { + // SDK values, so the wrapper's properties win. The required keys were on the built event, + // so the wrapper already saw them and may have removed them on purpose. + event.properties.merge(deferred) { current, _ in current } + event.carriesReplayDebugBundle = true + } + // Feature flags get the person properties the wrapper left on the event. + if sameIdentity { + event.flagUserProperties = event.properties["$set"] as? [String: Any] + event.flagUserPropertiesSetOnce = event.properties["$set_once"] as? [String: Any] + } else { + event.flagUserProperties = nil + event.flagUserPropertiesSetOnce = nil + } + guard let sent = finishCaptureEvent(event, aiLane: ai) else { + return nil + } + // A `reset()` since the build cleared the screen name, so the old one isn't restored. + if sent.event == "$screen", + event.wrapperBuildResetGeneration == wrapperSendLock.withLock({ wrapperResetGeneration }), + let screenName = sent.properties["$screen_name"] as? String, + let cleaned = PostHogScreenNameSanitizer.sanitize(rawScreenName: screenName) + { + didRecordScreen(cleaned, changed: cacheScreenName(cleaned)) + } + return sent.uuid.postHogUuidString } /// Records a screen view by capturing a `$screen` event. @@ -1931,33 +2178,57 @@ let maxRetryDelay = 30.0 /// `PostHogEvent.options`. @objc(screenWithTitle:properties:options:) public func screen(_ screenTitle: String, properties: [String: Any]? = nil, options: [String: Any]? = nil) { - if !isEnabled() { + guard let prepared = prepareScreenEvent(screenTitle, properties: properties, options: options) else { + return + } + guard let queue else { + releaseClaimIfCarried(prepared.event) + return + } + guard let event = applyBeforeSend(prepared.event) else { return } + queueEvent(event, queue: queue) + didRecordScreen(prepared.screenName, changed: prepared.screenNameChanged) + } + + private struct PreparedScreen { + let event: PostHogEvent + let screenName: String + let screenNameChanged: Bool + } + + /// Caches the screen name and builds the `$screen` event, without running `beforeSend` or queueing it. + private func prepareScreenEvent( + _ screenTitle: String, + properties: [String: Any]?, + options: [String: Any]?, + forWrapper: Bool = false + ) -> PreparedScreen? { + if !isEnabled() { + return nil + } + if isOptOutState() { - return + return nil } // Strip SwiftUI wrappers (UIHostingController → X). Drops degenerate // inputs (empty, stripped-AnyView) — for those we skip the $screen event // and leave the cache untouched so the last useful name survives. guard let cleaned = PostHogScreenNameSanitizer.sanitize(rawScreenName: screenTitle) else { - return + return nil } // Cache the name before the queue check so a screen() call during init // (queue not yet assigned) still seeds the cache for subsequent events. // Track whether the value actually changed so we can skip the crash- // replay snapshot refresh on duplicate viewDidAppears for the same VC. - let screenNameChanged = lastScreenLock.withLock { () -> Bool in - let changed = _lastScreenName != cleaned - _lastScreenName = cleaned - return changed - } + let screenNameChanged = forWrapper ? false : cacheScreenName(cleaned) - guard let queue else { - return + guard queue != nil else { + return nil } let props = [ @@ -1967,19 +2238,28 @@ let maxRetryDelay = 30.0 let distinctId = getDistinctId() let eventName = "$screen" - let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) + let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props, claimReplayDebugBundle: !forWrapper) - guard let event = buildEvent( + let event = makeEvent( event: eventName, distinctId: distinctId, properties: properties, - options: personProcessingOptions(sanitizeDictionary(options), properties: properties) - ) else { - return - } + options: personProcessingOptions(sanitizeDictionary(options), properties: properties), + timestamp: Date() + ) + return PreparedScreen(event: event, screenName: cleaned, screenNameChanged: screenNameChanged) + } - queueEvent(event, queue: queue) + /// Returns whether the cached screen name changed. + private func cacheScreenName(_ cleaned: String) -> Bool { + lastScreenLock.withLock { () -> Bool in + let changed = _lastScreenName != cleaned + _lastScreenName = cleaned + return changed + } + } + private func didRecordScreen(_ cleaned: String, changed screenNameChanged: Bool) { // Fanout to subscribers (sanitized; downstream listeners no longer // need to re-apply sanitize). Currently no in-tree subscribers — kept // as an extension point for external consumers. Subscribers must not @@ -2216,6 +2496,22 @@ let maxRetryDelay = 30.0 options: [String: Any]? = nil, timestamp: Date = Date() ) -> PostHogEvent? { + applyBeforeSend(makeEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: options, + timestamp: timestamp + )) + } + + private func makeEvent( + event eventName: String, + distinctId: String, + properties: [String: Any], + options: [String: Any]?, + timestamp: Date + ) -> PostHogEvent { var properties = properties let carriesReplayDebugBundle = properties.removeValue(forKey: Self.replayDebugClaimMarkerKey) != nil @@ -2226,7 +2522,13 @@ let maxRetryDelay = 30.0 options: options, timestamp: timestamp ) + event.carriesReplayDebugBundle = carriesReplayDebugBundle + return event + } + private func applyBeforeSend(_ event: PostHogEvent) -> PostHogEvent? { + let eventName = event.event + let carriesReplayDebugBundle = event.carriesReplayDebugBundle let resultEvent = config.runBeforeSend(event) if let resultEvent { @@ -3194,6 +3496,7 @@ let maxRetryDelay = 30.0 setupLock.withLock { enabled = false + wrapperSendLock.withLock { wrapperSetupGeneration += 1 } PostHogSDK.projectTokens.remove(config.projectToken) queue?.flush() diff --git a/PostHogTests/PostHogWrapperEventTest.swift b/PostHogTests/PostHogWrapperEventTest.swift new file mode 100644 index 0000000000..7066156ecd --- /dev/null +++ b/PostHogTests/PostHogWrapperEventTest.swift @@ -0,0 +1,353 @@ +// +// PostHogWrapperEventTest.swift +// PostHogTests +// + +import Foundation +@_spi(PostHogInternal) @testable import PostHog +import Testing + +@Suite("Wrapper build and send", .serialized, .resetsGlobalState) +final class PostHogWrapperEventTest { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + private func makeSDK(beforeSend: BeforeSendBlock? = nil, reuseAnonymousId: Bool = false) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_wrapper_\(UUID().uuidString)", host: "http://localhost:9001") + config.reuseAnonymousId = reuseAnonymousId + config.flushAt = 1 + config.preloadFeatureFlags = false + config.sendFeatureFlagEvent = false + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + if let beforeSend { + config.setBeforeSend(beforeSend) + } + server.batchProjectToken = config.projectToken + return PostHogSDK.with(config) + } + + private func build(_ sut: PostHogSDK, _ event: String = "test", properties: [String: Any]? = nil, ai: Bool = false) -> PostHogEvent? { + sut.buildEventForWrapper(event: event, distinctId: nil, properties: properties, userProperties: ["plan": "pro"], + userPropertiesSetOnce: nil, groups: nil, options: ["cookieless_mode": true], ai: ai) + } + + @Test("build returns the filled event without running beforeSend or queueing it") + func buildFillsWithoutSending() async throws { + var hookCalls = 0 + let sut = makeSDK { event in + hookCalls += 1 + return event + } + defer { sut.close() } + sut.register(["registered": "yes"]) + + let built = try #require(build(sut, "built", properties: ["call": 1])) + + #expect(built.properties["registered"] as? String == "yes") + #expect(built.properties["call"] as? Int == 1) + #expect(built.properties["$set"] as? [String: String] == ["plan": "pro"]) + #expect(built.properties["$session_id"] != nil) + #expect(built.properties["$lib"] != nil) + #expect(built.options["cookieless_mode"] as? Bool == true) + #expect(built.distinctId == sut.getDistinctId()) + #expect(hookCalls == 0) + + server.reset(batchCount: 1) + sut.capture("other") + #expect(try await getServerEvents(server).map(\.event) == ["other"]) + } + + @Test("send runs beforeSend, adds no properties, and keeps the uuid and timestamp") + func sendKeepsBuiltEvent() async throws { + var hookCalls = 0 + let sut = makeSDK { event in + hookCalls += 1 + event.properties["native"] = true + return event + } + defer { sut.close() } + sut.register(["registered": "yes"]) + let built = try #require(build(sut)) + built.properties.removeValue(forKey: "registered") + built.properties["wrapper"] = true + + server.reset(batchCount: 1) + let uuid = sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(try await getServerEvents(server).first) + + #expect(hookCalls == 1) + #expect(uuid == built.uuid.postHogUuidString) + #expect(sent.uuid == built.uuid) + #expect(toISO8601String(sent.timestamp) == toISO8601String(built.timestamp)) + #expect(sent.properties["registered"] == nil) + #expect(sent.properties["wrapper"] as? Bool == true) + #expect(sent.properties["native"] as? Bool == true) + } + + @Test("an AI event is sent on the AI lane") + func sendsAiOnAiLane() async throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$ai_generation", ai: true)) + + sut.sendBuiltEventForWrapper(built, ai: true) + await waitUntil { self.server.aiRequests.count == 1 } + + let request = try #require(server.aiRequests.first) + let sent = server.parsePostHogEvents(request) + #expect(sent.map(\.uuid) == [built.uuid]) + #expect(sent.first?.properties["$sdk_debug_session_start"] != nil) + #expect(server.batchRequests.isEmpty) + } + + @Test("build and send return nil when the SDK is opted out") + func optedOutReturnsNil() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + sut.optOut() + + #expect(build(sut) == nil) + #expect(build(sut, "$screen", properties: ["$screen_name": "Home"]) == nil) + #expect(sut.sendBuiltEventForWrapper(built, ai: false) == nil) + } + + @Test("send returns nil when native beforeSend drops the event") + func nativeDropReturnsNil() throws { + let sut = makeSDK { _ in nil } + defer { sut.close() } + + #expect(try sut.sendBuiltEventForWrapper(#require(build(sut)), ai: false) == nil) + } + + @Test("a sent $screen event becomes the current screen; a built one doesn't") + func screenRecordedAtSend() async throws { + let sut = makeSDK() + defer { sut.close() } + + let screen = try #require(build(sut, "$screen", properties: ["$screen_name": "Home", "tab": 1])) + #expect(screen.properties["$screen_name"] as? String == "Home") + #expect(screen.properties["tab"] as? Int == 1) + #expect(build(sut)?.properties["$screen_name"] == nil) + + sut.sendBuiltEventForWrapper(screen, ai: false) + + #expect(build(sut)?.properties["$screen_name"] as? String == "Home") + } + + @Test("a $screen the wrapper renames isn't recorded as a screen") + func renamedScreenNotRecorded() async throws { + let sut = makeSDK() + defer { sut.close() } + let screen = try #require(build(sut, "$screen", properties: ["$screen_name": "Home"])) + screen.event = "custom" + + sut.sendBuiltEventForWrapper(screen, ai: false) + + #expect(build(sut)?.properties["$screen_name"] == nil) + } + + @Test("a $screen sent after reset doesn't restore its screen name") + func screenNotRestoredAfterReset() throws { + let sut = makeSDK() + defer { sut.close() } + let screen = try #require(build(sut, "$screen", properties: ["$screen_name": "Home"])) + + sut.reset() + #expect(sut.sendBuiltEventForWrapper(screen, ai: false) != nil) + + #expect(build(sut)?.properties["$screen_name"] == nil) + } + + @Test("a $screen build without a $screen_name returns nil", arguments: [nil, "", " "] as [String?]) + func screenNeedsName(name: String?) { + let sut = makeSDK() + defer { sut.close() } + + #expect(build(sut, "$screen", properties: name.map { ["$screen_name": $0] }) == nil) + } + + @Test("an event is sent once, on the lane given at send") + func sendsOnceOnSendLane() async throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$ai_generation", ai: true)) + + let uuid = sut.sendBuiltEventForWrapper(built, ai: false) + await waitUntil { self.server.batchRequests.count == 1 } + + #expect(uuid == built.uuid.postHogUuidString) + #expect(sut.sendBuiltEventForWrapper(built, ai: true) == nil) + #expect(server.aiRequests.isEmpty) + } + + @Test("feature flags get the person properties the wrapper left on the sent event") + func flagsUseSentPersonProperties() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + built.properties["$set"] = ["kept": "yes"] + + sut.sendBuiltEventForWrapper(built, ai: false) + + let flagProperties = try #require(sut.remoteConfig).getPersonPropertiesForFlags() + #expect(flagProperties["kept"] as? String == "yes") + #expect(flagProperties["plan"] == nil) + } + + @Test("an event sent after reset doesn't give its person properties to the new user's flags") + func flagsSkipPersonPropertiesAfterReset() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + sut.reset() + sut.sendBuiltEventForWrapper(built, ai: false) + + #expect(try #require(sut.remoteConfig).getPersonPropertiesForFlags()["plan"] == nil) + } + + @Test("a reset that keeps the distinct ID still counts as a new identity at send") + func resetKeepingDistinctIdIsNewIdentity() throws { + let sut = makeSDK(reuseAnonymousId: true) + defer { sut.close() } + let built = try #require(build(sut)) + let distinctId = sut.getDistinctId() + + sut.reset() + #expect(sut.getDistinctId() == distinctId) + sut.sendBuiltEventForWrapper(built, ai: false) + + #expect(try #require(sut.remoteConfig).getPersonPropertiesForFlags()["plan"] == nil) + #expect(sut.config.storageManager?.isPersonProcessing() == false) + } + + @Test("a build turns on person processing only when it's sent") + func personProcessingDeferredToSend() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + #expect(sut.config.storageManager?.isPersonProcessing() == false) + #expect(built.options["process_person_profile"] == nil) + + sut.sendBuiltEventForWrapper(built, ai: false) + + #expect(sut.config.storageManager?.isPersonProcessing() == true) + } + + @Test("an event built before close and setup isn't sent through the new setup") + func sendAfterReconfigureDrops() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + sut.close() + let config = PostHogConfig(projectToken: "phc_wrapper_\(UUID().uuidString)", host: "http://localhost:9001") + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + sut.setup(config) + + #expect(sut.sendBuiltEventForWrapper(built, ai: false) == nil) + } + + @Test("an event built on one SDK instance isn't sent through another") + func sendOnOtherInstanceDrops() throws { + let sut = makeSDK() + let other = makeSDK() + defer { + sut.close() + other.close() + } + let built = try #require(build(sut)) + + #expect(other.sendBuiltEventForWrapper(built, ai: false) == nil) + } + + @Test("an event sent after reset doesn't turn on person processing for the new user") + func personProcessingSkippedAfterReset() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + sut.reset() + sut.sendBuiltEventForWrapper(built, ai: false) + + #expect(sut.config.storageManager?.isPersonProcessing() == false) + } + + @Test("the replay debug values sent are the ones from build, even if the session changed") + func replayDebugSnapshotAtBuild() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$wrapped")) + let builtStart = try #require(sut.sessionManager.sessionStartTimestampSnapshot) + + sut.endSession() + server.reset(batchCount: 1) + sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(getBatchedEvents(server).first) + + #expect(sent.properties["$sdk_debug_session_start"] as? Int64 == Int64(builtStart * 1000)) + } + + #if os(iOS) + #if !SWIFT_PACKAGE || SessionReplay + @Test("the replay debug bundle is claimed when a wrapper event is sent, not when it's built") + func wrapperClaimsReplayDebugBundleAtSend() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$wrapped")) + #expect(built.properties["$sdk_debug_replay_capture_mode"] == nil) + + server.reset(batchCount: 1) + sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(getBatchedEvents(server).first) + + #expect(sent.properties["$sdk_debug_replay_capture_mode"] != nil) + } + + @Test("a wrapper event renamed by the hook keeps the replay debug eligibility of its built name") + func wrapperReplayEligibilityFromBuiltName() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$wrapped")) + built.event = "renamed" + + server.reset(batchCount: 1) + sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(getBatchedEvents(server).first) + + #expect(sent.properties["$sdk_debug_replay_capture_mode"] != nil) + } + + @Test("send doesn't add back a required replay property the wrapper removed") + func wrapperRemovalOfRequiredReplayPropertyKept() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$wrapped")) + #expect(built.properties["$recording_status"] != nil) + built.properties.removeValue(forKey: "$recording_status") + + server.reset(batchCount: 1) + sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(getBatchedEvents(server).first) + + #expect(sent.properties["$recording_status"] == nil) + } + #endif + #endif +}