From 2e46974994f5987031d80197a71fffb038e9a6a7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 15:17:10 -0400 Subject: [PATCH 01/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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/34] 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 2b7cbd7a4d7eaa13e2764081f4241cb94c347931 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sun, 25 Oct 2026 10:58:03 -0400 Subject: [PATCH 34/34] 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.