From 0a20bf39c6677c51f113118f326ae66ec2b3fa91 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 19:25:31 -0400 Subject: [PATCH 1/9] fix: drop empty event names locally and count duplicates capture dropped Drop an event whose name is empty after beforeSend before it's queued, logging the first one. Read duplicates_dropped from capture v1 results and add it to the per-batch debug count. --- .changeset/v4-capture-v1.md | 1 + PostHog/PostHogApi.swift | 12 ++++--- PostHog/PostHogSDK.swift | 22 +++++++++++- PostHogTests/PostHogCaptureV1Test.swift | 45 +++++++++++++++++++++++++ docs/migration-3.x-to-4.0.md | 5 ++- 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 0acba9d88..f17eecab7 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -18,6 +18,7 @@ Send events through capture V1 and add `captureAi(...)` for AI events. See the [ - **Breaking:** rename `maxRetries` to `pushNotificationSubscriptionMaxRetries`, since it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. - Double the wait after a failed send from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing it by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. - Cap analytics batches at 5 MiB per request; an event larger than that is sent on its own. +- Drop an event with an empty name before sending it, including one `beforeSend` renames to `""`. **Added** diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 2e53fdda4..5bfd63bab 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -303,6 +303,7 @@ class PostHogApi { ) -> PostHogUploadInfo { var retryUuids: Set = [] var dropped = 0 + var duplicatesDropped = 0 let statusCode = info.statusCode if let statusCode, 200 ... 299 ~= statusCode { @@ -313,9 +314,12 @@ class PostHogApi { if results == nil { 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 { + // Events missing from the results count as delivered. A repeated UUID takes its + // first copy's result; capture counts the later copies it dropped in `duplicates_dropped`. + var seen: Set = [] + for uuid in uuids where seen.insert(uuid).inserted { guard let entry = results?[uuid] as? [String: Any] else { continue } + duplicatesDropped += (entry["duplicates_dropped"] as? Int) ?? 0 switch entry["result"] as? String { case "retry": retryUuids.insert(uuid) @@ -334,8 +338,8 @@ class PostHogApi { retryUuids = Set(uuids) } - if dropped > 0 || !retryUuids.isEmpty { - hedgeLog("Capture: \(dropped) dropped, \(retryUuids.count) to retry.") + if dropped > 0 || duplicatesDropped > 0 || !retryUuids.isEmpty { + hedgeLog("Capture: \(dropped) dropped, \(duplicatesDropped) duplicates dropped, \(retryUuids.count) to retry.") } captureV1Lock.withLock { diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2a20bf128..880a67087 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -52,6 +52,8 @@ let maxRetryDelay = 30.0 private let personPropsLock = NSLock() private let cachedPersonPropertiesLock = NSLock() private let identifyLock = NSLock() + private let emptyEventNameLock = NSLock() + private var warnedEmptyEventName = false private let lastScreenLock = NSLock() private let replayDebugPropertiesLock = NSLock() @@ -1562,7 +1564,7 @@ let maxRetryDelay = 30.0 /// `capture`'s `options`. /// /// - Returns: The event's UUID, or `nil` if the event wasn't queued: for example, the SDK is - /// opted out or disabled, `beforeSend` dropped it, or it's over `captureAiMaxEventBytes`. + /// opted out or disabled, its name is empty, `beforeSend` dropped it, or it's over `captureAiMaxEventBytes`. @discardableResult @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) public func captureAi(_ event: String, @@ -1876,6 +1878,14 @@ let maxRetryDelay = 30.0 return nil } + // PostHog drops an event with an empty name, so it's dropped here instead. Checked after + // beforeSend, which can rename the event. + if posthogEvent.event.isEmpty { + warnEmptyEventNameOnce() + releaseClaimIfCarried(posthogEvent) + return nil + } + // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook. // The AI lane always uses its own queue. isSnapshotEvent = !aiLane && posthogEvent.event == "$snapshot" @@ -2203,6 +2213,16 @@ let maxRetryDelay = 30.0 queueEvent(event, queue: queue) } + private func warnEmptyEventNameOnce() { + let shouldWarn = emptyEventNameLock.withLock { + defer { warnedEmptyEventName = true } + return !warnedEmptyEventName + } + if shouldWarn { + hedgeLog("Dropping an event with an empty name. Further empty names are dropped without a log.") + } + } + private func releaseClaimIfCarried(_ event: PostHogEvent) { if event.carriesReplayDebugBundle { releaseReplayDebugPropertiesClaim() diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index f86adb4ac..bd43b8bd2 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -161,6 +161,32 @@ final class PostHogCaptureV1Test { #expect(!output.contains(uuids[2])) } + @Test("counts duplicate copies capture dropped once per UUID") + func countsDuplicatesDropped() throws { + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let kept = UUID().postHogUuidString + let other = UUID().postHogUuidString + let uuids = [kept, kept, kept, other] + let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") + let body = try JSONSerialization.data(withJSONObject: ["results": [ + kept: ["result": "ok", "duplicates_dropped": 2], + other: ["result": "ok"], + ]]) + + var result: PostHogUploadInfo? + let output = try captureStdout { + result = makeApi().captureV1Result( + PostHogUploadInfo(statusCode: 200, error: nil), path: PostHogApi.captureV1AnalyticsPath, data: body, + uuids: uuids, identity: identity + ) + } + + #expect(output.contains("Capture: 0 dropped, 2 duplicates dropped, 0 to retry.")) + #expect(result?.retryRecordIds?.isEmpty ?? true) + } + @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] @@ -935,6 +961,25 @@ final class PostHogCaptureAiTest { #expect(sut.captureAi("$ai_generation") == nil) } + @Test("drops an event with an empty name, including one beforeSend renames to empty") + func dropsEmptyEventName() async throws { + let sut = makeSDK { event in + if event.event == "renamed" { event.event = "" } + return event + } + defer { sut.close() } + + #expect(sut.captureAi("") == nil) + #expect(sut.captureAi("renamed") == nil) + server.reset(batchCount: 1) + sut.capture("") + sut.capture("renamed") + sut.capture("kept") + let events = try await getServerEvents(server) + + #expect(events.map(\.event) == ["kept"]) + } + @Test("AI requests use captureAiTimeout and captureAiCompression, and analytics requests don't") func usesAiRequestSettings() async throws { let sut = makeSDK { config in diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index ef7b76122..e4d476cee 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -67,10 +67,13 @@ If a proxy redirects capture requests to another host, point `host` at the final ## 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 drops an event with an empty event name before sending it, for example `capture("")` or a name `beforeSend` sets to `""`. +`captureAi` then returns `nil`, and with `debug` on the SDK logs the first one. + 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 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. From 912aa68216b5917199b250e06baa03dd93b2d4a3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 20:00:21 -0400 Subject: [PATCH 2/9] fix: let typed per-call $set, $set_once and $groups win key by key over the same maps in properties --- .changeset/v4-capture-v1.md | 1 + PostHog/PostHogSDK.swift | 8 +++++++- PostHogTests/PostHogSDKTest.swift | 29 +++++++++++++++++++++++++++++ docs/migration-3.x-to-4.0.md | 1 + 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index f17eecab7..70f3a47a5 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -15,6 +15,7 @@ Send events through capture V1 and add `captureAi(...)` for AI events. See the [ - **Breaking:** pause sending for at most 30 seconds on `Retry-After`, including session replay and logs. - **Breaking:** send the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` properties as capture options. PostHog drops an event whose option value it can't read. - **Breaking:** merge event properties as: properties the SDK sets itself, then registered super properties, then the properties you pass, then `beforeSend`. Super properties now override SDK properties such as `$os`, and both super and event properties override `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug values. +- **Breaking:** let `userProperties`, `userPropertiesSetOnce` and `groups` win key by key over `$set`, `$set_once` and `$groups` maps passed in the same call's properties, instead of being replaced by them. - **Breaking:** rename `maxRetries` to `pushNotificationSubscriptionMaxRetries`, since it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. - Double the wait after a failed send from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing it by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. - Cap analytics batches at 5 MiB per request; an event larger than that is sent on its own. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 880a67087..c5b16e444 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -861,7 +861,13 @@ let maxRetryDelay = 30.0 } // 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 } + callerProps["$groups"] = currentGroups.merging(callerGroups) { _, new in new }.merging(groups ?? [:]) { _, new in new } + } + // A typed per-call value wins key by key over the same map in the caller's properties. + for (key, typed) in [("$set", userProperties), ("$set_once", userPropertiesSetOnce)] where appendSharedProps { + if let typed, let callerMap = callerProps[key] as? [String: Any] { + callerProps[key] = callerMap.merging(typed) { _, new in new } + } } props.merge(callerProps) { _, new in new } diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 3c7409279..e812e5195 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -1235,6 +1235,35 @@ final class PostHogSDKTests { sut.close() } + @Test("typed per-call values win key by key over the same maps in properties") + func typedPerCallValuesWinKeyByKeyOverCallerMaps() throws { + let sut = getSut() + + sut.capture( + "event", + properties: [ + "$set": ["plan": "free", "seen": true], + "$set_once": ["first": "a", "source": "ad"], + "$groups": ["company": "caller", "team": "t1"], + ], + userProperties: ["plan": "pro"], + userPropertiesSetOnce: ["first": "b"], + groups: ["company": "typed"] + ) + + let event = try #require(getBatchedEvents(server).first) + let set = try #require(event.properties["$set"] as? [String: Any]) + #expect(set["plan"] as? String == "pro") + #expect(set["seen"] as? Bool == true) + let setOnce = try #require(event.properties["$set_once"] as? [String: String]) + #expect(setOnce == ["first": "b", "source": "ad"]) + let groups = try #require(event.properties["$groups"] as? [String: String]) + #expect(groups == ["company": "typed", "team": "t1"]) + + sut.reset() + sut.close() + } + @Test("per-call userProperties, userPropertiesSetOnce and groups, and group() groups, win over registered ones") func perCallTypedValuesWinOverRegistered() throws { let sut = getSut(flushAt: 2) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index e4d476cee..1e30e2272 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -104,6 +104,7 @@ An event's properties are merged in this order, and later values win: 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`. +If you also pass `$set`, `$set_once` or `$groups` in the properties, the per-call `userProperties`, `userPropertiesSetOnce` and `groups` win key by key over them. In 3.x, `$set` and `$set_once` in the properties replaced the per-call values, and `$groups` in the properties won per key. In 3.x, the SDK's own values won over super properties, and `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug properties also won over the properties you pass. In 4.0 a value you register or pass wins, for example `capture("signed_up", properties: ["$geoip_disable": false])` sends that event with GeoIP enabled even when `disableGeoIp` is on. From 212f18656851f06053be3ffaad9b43ce00553fbe Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 20:50:40 -0400 Subject: [PATCH 3/9] fix: clamp the duplicates_dropped count so a malformed response can't overflow it --- PostHog/PostHogApi.swift | 3 ++- PostHogTests/PostHogCaptureV1Test.swift | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 5bfd63bab..99555006e 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -319,7 +319,8 @@ class PostHogApi { var seen: Set = [] for uuid in uuids where seen.insert(uuid).inserted { guard let entry = results?[uuid] as? [String: Any] else { continue } - duplicatesDropped += (entry["duplicates_dropped"] as? Int) ?? 0 + // Clamped to the batch size so a malformed count can't overflow the sum. + duplicatesDropped += min(max((entry["duplicates_dropped"] as? Int) ?? 0, 0), uuids.count) switch entry["result"] as? String { case "retry": retryUuids.insert(uuid) diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index bd43b8bd2..528faea79 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -187,6 +187,28 @@ final class PostHogCaptureV1Test { #expect(result?.retryRecordIds?.isEmpty ?? true) } + @Test("clamps a malformed duplicates_dropped count instead of overflowing") + func clampsMalformedDuplicatesDropped() throws { + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let uuids = [UUID().postHogUuidString, UUID().postHogUuidString] + let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") + let body = try JSONSerialization.data(withJSONObject: ["results": [ + uuids[0]: ["result": "ok", "duplicates_dropped": Int.max], + uuids[1]: ["result": "ok", "duplicates_dropped": -5], + ]]) + + let output = try captureStdout { + _ = makeApi().captureV1Result( + PostHogUploadInfo(statusCode: 200, error: nil), path: PostHogApi.captureV1AnalyticsPath, data: body, + uuids: uuids, identity: identity + ) + } + + #expect(output.contains("Capture: 0 dropped, 2 duplicates dropped, 0 to retry.")) + } + @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] From 8e7edec5c79bb2e28e4a4ff6c214321e3e895a7b Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:49:13 -0400 Subject: [PATCH 4/9] fix: drop a 2xx batch without readable results and send crash-context person processing as an option (#990) Count a capture v1 2xx whose body can't be read as per-event results as a dropped batch in the debug log, not as delivered. The records are still removed and not retried. Save person processing in the crash context as the process_person_profile option instead of the legacy $process_person_profile property, and pass it when the crash and memory exception reporters send the $exception. Co-authored-by: Claude Opus 5.5 --- ...gErrorTrackingAutoCaptureIntegration.swift | 1 + .../PostHogMemoryExceptionReporter.swift | 3 ++ PostHog/PostHogApi.swift | 7 +-- PostHog/PostHogSDK.swift | 17 +++----- PostHogTests/PostHogCaptureV1Test.swift | 43 ++++++++++++------- .../PostHogMemoryExceptionReporterTest.swift | 2 + 6 files changed, 44 insertions(+), 29 deletions(-) diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift index 0f8034cb5..b272128b2 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -286,6 +286,7 @@ import Foundation distinctId: crashDistinctId, properties: finalProperties, timestamp: crashTimestamp, + options: savedContext["options"] as? [String: Any], skipBuildProperties: true ) diff --git a/PostHog/ErrorTracking/PostHogMemoryExceptionReporter.swift b/PostHog/ErrorTracking/PostHogMemoryExceptionReporter.swift index e907bf411..f1a7434dc 100644 --- a/PostHog/ErrorTracking/PostHogMemoryExceptionReporter.swift +++ b/PostHog/ErrorTracking/PostHogMemoryExceptionReporter.swift @@ -52,10 +52,12 @@ import Foundation ) var distinctId: String? + var options: [String: Any]? if let pid = report.environment.pid, let saved = contextStore.takeContext(pid: pid, notAfter: report.timeRange.end) { distinctId = saved["distinct_id"] as? String + options = saved["options"] as? [String: Any] let eventProperties = saved["event_properties"] as? [String: Any] ?? [:] properties = eventProperties.merging(properties) { _, new in new } if let steps = saved[PostHogExceptionStepFields.stepsKey] as? [[String: Any]], !steps.isEmpty { @@ -76,6 +78,7 @@ import Foundation distinctId: distinctId, properties: properties, timestamp: report.timeRange.end, + options: options, skipBuildProperties: true ) hedgeLog("Memory exception report processed") diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 99555006e..f196c0da9 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -308,11 +308,12 @@ class PostHogApi { if let statusCode, 200 ... 299 ~= statusCode { // 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. + // after the status arrived, fails the whole batch. It isn't retried: + // the server may have 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 readable per-event results, treating \(uuids.count) events as delivered.") + dropped = uuids.count + hedgeLog("Capture returned \(statusCode) without readable per-event results, dropping \(dropped) events.") } // Events missing from the results count as delivered. A repeated UUID takes its // first copy's result; capture counts the later copies it dropped in `duplicates_dropped`. diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index c5b16e444..38f6627b9 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -787,14 +787,6 @@ let maxRetryDelay = 30.0 props["$is_identified"] = isIdentified } - // Person processing is sent as the `process_person_profile` option, only when it's off - // (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 - } - if config.disableGeoIp { props["$geoip_disable"] = true } @@ -1866,7 +1858,8 @@ let maxRetryDelay = 30.0 finalProperties[PostHogExceptionStepFields.stepsKey] = steps } - // Snapshots and pre-built crash events don't carry the SDK's person-processing option. + // Snapshots don't carry the SDK's person-processing option; pre-built crash events carry the + // one saved with their crash context. let eventOptions = skipBuildProperties || isSnapshotEvent ? sanitizeDictionary(options) : personProcessingOptions(sanitizeDictionary(options), properties: finalProperties) @@ -3871,10 +3864,14 @@ let maxRetryDelay = 30.0 // Build crash context with identity info + event properties // This structure allows crash reporting to reconstruct events with crash-time data - let context: [String: Any] = [ + var context: [String: Any] = [ "distinct_id": distinctId, "event_properties": eventProperties, ] + // Person processing is an option, not a property (see `personProcessingOptions`). + if let options = personProcessingOptions(nil, properties: eventProperties) { + context["options"] = options + } onEventContextChanged.invoke(context) } diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 528faea79..4b706fcad 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -124,16 +124,26 @@ final class PostHogCaptureV1Test { #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) - } + @Test("drops the whole batch on a 200 without a results map, without retrying it") + func malformedSuccessDropsBatch() throws { + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let uuids = [UUID().postHogUuidString, UUID().postHogUuidString] + let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") - let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + var result: PostHogUploadInfo? + let output = try captureStdout { + result = makeApi().captureV1Result( + PostHogUploadInfo(statusCode: 200, error: nil), path: PostHogApi.captureV1AnalyticsPath, + data: Data("not json".utf8), uuids: uuids, identity: identity + ) + } - #expect(result.statusCode == 200) - #expect(result.retryRecordIds?.isEmpty ?? true) + #expect(output.contains("Capture: 2 dropped, 0 duplicates dropped, 0 to retry.")) + #expect(!output.contains("not json")) + #expect(result?.statusCode == 200) + #expect(result?.retryRecordIds?.isEmpty ?? true) } @Test("logs each dropped event's name, UUID and reason, without its properties") @@ -209,8 +219,8 @@ final class PostHogCaptureV1Test { #expect(output.contains("Capture: 0 dropped, 2 duplicates dropped, 0 to retry.")) } - @Test("treats a 2xx whose transfer then failed as delivered, so its ok events aren't stored twice") - func failedTransferAfterSuccessIsDelivered() { + @Test("doesn't retry a 2xx whose transfer then failed, so its ok events aren't stored twice") + func failedTransferAfterSuccessIsNotRetried() { 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)) @@ -631,16 +641,17 @@ final class PostHogCaptureV1OptionsTest { #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)] { + @Test("the crash context saves process_person_profile false as an option only when person processing is off") + func crashContextSavesPersonlessOption() { + for (personProfiles, expected) in [(PostHogPersonProfiles.never, ["process_person_profile": false] as [String: Bool]?), (.always, nil)] { let sut = makeSDK(personProfiles: personProfiles) - var eventProperties: [String: Any]? - let token = sut.onEventContextChanged.subscribe { eventProperties = $0["event_properties"] as? [String: Any] } + var context: [String: Any]? + let token = sut.onEventContextChanged.subscribe { context = $0 } sut.notifyContextDidChange() withExtendedLifetime(token) {} sut.close() - #expect(eventProperties?["$process_person_profile"] as? Bool == expected) + #expect(context?["options"] as? [String: Bool] == expected) + #expect((context?["event_properties"] as? [String: Any])?["$process_person_profile"] == nil) } } diff --git a/PostHogTests/PostHogMemoryExceptionReporterTest.swift b/PostHogTests/PostHogMemoryExceptionReporterTest.swift index 140291186..c462e4705 100644 --- a/PostHogTests/PostHogMemoryExceptionReporterTest.swift +++ b/PostHogTests/PostHogMemoryExceptionReporterTest.swift @@ -98,6 +98,7 @@ import Testing store.write(try JSONSerialization.data(withJSONObject: [ "distinct_id": "killed-user", "event_properties": ["$session_id": "killed-session", "$app_version": "9.9"], + "options": ["process_person_profile": false], PostHogExceptionStepFields.stepsKey: [[PostHogExceptionStepFields.message: "opened gallery"]], ])) @@ -112,6 +113,7 @@ import Testing #expect(event.distinctId == "killed-user") #expect(event.properties["$session_id"] as? String == "killed-session") #expect(event.properties["$exception_level"] as? String == "fatal") + #expect(event.options["process_person_profile"] as? Bool == false) let steps = event.properties[PostHogExceptionStepFields.stepsKey] as? [[String: Any]] #expect(steps?.first?[PostHogExceptionStepFields.message] as? String == "opened gallery") From bda70868b0e8f5c5206efa576d147a8e84081228 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:26:45 -0400 Subject: [PATCH 5/9] fix: drop empty names on every path, ignore whitespace identify, and log terminal batch losses - Check for an empty event name in queueEvent, so screen, identify, alias, group and autocapture events that beforeSend renames to "" are dropped like capture's. - identify ignores a whitespace-only distinct ID and no longer logs it. - The capture v1 aggregate debug line names the endpoint and status, and counts the batch on a terminal status (400, 401, 402, 415, or a single-event 413). Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-capture-v1.md | 3 +- PostHog/PostHogApi.swift | 6 +++- PostHog/PostHogSDK.swift | 11 ++++++-- PostHogTests/PostHogCaptureV1Test.swift | 37 +++++++++++++++++++++---- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 70f3a47a5..2b27cc7f7 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -19,7 +19,8 @@ Send events through capture V1 and add `captureAi(...)` for AI events. See the [ - **Breaking:** rename `maxRetries` to `pushNotificationSubscriptionMaxRetries`, since it only limits push-notification subscription retries. Event, session replay and log retries still have no limit. - Double the wait after a failed send from 1 second up to 30 seconds, with up to 25% random jitter, instead of growing it by 1 second per failure. This applies to events, session replay and logs, and queued records are still kept until they're delivered. - Cap analytics batches at 5 MiB per request; an event larger than that is sent on its own. -- Drop an event with an empty name before sending it, including one `beforeSend` renames to `""`. +- Drop an event with an empty name before sending it, including one `beforeSend` renames to `""` from any method, such as `screen`, `alias` or `group`. +- Ignore `identify` with a whitespace-only distinct ID, as for an empty one. **Added** diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index f196c0da9..4f79c8b82 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -338,10 +338,14 @@ class PostHogApi { } else if statusCode.map(isCaptureV1RetriableStatusCode) ?? true { // No response or a retriable status: the whole batch is resent. retryUuids = Set(uuids) + } else if statusCode != 413 || uuids.count == 1 { + // A terminal status loses the whole batch. The queue halves and resends a multi-event 413. + dropped = uuids.count } if dropped > 0 || duplicatesDropped > 0 || !retryUuids.isEmpty { - hedgeLog("Capture: \(dropped) dropped, \(duplicatesDropped) duplicates dropped, \(retryUuids.count) to retry.") + let status = statusCode.map { " (status \($0))" } ?? "" + hedgeLog("Capture \(path)\(status): \(dropped) dropped, \(duplicatesDropped) duplicates dropped, \(retryUuids.count) to retry.") } captureV1Lock.withLock { diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 38f6627b9..0a5f05c30 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1063,8 +1063,8 @@ let maxRetryDelay = 30.0 return } - if distinctId.isEmpty { - hedgeLog("identify call not allowed, distinctId is invalid: \(distinctId)") + if Self.isBlank(distinctId) { + hedgeLog("identify call not allowed, distinctId is empty or whitespace-only.") return } @@ -2271,6 +2271,13 @@ let maxRetryDelay = 30.0 /// Returns whether the event was stored in `queue`. @discardableResult private func queueEvent(_ event: PostHogEvent, queue: PostHogQueue, deduplicatePersonProperties: Bool = false) -> Bool { + // PostHog drops an event with an empty name. Checked here so every path is covered after + // beforeSend, which can rename the event. + if event.event.isEmpty { + warnEmptyEventNameOnce() + releaseClaimIfCarried(event) + return false + } let storedInQueue: Bool let userProperties = event.properties["$set"] as? [String: Any] let userPropertiesSetOnce = event.properties["$set_once"] as? [String: Any] diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 4b706fcad..385198e05 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -140,12 +140,33 @@ final class PostHogCaptureV1Test { ) } - #expect(output.contains("Capture: 2 dropped, 0 duplicates dropped, 0 to retry.")) + #expect(output.contains("Capture \(PostHogApi.captureV1AnalyticsPath) (status 200): 2 dropped, 0 duplicates dropped, 0 to retry.")) #expect(!output.contains("not json")) #expect(result?.statusCode == 200) #expect(result?.retryRecordIds?.isEmpty ?? true) } + @Test("counts a terminal status as the whole batch dropped, except a multi-event 413", arguments: [ + (400, 2, 2), (401, 2, 2), (402, 2, 2), (415, 2, 2), (413, 1, 1), (413, 2, 0), + ]) + func terminalStatusCounts(status: Int, count: Int, dropped: Int) throws { + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let uuids = (0 ..< count).map { _ in UUID().postHogUuidString } + let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") + + let output = try captureStdout { + _ = makeApi().captureV1Result( + PostHogUploadInfo(statusCode: status, error: nil), path: PostHogApi.captureV1AiPath, + data: nil, uuids: uuids, identity: identity + ) + } + + let line = "Capture \(PostHogApi.captureV1AiPath) (status \(status)): \(dropped) dropped" + #expect(output.contains(line) == (dropped > 0)) + } + @Test("logs each dropped event's name, UUID and reason, without its properties") func logsDropReasons() throws { let wasLogging = hedgeLogEnabled @@ -193,7 +214,7 @@ final class PostHogCaptureV1Test { ) } - #expect(output.contains("Capture: 0 dropped, 2 duplicates dropped, 0 to retry.")) + #expect(output.contains("Capture \(PostHogApi.captureV1AnalyticsPath) (status 200): 0 dropped, 2 duplicates dropped, 0 to retry.")) #expect(result?.retryRecordIds?.isEmpty ?? true) } @@ -216,7 +237,7 @@ final class PostHogCaptureV1Test { ) } - #expect(output.contains("Capture: 0 dropped, 2 duplicates dropped, 0 to retry.")) + #expect(output.contains("Capture \(PostHogApi.captureV1AnalyticsPath) (status 200): 0 dropped, 2 duplicates dropped, 0 to retry.")) } @Test("doesn't retry a 2xx whose transfer then failed, so its ok events aren't stored twice") @@ -994,10 +1015,10 @@ final class PostHogCaptureAiTest { #expect(sut.captureAi("$ai_generation") == nil) } - @Test("drops an event with an empty name, including one beforeSend renames to empty") + @Test("drops an event with an empty name, including one beforeSend renames to empty on any path") func dropsEmptyEventName() async throws { let sut = makeSDK { event in - if event.event == "renamed" { event.event = "" } + if ["renamed", "$screen", "$create_alias", "$groupidentify"].contains(event.event) { event.event = "" } return event } defer { sut.close() } @@ -1007,6 +1028,12 @@ final class PostHogCaptureAiTest { server.reset(batchCount: 1) sut.capture("") sut.capture("renamed") + sut.screen("Home") + sut.alias("alias") + sut.group(type: "company", key: "acme", groupProperties: ["name": "Acme"]) + let distinctId = sut.getDistinctId() + sut.identify(" ") + #expect(sut.getDistinctId() == distinctId) sut.capture("kept") let events = try await getServerEvents(server) From 093ec6e80d2764a200629b9dec6d0c70b55d7b41 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 12:42:23 -0400 Subject: [PATCH 6/9] fix: log capture V1 drop reasons in the batch line instead of per event The v1 rules allow one aggregate line per batch on a client SDK, carrying the count, the cause and the endpoint, in place of per-event reporting. Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-capture-v1.md | 1 - PostHog/PostHogApi.swift | 19 ++++++++----------- PostHogTests/PostHogCaptureV1Test.swift | 11 ++++++----- docs/migration-3.x-to-4.0.md | 2 +- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index 2b27cc7f7..05a67283c 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -27,4 +27,3 @@ Send events through capture V1 and add `captureAi(...)` for AI events. See the [ - Add per-event capture options with `capture(..., timestamp:options:)` and `PostHogEvent.options`, which `beforeSend` can read and change. `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take the same `options` argument. - Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. It returns the event UUID, and its requests time out after 60 seconds without network activity. - Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults are gzip, 100 queued events, 60 seconds and 8 MiB plus 64 KiB per serialized event; a larger `captureAiMaxEventBytes` is clamped to that ceiling with a debug warning. -- Log the name, UUID and reason of each event PostHog drops when `debug` is on. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 4f79c8b82..2194d795d 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -216,7 +216,6 @@ class PostHogApi { } let uuids = events.map(\.uuid.postHogUuidString) - let eventNames = Dictionary(events.map { ($0.uuid.postHogUuidString, $0.event) }, uniquingKeysWith: { first, _ in first }) let identity = nextCaptureV1Identity(path: path, uuids: Set(uuids)) let toSend: [String: Any] = [ @@ -250,7 +249,7 @@ class PostHogApi { if info.statusCode == 404 { self.warnCaptureNotFoundOnce(url: url, path: path) } - completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity, eventNames: eventNames)) + completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity)) } }.resume() } @@ -291,18 +290,17 @@ class PostHogApi { } /// Reads the per-event results of a 2xx and records which events, if any, - /// the next request retries under the same identity. `eventNames` maps - /// UUIDs to event names for the per-event drop log. + /// the next request retries under the same identity. func captureV1Result( _ info: PostHogUploadInfo, path: String, data: Data?, uuids: [String], - identity: CaptureV1RequestIdentity, - eventNames: [String: String] = [:] + identity: CaptureV1RequestIdentity ) -> PostHogUploadInfo { var retryUuids: Set = [] var dropped = 0 + var droppedByDetails: [String: Int] = [:] var duplicatesDropped = 0 let statusCode = info.statusCode @@ -327,10 +325,7 @@ class PostHogApi { retryUuids.insert(uuid) case "drop": dropped += 1 - // Debug only. Name, UUID, and the server's reason; never content or properties. - let name = eventNames[uuid].map { "'\($0)'" } ?? "event" - let details = entry["details"] as? String ?? "no details" - hedgeLog("Capture dropped \(name) (\(uuid)): \(details).") + droppedByDetails[entry["details"] as? String ?? "no details", default: 0] += 1 default: break } @@ -345,7 +340,9 @@ class PostHogApi { if dropped > 0 || duplicatesDropped > 0 || !retryUuids.isEmpty { let status = statusCode.map { " (status \($0))" } ?? "" - hedgeLog("Capture \(path)\(status): \(dropped) dropped, \(duplicatesDropped) duplicates dropped, \(retryUuids.count) to retry.") + // One line per batch: counts and the server's reasons, never event names, UUIDs or properties. + let reasons = droppedByDetails.isEmpty ? "" : " (" + droppedByDetails.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)" }.joined(separator: ", ") + ")" + hedgeLog("Capture \(path)\(status): \(dropped) dropped\(reasons), \(duplicatesDropped) duplicates dropped, \(retryUuids.count) to retry.") } captureV1Lock.withLock { diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 385198e05..09af61ad7 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -167,7 +167,7 @@ final class PostHogCaptureV1Test { #expect(output.contains(line) == (dropped > 0)) } - @Test("logs each dropped event's name, UUID and reason, without its properties") + @Test("logs one line per batch with the drop reasons, without event names or UUIDs") func logsDropReasons() throws { let wasLogging = hedgeLogEnabled hedgeLogEnabled = true @@ -183,13 +183,14 @@ final class PostHogCaptureV1Test { let output = try captureStdout { _ = makeApi().captureV1Result( PostHogUploadInfo(statusCode: 200, error: nil), path: PostHogApi.captureV1AiPath, data: body, - uuids: uuids, identity: identity, eventNames: [uuids[0]: "$ai_generation", uuids[1]: "signup"] + uuids: uuids, identity: identity ) } - #expect(output.contains("Capture dropped '$ai_generation' (\(uuids[0])): ai_event_too_big.")) - #expect(output.contains("Capture dropped 'signup' (\(uuids[1])): misrouted_event.")) - #expect(!output.contains(uuids[2])) + #expect(output.contains("Capture \(PostHogApi.captureV1AiPath) (status 200): 2 dropped (ai_event_too_big: 1, misrouted_event: 1), 0 duplicates dropped, 0 to retry.")) + for uuid in uuids { + #expect(!output.contains(uuid)) + } } @Test("counts duplicate copies capture dropped once per UUID") diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 1e30e2272..ddfa94661 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -72,7 +72,7 @@ The SDK drops an event with an empty event name before sending it, for example ` `captureAi` then returns `nil`, and with `debug` on the SDK logs the first one. 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. +With `debug` on, the SDK logs how many events were dropped and why. - 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. From 1474cacbc5525d1c202a109340448b8e9d1fa80c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 12:42:27 -0400 Subject: [PATCH 7/9] fix: merge caller-supplied $groups key by key when a value isn't a string A non-string value failed the [String: String] cast, so the caller's map replaced the registered and per-call groups entirely. Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogSDK.swift | 4 ++-- PostHogTests/PostHogSDKTest.swift | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 0a5f05c30..4bf2560aa 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -852,8 +852,8 @@ let maxRetryDelay = 30.0 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 }.merging(groups ?? [:]) { _, new in new } + if let callerGroups = callerProps["$groups"] as? [String: Any], let currentGroups = props["$groups"] as? [String: Any] { + callerProps["$groups"] = currentGroups.merging(callerGroups) { _, new in new }.merging((groups ?? [:]) as [String: Any]) { _, new in new } } // A typed per-call value wins key by key over the same map in the caller's properties. for (key, typed) in [("$set", userProperties), ("$set_once", userPropertiesSetOnce)] where appendSharedProps { diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index e812e5195..b40e3fbb6 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -1235,6 +1235,27 @@ final class PostHogSDKTests { sut.close() } + @Test("caller-supplied $groups with a non-string value still merge key by key") + func callerGroupsWithNonStringValueMerge() throws { + let sut = getSut(flushAt: 2) + + sut.group(type: "project", key: "stored") + sut.capture( + "event", + properties: ["$groups": ["company": "caller", "team": 42]], + groups: ["company": "typed"] + ) + + let event = try #require(getBatchedEvents(server).first { $0.event == "event" }) + let groups = try #require(event.properties["$groups"] as? [String: Any]) + #expect(groups["project"] as? String == "stored") + #expect(groups["company"] as? String == "typed") + #expect(groups["team"] as? Int == 42) + + sut.reset() + sut.close() + } + @Test("typed per-call values win key by key over the same maps in properties") func typedPerCallValuesWinKeyByKeyOverCallerMaps() throws { let sut = getSut() From a3abf17b67d900d17270c195e9b341554c0b86c0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 15:07:00 -0400 Subject: [PATCH 8/9] fix: treat a nil boxed in Any as unset for person processing and option hoisting Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogApi.swift | 2 +- PostHog/PostHogSDK.swift | 4 ++-- PostHog/Utils/DictUtils.swift | 8 ++++++++ PostHogTests/PostHogCaptureV1Test.swift | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 2194d795d..8a370bed7 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -384,7 +384,7 @@ class PostHogApi { 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 { + if isNullValue(options[option]) { options[option] = legacy } } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 4bf2560aa..93d3666d2 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -626,11 +626,11 @@ let maxRetryDelay = 30.0 /// 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) { + if !isNullValue(properties["$process_person_profile"]) { return options } var options = options ?? [:] - if options["process_person_profile"] == nil || options["process_person_profile"] is NSNull { + if isNullValue(options["process_person_profile"]) { options["process_person_profile"] = false } return options diff --git a/PostHog/Utils/DictUtils.swift b/PostHog/Utils/DictUtils.swift index 66a893632..e11ab2b56 100644 --- a/PostHog/Utils/DictUtils.swift +++ b/PostHog/Utils/DictUtils.swift @@ -103,6 +103,14 @@ private func sanitizeArray(_ array: [Any]) -> [Any] { return sanitized } +/// Whether a value is missing or serializes as JSON `null`: `nil`, `NSNull`, or a `nil` optional boxed in `Any`. +func isNullValue(_ value: Any?) -> Bool { + guard let value else { return true } + if value is NSNull { return true } + let mirror = Mirror(reflecting: value) + return mirror.displayStyle == .optional && mirror.children.isEmpty +} + private func isValidObject(_ object: Any) -> Bool { if object is String || object is Bool { return true diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 09af61ad7..85dc8ef8d 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -643,6 +643,25 @@ final class PostHogCaptureV1OptionsTest { #expect(sent["options"] as? [String: Bool] == ["process_person_profile": true, "cookieless_mode": true]) } + @Test("a nil boxed in Any counts as unset for process_person_profile") + func boxedNilProcessPersonProfile() async throws { + let sut = makeSDK(personProfiles: .never) + defer { sut.close() } + let unset: Bool? = nil + + var sent = try await captureAndSend(sut) { $0.capture("test", options: ["process_person_profile": unset as Any]) } + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": false]) + + sent = try await captureAndSend(sut) { $0.capture("test", properties: ["$process_person_profile": unset as Any]) } + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": false]) + + // The legacy property still fills an option that is only a boxed nil. + sent = try await captureAndSend(sut) { + $0.capture("test", properties: ["$process_person_profile": true], options: ["process_person_profile": unset as Any]) + } + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": 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) From 17e283ecccb3fbc086ae8d4b46f505b409b683f0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 15:14:16 -0400 Subject: [PATCH 9/9] fix: keep person processing and GeoIP settings on a crash event with no saved context Co-Authored-By: Claude Opus 5.5 --- ...stHogErrorTrackingAutoCaptureIntegration.swift | 5 +++++ PostHog/PostHogSDK.swift | 13 +++++++++++++ PostHogTests/PostHogCaptureV1Test.swift | 15 +++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift index b272128b2..ad2f6c0d8 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -256,6 +256,11 @@ import Foundation crashSteps = decoded[PostHogExceptionStepFields.stepsKey] as? [[String: Any]] ?? [] } + // Steps alone don't count as a saved context: customData always carries the steps key. + if savedContext["event_properties"] == nil { + savedContext.merge(postHog.crashContextFallback()) { _, fallback in fallback } + } + // Extract identity and event properties from saved context let crashDistinctId = savedContext["distinct_id"] as? String let crashEventProperties = savedContext["event_properties"] as? [String: Any] ?? [:] diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 93d3666d2..141be7080 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -3835,6 +3835,19 @@ let maxRetryDelay = 30.0 #endif } + /// What a crash event needs from the current state when its report has no saved context, in the + /// shape of the saved context: its person-processing option and `$geoip_disable`. + func crashContextFallback() -> [String: Any] { + var context: [String: Any] = [:] + if config.disableGeoIp { + context["event_properties"] = ["$geoip_disable": true] + } + if let options = personProcessingOptions(nil, properties: [:]) { + context["options"] = options + } + return context + } + /// Point-in-time counters: the crash path replays the context snapshot verbatim on the next /// launch, so these would be stale by definition. Status/config keys stay because the replay /// integration re-notifies on every recording transition. diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 85dc8ef8d..b7d0331de 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -696,6 +696,21 @@ final class PostHogCaptureV1OptionsTest { } } + @Test("the crash context fallback carries the current person-processing option and $geoip_disable") + func crashContextFallback() { + let personless = makeSDK(personProfiles: .never) + personless.config.disableGeoIp = true + let fallback = personless.crashContextFallback() + personless.close() + #expect(fallback["options"] as? [String: Bool] == ["process_person_profile": false]) + #expect(fallback["event_properties"] as? [String: Bool] == ["$geoip_disable": true]) + + let processed = makeSDK(personProfiles: .always) + processed.config.disableGeoIp = false + defer { processed.close() } + #expect(processed.crashContextFallback().isEmpty) + } + @Test("beforeSend has the final say over options") func beforeSendEditsOptions() async throws { let sut = makeSDK(personProfiles: .never) { event in