diff --git a/.changeset/v4-major-release.md b/.changeset/v4-major-release.md index c3e59f138..b46696515 100644 --- a/.changeset/v4-major-release.md +++ b/.changeset/v4-major-release.md @@ -32,12 +32,14 @@ PostHog iOS 4.0: see the [migration guide](https://github.com/PostHog/posthog-io - **Breaking:** pause sending for at most 30 seconds on `Retry-After`, including session replay and logs - **Breaking:** send `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and `$product_tour_id` as capture options, so PostHog drops an event whose option value it can't read - **Breaking:** let registered super properties override SDK properties such as `$os`, and let 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` passed in the same call's properties - **Breaking:** rename `maxRetries` to `pushNotificationSubscriptionMaxRetries` - Add per-event capture options with `capture(..., timestamp:options:)` and `PostHogEvent.options`, also accepted by `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` - Add `captureAi(...)` to send AI events such as `$ai_generation` to `/i/v1/ai/events`, tuned with `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` - Change the wait after a failed send to double from 1 second up to 30 seconds with jitter, for events, session replay and logs - Cap analytics batches at 5 MiB per request and send a larger event on its own -- Log the name, UUID and reason of each event PostHog drops when `debug` is on +- Drop an event with an empty name before sending it, including one `beforeSend` renames to `""` +- Ignore `identify` with a whitespace-only distinct ID - Add `SessionReplay`, `CrashReporting` and `Surveys` Swift package traits (on by default) that remove the feature and its public API when turned off - Change network reachability detection to `NWPathMonitor`, so a path needing user action counts as reachable, queues pause while cellular data is off for the app, and early events may omit `$network_wifi` and `$network_cellular` - Change the replay screen name to the visible screen, reporting the view name for SwiftUI screens and no longer using a plain `UIViewController`'s `title` diff --git a/MIGRATION.md b/MIGRATION.md index 2d71aaffd..df2e31eb0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -173,10 +173,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. +With `debug` on, the SDK logs how many events were dropped and why. -- 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. @@ -207,6 +210,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. diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift index 0f8034cb5..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] ?? [:] @@ -286,6 +291,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 2e53fdda4..8a370bed7 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,40 +290,42 @@ 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 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. - 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 } + // 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) 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 } @@ -332,10 +333,16 @@ 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 || !retryUuids.isEmpty { - hedgeLog("Capture: \(dropped) dropped, \(retryUuids.count) to retry.") + if dropped > 0 || duplicatesDropped > 0 || !retryUuids.isEmpty { + let status = statusCode.map { " (status \($0))" } ?? "" + // 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 { @@ -377,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 99e3aa264..84b62db3d 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() @@ -631,11 +633,11 @@ let maxRetryDelay = 30.0 /// counts a person-processing change a wrapper build defers to its send. private func personProcessingOptions(_ options: [String: Any]?, properties: [String: Any], personProcessingPending: Bool = false) -> [String: Any]? { guard !hasPersonProcessing(), !(personProcessingPending && config.personProfiles != .never) else { return options } - if let legacy = properties["$process_person_profile"], !(legacy is NSNull) { + 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 @@ -810,14 +812,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 } @@ -874,8 +868,14 @@ 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 } + 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 { + if let typed, let callerMap = callerProps[key] as? [String: Any] { + callerProps[key] = callerMap.merging(typed) { _, new in new } + } } props.merge(callerProps) { _, new in new } @@ -1080,8 +1080,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 } @@ -1579,7 +1579,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, @@ -1910,7 +1910,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, personProcessingPending: forWrapper && hasPersonData) @@ -1944,6 +1945,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. let isSnapshotEvent = !aiLane && posthogEvent.event == "$snapshot" @@ -2483,6 +2492,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() @@ -2554,6 +2573,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] @@ -4112,6 +4138,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. @@ -4148,10 +4187,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/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 f86adb4ac..b7d0331de 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -124,19 +124,50 @@ 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") + + 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 + ) } - let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + #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) + } - #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") + @Test("logs one line per batch with the drop reasons, without event names or UUIDs") func logsDropReasons() throws { let wasLogging = hedgeLogEnabled hedgeLogEnabled = true @@ -152,17 +183,66 @@ 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 \(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") + 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 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.captureV1AnalyticsPath) (status 200): 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() { + @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 \(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") + 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)) @@ -563,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) @@ -583,19 +682,35 @@ 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) } } + @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 @@ -935,6 +1050,31 @@ final class PostHogCaptureAiTest { #expect(sut.captureAi("$ai_generation") == nil) } + @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 ["renamed", "$screen", "$create_alias", "$groupidentify"].contains(event.event) { 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.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) + + #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/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") diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 3c7409279..b40e3fbb6 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -1235,6 +1235,56 @@ 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() + + 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)