From 77b0a21acf8e534301a2710d369d6fefab46aee0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 20:02:42 -0400 Subject: [PATCH] fix: drop a 2xx batch without readable results and send crash-context person processing as an option 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 69a1cb5b6..218c01b46 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -307,11 +307,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")