diff --git a/.changeset/v4-major-release.md b/.changeset/v4-major-release.md index 3c53f83d4..c3e59f138 100644 --- a/.changeset/v4-major-release.md +++ b/.changeset/v4-major-release.md @@ -25,6 +25,19 @@ PostHog iOS 4.0: see the [migration guide](https://github.com/PostHog/posthog-io - **Breaking:** remove `PostHogDataMode.cellular` — use `.any` - **Breaking:** move `postHogSdkName`, `postHogVersion`, `PostHogConfig.snapshotEndpoint`, `PostHogSessionManager`, `PostHogStorageManager` and `PostHogConfig.storageManager` behind `@_spi(PostHogInternal)` — use `PostHogSDK` session and identity methods - **Breaking:** remove `dateToMillis(_:)`, `imageToBase64(_:_:)`, `toISO8601String(_:)`, `toISO8601Date(_:)`, `sanitizeDictionary(_:)`, `deleteSafely(_:)`, `postHogiOSSdkName`, `Gzip`, `GzipError`, `CompressionLevel`, `ReachabilityError`, `ReachabilityChangedNotification`, `UIColor.init(hex:)` and `UIColor.hexDescription(_:)` from the public API +- **Breaking:** send analytics events to capture V1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header, replacing any `Authorization` from `requestHeaders` — serve this path on reverse proxies and self-hosted instances +- **Breaking:** follow only 307 and 308 redirects to the same origin as `host`, at most 5 times, and drop events on any other redirect +- **Breaking:** drop an event whose distinct ID is over 200 UTF-8 bytes instead of shortening the ID +- **Breaking:** disable the SDK at setup when `projectToken` is a personal API key (`phx_...`) — use your project token (`phc_...`) +- **Breaking:** pause sending for at most 30 seconds on `Retry-After`, including session replay and logs +- **Breaking:** send `$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:** 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 - 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 b2cbf346e..2d71aaffd 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -4,9 +4,10 @@ This guide covers what you need to change when you upgrade an app from PostHog i 1. [Before you upgrade](#before-you-upgrade): toolchain, deployment targets and installation. 2. [Changes the compiler won't flag](#changes-the-compiler-wont-flag): behavior that changes without a build error. Read this even if your app builds cleanly. -3. [Compile errors and how to fix them](#compile-errors-and-how-to-fix-them): removed and renamed APIs. -4. [Package traits](#package-traits): leaving out session replay, crash reporting or surveys. -5. [Upgrade checklist](#upgrade-checklist). +3. [Capture v1](#capture-v1): the new analytics endpoint, event validation, property precedence, capture options, AI events and retries. +4. [Compile errors and how to fix them](#compile-errors-and-how-to-fix-them): removed and renamed APIs. +5. [Package traits](#package-traits): leaving out session replay, crash reporting or surveys. +6. [Upgrade checklist](#upgrade-checklist). For the full list of changes, see the [changelog](CHANGELOG.md). @@ -107,6 +108,184 @@ If an app updates from a 2.x SDK straight to 4.0, events still queued on disk by - Queues pause while cellular data is turned off for your app. - Events captured in the first 100 ms after `setup()` may leave out `$network_wifi` and `$network_cellular`. +## Capture v1 + +4.0 sends analytics events to capture v1 (`/i/v1/analytics/events`) instead of `/batch`. Most apps upgrade without code changes, and nothing here produces a compile error. Read the checklist first, then the sections that apply to you. + + + +### Do you need to change anything? + +You need to change something if your app does any of these: + +- sends events to a self-hosted PostHog or a reverse proxy that doesn't serve `/i/v1/analytics/events` +- authenticates its proxy with an `Authorization` header in `requestHeaders` +- sends events through a proxy that redirects capture requests, other than with a 307 or 308 to the same origin as `host` +- captures events with an empty event name, or with an event name or distinct ID over 200 bytes +- sets an empty distinct ID in `beforeSend` +- sets `projectToken` to a personal API key (`phx_...`) +- sets `$lib` or `$lib_version` and expects PostHog to store that value +- reads `$session_id` or `$window_id` from the raw request body, for example in a proxy +- registers a super property, or passes an event property, with the same key as one the SDK sets, such as `$os`, `$is_identified` or `$geoip_disable`, and expects the SDK's value to win +- sets `$cookieless_mode`, `$ignore_sent_at` or `$product_tour_id` to a value PostHog can't read, such as `"maybe"` +- relies on a `Retry-After` longer than 30 seconds + +### Endpoints + +| Data | 3.x | 4.0 | +| --- | --- | --- | +| Analytics events | `/batch` | `/i/v1/analytics/events` | +| Session replay | `/s/` | `/s/`, unchanged | +| AI events (`captureAi`) | – | `/i/v1/ai/events` (see [AI events](#ai-events)) | +| Logs | `/i/v1/logs` | `/i/v1/logs`, unchanged | +| Feature flags | `/flags` | `/flags`, unchanged | + +Capture v1 requests send the project token in an `Authorization: Bearer ` header instead of an `api_key` body field. +This header replaces any `Authorization` header you set in `PostHogConfig.requestHeaders`, so authenticate your proxy with a different header, such as `X-Proxy-Token`. +Other requests still send your `Authorization` header. + +A personal API key (`phx_...`) in `projectToken` now disables the SDK at setup, as an empty token does, and logs why when `debug` is on. +In 3.x, the SDK started and PostHog rejected every event with a 401. Use your project token (`phc_...`). + +#### Hosts without capture v1 + +PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves both capture v1 endpoints. +If you self-host PostHog, check that it serves `/i/v1/analytics/events` and `/i/v1/ai/events` before you upgrade. + +If you send events through your own reverse proxy, update it before you upgrade: + +- Forward `/i/v1/analytics/events` and `/i/v1/ai/events` to PostHog. +- Pass `Authorization` (the project token, sent as `Bearer `), every `PostHog-*` header, `Content-Type`, `Content-Encoding` and `User-Agent` through unchanged. PostHog answers `401` without `Authorization` and `400` without the others, and the batch is dropped. +- If your proxy strips other credentials, keep `Authorization` values that start with `Bearer phc_`, or the exact `Bearer ` if your project token doesn't start with `phc_`. +- Accept request bodies up to 20 MB. AI requests are often larger than 1 MB, the default limit in nginx and ingress-nginx. On a `413` from the proxy, the SDK splits the batch and drops any event that is still too large on its own. + +PostHog's managed reverse proxy already does this. See the [proxy guides](https://posthog.com/docs/advanced/proxy). + +The SDK doesn't fall back to `/batch`. A 404 is retried, so events stay queued until the route is served. Other error statuses that aren't retried, such as 403 or 405, drop the events. + +#### Redirects + +The SDK follows a 307 or 308 redirect only to the origin of `host` (same scheme, host and port), at most 5 times, and resends the same body and headers. +Any other redirect is not followed and the events are dropped. +In 3.x, `URLSession` followed redirects to any host. +If a proxy redirects capture requests to another host, point `host` at the final host. + +### Event validation + +Capture v1 checks each event on its own and drops only the invalid ones, so the rest of the batch is delivered. +The SDK doesn't check these before sending. PostHog drops the event, and the SDK removes it from the queue. +With `debug` on, the SDK logs how many events were dropped. + +- An event with an empty event name is dropped, for example `capture("")`. +- An event with an empty or whitespace-only distinct ID is dropped, for example one `beforeSend` sets to `""`. + An empty or whitespace-only `distinctId` argument isn't dropped: like `nil`, it uses the current distinct ID. +- An event whose name is over 200 UTF-8 bytes is dropped. Legacy capture had no limit on event name length. +- An event whose distinct ID is over 200 UTF-8 bytes is dropped. Legacy capture shortened the ID to 200 characters and kept the event. + +A `NaN`, infinite or other non-JSON property value is removed before sending, and the event is still sent. + +In 3.x, an empty event name or distinct ID made legacy capture reject the whole batch it was in. + +The distinct ID can come from `identify(_:)`, the `distinctId` argument of `capture` or `beforeSend`. +If you identify users with a long ID, such as a JWT or a long email address, shorten or hash it before you upgrade. Otherwise every event for that user is dropped. + +### SDK identity + +PostHog sets `$lib` and `$lib_version` from the `PostHog-Sdk-Info` request header, for example `posthog-ios/4.0.0`. +The SDK removes `$lib` and `$lib_version` from the properties it sends to capture v1. +A value you set in properties, in a super property or in `beforeSend` doesn't reach PostHog. +The SDK removes them when it sends the event, so `beforeSend` still sees them. + +### Property precedence + +An event's properties are merged in this order, and later values win: + +1. properties the SDK sets itself, such as `$os`, `$app_version`, `$screen_name`, `$is_identified`, `$geoip_disable` and the session replay debug properties +2. registered super properties (`register`) +3. the properties you pass to `capture` or another event-sending method +4. `beforeSend` + +Per-call `userProperties` and `userPropertiesSetOnce` are applied between steps 2 and 3, so they win over a registered `$set` or `$set_once`, as in 3.x. +Groups set with `group()` and per-call `groups` are applied there too, and replace a registered `$groups`. + +In 3.x, the SDK's own values won over super properties, and `$is_identified`, `$process_person_profile`, `$geoip_disable` and the session replay debug properties also won over the properties you pass. +In 4.0 a value you register or pass wins, for example `capture("signed_up", properties: ["$geoip_disable": false])` sends that event with GeoIP enabled even when `disableGeoIp` is on. + +### Session and window IDs + +Capture v1 sends `$session_id` and `$window_id` as top-level `session_id` and `window_id` fields, not as properties. +The SDK moves them out of `properties` for you. +A string value, including an empty string, is sent unchanged. +Any other value is removed and not sent, and the SDK logs a warning that names the key and the value's JSON type, never the value. +A null value counts as unset and is removed without a warning. +In PostHog, the events still have `$session_id` and `$window_id`, so insights, filters and session replay work as before. +A registered `$session_id` now overrides the SDK's session ID (see [Property precedence](#property-precedence)), which can detach events from their session replay. + +### Event options + +Capture v1 reads processing settings from an `options` object sent next to `properties`. +`capture` takes a new `options` argument, and `beforeSend` can read and change `PostHogEvent.options`. +`screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException` take the same argument. +For `identify`, the options apply to the `$identify` or `$set` event the call sends. + +```swift +// 3.x +PostHogSDK.shared.capture("signed_up", properties: ["$cookieless_mode": true]) + +// 4.0 +PostHogSDK.shared.capture("signed_up", options: ["cookieless_mode": true]) +``` + +In Objective-C, use the selectors that end in `options:`, such as `captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`, `screenWithTitle:properties:options:`, `identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:`, `setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:`, `alias:options:`, `groupWithType:key:groupProperties:options:`, `captureExceptionWithError:properties:options:` and `captureExceptionWithNSException:properties:options:`. The selectors without `options:` still work. + +The legacy option properties still work. +When the event is sent, the SDK removes each one from `properties` and uses it for its option: + +| Legacy property | Option | +| --- | --- | +| `$process_person_profile` | `process_person_profile` | +| `$cookieless_mode` | `cookieless_mode` | +| `$ignore_sent_at` | `disable_skew_correction` | +| `$product_tour_id` | `product_tour_id` | + +- An option you pass to any of these methods or set in `beforeSend` wins over its legacy property. An option set to `NSNull()` counts as unset, so the legacy property fills it. +- When `personProfiles` turns person processing off, the SDK sets the `process_person_profile` option to `false`. Otherwise it sends neither, because PostHog processes person profiles by default. To change person processing for one event, pass the `process_person_profile` option or the `$process_person_profile` property. A registered `$process_person_profile` changes it for every event. +- The SDK sends option values as given. PostHog reads common forms of a value, such as `"false"` or `0` for a boolean, and drops an event whose option value it can't read. +- Other option keys are sent unchanged, and PostHog ignores keys it doesn't know. +- There are no global options. To set one for every event, register its legacy property, for example `register(["$cookieless_mode": true])`. +- Events still queued on disk by 3.x are sent to capture v1 after the update, with their legacy properties moved to options. + +### AI events + +4.0 adds `captureAi`, which sends AI events, such as `$ai_generation`, to PostHog's AI endpoint (`/i/v1/ai/events`) instead of the analytics endpoint. +It takes the same arguments as `capture`, and the events go through the same `beforeSend` hooks. + +```swift +PostHogSDK.shared.captureAi("$ai_generation", properties: ["$ai_model": "gpt-5", "$ai_trace_id": traceId]) +``` + +In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`. + +- An AI event's properties can be up to 8 MiB. The SDK drops a serialized event over 8 MiB + 64 KiB, and `captureAi` returns `nil`. Set `captureAiMaxEventBytes` to lower the limit; it measures the whole serialized event. +- PostHog drops an event sent with `captureAi` whose name doesn't start with `$ai_`. `capture` never sends events to the AI endpoint, even ones named `$ai_*`. +- AI requests time out after 60 seconds without network activity, or `captureAiTimeout`. Other requests still time out after 10 seconds. +- AI events are queued on disk separately from other events. Up to 100 events (`captureAiMaxQueueSize`) and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. +- AI requests are gzipped unless you set `captureAiCompression` to `.none`. `compression` doesn't apply. +- AI events use `flushAt`, `maxBatchSize` and `flushIntervalSeconds`, like other events. A request carries up to about 5 MiB, as for analytics events (see [Retries](#retries)). +- `flush()` and `close()` also send AI events, and AI events left on disk are sent after the next launch. +- The SDK sends AI events unchanged. It doesn't redact or truncate prompts, outputs or media, so use `beforeSend` to remove anything you don't want to send. + +### Retries + +- The SDK retries network errors and 404, 408, 429, 500, 502, 503 and 504 responses. Other statuses, such as a 402 when you're over your billing limit, drop the whole request. 3.x did not retry 404, and retried 3xx responses. +- When the response asks to retry only some events, the SDK keeps those events queued and removes the rest. +- A `Retry-After` header pauses sending for at most 30 seconds. This also applies to session replay and logs. In 3.x, analytics events and session replay had no cap, and logs allowed up to 5 minutes. +- After a failed send, the wait before the next one doubles from 1 second up to 30 seconds, with up to 25% random jitter. In 3.x it grew by 1 second per failure. This applies to events, session replay and logs. +- Event, session replay and log retries have no limit, as in 3.x: queued records stay until they're delivered or a full queue evicts them (`maxQueueSize`, or `logs.maxBufferSize` for logs). +- `maxRetries` is renamed to `pushNotificationSubscriptionMaxRetries`, with no deprecated alias. Since 3.73.0 it has only limited push-notification subscription retries. +- A request carries at most `maxBatchSize` events and about 5 MiB, and the SDK sends as soon as about 5 MiB of events is queued, even before `flushAt` is reached. In 3.x, batches were limited by event count only. +- A 413 still splits the batch into smaller requests. + ## Compile errors and how to fix them Removed and renamed APIs fail to build, usually with a "has no member" or "cannot find in scope" error that doesn't name the replacement, so look them up here. Objective-C names are listed where they differ. @@ -121,6 +300,7 @@ Removed and renamed APIs fail to build, usually with a "has no member" or "canno | `config.remoteConfig` | Delete the assignment. Remote config is always loaded. | | `config.propertiesSanitizer`, `PostHogPropertiesSanitizer` | `config.setBeforeSend(_:)` (see below) | | `config.dataMode = .cellular`
ObjC: `PostHogDataModeCellular` | `.any`
ObjC: `PostHogDataModeAny`. `PostHogDataMode(rawValue: 1)` now returns `nil`. | +| `config.maxRetries` | `config.pushNotificationSubscriptionMaxRetries`. It only ever limited push-notification subscription retries. | | `config.storageManager`, `PostHogStorageManager` | `identify(_:)`, `getDistinctId()` and `reset()` on `PostHogSDK` | Replacing `propertiesSanitizer`: @@ -253,5 +433,6 @@ SwiftPM turns a trait on if any package in your dependency graph depends on post - [ ] If your app is SwiftUI-rooted and has replay on, expect recordings to start, and check quota and consent. - [ ] Update insights, cohorts and actions that use `version` or `build` on lifecycle events, or `wireframe` in `$sdk_debug_replay_capture_mode`. - [ ] Check code that reads feature flag payloads and could get `nil` for a malformed one. +- [ ] Work through the [capture v1 checklist](#do-you-need-to-change-anything), especially reverse proxies, self-hosted instances and long distinct IDs. - [ ] Fix the compile errors using the tables above, including `reloadFeatureFlags` closures. - [ ] If you use package traits, list every trait you want to keep. diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index dd26ca9c6..ac124cf87 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -106,6 +106,8 @@ 6A0C1F2E3B4D5A6B7C8D9E02 /* PostHogOptOutPersistenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C1F2E3B4D5A6B7C8D9E01 /* PostHogOptOutPersistenceTest.swift */; }; A1C081010000000000000001 /* PostHogReplayBatchBoundaryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */; }; 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */; }; + A1C0DE0000000000000000F2 /* PostHogCaptureV1Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */; }; + A1C0DE00000000000000FAB2 /* PostHogWrapperEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE00000000000000FAB1 /* PostHogWrapperEventTest.swift */; }; 690FF0EF2AEFF23D00A0B06B /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 690FF0EE2AEFF23D00A0B06B /* OHHTTPStubsSwift */; }; 690FF0F12AEFF24200A0B06B /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = 690FF0F02AEFF24200A0B06B /* OHHTTPStubs */; }; E1A0B0203A0D000100000001 /* CwlCatchException in Frameworks */ = {isa = PBXBuildFile; productRef = E1A0B0213A0D000100000001 /* CwlCatchException */; }; @@ -961,6 +963,8 @@ 6A0C1F2E3B4D5A6B7C8D9E01 /* PostHogOptOutPersistenceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogOptOutPersistenceTest.swift; sourceTree = ""; }; A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReplayBatchBoundaryTest.swift; sourceTree = ""; }; 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogQueueTest.swift; sourceTree = ""; }; + A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogCaptureV1Test.swift; sourceTree = ""; }; + A1C0DE00000000000000FAB1 /* PostHogWrapperEventTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogWrapperEventTest.swift; sourceTree = ""; }; 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSDKTest.swift; sourceTree = ""; }; 690FF1732AF3CE8A00A0B06B /* PostHogExampleWatchOS.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleWatchOS.xcodeproj; path = PostHogExampleWatchOS/PostHogExampleWatchOS.xcodeproj; sourceTree = ""; }; 69261D122AD5685B00232EC7 /* PostHogRemoteConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogRemoteConfig.swift; sourceTree = ""; }; @@ -1884,6 +1888,8 @@ DAA1B2D02F8C100000112233 /* RageClickDetectorTest.swift */, DAA1B2D22F8C100000112233 /* PostHogRageClickIntegrationTest.swift */, 690FF0E82AEFD3BD00A0B06B /* PostHogQueueTest.swift */, + A1C0DE0000000000000000F1 /* PostHogCaptureV1Test.swift */, + A1C0DE00000000000000FAB1 /* PostHogWrapperEventTest.swift */, A1C081010000000000000002 /* PostHogReplayBatchBoundaryTest.swift */, 690FF0F42AF0F06100A0B06B /* PostHogSDKTest.swift */, 5CA51F0E2B5C9A0100000002 /* PostHogScreenNameTest.swift */, @@ -3786,6 +3792,8 @@ AE3C6A5B6DAB01EF554A0270 /* PostHogPushNotificationTest.swift in Sources */, D36F92424A13D7CE61B6E348 /* PostHogSwizzlerTest.swift in Sources */, 690FF0E92AEFD3BD00A0B06B /* PostHogQueueTest.swift in Sources */, + A1C0DE0000000000000000F2 /* PostHogCaptureV1Test.swift in Sources */, + A1C0DE00000000000000FAB2 /* PostHogWrapperEventTest.swift in Sources */, A1C081010000000000000001 /* PostHogReplayBatchBoundaryTest.swift in Sources */, 3AE3FB4B2993A68500AFFC18 /* PostHogStorageTest.swift in Sources */, DAD39DF62D56178D002A1A69 /* UtilsTest.swift in Sources */, diff --git a/PostHog/Models/PostHogEvent.swift b/PostHog/Models/PostHogEvent.swift index c200a8f58..f6d6d1b0a 100644 --- a/PostHog/Models/PostHogEvent.swift +++ b/PostHog/Models/PostHogEvent.swift @@ -22,12 +22,21 @@ import Foundation /// Event properties that will be serialized with the event. @objc public var properties: [String: Any] + /// Options that tell PostHog how to process this event, separate from its properties. + /// + /// Holds the options passed to `capture(..., options:)`. Known keys are `process_person_profile`, + /// `cookieless_mode`, `disable_skew_correction` and `product_tour_id`; unknown keys are sent + /// unchanged and ignored by PostHog. When the event is sent, the legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` and `$product_tour_id` properties are removed from `properties` and used + /// for their options, unless that option is set here. + @objc public var options: [String: Any] + /// Event timestamp. The absolute instant is serialized in UTC, regardless of the calendar or /// time zone used to create it. @objc public var timestamp: Date /// Unique event identifier generated by the SDK. - @objc public private(set) var uuid: UUID + @objc public internal(set) var uuid: UUID // Only used for Replay. // Serialized as api_key on the wire. var projectToken: String? @@ -35,10 +44,41 @@ import Foundation /// Set by the SDK when this event took the throttled replay debug bundle, so it survives `beforeSend` renames or replacement. var carriesReplayDebugBundle = false - init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), projectToken: String? = nil) { + /// The caller's person properties, kept from build to send so feature flags see them once the event is accepted. + var flagUserProperties: [String: Any]? + var flagUserPropertiesSetOnce: [String: Any]? + + /// Set when a wrapper build returns this event: true for the AI lane. Cleared once it's sent, so it's sent once. + var wrapperAiLane: Bool? + /// The SDK's distinct ID when a wrapper built this event, so a send after `reset()` or `identify()` + /// doesn't give the new user this event's person properties for feature flags. + var wrapperBuildDistinctId: String? + /// The SDK's reset count when a wrapper built this event, so a `reset()` that keeps the distinct ID + /// (`reuseAnonymousId`) still counts as a new identity at send. + var wrapperBuildResetGeneration: Int? + /// The SDK instance and setup generation that built this wrapper event, so a send after `close()`/`setup()` + /// or on another instance is dropped instead of sent with the new configuration. + var wrapperBuildSDK: ObjectIdentifier? + var wrapperBuildSetupGeneration: Int? + /// The optional replay debug values snapshotted when a wrapper built this event, set only when its built name + /// is eligible. Send adds them if it claims the throttled window. + var wrapperDeferredReplayDebug: [String: Any]? + /// True when a wrapper build passed person properties or groups. Person processing turns on at send. + var wrapperRequiresPersonProcessing = false + + init( + event: String, + distinctId: String, + properties: [String: Any]? = nil, + options: [String: Any]? = nil, + timestamp: Date = Date(), + uuid: UUID = UUID.v7(), + projectToken: String? = nil + ) { self.event = event self.distinctId = distinctId self.properties = properties ?? [:] + self.options = options ?? [:] self.timestamp = timestamp self.uuid = uuid self.projectToken = projectToken @@ -75,6 +115,8 @@ import Foundation event: event, distinctId: distinctId, properties: properties, + // Events queued by older versions have no options. + options: json["options"] as? [String: Any], timestamp: timestampDate, uuid: uuidObj, projectToken: projectToken @@ -90,6 +132,11 @@ import Foundation "uuid": uuid.postHogUuidString, ] + // Stored for the queue only: each endpoint shapes its own wire format. + if !options.isEmpty { + json["options"] = options + } + if let projectToken { // Wire field name remains api_key, but it carries the PostHog project token. json["api_key"] = projectToken diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 1f66010e3..2e53fdda4 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -7,7 +7,7 @@ import Foundation -/// Common URLSession upload-response handler shared by `/batch`, `/snapshot`, +/// Common URLSession upload-response handler shared by capture V1, `/snapshot`, /// and `/i/v1/logs`. Routes through `as?` so a missing HTTP response can't /// crash inside a customer process. func processUploadResponse( @@ -51,7 +51,7 @@ func processUploadResponse( class PostHogApi { static var gzipData: (Data) throws -> Data = { try $0.gzipped() } - private let config: PostHogConfig + let config: PostHogConfig /// Snapshot at init; treated as immutable after setup. private let customRequestHeaders: [String: String] @@ -65,6 +65,15 @@ class PostHogApi { static let flagsRetryDelay: TimeInterval = 0.3 + /// Guards the capture V1 state below. In memory only: a new request ID + /// after an app restart is fine, since the server only logs it. + private let captureV1Lock = NSLock() + // Keyed by endpoint path, so analytics and AI retries keep separate identities. + private var captureV1PendingRetry: [String: CaptureV1RequestIdentity] = [:] + // A 404 keeps the events queued, so the warning is printed once instead of on every retry. + private var warnedCaptureNotFound = false + var printWarning: (String) -> Void = hedgeLogWarning + init(_ config: PostHogConfig) { self.config = config customRequestHeaders = config.requestHeaders ?? [:] @@ -86,21 +95,23 @@ class PostHogApi { headers["User-Agent"] = "\(postHogSdkName)/\(postHogVersion)" headers["Accept-Encoding"] = "gzip" sessionConfig.httpAdditionalHeaders = headers - // Strip custom headers on cross-host redirects so they don't leak to another origin. - if customRequestHeaders.isEmpty { - session = URLSession(configuration: sessionConfig) - } else { - let stripper = PostHogRedirectHeaderStripper(allowedHost: config.host.host, headerKeys: Array(customRequestHeaders.keys)) - session = URLSession(configuration: sessionConfig, delegate: stripper, delegateQueue: nil) - } + // Decides each redirect: capture V1 follows only the configured origin, and other + // endpoints strip custom headers on cross-host redirects so they don't leak. + let redirectHandler = PostHogRedirectHandler(host: config.host, headerKeys: Array(customRequestHeaders.keys)) + session = URLSession(configuration: sessionConfig, delegate: redirectHandler, delegateQueue: nil) + } + + deinit { + // A session retains its delegate until it's invalidated; in-flight tasks still finish. + session.finishTasksAndInvalidate() } /// `gzipped: true` adds `Content-Encoding: gzip` for upload endpoints - /// (/batch, /s/, /i/v1/logs) whose bodies are gzipped. - private func getURLRequest(_ url: URL, gzipped: Bool = false, httpMethod: String = "POST") -> URLRequest { + /// (capture V1, /s/, /i/v1/logs) whose bodies are gzipped. + private func getURLRequest(_ url: URL, gzipped: Bool = false, httpMethod: String = "POST", timeout: TimeInterval? = nil) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = httpMethod - request.timeoutInterval = defaultTimeout + request.timeoutInterval = timeout ?? defaultTimeout applyCustomHeaders(&request) if gzipped { request.setValue("gzip", forHTTPHeaderField: "Content-Encoding") @@ -120,19 +131,25 @@ class PostHogApi { } /// SDK-managed headers that custom values can't override (compared lowercase). + /// `Authorization` stays overridable for proxies on other endpoints; capture + /// V1 sets its Bearer token after custom headers, so it always wins there. private static let reservedHeaderKeys: Set = [ "content-type", "user-agent", "accept-encoding", "content-encoding", + "posthog-sdk-info", "posthog-attempt", "posthog-request-id", "posthog-request-timestamp", ] - private func requestAndPayload(url: URL, data: Data, endpointName: String, httpMethod: String = "POST") -> (URLRequest, Data) { - guard config.compression == .gzip else { - return (getURLRequest(url, httpMethod: httpMethod), data) + private func requestAndPayload( + url: URL, data: Data, endpointName: String, httpMethod: String = "POST", timeout: TimeInterval? = nil, + compression: PostHogCompression? = nil + ) -> (URLRequest, Data) { + guard (compression ?? config.compression) == .gzip else { + return (getURLRequest(url, httpMethod: httpMethod, timeout: timeout), data) } do { - return (getURLRequest(url, gzipped: true, httpMethod: httpMethod), try Self.gzipData(data)) + return (getURLRequest(url, gzipped: true, httpMethod: httpMethod, timeout: timeout), try Self.gzipData(data)) } catch { hedgeLog("Error gzipping the \(endpointName) body, sending it uncompressed: \(error).") - return (getURLRequest(url, httpMethod: httpMethod), data) + return (getURLRequest(url, httpMethod: httpMethod, timeout: timeout), data) } } @@ -175,31 +192,244 @@ class PostHogApi { return request } - func batch(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { - guard let url = getEndpointURL("/batch", relativeTo: config.host) else { - hedgeLog("Malformed batch URL error.") + /// POSTs analytics events to capture V1 (`/i/v1/analytics/events`). + /// + /// The completion's `retryRecordIds` holds the UUIDs of events a 200 asked + /// to retry. + func captureV1(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { + postCaptureV1(path: Self.captureV1AnalyticsPath, events: events, completion: completion) + } + + /// POSTs AI events to the capture V1 AI endpoint (`/i/v1/ai/events`). + /// Same wire format, per-event results and retry policy as `captureV1`. + func captureAi(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { + postCaptureV1(path: Self.captureV1AiPath, events: events, completion: completion) + } + + static let captureV1AnalyticsPath = "/i/v1/analytics/events" + static let captureV1AiPath = "/i/v1/ai/events" + + private func postCaptureV1(path: String, events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { + guard let url = getEndpointURL(path, relativeTo: config.host) else { + hedgeLog("Malformed capture URL error.") return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } + let uuids = events.map(\.uuid.postHogUuidString) + let 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] = [ - // Wire field name remains api_key, but it carries the PostHog project token. - "api_key": config.projectToken, - "batch": events.map { $0.toJSON() }, - "sent_at": toISO8601String(Date()), + "created_at": identity.createdAt, + "batch": events.map(Self.captureV1JSON), ] guard let data = try? JSONSerialization.data(withJSONObject: toSend) else { - hedgeLog("Error parsing the batch body") + hedgeLog("Error parsing the capture body") return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - let (request, payload) = requestAndPayload(url: url, data: data, endpointName: "batch") - - session.uploadTask(with: request, from: payload) { data, response, error in - processUploadResponse(endpointName: "batch", data: data, response: response, error: error, completion: completion) + // AI batches can be several MB, so they get their own timeout and compression. + let isAi = path == Self.captureV1AiPath + var (request, payload) = requestAndPayload( + url: url, data: data, endpointName: "capture", + timeout: isAi ? config.effectiveCaptureAiTimeout : nil, + compression: isAi ? config.captureAiCompression : nil + ) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(config.projectToken)", forHTTPHeaderField: "Authorization") + request.setValue("\(postHogSdkName)/\(postHogVersion)", forHTTPHeaderField: "PostHog-Sdk-Info") + request.setValue(String(identity.attempt), forHTTPHeaderField: "PostHog-Attempt") + request.setValue(identity.requestId, forHTTPHeaderField: "PostHog-Request-Id") + request.setValue(toISO8601String(Date()), forHTTPHeaderField: "PostHog-Request-Timestamp") + + session.uploadTask(with: request, from: payload) { [weak self] data, response, error in + // No body for the error log: a proxy's error body can echo request content. + processUploadResponse(endpointName: "capture", data: nil, response: response, error: error) { info in + guard let self else { return completion(info) } + if info.statusCode == 404 { + self.warnCaptureNotFoundOnce(url: url, path: path) + } + completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity, eventNames: eventNames)) + } }.resume() } + private func warnCaptureNotFoundOnce(url: URL, path: String) { + let shouldWarn = captureV1Lock.withLock { + defer { warnedCaptureNotFound = true } + return !warnedCaptureNotFound + } + guard shouldWarn else { return } + // Warnings print even without debug, so drop any credentials in the host. Clearing the + // query also drops the trailing "?" that getEndpointURL's empty query prints as. + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.user = nil + components?.password = nil + components?.query = nil + components?.fragment = nil + printWarning( + "\(components?.url?.absoluteString ?? path) returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves \(path)." + ) + } + + /// Reuses the request ID with the next attempt number when `uuids` is the + /// set of events the previous request left to retry. Otherwise starts a new + /// request at attempt 1. + private func nextCaptureV1Identity(path: String, uuids: Set) -> CaptureV1RequestIdentity { + captureV1Lock.withLock { + if let pending = captureV1PendingRetry[path], pending.uuids == uuids { + return CaptureV1RequestIdentity( + uuids: uuids, requestId: pending.requestId, attempt: pending.attempt + 1, createdAt: pending.createdAt + ) + } + return CaptureV1RequestIdentity( + uuids: uuids, requestId: UUID().postHogUuidString, attempt: 1, createdAt: toISO8601String(Date()) + ) + } + } + + /// Reads the per-event results of a 2xx and records which events, if any, + /// the next request retries under the same identity. `eventNames` maps + /// UUIDs to event names for the per-event drop log. + func captureV1Result( + _ info: PostHogUploadInfo, + path: String, + data: Data?, + uuids: [String], + identity: CaptureV1RequestIdentity, + eventNames: [String: String] = [:] + ) -> PostHogUploadInfo { + var retryUuids: Set = [] + var dropped = 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. + 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.") + } + // Events missing from the results count as delivered. + for uuid in uuids { + guard let entry = results?[uuid] as? [String: Any] else { continue } + switch entry["result"] as? String { + 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).") + default: + break + } + } + } else if statusCode.map(isCaptureV1RetriableStatusCode) ?? true { + // No response or a retriable status: the whole batch is resent. + retryUuids = Set(uuids) + } + + if dropped > 0 || !retryUuids.isEmpty { + hedgeLog("Capture: \(dropped) dropped, \(retryUuids.count) to retry.") + } + + captureV1Lock.withLock { + captureV1PendingRetry[path] = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( + uuids: retryUuids, requestId: identity.requestId, attempt: identity.attempt, createdAt: identity.createdAt + ) + } + + return PostHogUploadInfo( + statusCode: statusCode, + error: info.error, + retryAfter: info.retryAfter, + retryRecordIds: statusCode.map { 200 ... 299 ~= $0 } == true ? retryUuids : nil + ) + } + + /// Legacy properties and the capture V1 option each one fills, in the + /// order posthog-python, posthog-go and posthog-rs use. + static let legacyOptionProperties: [(property: String, option: String)] = [ + ("$cookieless_mode", "cookieless_mode"), + ("$ignore_sent_at", "disable_skew_correction"), + ("$product_tour_id", "product_tour_id"), + ("$process_person_profile", "process_person_profile"), + ] + + /// Shapes a stored event for capture V1. `$session_id` and `$window_id` + /// move to the event root, and `$lib`/`$lib_version` are dropped because + /// the server reads them from `PostHog-Sdk-Info`. + /// + /// Legacy option properties are hoisted here, after `beforeSend`, so events + /// queued by older versions are hoisted too. Each is always removed from + /// properties and fills its option only when the option is missing or null. + /// Values aren't coerced: the server validates them. + private static func captureV1JSON(_ event: PostHogEvent) -> [String: Any] { + var properties = event.properties + properties.removeValue(forKey: "$lib") + properties.removeValue(forKey: "$lib_version") + + var options = event.options + for (property, option) in legacyOptionProperties { + guard let legacy = properties.removeValue(forKey: property) else { continue } + if options[option] == nil || options[option] is NSNull { + options[option] = legacy + } + } + + var json: [String: Any] = [ + "event": event.event, + "uuid": event.uuid.postHogUuidString, + "distinct_id": event.distinctId, + "timestamp": toISO8601String(event.timestamp), + ] + if let sessionId = liftStringProperty("$session_id", from: &properties) { + json["session_id"] = sessionId + } + if let windowId = liftStringProperty("$window_id", from: &properties) { + json["window_id"] = windowId + } + json["options"] = options + json["properties"] = properties + return json + } + + /// Removes `key` from `properties` and returns its value when it's a string, + /// including an empty one, which is sent unchanged. Capture rejects the whole + /// request when a root `session_id` or `window_id` isn't a string, so any other + /// value is dropped with a warning that names its JSON type, never the value. + /// A null value counts as unset and is dropped without a warning. + static func liftStringProperty( + _ key: String, + from properties: inout [String: Any], + warn: (String) -> Void = hedgeLog + ) -> String? { + guard let value = properties.removeValue(forKey: key) else { return nil } + if let string = value as? String { return string } + if value is NSNull { return nil } + warn("dropping \(key): a \(jsonTypeName(value)) value is not a string") + return nil + } + + /// The JSON type name of a property value, the names posthog-go and posthog-rs log. + private static func jsonTypeName(_ value: Any) -> String { + switch value { + case let number as NSNumber: + return CFGetTypeID(number) == CFBooleanGetTypeID() ? "bool" : "number" + case is [Any]: + return "array" + case is [AnyHashable: Any]: + return "object" + default: + return String(describing: type(of: value)) + } + } + func snapshot(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { guard let url = getEndpointURL(config.snapshotEndpoint, relativeTo: config.host) else { hedgeLog("Malformed snapshot URL error.") @@ -210,7 +440,12 @@ class PostHogApi { event.projectToken = config.projectToken } - let toSend = events.map { $0.toJSON() } + // Options are an analytics capture field; replay doesn't send them. + let toSend = events.map { event -> [String: Any] in + var json = event.toJSON() + json.removeValue(forKey: "options") + return json + } guard let data = try? JSONSerialization.data(withJSONObject: toSend) else { hedgeLog("Error parsing the snapshot body") @@ -565,24 +800,47 @@ extension PostHogApi { }() } -/// Strips custom headers on redirects that leave the configured host. -private final class PostHogRedirectHeaderStripper: NSObject, URLSessionTaskDelegate { - private let allowedHost: String? +/// Session delegate that decides every redirect. +/// +/// Capture V1 requests follow only a 307 or 308 to the origin of `config.host`, at most +/// `maxCaptureV1Redirects` times, with the original headers re-set. URLSession would otherwise +/// drop `Authorization`, resend the batch to another host, or turn a 302 into a body-less GET. +/// Any other redirect is not followed, so its 3xx is the response. Other requests follow +/// redirects but lose the custom headers when they leave the configured host. +final class PostHogRedirectHandler: NSObject, URLSessionTaskDelegate { + static let maxCaptureV1Redirects = 5 + + private let host: URL private let headerKeys: [String] + private let lock = NSLock() + // Redirects followed per capture task. Weak keys drop finished tasks. + private let captureV1Hops = NSMapTable.weakToStrongObjects() - init(allowedHost: String?, headerKeys: [String]) { - self.allowedHost = allowedHost + init(host: URL, headerKeys: [String]) { + self.host = host self.headerKeys = headerKeys } func urlSession( _: URLSession, - task _: URLSessionTask, - willPerformHTTPRedirection _: HTTPURLResponse, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { - guard request.url?.host != allowedHost else { + if let original = task.originalRequest, Self.isCaptureV1(original) { + let hop = lock.withLock { () -> Int in + let hop = (captureV1Hops.object(forKey: task)?.intValue ?? 0) + 1 + captureV1Hops.setObject(NSNumber(value: hop), forKey: task) + return hop + } + completionHandler(Self.captureV1Redirect( + original: original, statusCode: response.statusCode, newRequest: request, host: host, hop: hop + )) + return + } + + guard request.url?.host != host.host else { completionHandler(request) return } @@ -592,4 +850,52 @@ private final class PostHogRedirectHeaderStripper: NSObject, URLSessionTaskDeleg } completionHandler(redirected) } + + /// Capture V1 requests are the only ones that carry `PostHog-Request-Id`, + /// which custom headers can't set. + static func isCaptureV1(_ request: URLRequest) -> Bool { + request.value(forHTTPHeaderField: "PostHog-Request-Id") != nil + } + + /// The request to follow for the `hop`th redirect of a capture V1 request, or `nil` to stop + /// and return the 3xx. + static func captureV1Redirect( + original: URLRequest, + statusCode: Int, + newRequest: URLRequest, + host: URL, + hop: Int + ) -> URLRequest? { + guard [307, 308].contains(statusCode), + hop <= maxCaptureV1Redirects, + let target = newRequest.url, + let targetOrigin = origin(target), + targetOrigin == origin(host) + else { + hedgeLog("Capture did not follow a \(statusCode) redirect.") + return nil + } + var redirected = newRequest + redirected.httpMethod = original.httpMethod + // Same origin, so every original header is safe to resend. + for (key, value) in original.allHTTPHeaderFields ?? [:] { + redirected.setValue(value, forHTTPHeaderField: key) + } + return redirected + } + + private static func origin(_ url: URL) -> String? { + guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil } + let port = url.port ?? (scheme == "https" ? 443 : 80) + return "\(scheme)://\(host):\(port)" + } +} + +/// Identity of a capture V1 request, kept so a retry of the same events reuses +/// `PostHog-Request-Id` and `created_at` with an incremented `PostHog-Attempt`. +struct CaptureV1RequestIdentity { + let uuids: Set + let requestId: String + let attempt: Int + let createdAt: String } diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index c9ebedc2e..4f6c55d5d 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -32,8 +32,12 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? #endif static let maxBatchSize: Int = 50 static let flushIntervalSeconds: TimeInterval = 30 - static let maxRetries: Int = 3 + static let pushNotificationSubscriptionMaxRetries: Int = 3 static let featureFlagRequestMaxRetries: Int = 1 + static let captureAiMaxQueueSize: Int = 100 + static let captureAiTimeout: TimeInterval = 60 + /// PostHog's 8 MiB AI properties limit, plus 64 KiB for the rest of the event. + static let captureAiMaxEventBytes: Int = 8 * 1024 * 1024 + 64 * 1024 } /// Network connectivity mode required before queued data may be flushed. @@ -86,7 +90,7 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Use `maxQueueSize` for events and replay, and `logs.maxBufferSize` for logs. /// /// Default: `3`. - @objc public var maxRetries: Int = Defaults.maxRetries + @objc public var pushNotificationSubscriptionMaxRetries: Int = Defaults.pushNotificationSubscriptionMaxRetries /// Maximum number of retries for feature flag requests after transient network errors or retryable HTTP responses. /// Defaults to 1. Set to 0 to disable feature flag request retries. @@ -464,7 +468,10 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? @objc public var urlSessionConfiguration: URLSessionConfiguration? /// Custom headers to send with every request to the PostHog API. - /// Useful for reverse-proxy setups that require authentication, e.g. an `Authorization` header. + /// Useful for reverse-proxy setups that require authentication, e.g. an `X-Proxy-Token` header. + /// + /// Event capture requests always send `Authorization: Bearer `, which replaces any + /// `Authorization` header set here, so authenticate your proxy with a different header. /// Read once when the SDK is set up; changes after setup are ignored. @objc public var requestHeaders: [String: String]? @@ -476,6 +483,33 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Default: `.gzip`. @objc public var compression: PostHogCompression = .gzip + /// How the SDK compresses `captureAi` request bodies, separately from `compression`. + /// + /// Default: `.gzip`. + @objc public var captureAiCompression: PostHogCompression = .gzip + + /// Maximum number of `captureAi` events kept in their on-disk queue before older events are dropped. + /// + /// AI events can be megabytes, so this is lower than `maxQueueSize`. The queue also keeps at most + /// 50 MiB of AI events, whatever this is set to. A value of `0` or less uses the default. + /// Default: `100`. + @objc public var captureAiMaxQueueSize: Int = Defaults.captureAiMaxQueueSize + + /// How long, in seconds, a `captureAi` request can go without network activity before it times out. + /// + /// AI requests can carry several MB, so this is longer than for other requests, which time out + /// after 10 seconds. A value of `0` or less uses the default. + /// Default: `60`. + @objc public var captureAiTimeout: TimeInterval = Defaults.captureAiTimeout + + /// Maximum size, in bytes, of a serialized `captureAi` event. The SDK drops a larger event. + /// + /// Use it to lower the limit. PostHog drops AI events whose properties are over 8 MiB, so the default + /// allows 8 MiB plus 64 KiB for the rest of the event. A larger value uses the default and logs a + /// warning when `debug` is on. A value of `0` or less uses the default. + /// Default: `8454144` (8 MiB + 64 KiB). + @objc public var captureAiMaxEventBytes: Int = Defaults.captureAiMaxEventBytes + // only internal var disableReachabilityForTesting: Bool = false var disableQueueTimerForTesting: Bool = false diff --git a/PostHog/PostHogConsumerPayload.swift b/PostHog/PostHogConsumerPayload.swift index 905206ead..f94b1c1b2 100644 --- a/PostHog/PostHogConsumerPayload.swift +++ b/PostHog/PostHogConsumerPayload.swift @@ -13,4 +13,7 @@ import Foundation struct PostHogConsumerPayload { let records: [Record] let completion: (Bool) -> Void + /// Removes every record except those whose `QueueEndpoint.recordId` is in + /// the given set, then ends the flush so the kept records wait for backoff. + let completeKeeping: (Set) -> Void } diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index 9206ca0a0..19d035f51 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -15,16 +15,31 @@ class PostHogFileBackedQueue { let queue: URL private let maxSize: Int? + /// FIFO limit on the total size of the stored files, on top of `maxSize`. + /// The newest entry is always kept, even when it's larger on its own. + private let maxBytes: Int? + /// Whether file sizes are tracked: when `maxBytes` is set or the caller asked for `byteCount`. + private let tracksBytes: Bool private var items = [String]() + /// File sizes by id, tracked only when `tracksBytes`. Guarded by `itemsLock`. + private var sizes = [String: Int]() + private var totalBytes = 0 private let itemsLock = NSLock() var depth: Int { itemsLock.withLock { items.count } } - init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil) { + /// Total size of the stored files. Tracked only when `maxBytes` or `trackBytes` is set, 0 otherwise. + var byteCount: Int { + itemsLock.withLock { totalBytes } + } + + init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil, maxBytes: Int? = nil, trackBytes: Bool = false) { self.queue = queue self.maxSize = maxSize.map { max(1, $0) } + self.maxBytes = maxBytes.map { max(1, $0) } + tracksBytes = trackBytes || maxBytes != nil setup(oldQueues: oldQueues) } @@ -60,14 +75,16 @@ class PostHogFileBackedQueue { peekEntries(count).map(\.data) } - func peekEntries(_ count: Int) -> [Entry] { - loadEntries(count) + /// Reads up to `count` entries, oldest first. With `maxBytes`, stops after the + /// entry that reaches it, so large entries aren't all held in memory at once. + func peekEntries(_ count: Int, maxBytes: Int? = nil) -> [Entry] { + loadEntries(count, maxBytes: maxBytes) } func delete(index: Int) { let removed: String? = itemsLock.withLock { guard index < items.count else { return nil } - return items.remove(at: index) + return untrack(items.remove(at: index)) } if let removed { @@ -84,6 +101,7 @@ class PostHogFileBackedQueue { let removed: [String] = itemsLock.withLock { let removed = items.filter { ids.contains($0) } items.removeAll { ids.contains($0) } + removed.forEach { untrack($0) } return removed } @@ -93,32 +111,34 @@ class PostHogFileBackedQueue { } /// Persists one entry and optionally enforces a FIFO capacity in the same - /// critical section. Returning an evicted id lets the queue report + /// critical section. Returning the evicted ids lets the queue report /// backpressure without racing a separate depth check against other adds. @discardableResult - func add(_ contents: Data, maxSize: Int? = nil) -> (success: Bool, evicted: String?) { + func add(_ contents: Data, maxSize: Int? = nil) -> (success: Bool, evicted: [String]) { do { let filename = UUID.v7String() let effectiveMaxSize = maxSize.map { max(1, $0) } ?? self.maxSize - var evicted: String? + var evicted = [String]() try itemsLock.withLock { try contents.write(to: queue.appendingPathComponent(filename)) if let effectiveMaxSize, items.count >= effectiveMaxSize { - evicted = items.removeFirst() - if let evicted { - deleteSafely(queue.appendingPathComponent(evicted)) - } + evicted.append(untrack(items.removeFirst())) } items.append(filename) + track(filename, size: contents.count) + evicted += evictOverByteLimit() } + for id in evicted { + deleteSafely(queue.appendingPathComponent(id)) + } return (true, evicted) } catch { hedgeLog("Could not write file \(error)") - return (false, nil) + return (false, []) } } @@ -148,7 +168,15 @@ class PostHogFileBackedQueue { let sortedItems = try FileManager.default.contentsOfDirectory(at: queue, sortedBy: .creationDateKey) let overflow = maxSize.map { max(0, sortedItems.count - $0) } ?? 0 items = Array(sortedItems.dropFirst(overflow)) - return Array(sortedItems.prefix(overflow)) + sizes = [:] + totalBytes = 0 + if tracksBytes { + for item in items { + let attributes = try? FileManager.default.attributesOfItem(atPath: queue.appendingPathComponent(item).path) + track(item, size: (attributes?[.size] as? NSNumber)?.intValue ?? 0) + } + } + return Array(sortedItems.prefix(overflow)) + evictOverByteLimit() } for item in dropped { @@ -159,9 +187,10 @@ class PostHogFileBackedQueue { } } - private func loadEntries(_ count: Int) -> [Entry] { + private func loadEntries(_ count: Int, maxBytes: Int? = nil) -> [Entry] { var results = [Entry]() var skipped = Set() + var loadedBytes = 0 let itemsCopy = itemsLock.withLock { items } @@ -176,6 +205,7 @@ class PostHogFileBackedQueue { let contents = try Data(contentsOf: itemURL) results.append(Entry(id: item, data: contents)) + loadedBytes += contents.count } catch { if isTemporarilyUnavailable(error) { hedgeLog("File \(itemURL) is temporarily unavailable, will retry \(error)") @@ -188,13 +218,16 @@ class PostHogFileBackedQueue { skipped.insert(item) } - if results.count == count { + if results.count == count || maxBytes.map({ loadedBytes >= $0 }) == true { break } } if !skipped.isEmpty { - itemsLock.withLock { items.removeAll { skipped.contains($0) } } + itemsLock.withLock { + items.removeAll { skipped.contains($0) } + skipped.forEach { untrack($0) } + } } return results @@ -216,11 +249,38 @@ class PostHogFileBackedQueue { return false } + /// Caller must hold `itemsLock`. + private func track(_ id: String, size: Int) { + guard tracksBytes else { return } + sizes[id] = size + totalBytes += size + } + + /// Caller must hold `itemsLock`. Returns `id` for chaining. + @discardableResult + private func untrack(_ id: String) -> String { + if let size = sizes.removeValue(forKey: id) { + totalBytes -= size + } + return id + } + + /// Drops the oldest items until the total fits `maxBytes`, keeping the + /// newest. Caller must hold `itemsLock` and delete the returned files. + private func evictOverByteLimit() -> [String] { + guard let maxBytes else { return [] } + var evicted = [String]() + while totalBytes > maxBytes, items.count > 1 { + evicted.append(untrack(items.removeFirst())) + } + return evicted + } + private func deleteFiles(_ count: Int) { for _ in 0 ..< count { let removed: String? = itemsLock.withLock { guard !items.isEmpty else { return nil } - return items.remove(at: 0) // We always remove from the top of the queue + return untrack(items.remove(at: 0)) // We always remove from the top of the queue } guard let removed else { return } diff --git a/PostHog/PostHogPersonProfiles.swift b/PostHog/PostHogPersonProfiles.swift index ad1e40c34..3116dc02e 100644 --- a/PostHog/PostHogPersonProfiles.swift +++ b/PostHog/PostHogPersonProfiles.swift @@ -10,11 +10,11 @@ import Foundation /// Determines the behavior for processing user profiles. /// - `never`: We won't process persons for any event. This means that anonymous users will not be merged once /// they sign up or login, so you lose the ability to create funnels that track users from anonymous to identified. -/// All events (including `$identify`) will be sent with `$process_person_profile: False`. +/// All events (including `$identify`) will be sent with the `process_person_profile: false` option. /// - `always`: We will process persons data for all events. /// - `identifiedOnly`: (default): we will only process persons when you call `identify`, `alias`, and `group`, Anonymous users won't get person profiles. @objc(PostHogPersonProfiles) public enum PostHogPersonProfiles: Int { - /// Never process person profiles. All events are sent with `$process_person_profile: false`. + /// Never process person profiles. All events are sent with the `process_person_profile: false` option. case never /// Always process person profiles for all events. case always diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 51e5149d9..fb4ea06a7 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -11,6 +11,37 @@ private func clamped(_ value: Int) -> Int { max(1, value) } +/// Largest fraction a retry wait moves either way, so devices refused together +/// don't all come back at the same moment. Matches the capture V1 server SDKs +/// (a factor drawn from `[0.75, 1.25)`). +let retryJitter = 0.25 + +/// Number of consecutive failures after which the exponential backoff reaches +/// `maxRetryDelay`. The retry counter stops there; it only ramps the delay. +let maxRetryBackoffStep = 1 + Int(ceil(log2(maxRetryDelay / retryDelay))) + +/// Wait before the queue sends again after its `attempt`-th consecutive failure. +/// +/// The delay doubles from `retryDelay` and is capped at `maxRetryDelay`, then +/// scaled by a jitter factor in `[1 - retryJitter, 1 + retryJitter)` and capped +/// again. A server `Retry-After`, clamped to `retryAfterCap`, is a floor applied +/// after jitter, so jitter never retries earlier than the server asked. +/// +/// - Parameter jitterUnit: A uniform value in `[0, 1)`. A value outside that +/// range (e.g. no randomness available) falls back to the unjittered delay. +func queueRetryDelay(attempt: Int, + jitterUnit: Double, + retryAfter: TimeInterval?, + retryAfterCap: TimeInterval) -> TimeInterval +{ + let step = min(max(attempt, 1), maxRetryBackoffStep) + let computed = min(retryDelay * pow(2, Double(step - 1)), maxRetryDelay) + let factor = (0 ..< 1).contains(jitterUnit) ? 1 - retryJitter + 2 * retryJitter * jitterUnit : 1 + let jittered = min(computed * factor, maxRetryDelay) + let retryAfterFloor = min(max(retryAfter ?? 0, 0), retryAfterCap) + return max(jittered, retryAfterFloor) +} + /// Adaptive limits stored privately on the queue rather than mutating /// `config.maxBatchSize` / `config.flushAt` so user-supplied config fields /// aren't silently changed. Both `cap` and `flushAt` are halved together on @@ -55,6 +86,8 @@ class PostHogQueue { private let endpoint: QueueEndpoint private let configuredMaxQueueSize: Int private let timerInterval: TimeInterval + /// Source of the backoff jitter: a uniform value in `[0, 1)`. + private let jitterUnit: () -> Double /// Guards `paused`, `pausedUntil`, and `retryCount`. These are touched from /// the URLSession completion queue (handleResult), the timer's main-thread @@ -106,10 +139,13 @@ class PostHogQueue { let fileQueue: PostHogFileBackedQueue #if !os(watchOS) - init(_ config: PostHogConfig, _ storage: PostHogStorage, _ endpoint: QueueEndpoint, _ reachability: Reachability?) { + init(_ config: PostHogConfig, _ storage: PostHogStorage, _ endpoint: QueueEndpoint, _ reachability: Reachability?, + jitterUnit: @escaping () -> Double = { Double.random(in: 0 ..< 1) }) + { self.config = config self.endpoint = endpoint self.reachability = reachability + self.jitterUnit = jitterUnit configuredMaxQueueSize = endpoint.maxQueueSize(config) timerInterval = endpoint.flushIntervalSeconds(config) batchLimits = .initial(cap: endpoint.initialCap(config), flushAt: endpoint.initialFlushAt(config)) @@ -118,14 +154,20 @@ class PostHogQueue { fileQueue = PostHogFileBackedQueue( queue: storage.url(forKey: endpoint.storageKey), oldQueues: endpoint.oldStorageKeys.map { storage.url(forKey: $0) }, - maxSize: configuredMaxQueueSize + maxSize: configuredMaxQueueSize, + maxBytes: endpoint.maxQueueBytes, + // `flushIfOverThreshold` reads `byteCount` for the batch byte threshold. + trackBytes: endpoint.maxBatchBytes != nil ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } #else - init(_ config: PostHogConfig, _ storage: PostHogStorage, _ endpoint: QueueEndpoint) { + init(_ config: PostHogConfig, _ storage: PostHogStorage, _ endpoint: QueueEndpoint, + jitterUnit: @escaping () -> Double = { Double.random(in: 0 ..< 1) }) + { self.config = config self.endpoint = endpoint + self.jitterUnit = jitterUnit configuredMaxQueueSize = endpoint.maxQueueSize(config) timerInterval = endpoint.flushIntervalSeconds(config) batchLimits = .initial(cap: endpoint.initialCap(config), flushAt: endpoint.initialFlushAt(config)) @@ -134,7 +176,10 @@ class PostHogQueue { fileQueue = PostHogFileBackedQueue( queue: storage.url(forKey: endpoint.storageKey), oldQueues: endpoint.oldStorageKeys.map { storage.url(forKey: $0) }, - maxSize: configuredMaxQueueSize + maxSize: configuredMaxQueueSize, + maxBytes: endpoint.maxQueueBytes, + // `flushIfOverThreshold` reads `byteCount` for the batch byte threshold. + trackBytes: endpoint.maxBatchBytes != nil ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } @@ -154,15 +199,7 @@ class PostHogQueue { let isRetriable = statusCode == -1 || endpoint.isRetriableStatusCode(statusCode) if isRetriable { - let newCount = nextRetryCount() - let backoffDelay = min(TimeInterval(newCount) * retryDelay, maxRetryDelay) - var retryAfter = result.retryAfter ?? 0 - if let maxRetryAfter = endpoint.maxRetryAfterSeconds { - retryAfter = min(retryAfter, maxRetryAfter) - } - let delay = max(backoffDelay, retryAfter) - pauseFor(seconds: delay) - hedgeLog("Pausing queue consumption for \(delay) seconds due to \(newCount) API failure(s).") + pauseForBackoff(retryAfter: result.retryAfter) payload.completion(false) return } @@ -189,16 +226,34 @@ class PostHogQueue { return } + // A 2xx that asks to retry some records (capture V1) removes the rest + // and backs off before resending the kept ones. + if let retryRecordIds = result.retryRecordIds, !retryRecordIds.isEmpty { + pauseForBackoff(retryAfter: result.retryAfter) + payload.completeKeeping(retryRecordIds) + return + } + // 2xx success or a terminal response removes the exact snapshotted // entries. The adaptive cap stays where it is. resetRetryState() payload.completion(true) } + private func pauseForBackoff(retryAfter: TimeInterval?) { + let newCount = nextRetryCount() + // A server's Retry-After can't hold the queue longer than the backoff ceiling + // or the endpoint's documented maximum, whichever is lower. + let retryAfterCap = min(maxRetryDelay, endpoint.maxRetryAfterSeconds ?? maxRetryDelay) + let delay = queueRetryDelay(attempt: newCount, jitterUnit: jitterUnit(), + retryAfter: retryAfter, retryAfterCap: retryAfterCap) + pauseFor(seconds: delay) + hedgeLog("Pausing queue consumption for \(delay) seconds due to \(newCount) API failure(s).") + } + private func nextRetryCount() -> Int { stateLock.withLock { - let maximumBackoffStep = max(1, Int(maxRetryDelay / retryDelay)) - if retryCount < maximumBackoffStep { + if retryCount < maxRetryBackoffStep { retryCount += 1 } return retryCount @@ -325,7 +380,10 @@ class PostHogQueue { func flushIfOverThreshold() { let threshold = batchLimitsLock.withLock { batchLimits.flushAt } - if fileQueue.depth >= threshold { + // A full byte-sized batch also flushes, so large records go out before + // the queue's byte cap starts evicting them. + let overByteThreshold = endpoint.maxBatchBytes.map { fileQueue.byteCount >= $0 } == true + if fileQueue.depth >= threshold || overByteThreshold { flush() } } @@ -351,11 +409,16 @@ class PostHogQueue { hedgeLog("Tried to queue unserialisable record") return false } + // Name and size only: AI properties can hold prompts and media. + if let maxRecordBytes = endpoint.maxRecordBytes, data.count > maxRecordBytes { + hedgeLog("Dropping \(endpoint.describe(record)) of \(data.count) bytes, over the \(maxRecordBytes) byte limit.") + return false + } let result = fileQueue.add(data, maxSize: configuredMaxQueueSize) guard result.success else { return false } - if result.evicted != nil { - hedgeLog("Queue is full, dropping oldest record") + if !result.evicted.isEmpty { + hedgeLog("Queue is full, dropped \(result.evicted.count) oldest record(s)") } hedgeLog("Queued \(endpoint.describe(record)). Depth: \(fileQueue.depth)") flushIfOverThreshold() @@ -431,7 +494,8 @@ class PostHogQueue { // Bound this flush to its original FIFO window, including across // replay boundaries. Concurrent appends belong to a later flush. - let entries = self.fileQueue.peekEntries(count) + // A byte-limited endpoint reads about one batch, not the whole window. + let entries = self.fileQueue.peekEntries(count, maxBytes: self.endpoint.maxBatchBytes) self.consume(entries, from: 0, completion: completion) } } @@ -450,6 +514,9 @@ class PostHogQueue { var processing: [Record] = [] var selectedIds: [String] = [] + // Entry ID -> `endpoint.recordId`, for removing all but retried records. + var recordIds: [String: String] = [:] + var batchBytes = 0 var next = start while next < entries.count { let entry = entries[next] @@ -460,14 +527,26 @@ class PostHogQueue { { break } + if !processing.isEmpty, let maxBatchBytes = endpoint.maxBatchBytes, + batchBytes + entry.data.count > maxBatchBytes + { + break + } + batchBytes += entry.data.count processing.append(record) + recordIds[entry.id] = endpoint.recordId?(record) } selectedIds.append(entry.id) next += 1 } let nextIndex = next - completion(PostHogConsumerPayload(records: processing) { [weak self] success in + let finishFlush: () -> Void = { [weak self] in + guard let self else { return } + self.isFlushingLock.withLock { self.isFlushing = false } + } + + completion(PostHogConsumerPayload(records: processing, completion: { [weak self] success in guard let self else { return } if success, !selectedIds.isEmpty { self.fileQueue.remove(ids: selectedIds) @@ -479,9 +558,19 @@ class PostHogQueue { self?.consume(entries, from: nextIndex, completion: completion) } } else { - self.isFlushingLock.withLock { self.isFlushing = false } + finishFlush() + } + }, completeKeeping: { [weak self] keepRecordIds in + guard let self else { return } + let delivered = selectedIds.filter { id in + recordIds[id].map { !keepRecordIds.contains($0) } ?? true + } + if !delivered.isEmpty { + self.fileQueue.remove(ids: delivered) } - }) + hedgeLog("Completed \(delivered.count) of \(selectedIds.count) records, keeping the rest for retry.") + finishFlush() + })) } #if !os(watchOS) diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index de967d0a7..99e3aa264 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -55,6 +55,11 @@ let maxRetryDelay = 30.0 private let lastScreenLock = NSLock() private let replayDebugPropertiesLock = NSLock() + private let wrapperSendLock = NSLock() + /// Bumped by `setup` and `close`, so a wrapper event built before a reconfigure isn't sent through the new one. + private var wrapperSetupGeneration = 0 + /// Bumped by `reset()`, so a wrapper send treats a reset that keeps the distinct ID as a new identity. + private var wrapperResetGeneration = 0 private var _lastScreenName: String? var lastScreenName: String? { lastScreenLock.withLock { _lastScreenName } @@ -85,6 +90,11 @@ let maxRetryDelay = 30.0 } private(set) var logsQueue: PostHogQueue? + private var _aiQueue: PostHogQueue? + private(set) var aiQueue: PostHogQueue? { + get { setupLock.withLock { _aiQueue } } + set { setupLock.withLock { _aiQueue = newValue } } + } private(set) var storage: PostHogStorage? private var surveyIdentityGeneration: String? #if !os(watchOS) @@ -184,6 +194,12 @@ let maxRetryDelay = 30.0 return } + // PostHog rejects a personal API key for capture with a 401, and every event would be dropped. + if config.projectToken.hasPrefix("phx_") { + hedgeLog("PostHog SDK will be disabled because projectToken is a personal API key (phx_...), not a project token (phc_...).") + return + } + if PostHogSDK.projectTokens.contains(config.projectToken) { hedgeLog("Project token: \(config.projectToken) already has a PostHog instance.") } else { @@ -192,6 +208,7 @@ let maxRetryDelay = 30.0 enabled = true self.config = config + wrapperSendLock.withLock { wrapperSetupGeneration += 1 } let theStorage = PostHogStorage(config) storage = theStorage let api = PostHogApi(config) @@ -285,10 +302,12 @@ let maxRetryDelay = 30.0 queue = PostHogQueue(config, theStorage, .batch(api: api), reachability) replayQueue = PostHogReplayQueue(config, theStorage, api, reachability) logsQueue = PostHogQueue(config, theStorage, logsEndpoint, reachability) + aiQueue = PostHogQueue(config, theStorage, .ai(api: api), reachability) #else queue = PostHogQueue(config, theStorage, .batch(api: api)) replayQueue = PostHogReplayQueue(config, theStorage, api) logsQueue = PostHogQueue(config, theStorage, logsEndpoint) + aiQueue = PostHogQueue(config, theStorage, .ai(api: api)) #endif queue?.start(disableReachabilityForTesting: config.disableReachabilityForTesting, @@ -300,6 +319,9 @@ let maxRetryDelay = 30.0 logsQueue?.start(disableReachabilityForTesting: config.disableReachabilityForTesting, disableQueueTimerForTesting: config.disableQueueTimerForTesting) + aiQueue?.start(disableReachabilityForTesting: config.disableReachabilityForTesting, + disableQueueTimerForTesting: config.disableQueueTimerForTesting) + // Create session manager instance for this PostHogSDK instance sessionManager.setup(config: config) sessionManager.startSession() @@ -562,15 +584,7 @@ let maxRetryDelay = 30.0 // EVENT CAPTURE private func dynamicContext() -> [String: Any] { - var properties = getRegisteredProperties() - - var groups: [String: String]? - groupsLock.withLock { - groups = getGroups() - } - if groups != nil, !groups!.isEmpty { - properties["$groups"] = groups! - } + var properties: [String: Any] = [:] guard let flags = remoteConfig?.getFeatureFlags() as? [String: Any] else { return properties @@ -611,6 +625,22 @@ let maxRetryDelay = 30.0 ) } + /// Adds `process_person_profile: false` when the SDK doesn't process person profiles for this + /// event, unless the caller set that option or the legacy `$process_person_profile` property. + /// Nothing is added otherwise: PostHog processes person profiles by default. `personProcessingPending` + /// counts a person-processing change a wrapper build defers to its send. + private func personProcessingOptions(_ options: [String: Any]?, properties: [String: Any], personProcessingPending: Bool = false) -> [String: Any]? { + guard !hasPersonProcessing(), !(personProcessingPending && config.personProfiles != .never) else { return options } + if let legacy = properties["$process_person_profile"], !(legacy is NSNull) { + return options + } + var options = options ?? [:] + if options["process_person_profile"] == nil || options["process_person_profile"] is NSNull { + options["process_person_profile"] = false + } + return options + } + @discardableResult private func requirePersonProcessing(functionName: String = #function) -> Bool { if config.personProfiles == .never { @@ -685,6 +715,21 @@ let maxRetryDelay = 30.0 } } + private func replayDebugProperties() -> [String: Any] { + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) + var replayDebugProperties = replayIntegration?.debugProperties() ?? [ + "$recording_status": "disabled", + "$sdk_debug_replay_capture_mode": PostHogReplayIntegration.captureMode, + ] + #else + var replayDebugProperties: [String: Any] = ["$recording_status": "disabled"] + #endif + if let sessionStart = sessionManager.sessionStartTimestampSnapshot { + replayDebugProperties["$sdk_debug_session_start"] = Int64(sessionStart * 1000) + } + return replayDebugProperties + } + private func buildProperties(event: String?, distinctId: String, properties: [String: Any]?, @@ -695,7 +740,10 @@ let maxRetryDelay = 30.0 timestamp: Date? = nil, // Snapshot callers pass true: resolving the session read-only keeps a // background crash-context refresh from rotating an idle session. - readOnlySession: Bool = false) -> [String: Any] + readOnlySession: Bool = false, + // Wrapper builds pass false and claim at send instead: the claim never expires, so it + // can't wait on a wrapper round trip. + claimReplayDebugBundle: Bool = true) -> [String: Any] { var props: [String: Any] = [:] @@ -720,18 +768,6 @@ let maxRetryDelay = 30.0 props = props.merging(dynamicCtx ?? [:]) { current, _ in current } } props = props.merging(localDynamicCtx) { current, _ in current } - if userProperties != nil { - props["$set"] = (userProperties ?? [:]) - } - if userPropertiesSetOnce != nil { - props["$set_once"] = (userPropertiesSetOnce ?? [:]) - } - if groups != nil { - // $groups are also set via the dynamicContext - let currentGroups = props["$groups"] as? [String: String] ?? [:] - let mergedGroups = currentGroups.merging(groups ?? [:]) { _, new in new } - props["$groups"] = mergedGroups - } // Only stamp if the caller didn't supply a non-empty value. Whitespace-only // caller values are treated as absent (almost always accidental). @@ -767,49 +803,29 @@ let maxRetryDelay = 30.0 props["distinct_id"] = distinctId } - // Caller-supplied properties win over registered super properties and SDK context. - // A blank caller value for a key the SDK filled in above is treated as absent. - var callerProps = properties ?? [:] - for key in ["$session_id", "$screen_name", "distinct_id"] where props[key] != nil { - if let value = callerProps[key] as? String, !Self.isBlank(value) { - continue - } - callerProps.removeValue(forKey: key) - } - // A caller-supplied $groups adds to the registered and per-call groups instead of replacing them. - if let callerGroups = callerProps["$groups"] as? [String: String], let currentGroups = props["$groups"] as? [String: String] { - callerProps["$groups"] = currentGroups.merging(callerGroups) { _, new in new } - } - props.merge(callerProps) { _, new in new } - - // After the caller merge so the SDK's person-processing state, geoip opt-out and debug - // values can't be overridden per event, matching posthog-js and posthog-android. + // Values the SDK sets itself. Registered super properties and then the caller's properties + // override them below, and `beforeSend` has the final say. if appendSharedProps { if let isIdentified = config.storageManager?.isIdentified() { props["$is_identified"] = isIdentified } - props["$process_person_profile"] = hasPersonProcessing() + // 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 } - // SDK-computed debug keys overwrite a same-named registered super property (js: `extend` - // after super properties), so a stale `register()` can't shadow the live status. - #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) - var replayDebugProperties = replayIntegration?.debugProperties() ?? [ - "$recording_status": "disabled", - "$sdk_debug_replay_capture_mode": PostHogReplayIntegration.captureMode, - ] - #else - var replayDebugProperties: [String: Any] = ["$recording_status": "disabled"] - #endif - if let sessionStart = sessionManager.sessionStartTimestampSnapshot { - replayDebugProperties["$sdk_debug_session_start"] = Int64(sessionStart * 1000) - } + let replayDebugProperties = replayDebugProperties() // Read-only builds are the crash-context snapshot, refreshed on every status transition. - let claimedReplayDebugWindow = !readOnlySession && Self.isReplayDebugEvent(event) && claimReplayDebugPropertiesWindow() + let claimedReplayDebugWindow = claimReplayDebugBundle && !readOnlySession && Self.isReplayDebugEvent(event) + && claimReplayDebugPropertiesWindow() if readOnlySession || claimedReplayDebugWindow { props.merge(replayDebugProperties) { _, new in new } if claimedReplayDebugWindow { @@ -827,11 +843,47 @@ let maxRetryDelay = 30.0 } } + // Registered super properties win over everything the SDK set above. + if appendSharedProps { + props.merge(getRegisteredProperties()) { _, new in new } + + // Per-call values, and groups set with `group()`, win over registered ones. + if userProperties != nil { + props["$set"] = (userProperties ?? [:]) + } + if userPropertiesSetOnce != nil { + props["$set_once"] = (userPropertiesSetOnce ?? [:]) + } + var mergedGroups: [String: String] = [:] + groupsLock.withLock { + mergedGroups = getGroups() + } + mergedGroups.merge(groups ?? [:]) { _, new in new } + if !mergedGroups.isEmpty { + props["$groups"] = mergedGroups + } + } + + // Caller-supplied properties win over registered super properties and SDK-set values. + // A blank caller value for a key the SDK filled in above is treated as absent. + var callerProps = properties ?? [:] + for key in ["$session_id", "$screen_name", "distinct_id"] where props[key] != nil { + if let value = callerProps[key] as? String, !Self.isBlank(value) { + continue + } + callerProps.removeValue(forKey: key) + } + // A caller-supplied $groups adds to the registered and per-call groups instead of replacing them. + if let callerGroups = callerProps["$groups"] as? [String: String], let currentGroups = props["$groups"] as? [String: String] { + callerProps["$groups"] = currentGroups.merging(callerGroups) { _, new in new } + } + props.merge(callerProps) { _, new in new } + return props } - /// Trigger an immediate flush of every queue — events, session-replay - /// snapshots, and structured logs. + /// Trigger an immediate flush of every queue — events, AI events, + /// session-replay snapshots, and structured logs. @objc public func flush() { if !isEnabled() { return @@ -840,6 +892,7 @@ let maxRetryDelay = 30.0 queue?.flush() replayQueue?.flush() logsQueue?.flush() + aiQueue?.flush() pushSubscriptionHandler?.retryIfNeeded() } @@ -893,6 +946,7 @@ let maxRetryDelay = 30.0 flagCallReportedLock.withLock { flagCallReported.removeAll() } + wrapperSendLock.withLock { wrapperResetGeneration += 1 } sessionManager.reset() // Clear all in-memory caches (feature flags, session replay state, etc.) @@ -1008,10 +1062,19 @@ let maxRetryDelay = 30.0 /// - distinctId: Stable user identifier from your application. /// - userProperties: Properties to set on the person profile. Existing values are overwritten. /// - userPropertiesSetOnce: Properties to set only if they do not already exist. - @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) + /// - options: Optional settings that tell PostHog how to process the `$identify` or `$set` event it sends, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:) public func identify(_ distinctId: String, userProperties: [String: Any]? = nil, - userPropertiesSetOnce: [String: Any]? = nil) + userPropertiesSetOnce: [String: Any]? = nil, + options: [String: Any]? = nil) { if !isEnabled() { return @@ -1078,7 +1141,12 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce) ) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: personProcessingOptions(sanitizeDictionary(options), properties: properties) + ) else { return } @@ -1105,7 +1173,8 @@ let maxRetryDelay = 30.0 capture(PostHogKnownUnsafeEditableEvent.set.rawValue, distinctId: distinctId, userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce) + userPropertiesSetOnce: userPropertiesSetOnce, + options: options) // The identified state itself is not part of the flags request; reload only when the // caller supplied properties that can affect flag evaluation. @@ -1119,6 +1188,7 @@ let maxRetryDelay = 30.0 distinctId: distinctId, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, + options: options, deduplicatePersonProperties: true) // Automatically set person properties for feature flags during user property updates @@ -1131,6 +1201,23 @@ let maxRetryDelay = 30.0 } } + // A separate Swift name keeps this out of Swift overload resolution. + + /// Identifies the current user and sets person properties from Objective-C. + /// + /// - Parameters: + /// - distinctId: Stable user identifier from your application. + /// - userProperties: Properties to set on the person profile. Existing values are overwritten. + /// - userPropertiesSetOnce: Properties to set only if they do not already exist. + @available(swift, obsoleted: 1.0, message: "Use identify(_:userProperties:userPropertiesSetOnce:options:) instead") + @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) + public func identifyObjC(_ distinctId: String, + userProperties: [String: Any]?, + userPropertiesSetOnce: [String: Any]?) + { + identify(distinctId, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, options: nil) + } + /// Sets properties on the person profile associated with the current distinct_id. /// /// Updates user properties that are stored with the person profile in PostHog. @@ -1159,10 +1246,19 @@ let maxRetryDelay = 30.0 /// - userPropertiesToSetOnce: Properties to store about the user only if not previously set. /// Note: For feature flag evaluations, if the same key is present in both parameters, /// the value from userPropertiesToSet will take precedence. - @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) + /// - options: Optional settings that tell PostHog how to process the `$set` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:) public func setPersonProperties( userPropertiesToSet: [String: Any]?, - userPropertiesToSetOnce: [String: Any]? + userPropertiesToSetOnce: [String: Any]? = nil, + options: [String: Any]? = nil ) { if !isEnabled() { return @@ -1196,10 +1292,29 @@ let maxRetryDelay = 30.0 distinctId: currentDistinctId, userProperties: userPropertiesToSet, userPropertiesSetOnce: userPropertiesToSetOnce, + options: options, deduplicatePersonProperties: true ) } + // A separate Swift name keeps this out of Swift overload resolution. + + /// Sets properties on the person profile associated with the current distinct_id from Objective-C. + /// + /// This method sends a `$set` event to PostHog. + /// + /// - Parameters: + /// - userPropertiesToSet: Properties to store about the user. These will overwrite existing values. + /// - userPropertiesToSetOnce: Properties to store about the user only if not previously set. + @available(swift, obsoleted: 1.0, message: "Use setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:options:) instead") + @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) + public func setPersonPropertiesObjC( + userPropertiesToSet: [String: Any]?, + userPropertiesToSetOnce: [String: Any]? + ) { + setPersonProperties(userPropertiesToSet: userPropertiesToSet, userPropertiesToSetOnce: userPropertiesToSetOnce, options: nil) + } + /// Computes a hash for deduplicating person properties calls. private func getPersonPropertiesHash( distinctId: String, @@ -1362,7 +1477,7 @@ let maxRetryDelay = 30.0 /// /// - Parameters: /// - event: Event name to capture. - /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID when nil, empty or whitespace-only. /// - properties: Event properties attached only to this event. /// - userProperties: Person properties to set. Existing values are overwritten. /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. @@ -1384,25 +1499,43 @@ let maxRetryDelay = 30.0 timestamp: nil) } - /// Captures a custom event for an explicit distinct ID and timestamp. + /// Captures a custom event for an explicit distinct ID, timestamp and options. /// /// - Parameters: /// - event: Event name to capture. - /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID when nil, empty or whitespace-only. /// - properties: Event properties attached only to this event. /// - userProperties: Person properties to set. Existing values are overwritten. /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. /// - groups: Group type/key pairs to attach to this event. /// - timestamp: Optional event timestamp. Defaults to the current time. The absolute instant /// is serialized in UTC, regardless of the calendar or time zone used to create it. - @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) + /// - options: Optional settings that tell PostHog how to process this event, sent apart from + /// its properties. Known keys: + /// - `process_person_profile` (`Bool`): whether the event creates or updates a person profile. + /// Defaults to whether the SDK currently processes person profiles for this user (see + /// `personProfiles`). + /// - `cookieless_mode` (`Bool`): process the event in cookieless mode. + /// - `disable_skew_correction` (`Bool`): keep `timestamp` as sent, without correcting for + /// device clock skew. + /// - `product_tour_id` (`String`): the product tour the event belongs to. + /// + /// These replace the `$process_person_profile`, `$cookieless_mode`, `$ignore_sent_at` and + /// `$product_tour_id` properties. Those properties still work: they are removed from the + /// event's properties and used as the option, unless you set that option here. A + /// `$process_person_profile` property you pass or register overrides the SDK's default. + /// Other keys are sent unchanged and ignored by PostHog. PostHog drops an + /// event whose option value it can't read. + /// `beforeSend` can change them through `PostHogEvent.options`. + @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) public func capture(_ event: String, distinctId: String? = nil, properties: [String: Any]? = nil, userProperties: [String: Any]? = nil, userPropertiesSetOnce: [String: Any]? = nil, groups: [String: String]? = nil, - timestamp: Date? = nil) + timestamp: Date? = nil, + options: [String: Any]? = nil) { captureInternal( event, @@ -1412,10 +1545,99 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: userPropertiesSetOnce, groups: groups, timestamp: timestamp, + options: options, skipBuildProperties: false ) } + /// Captures an AI observability event, such as `$ai_generation`, `$ai_span`, `$ai_trace` or + /// `$ai_embedding`, and sends it to PostHog's AI endpoint, which accepts much larger events than + /// `capture`. + /// + /// Takes the same arguments as `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)` + /// and goes through the same `beforeSend` hooks. The event is queued on disk separately from other + /// events and sent unchanged: the SDK doesn't redact or truncate prompts, outputs or media. + /// An event over `captureAiMaxEventBytes` (8 MiB plus 64 KiB by default) is dropped. PostHog drops an event + /// sent here whose name doesn't start with `$ai_`, and the SDK logs a debug message when it's + /// given one. `capture` never sends events here, even ones named `$ai_*`. + /// + /// AI events use `flushAt`, `maxBatchSize` and `flushIntervalSeconds` from `PostHogConfig`, like + /// other events, and `captureAiCompression`, `captureAiMaxQueueSize` and `captureAiTimeout` instead + /// of `compression`, `maxQueueSize` and the 10-second timeout. These limits are fixed: + /// - Requests carry up to about 5 MiB of events, and the SDK sends as soon as that much is queued. + /// - Up to 50 MiB of AI events are kept on disk. Beyond that, the oldest are dropped. + /// + /// - Parameters: + /// - event: AI event name, such as `$ai_generation`. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID. + /// - properties: Event properties attached only to this event. + /// - userProperties: Person properties to set. Existing values are overwritten. + /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. + /// - groups: Group type/key pairs to attach to this event. + /// - timestamp: Optional event timestamp. Defaults to the current time. + /// - options: Optional settings that tell PostHog how to process this event. Same keys as + /// `capture`'s `options`. + /// + /// - 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`. + @discardableResult + @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) + public func captureAi(_ event: String, + distinctId: String? = nil, + properties: [String: Any]? = nil, + userProperties: [String: Any]? = nil, + userPropertiesSetOnce: [String: Any]? = nil, + groups: [String: String]? = nil, + timestamp: Date? = nil, + options: [String: Any]? = nil) -> String? + { + warnIfNotAiEvent(event) + + return captureInternal( + event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + options: options, + aiLane: true + ) + } + + // A separate Swift name keeps this out of Swift overload resolution. + + /// Captures a custom event for an explicit distinct ID and timestamp from Objective-C. + /// + /// - Parameters: + /// - event: Event name to capture. + /// - distinctId: Optional distinct ID override. Defaults to the current SDK distinct ID when nil, empty or whitespace-only. + /// - properties: Event properties attached only to this event. + /// - userProperties: Person properties to set. Existing values are overwritten. + /// - userPropertiesSetOnce: Person properties to set only if they do not already exist. + /// - groups: Group type/key pairs to attach to this event. + /// - timestamp: Optional event timestamp. Defaults to the current time. + @available(swift, obsoleted: 1.0, message: "Use capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) instead") + @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) + public func captureObjC(_ event: String, // swiftlint:disable:this function_parameter_count + distinctId: String?, + properties: [String: Any]?, + userProperties: [String: Any]?, + userPropertiesSetOnce: [String: Any]?, + groups: [String: String]?, + timestamp: Date?) + { + capture(event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + options: nil) + } + // MARK: - Logs capture /// Capture a structured log record at `.info` severity. @@ -1560,6 +1782,8 @@ let maxRetryDelay = 30.0 /// - Parameters: /// - skipBuildProperties: When true, skips buildProperties call and uses properties as-is. /// Used by crash reporting to capture events with pre-built crash-time context. + /// - Returns: The queued event's UUID, or `nil` when the event isn't queued. + @discardableResult func captureInternal( _ event: String, distinctId: String? = nil, @@ -1568,20 +1792,57 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: [String: Any]? = nil, groups: [String: String]? = nil, timestamp: Date? = nil, + options: [String: Any]? = nil, skipBuildProperties: Bool = false, propertyAllowlist: Set? = nil, - deduplicatePersonProperties: Bool = false - ) { + deduplicatePersonProperties: Bool = false, + aiLane: Bool = false + ) -> String? { + guard let prepared = prepareCaptureEvent( + event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + options: options, + skipBuildProperties: skipBuildProperties, + propertyAllowlist: propertyAllowlist, + aiLane: aiLane + ) else { + return nil + } + return finishCaptureEvent(prepared, aiLane: aiLane, deduplicatePersonProperties: deduplicatePersonProperties)? + .uuid.postHogUuidString + } + + /// Builds the event a capture would queue, without running `beforeSend` or queueing it. + /// Returns `nil` when the capture is skipped. + private func prepareCaptureEvent( // swiftlint:disable:this function_parameter_count + _ event: String, + distinctId: String?, + properties: [String: Any]?, + userProperties: [String: Any]?, + userPropertiesSetOnce: [String: Any]?, + groups: [String: String]?, + timestamp: Date?, + options: [String: Any]?, + skipBuildProperties: Bool, + propertyAllowlist: Set?, + aiLane: Bool, + forWrapper: Bool = false + ) -> PostHogEvent? { if !isEnabled() { - return + return nil } if isOptOutState() { - return + return nil } - guard let queue else { - return + guard (aiLane ? aiQueue : queue) != nil else { + return nil } // $exception_list only ever comes from the caller, so raw properties are sufficient here @@ -1591,13 +1852,14 @@ let maxRetryDelay = 30.0 PostHogErrorTrackingAutoCaptureIntegration.exceptionListMatchesIgnoredTypes(properties ?? [:], ignoredTypes: ignored) { hedgeLog("$exception skipped: exception type is in errorTrackingConfig.ignoredExceptionTypes") - return + return nil } } - var isSnapshotEvent = event == "$snapshot" + let isSnapshotEvent = !aiLane && event == "$snapshot" let eventTimestamp = timestamp ?? now() - let eventDistinctId = distinctId ?? getDistinctId() + // A blank distinct ID falls back to the current one, like nil, instead of PostHog dropping the event. + let eventDistinctId = distinctId.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } ?? getDistinctId() // if the user isn't identified but passed userProperties, userPropertiesSetOnce or groups, // we should still enable person processing since this is intentional @@ -1605,7 +1867,8 @@ let maxRetryDelay = 30.0 || userPropertiesSetOnce?.isEmpty == false || groups?.isEmpty == false - if !skipBuildProperties, hasPersonData { + // A wrapper build defers this to its send, so a build the wrapper drops changes no state. + if !skipBuildProperties, hasPersonData, !forWrapper { requirePersonProcessing() } @@ -1622,7 +1885,8 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: sanitizeDictionary(userPropertiesSetOnce), groups: groups, appendSharedProps: !isSnapshotEvent, - timestamp: timestamp + timestamp: timestamp, + claimReplayDebugBundle: !forWrapper ) } @@ -1646,40 +1910,243 @@ let maxRetryDelay = 30.0 finalProperties[PostHogExceptionStepFields.stepsKey] = steps } - // Sanitize is now called in buildEvent - let posthogEvent = buildEvent( + // Snapshots and pre-built crash events don't carry the SDK's person-processing option. + let eventOptions = skipBuildProperties || isSnapshotEvent + ? sanitizeDictionary(options) + : personProcessingOptions(sanitizeDictionary(options), properties: finalProperties, personProcessingPending: forWrapper && hasPersonData) + + let posthogEvent = makeEvent( event: event, distinctId: eventDistinctId, properties: finalProperties, + options: eventOptions, timestamp: eventTimestamp ) + if !skipBuildProperties { + posthogEvent.flagUserProperties = userProperties + posthogEvent.flagUserPropertiesSetOnce = userPropertiesSetOnce + } + if forWrapper { + posthogEvent.wrapperRequiresPersonProcessing = hasPersonData + } + return posthogEvent + } - guard let posthogEvent else { - return + /// Runs `beforeSend` on an event from `prepareCaptureEvent` and queues the result. + /// - Returns: The queued event, as `beforeSend` returned it, or `nil` when the event isn't queued. + private func finishCaptureEvent(_ prepared: PostHogEvent, aiLane: Bool, deduplicatePersonProperties: Bool = false) -> PostHogEvent? { + guard let queue = aiLane ? aiQueue : queue else { + releaseClaimIfCarried(prepared) + return nil + } + + guard let posthogEvent = applyBeforeSend(prepared) else { + return nil } - // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook - isSnapshotEvent = posthogEvent.event == "$snapshot" + // Reevaluate if this is a snapshot event because the event might have been updated by the beforeSend hook. + // The AI lane always uses its own queue. + let isSnapshotEvent = !aiLane && posthogEvent.event == "$snapshot" // if this is a $snapshot event and $session_id is missing, don't process then event if isSnapshotEvent, posthogEvent.properties["$session_id"] == nil { releaseClaimIfCarried(posthogEvent) - return + return nil } // Automatically set person properties for feature flags during capture event - if !skipBuildProperties { - setPersonPropertiesForFlagsIfNeeded(userProperties, userPropertiesSetOnce: userPropertiesSetOnce) - } + setPersonPropertiesForFlagsIfNeeded(prepared.flagUserProperties, userPropertiesSetOnce: prepared.flagUserPropertiesSetOnce) // Session Replay has its own queue if isSnapshotEvent { releaseClaimIfCarried(posthogEvent) replayQueue?.add(posthogEvent) onEventCaptured.invoke(posthogEvent) + return replayQueue == nil ? nil : posthogEvent + } + let stored = queueEvent(posthogEvent, queue: queue, deduplicatePersonProperties: deduplicatePersonProperties) + return stored ? posthogEvent : nil + } + + private func warnIfNotAiEvent(_ event: String) { + if !event.hasPrefix("$ai_") { + hedgeLog("captureAi event '\(event)' doesn't start with '$ai_', so PostHog will drop it. Use capture for non-AI events.") + } + } + + /// For wrapper SDKs only (such as posthog-flutter); not for app code. + /// + /// Builds the event `capture` (or `captureAi` when `ai` is true) would queue, with SDK, registered and + /// caller properties already merged, but doesn't run `beforeSend` or queue it. A `$screen` event is built + /// like `screen(_:properties:options:)` instead: it needs a non-blank `$screen_name` in `properties`, and + /// `distinctId`, person properties, groups and `timestamp` are ignored. It becomes the current screen once sent. This lets a wrapper run + /// its own `beforeSend` on the final event, then hand it back with `sendBuiltEventForWrapper(_:ai:)`. + /// Edit the returned event in place; its `uuid` and `timestamp` are kept. An event the wrapper drops needs no + /// cleanup. Distinct ID, session and person processing are read here, at build time. + /// + /// - Parameters: + /// - event: Event name. + /// - distinctId: Optional distinct ID. Defaults to the current one when nil, empty or whitespace-only. + /// - properties: The call's event properties. + /// - userProperties: Person properties to `$set`. + /// - userPropertiesSetOnce: Person properties to `$set_once`. + /// - groups: Groups for the event, keyed by group type. + /// - options: Event options, the same keys as `capture`'s `options`. + /// - timestamp: Optional event timestamp. Defaults to the current time. + /// - ai: True for an AI event, sent on the AI lane. + /// - Returns: The built event, or `nil` when the capture is skipped: the SDK isn't set up, is disabled or + /// opted out, the event is an ignored exception type, or a `$screen` event has no `$screen_name`. + /// + /// ```swift + /// @_spi(PostHogInternal) import PostHog + /// + /// if let event = PostHogSDK.shared.buildEventForWrapper(event: "signed_up", distinctId: nil, properties: ["plan": "pro"], + /// userProperties: nil, userPropertiesSetOnce: nil, groups: nil, + /// options: nil, ai: false) { + /// event.properties["source"] = "flutter" + /// PostHogSDK.shared.sendBuiltEventForWrapper(event, ai: false) + /// } + /// ``` + @_spi(PostHogInternal) public func buildEventForWrapper( // swiftlint:disable:this function_parameter_count + event: String, + distinctId: String?, + properties: [String: Any]?, + userProperties: [String: Any]?, + userPropertiesSetOnce: [String: Any]?, + groups: [String: String]?, + options: [String: Any]?, + timestamp: Date? = nil, + ai: Bool // swiftlint:disable:this identifier_name + ) -> PostHogEvent? { + if ai { + warnIfNotAiEvent(event) + } else if event == "$screen" { + let screen = buildScreenEventForWrapper(properties: properties, options: options) + stampWrapperBuild(screen, event: event) + return screen + } + let built = prepareCaptureEvent( + event, + distinctId: distinctId, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + groups: groups, + timestamp: timestamp, + options: options, + skipBuildProperties: false, + propertyAllowlist: nil, + aiLane: ai, + forWrapper: true + ) + built?.wrapperAiLane = ai + stampWrapperBuild(built, event: event) + return built + } + + /// Records the build-time state a wrapper send checks: this SDK and its setup, the identity, and the + /// replay debug values, so a `close()`/`setup()`, `reset()` or new session during the round trip doesn't + /// leak into the event. + private func stampWrapperBuild(_ built: PostHogEvent?, event: String) { + guard let built else { return } + built.wrapperBuildSDK = ObjectIdentifier(self) + built.wrapperBuildSetupGeneration = wrapperSendLock.withLock { wrapperSetupGeneration } + built.wrapperBuildDistinctId = getDistinctId() + built.wrapperBuildResetGeneration = wrapperSendLock.withLock { wrapperResetGeneration } + if Self.isReplayDebugEvent(event) { + built.wrapperDeferredReplayDebug = replayDebugProperties().filter { !Self.requiredReplayDebugPropertyKeys.contains($0.key) } + } + } + + /// Builds a wrapper's `$screen` event like `screen(_:properties:options:)`, from its required `$screen_name`. + /// The screen is recorded at send, from the final event, so a wrapper hook that renames or drops it is respected. + private func buildScreenEventForWrapper(properties: [String: Any]?, options: [String: Any]?) -> PostHogEvent? { + guard let screenName = properties?["$screen_name"] as? String, !Self.isBlank(screenName) else { + hedgeLog("PostHog $screen event without a $screen_name was dropped") + return nil + } + guard let prepared = prepareScreenEvent(screenName, properties: properties, options: options, forWrapper: true) else { + return nil + } + prepared.event.wrapperAiLane = false + return prepared.event + } + + /// For wrapper SDKs only (such as posthog-flutter); not for app code. + /// + /// Sends an event from `buildEventForWrapper`: runs the native + /// `beforeSend` chain on it, then queues it on the analytics lane, or the AI lane when `ai` is true. + /// The SDK adds only the throttled replay debug properties a normal capture would, and keeps the event's + /// `uuid` and `timestamp`. A sent `$screen` event becomes the current screen. + /// + /// - Parameters: + /// - event: The built event, as edited by the wrapper. + /// - ai: True to send on the AI lane. Pass the same value given to `buildEventForWrapper`; this one wins, + /// with a log, if they differ. + /// - Returns: The queued event's UUID, or `nil` when it isn't queued: the SDK was opted out since the build, + /// `beforeSend` dropped it, the event was already sent or wasn't built for a wrapper, or it was built by + /// another instance or before `close()`/`setup()`. + /// + /// ```swift + /// let uuid = PostHogSDK.shared.sendBuiltEventForWrapper(event, ai: true) + /// ``` + @_spi(PostHogInternal) @discardableResult public func sendBuiltEventForWrapper( + _ event: PostHogEvent, + ai: Bool // swiftlint:disable:this identifier_name + ) -> String? { + let builtLane: Bool? = wrapperSendLock.withLock { + defer { event.wrapperAiLane = nil } + return event.wrapperAiLane + } + guard let builtLane else { + hedgeLog("PostHog event \(event.event) wasn't built by buildEventForWrapper, or was already sent, so it was dropped") + return nil + } + let currentSetup = wrapperSendLock.withLock { wrapperSetupGeneration } + guard event.wrapperBuildSDK == ObjectIdentifier(self), event.wrapperBuildSetupGeneration == currentSetup else { + hedgeLog("PostHog event \(event.event) was built before the SDK was closed or set up again, so it was dropped") + return nil + } + if ai != builtLane { + hedgeLog("PostHog event \(event.event) was built for the \(builtLane ? "AI" : "analytics") lane, but is sent on the lane given here") + } + if !isEnabled() || isOptOutState() { + return nil + } + // Person processing and flag person properties apply only while the SDK still has the identity + // the event was built for, so a `reset()` or `identify()` during the round trip doesn't move them. + let sameIdentity = event.wrapperBuildDistinctId == getDistinctId() + && event.wrapperBuildResetGeneration == wrapperSendLock.withLock { wrapperResetGeneration } + if sameIdentity, event.wrapperRequiresPersonProcessing { + requirePersonProcessing() + } + // Eligibility and values come from the build, since the hook can rename the event and the session can change. + if let deferred = event.wrapperDeferredReplayDebug, claimReplayDebugPropertiesWindow() { + // SDK values, so the wrapper's properties win. The required keys were on the built event, + // so the wrapper already saw them and may have removed them on purpose. + event.properties.merge(deferred) { current, _ in current } + event.carriesReplayDebugBundle = true + } + // Feature flags get the person properties the wrapper left on the event. + if sameIdentity { + event.flagUserProperties = event.properties["$set"] as? [String: Any] + event.flagUserPropertiesSetOnce = event.properties["$set_once"] as? [String: Any] } else { - queueEvent(posthogEvent, queue: queue, deduplicatePersonProperties: deduplicatePersonProperties) + event.flagUserProperties = nil + event.flagUserPropertiesSetOnce = nil } + guard let sent = finishCaptureEvent(event, aiLane: ai) else { + return nil + } + // A `reset()` since the build cleared the screen name, so the old one isn't restored. + if sent.event == "$screen", + event.wrapperBuildResetGeneration == wrapperSendLock.withLock({ wrapperResetGeneration }), + let screenName = sent.properties["$screen_name"] as? String, + let cleaned = PostHogScreenNameSanitizer.sanitize(rawScreenName: screenName) + { + didRecordScreen(cleaned, changed: cacheScreenName(cleaned)) + } + return sent.uuid.postHogUuidString } /// Records a screen view by capturing a `$screen` event. @@ -1701,35 +2168,67 @@ let maxRetryDelay = 30.0 /// - screenTitle: The screen name to record. It takes precedence over a /// `$screen_name` key in `properties`. /// - properties: Additional properties to attach to this `$screen` event. - @objc(screenWithTitle:properties:) - public func screen(_ screenTitle: String, properties: [String: Any]? = nil) { - if !isEnabled() { + /// - options: Optional settings that tell PostHog how to process this `$screen` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(screenWithTitle:properties:options:) + public func screen(_ screenTitle: String, properties: [String: Any]? = nil, options: [String: Any]? = nil) { + guard let prepared = prepareScreenEvent(screenTitle, properties: properties, options: options) else { + return + } + guard let queue else { + releaseClaimIfCarried(prepared.event) + return + } + guard let event = applyBeforeSend(prepared.event) else { return } + queueEvent(event, queue: queue) + didRecordScreen(prepared.screenName, changed: prepared.screenNameChanged) + } + + private struct PreparedScreen { + let event: PostHogEvent + let screenName: String + let screenNameChanged: Bool + } + + /// Caches the screen name and builds the `$screen` event, without running `beforeSend` or queueing it. + private func prepareScreenEvent( + _ screenTitle: String, + properties: [String: Any]?, + options: [String: Any]?, + forWrapper: Bool = false + ) -> PreparedScreen? { + if !isEnabled() { + return nil + } + if isOptOutState() { - return + return nil } // Strip SwiftUI wrappers (UIHostingController → X). Drops degenerate // inputs (empty, stripped-AnyView) — for those we skip the $screen event // and leave the cache untouched so the last useful name survives. guard let cleaned = PostHogScreenNameSanitizer.sanitize(rawScreenName: screenTitle) else { - return + return nil } // Cache the name before the queue check so a screen() call during init // (queue not yet assigned) still seeds the cache for subsequent events. // Track whether the value actually changed so we can skip the crash- // replay snapshot refresh on duplicate viewDidAppears for the same VC. - let screenNameChanged = lastScreenLock.withLock { () -> Bool in - let changed = _lastScreenName != cleaned - _lastScreenName = cleaned - return changed - } + let screenNameChanged = forWrapper ? false : cacheScreenName(cleaned) - guard let queue else { - return + guard queue != nil else { + return nil } let props = [ @@ -1739,14 +2238,28 @@ let maxRetryDelay = 30.0 let distinctId = getDistinctId() let eventName = "$screen" - let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) + let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props, claimReplayDebugBundle: !forWrapper) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { - return - } + let event = makeEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: personProcessingOptions(sanitizeDictionary(options), properties: properties), + timestamp: Date() + ) + return PreparedScreen(event: event, screenName: cleaned, screenNameChanged: screenNameChanged) + } - queueEvent(event, queue: queue) + /// Returns whether the cached screen name changed. + private func cacheScreenName(_ cleaned: String) -> Bool { + lastScreenLock.withLock { () -> Bool in + let changed = _lastScreenName != cleaned + _lastScreenName = cleaned + return changed + } + } + private func didRecordScreen(_ cleaned: String, changed screenNameChanged: Bool) { // Fanout to subscribers (sanitized; downstream listeners no longer // need to re-apply sanitize). Currently no in-tree subscribers — kept // as an extension point for external consumers. Subscribers must not @@ -1763,6 +2276,20 @@ let maxRetryDelay = 30.0 } } + // A separate Swift name keeps this out of Swift overload resolution. + + /// Records a screen view by capturing a `$screen` event from Objective-C. + /// + /// - Parameters: + /// - screenTitle: The screen name to record. It takes precedence over a + /// `$screen_name` key in `properties`. + /// - properties: Additional properties to attach to this `$screen` event. + @available(swift, obsoleted: 1.0, message: "Use screen(_:properties:options:) instead") + @objc(screenWithTitle:properties:) + public func screenObjC(_ screenTitle: String, properties: [String: Any]?) { + screen(screenTitle, properties: properties, options: nil) + } + func autocapture( eventType: String, elementsChain: String, @@ -1816,7 +2343,7 @@ let maxRetryDelay = 30.0 let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties, options: personProcessingOptions(nil, properties: properties)) else { return } @@ -1830,6 +2357,26 @@ let maxRetryDelay = 30.0 /// /// - Parameter alias: The additional distinct ID to associate with the current user. @objc public func alias(_ alias: String) { + self.alias(alias, options: nil) + } + + /// Assigns an additional distinct ID to the current user. + /// + /// Use alias when a user should be connected to another identifier that was previously used + /// to track them. This sends a `$create_alias` event. + /// + /// - Parameters: + /// - alias: The additional distinct ID to associate with the current user. + /// - options: Optional settings that tell PostHog how to process the `$create_alias` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(alias:options:) + public func alias(_ alias: String, options: [String: Any]? = nil) { if !isEnabled() { return } @@ -1853,7 +2400,12 @@ let maxRetryDelay = 30.0 let eventName = "$create_alias" let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: personProcessingOptions(sanitizeDictionary(options), properties: properties) + ) else { return } @@ -1888,7 +2440,7 @@ let maxRetryDelay = 30.0 return mergedGroups ?? [:] } - private func groupIdentify(type: String, key: String, groupProperties: [String: Any]? = nil) { + private func groupIdentify(type: String, key: String, groupProperties: [String: Any]? = nil, options: [String: Any]? = nil) { if !isEnabled() { return } @@ -1919,7 +2471,12 @@ let maxRetryDelay = 30.0 let eventName = "$groupidentify" let properties = buildProperties(event: eventName, distinctId: distinctId, properties: props) - guard let event = buildEvent(event: eventName, distinctId: distinctId, properties: properties) else { + guard let event = buildEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: personProcessingOptions(sanitizeDictionary(options), properties: properties) + ) else { return } @@ -1932,7 +2489,29 @@ let maxRetryDelay = 30.0 } } - func buildEvent(event eventName: String, distinctId: String, properties: [String: Any], timestamp: Date = Date()) -> PostHogEvent? { + func buildEvent( + event eventName: String, + distinctId: String, + properties: [String: Any], + options: [String: Any]? = nil, + timestamp: Date = Date() + ) -> PostHogEvent? { + applyBeforeSend(makeEvent( + event: eventName, + distinctId: distinctId, + properties: properties, + options: options, + timestamp: timestamp + )) + } + + private func makeEvent( + event eventName: String, + distinctId: String, + properties: [String: Any], + options: [String: Any]?, + timestamp: Date + ) -> PostHogEvent { var properties = properties let carriesReplayDebugBundle = properties.removeValue(forKey: Self.replayDebugClaimMarkerKey) != nil @@ -1940,12 +2519,22 @@ let maxRetryDelay = 30.0 event: eventName, distinctId: distinctId, properties: properties, + options: options, timestamp: timestamp ) + event.carriesReplayDebugBundle = carriesReplayDebugBundle + return event + } + private func applyBeforeSend(_ event: PostHogEvent) -> PostHogEvent? { + let eventName = event.event + let carriesReplayDebugBundle = event.carriesReplayDebugBundle let resultEvent = config.runBeforeSend(event) if let resultEvent { + // beforeSend can return an event kept from an earlier call. Capture V1 + // rejects a batch that repeats a UUID, so each capture keeps its own. + resultEvent.uuid = event.uuid resultEvent.carriesReplayDebugBundle = carriesReplayDebugBundle } else { if carriesReplayDebugBundle { @@ -1962,14 +2551,18 @@ let maxRetryDelay = 30.0 return resultEvent } - private func queueEvent(_ event: PostHogEvent, queue: PostHogQueue, deduplicatePersonProperties: Bool = false) { + /// Returns whether the event was stored in `queue`. + @discardableResult + private func queueEvent(_ event: PostHogEvent, queue: PostHogQueue, deduplicatePersonProperties: Bool = false) -> Bool { let storedInQueue: Bool let userProperties = event.properties["$set"] as? [String: Any] let userPropertiesSetOnce = event.properties["$set_once"] as? [String: Any] // Presence, not content: sanitizing can empty a non-empty input (a `UUID` or `Data` value // is dropped), and that empty `$set` is still queued, so it needs a marker too. + // A personless event doesn't update the person, so it neither marks nor matches a hash. if event.event == PostHogKnownUnsafeEditableEvent.set.rawValue || event.event == PostHogKnownUnsafeEditableEvent.identify.rawValue, userProperties != nil || userPropertiesSetOnce != nil, + event.options["process_person_profile"] as? Bool != false, let storageManager = config.storageManager { let hash = getPersonPropertiesHash( @@ -1993,7 +2586,7 @@ let maxRetryDelay = 30.0 releaseClaimIfCarried(event) // Only a suppressed duplicate skips the callback below: subscribers such as replay // triggers and event-activated surveys don't depend on this queue reaching disk. - return + return false } storedInQueue = stored } else { @@ -2007,6 +2600,7 @@ let maxRetryDelay = 30.0 } } onEventCaptured.invoke(event) + return storedInQueue } /// Associates subsequent events with a group. @@ -2028,8 +2622,16 @@ let maxRetryDelay = 30.0 /// - type: Group type, such as `"company"` or `"organization"`. /// - key: Group key from your application. /// - groupProperties: Optional properties to set on the group profile. - @objc(groupWithType:key:groupProperties:) - public func group(type: String, key: String, groupProperties: [String: Any]? = nil) { + /// - options: Optional settings that tell PostHog how to process the `$groupidentify` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + @objc(groupWithType:key:groupProperties:options:) + public func group(type: String, key: String, groupProperties: [String: Any]? = nil, options: [String: Any]? = nil) { if !isEnabled() { return } @@ -2049,12 +2651,26 @@ let maxRetryDelay = 30.0 _ = groups([type: key]) - groupIdentify(type: type, key: key, groupProperties: sanitizeDictionary(groupProperties)) + groupIdentify(type: type, key: key, groupProperties: sanitizeDictionary(groupProperties), options: options) // Notify integrations of context change (e.g., for crash reporting) notifyContextDidChange() } + // A separate Swift name keeps this out of Swift overload resolution. + + /// Associates subsequent events with a group and optionally updates group properties from Objective-C. + /// + /// - Parameters: + /// - type: Group type, such as `"company"` or `"organization"`. + /// - key: Group key from your application. + /// - groupProperties: Optional properties to set on the group profile. + @available(swift, obsoleted: 1.0, message: "Use group(type:key:groupProperties:options:) instead") + @objc(groupWithType:key:groupProperties:) + public func groupObjC(type: String, key: String, groupProperties: [String: Any]?) { + group(type: type, key: key, groupProperties: groupProperties, options: nil) + } + // FEATURE FLAGS /// Sets person properties that will be included in feature flag evaluation requests. @@ -2620,7 +3236,7 @@ let maxRetryDelay = 30.0 /// The strict property allowlist for minimal `$feature_flag_called` events. Everything else — /// other registered super properties, `$active_feature_flags`, the `$feature/` enumeration, /// bootstrap enrichment — is stripped. Kept in sync with the cross-SDK minimal - /// `$feature_flag_called` contract. + /// `$feature_flag_called` contract, plus the legacy option properties that capture V1 sends as options. private static let minimalFeatureFlagCalledProperties: Set = [ "$feature_flag", "$feature_flag_response", @@ -2632,6 +3248,10 @@ let maxRetryDelay = 30.0 "$feature_flag_evaluated_at", "$groups", "$process_person_profile", + // Legacy option properties, e.g. registered as super properties, so they still become options. + "$cookieless_mode", + "$ignore_sent_at", + "$product_tour_id", "$session_id", "$lib", "$lib_version", @@ -2866,7 +3486,7 @@ let maxRetryDelay = 30.0 /// Shuts down this SDK instance and clears its in-memory state. /// - /// Starts sending queued events, session replay snapshots, and logs (up to one batch each) and returns without + /// Starts sending queued events, AI events, session replay snapshots, and logs (up to one batch each) and returns without /// waiting for delivery. Anything not sent, for example while offline, stays on disk and is sent after the next /// setup. Integrations are uninstalled, and this instance must be set up again before reuse. @objc public func close() { @@ -2876,19 +3496,23 @@ let maxRetryDelay = 30.0 setupLock.withLock { enabled = false + wrapperSendLock.withLock { wrapperSetupGeneration += 1 } PostHogSDK.projectTokens.remove(config.projectToken) queue?.flush() + aiQueue?.flush() replayQueue?.flush() logsQueue?.flush() queue?.stop() replayQueue?.stop() logsQueue?.stop() + aiQueue?.stop() queue = nil replayQueue = nil logsQueue = nil + aiQueue = nil pushSubscriptionHandler = nil #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) startSessionRecordingOnOptIn = true @@ -3149,11 +3773,20 @@ let maxRetryDelay = 30.0 /// - Parameters: /// - error: The error to capture (can be any Error or NSError) /// - properties: Optional additional properties to attach to the event - /// - @objc(captureExceptionWithError:properties:) + /// - options: Optional settings that tell PostHog how to process the `$exception` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + /// + @objc(captureExceptionWithError:properties:options:) public func captureException( _ error: Error, - properties: [String: Any]? = nil + properties: [String: Any]? = nil, + options: [String: Any]? = nil ) { guard isEnabled() else { return } @@ -3163,7 +3796,21 @@ let maxRetryDelay = 30.0 config: config.errorTrackingConfig ) - captureExceptionEvent(errorProperties, additionalProperties: properties) + captureExceptionEvent(errorProperties, additionalProperties: properties, options: options) + } + + // A separate Swift name keeps this out of Swift overload resolution. + + /// Capture a Swift Error or NSError from Objective-C + /// + /// - Parameters: + /// - error: The error to capture (can be any Error or NSError) + /// - properties: Optional additional properties to attach to the event + /// + @available(swift, obsoleted: 1.0, message: "Use captureException(_:properties:options:) instead") + @objc(captureExceptionWithError:properties:) + public func captureExceptionObjC(_ error: Error, properties: [String: Any]?) { + captureException(error, properties: properties, options: nil) } /// Capture a Swift Error or NSError without additional properties @@ -3196,11 +3843,20 @@ let maxRetryDelay = 30.0 /// - Parameters: /// - exception: The NSException to capture /// - properties: Optional additional properties to attach to the event - /// - @objc(captureExceptionWithNSException:properties:) + /// - options: Optional settings that tell PostHog how to process the `$exception` event, + /// sent apart from its properties. Known keys are `process_person_profile` (`Bool`), + /// `cookieless_mode` (`Bool`), `disable_skew_correction` (`Bool`) and `product_tour_id` + /// (`String`), as described on + /// `capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:)`. + /// An option wins over its legacy `$process_person_profile`, `$cookieless_mode`, + /// `$ignore_sent_at` or `$product_tour_id` property. `beforeSend` can change them through + /// `PostHogEvent.options`. + /// + @objc(captureExceptionWithNSException:properties:options:) public func captureException( _ exception: NSException, - properties: [String: Any]? = nil + properties: [String: Any]? = nil, + options: [String: Any]? = nil ) { guard isEnabled() else { return } @@ -3210,7 +3866,21 @@ let maxRetryDelay = 30.0 config: config.errorTrackingConfig ) - captureExceptionEvent(exceptionProperties, additionalProperties: properties) + captureExceptionEvent(exceptionProperties, additionalProperties: properties, options: options) + } + + // A separate Swift name keeps this out of Swift overload resolution. + + /// Capture an NSException from Objective-C + /// + /// - Parameters: + /// - exception: The NSException to capture + /// - properties: Optional additional properties to attach to the event + /// + @available(swift, obsoleted: 1.0, message: "Use captureException(_:properties:options:) instead") + @objc(captureExceptionWithNSException:properties:) + public func captureExceptionObjC(_ exception: NSException, properties: [String: Any]?) { + captureException(exception, properties: properties, options: nil) } /// Capture an NSException without additional properties @@ -3323,7 +3993,8 @@ let maxRetryDelay = 30.0 private func captureExceptionEvent( _ exceptionProperties: [String: Any], - additionalProperties: [String: Any]? + additionalProperties: [String: Any]?, + options: [String: Any]? ) { var mergedProperties = exceptionProperties additionalProperties?.forEach { key, value in @@ -3333,7 +4004,7 @@ let maxRetryDelay = 30.0 } // ignoredExceptionTypes is enforced in captureInternal, the chokepoint for every $exception path - capture("$exception", properties: mergedProperties) + capture("$exception", properties: mergedProperties, options: options) } private func installIntegrations() { diff --git a/PostHog/PostHogStorage.swift b/PostHog/PostHogStorage.swift index 26ae00203..386e54135 100644 --- a/PostHog/PostHogStorage.swift +++ b/PostHog/PostHogStorage.swift @@ -236,6 +236,7 @@ class PostHogStorage { case oldReplayQueue = "posthog.replayFolder" // replay queue with legacy timestamp filenames case replayBufferQueue = "posthog.replayBufferFolder" case logsQueue = "posthog.logsFolder" + case aiQueue = "posthog.aiFolder" case enabledFeatureFlags = "posthog.enabledFeatureFlags" case enabledFeatureFlagPayloads = "posthog.enabledFeatureFlagPayloads" case flags = "posthog.flags" @@ -497,7 +498,7 @@ class PostHogStorage { if !keepAnonymousId { deleteSafely(url(forKey: .anonymousId)) } - // .queue, .replayQeueue, .logsQueue not deleted here — each queue manages its own + // .queue, .replayQeueue, .logsQueue, .aiQueue not deleted here — each queue manages its own // disk state via clear() and the per-record distinctId captured at enqueue time // (see PostHogLogRecord) lets in-flight telemetry survive an identity change. deleteSafely(url(forKey: .oldQueueFolder)) diff --git a/PostHog/PostHogUploadInfo.swift b/PostHog/PostHogUploadInfo.swift index 698df7809..01b842e79 100644 --- a/PostHog/PostHogUploadInfo.swift +++ b/PostHog/PostHogUploadInfo.swift @@ -11,10 +11,15 @@ struct PostHogUploadInfo { let statusCode: Int? let error: Error? let retryAfter: TimeInterval? + /// Record IDs a 2xx response asked to retry (capture V1 per-event results). + /// The rest of the batch is delivered. `nil` or empty means the whole batch + /// shares `statusCode`'s outcome. + let retryRecordIds: Set? - init(statusCode: Int?, error: Error?, retryAfter: TimeInterval? = nil) { + init(statusCode: Int?, error: Error?, retryAfter: TimeInterval? = nil, retryRecordIds: Set? = nil) { self.statusCode = statusCode self.error = error self.retryAfter = retryAfter + self.retryRecordIds = retryRecordIds } } diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index e0bb2748b..7de15ce4c 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -824,15 +824,15 @@ final class PostHogPushSubscriptionHandler { return retryCount } - if attempt > config.maxRetries { + if attempt > config.pushNotificationSubscriptionMaxRetries { stateLock.withLock { halted = true } - hedgeLog("Push subscription: max retries (\(config.maxRetries)) exceeded. Keeping record for next launch.") + hedgeLog("Push subscription: max retries (\(config.pushNotificationSubscriptionMaxRetries)) exceeded. Keeping record for next launch.") return } let delay = info.retryAfter ?? retryDelay(forAttempt: attempt) stateLock.withLock { pausedUntil = Date().addingTimeInterval(delay) } - hedgeLog("Push subscription failed (attempt \(attempt)/\(config.maxRetries)). Retrying in \(delay)s.") + hedgeLog("Push subscription failed (attempt \(attempt)/\(config.pushNotificationSubscriptionMaxRetries)). Retrying in \(delay)s.") } /// Keep the delivered record so the token can be re-sent when the distinct id changes, but only if diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index c1043e5aa..25d93d797 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -5,9 +5,8 @@ import Foundation -/// Retry policy shared by `/batch` (events) and `/snapshot` (replay): 408, 429, -/// the listed 5xx, plus 3xx redirects. -private func isEventsRetriableStatusCode(_ code: Int) -> Bool { +/// `/snapshot` (replay) retry policy: 408, 429, the listed 5xx, plus 3xx redirects. +private func isSnapshotRetriableStatusCode(_ code: Int) -> Bool { [408, 429, 500, 502, 503, 504].contains(code) || (300 ... 399).contains(code) } @@ -17,8 +16,24 @@ private func isEventsRetriableStatusCode(_ code: Int) -> Bool { /// floors the backoff, so it can't strand the logs queue. Matches posthog-js. let logsMaxRetryAfterSeconds: TimeInterval = 5 * 60 +/// Capture V1 retry policy: 404, 408, 429 and the listed 5xx. Everything else, +/// including 3xx, is terminal; 413 is handled by the queue's batch halving. +func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { + [404, 408, 429, 500, 502, 503, 504].contains(code) +} + +/// Size limit for a whole serialized AI event at the default `captureAiMaxEventBytes`. +let aiMaxEventBytes = PostHogConfig.Defaults.captureAiMaxEventBytes +/// Soft request size for the capture V1 analytics and AI endpoints, matching +/// the other PostHog SDKs and well under capture V1's 10 MiB request limit. +let captureV1MaxBatchBytes = 5 * 1024 * 1024 +/// Disk budget for queued AI events; the oldest are dropped first. +let aiMaxQueueBytes = 50 * 1024 * 1024 + extension QueueEndpoint where Record == PostHogEvent { - /// `/batch` endpoint for analytics events. + /// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`). + /// Batches by bytes as well as count, so a batch of large events stays + /// under the request limit. static func batch(api: PostHogApi) -> QueueEndpoint { QueueEndpoint( storageKey: .queue, @@ -33,15 +48,49 @@ extension QueueEndpoint where Record == PostHogEvent { encode: { event in toJSONData(event.toJSON()) }, decode: { data in PostHogEvent.fromJSON(data) }, describe: { event in "event '\(event.event)'" }, + recordId: { event in event.uuid.postHogUuidString }, + maxBatchBytes: captureV1MaxBatchBytes, send: { events, completion in - api.batch(events: events, completion: completion) + api.captureV1(events: events, completion: completion) }, - isRetriableStatusCode: isEventsRetriableStatusCode + isRetriableStatusCode: isCaptureV1RetriableStatusCode ) } - /// `/snapshot` endpoint for session-replay snapshots. Shares its retry - /// policy with `/batch`. + /// Capture V1 AI endpoint (`/i/v1/ai/events`) for events sent with + /// `captureAi`. Same retry policy as capture V1. + /// Batches by bytes as well as count, since AI events can be megabytes. + static func ai(api: PostHogApi) -> QueueEndpoint { + let maxEventBytes = api.config.effectiveCaptureAiMaxEventBytes + // Read once per SDK setup, so this warns once. + if api.config.captureAiMaxEventBytes > maxEventBytes { + hedgeLog("Warning: captureAiMaxEventBytes \(api.config.captureAiMaxEventBytes) is over the \(maxEventBytes) byte ceiling, using \(maxEventBytes).") + } + return QueueEndpoint( + storageKey: .aiQueue, + oldStorageKeys: [], + dispatchQueueLabel: "com.posthog.AiQueue", + initialCap: { $0.maxBatchSize }, + initialFlushAt: { $0.flushAt }, + maxQueueSize: { $0.effectiveCaptureAiMaxQueueSize }, + flushIntervalSeconds: { $0.flushIntervalSeconds }, + rateCapMax: { _ in 0 }, + rateCapWindowSeconds: { _ in 0 }, + encode: { event in toJSONData(event.toJSON()) }, + decode: { data in PostHogEvent.fromJSON(data) }, + describe: { event in "AI event '\(event.event)'" }, + recordId: { event in event.uuid.postHogUuidString }, + maxBatchBytes: captureV1MaxBatchBytes, + maxRecordBytes: maxEventBytes, + maxQueueBytes: aiMaxQueueBytes, + send: { events, completion in + api.captureAi(events: events, completion: completion) + }, + isRetriableStatusCode: isCaptureV1RetriableStatusCode + ) + } + + /// `/snapshot` endpoint for session-replay snapshots. static func snapshot(api: PostHogApi) -> QueueEndpoint { QueueEndpoint( storageKey: .replayQeueue, @@ -64,7 +113,7 @@ extension QueueEndpoint where Record == PostHogEvent { send: { events, completion in api.snapshot(events: events, completion: completion) }, - isRetriableStatusCode: isEventsRetriableStatusCode + isRetriableStatusCode: isSnapshotRetriableStatusCode ) } } @@ -117,3 +166,21 @@ extension QueueEndpoint where Record == PostHogLogRecord { ) } } + +extension PostHogConfig { + /// `captureAiMaxQueueSize`, or its default when it isn't positive. + var effectiveCaptureAiMaxQueueSize: Int { + captureAiMaxQueueSize > 0 ? captureAiMaxQueueSize : Defaults.captureAiMaxQueueSize + } + + /// `captureAiTimeout`, or its default when it isn't positive. + var effectiveCaptureAiTimeout: TimeInterval { + captureAiTimeout > 0 ? captureAiTimeout : Defaults.captureAiTimeout + } + + /// `captureAiMaxEventBytes`, capped at its default, or the default when it isn't positive. + var effectiveCaptureAiMaxEventBytes: Int { + let limit = Defaults.captureAiMaxEventBytes + return captureAiMaxEventBytes > 0 ? min(captureAiMaxEventBytes, limit) : limit + } +} diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index 0587deb84..3886d84ca 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -7,7 +7,7 @@ import Foundation /// Per-wire spec consumed by the generic `PostHogQueue`. /// -/// Encapsulates everything that differs between the events `/batch`, replay +/// Encapsulates everything that differs between the events `/i/v1/analytics/events`, replay /// `/snapshot`, and logs `/i/v1/logs` endpoints — disk codec, payload assembly, /// retriable status set — so the queue itself stays record-type-agnostic. /// Implemented as a struct of closures rather than a protocol with @@ -59,11 +59,27 @@ struct QueueEndpoint { /// Short label for this record used in queue debug logs. Events return /// the event name; snapshots / logs return a generic label. let describe: (Record) -> String + /// Stable ID that a response can use to name a record, so a partial + /// result can keep just that record queued. Events: the event UUID. + var recordId: ((Record) -> String)? /// Whether a later record can share a request with the first record. /// When set, the queue sends only a compatible FIFO prefix. var canBatchTogether: ((Record, Record) -> Bool)? + /// Soft byte limit for one request, measured on the encoded records. A + /// record that doesn't fit starts the next request; a record larger than + /// the limit goes alone. `nil` batches by count only. + var maxBatchBytes: Int? + + /// Largest encoded record `add()` accepts; a larger one is dropped with a + /// size log. `nil` accepts any size. + var maxRecordBytes: Int? + + /// FIFO limit on the total size of queued records on disk, on top of + /// `maxQueueSize`. `nil` limits by count only. + var maxQueueBytes: Int? + // MARK: Send /// Build the wire payload from a list of records and POST it. The queue @@ -78,8 +94,9 @@ struct QueueEndpoint { /// since that case is universal. let isRetriableStatusCode: (Int) -> Bool /// Upper bound applied to a parsed `Retry-After` header before it floors - /// the backoff: the wait is `max(backoff, min(retryAfter, maximum))`. - /// `nil` honors the header unbounded; events and replay keep that, logs - /// sets `logsMaxRetryAfterSeconds`. + /// the jittered backoff: the wait is `max(backoff, min(retryAfter, maximum))`. + /// The queue never lets `maximum` exceed `maxRetryDelay`; `nil` means that + /// ceiling alone applies. Events and replay keep `nil`, logs sets + /// `logsMaxRetryAfterSeconds`. var maxRetryAfterSeconds: TimeInterval? } diff --git a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift index 3934c29f9..58f21bd38 100644 --- a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift +++ b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift @@ -101,6 +101,8 @@ } let path = strippingAPIHostPathPrefix(from: url.path, apiHostPath: apiHost.path) + // The SDK no longer sends to /batch, but React Native apps record replay through + // this plugin while their JS SDK still posts there. let ignoredPaths = [snapshotEndpoint, "/batch", "/e/", "/i/"] return ignoredPaths.contains { ignoredPath in diff --git a/PostHog/Utils/Hedgelog.swift b/PostHog/Utils/Hedgelog.swift index f53ad8694..ce8318f1e 100644 --- a/PostHog/Utils/Hedgelog.swift +++ b/PostHog/Utils/Hedgelog.swift @@ -18,3 +18,8 @@ func hedgeLog(_ message: String) { if !hedgeLogEnabled { return } print("[PostHog] \(message)") } + +/// Prints even when debug is off, for a problem the app can't see any other way. +func hedgeLogWarning(_ message: String) { + print("[PostHog] \(message)") +} diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 16df487da..d0911c204 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -313,6 +313,31 @@ struct ContentView: View { } } + // Events go to /i/v1/analytics/events; captureAi events go to /i/v1/ai/events. + Section("Capture v1") { + Button("Capture with options") { + PostHogSDK.shared.capture("example_event_with_options", properties: [ + "source": "example_app", + ], options: [ + "process_person_profile": false, + ]) + } + + Button("Capture $ai_generation") { + let uuid = PostHogSDK.shared.captureAi("$ai_generation", properties: [ + "$ai_trace_id": UUID().uuidString, + "$ai_provider": "openai", + "$ai_model": "gpt-4o-mini", + "$ai_input": [["role": "user", "content": "Tell me a fun fact about hedgehogs"]], + "$ai_output_choices": [["role": "assistant", "content": "Hedgehogs have about 5,000 spines."]], + "$ai_input_tokens": 12, + "$ai_output_tokens": 9, + "$ai_latency": 0.42, + ]) + print("captureAi queued $ai_generation: \(uuid ?? "not queued")") + } + } + // Manual testing of exception steps. Section("Exception steps") { Button("Add 3 exception steps") { diff --git a/PostHogObjCExample/AppDelegate.m b/PostHogObjCExample/AppDelegate.m index 3df93236d..f21b61b74 100644 --- a/PostHogObjCExample/AppDelegate.m +++ b/PostHogObjCExample/AppDelegate.m @@ -127,6 +127,24 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( groups:groupProps timestamp:[NSDate date]]; + [[PostHogSDK shared] captureWithEvent:@"theEvent" + distinctId:nil + properties:props + userProperties:nil + userPropertiesSetOnce:nil + groups:nil + timestamp:nil + options:@{@"process_person_profile": @NO}]; + + NSString *aiEventUuid = [[PostHogSDK shared] captureAiWithEvent:@"$ai_generation" + distinctId:nil + properties:@{@"$ai_model": @"gpt-4o-mini", @"$ai_provider": @"openai"} + userProperties:nil + userPropertiesSetOnce:nil + groups:nil + timestamp:nil + options:nil]; + NSLog(@"captureAi uuid: %@", aiEventUuid); [[PostHogSDK shared] groupWithType:@"theType" key:@"theKey"]; [[PostHogSDK shared] groupWithType:@"theType" key:@"theKey" groupProperties:groupProps]; diff --git a/PostHogTests/ApplicationViewLayoutPublisherTest.swift b/PostHogTests/ApplicationViewLayoutPublisherTest.swift index 6266f02bc..8b1379270 100644 --- a/PostHogTests/ApplicationViewLayoutPublisherTest.swift +++ b/PostHogTests/ApplicationViewLayoutPublisherTest.swift @@ -379,26 +379,6 @@ try #require(finished.wait(timeout: .now() + 5) == .success) } - private func captureStdout(_ body: () throws -> Void) throws -> String { - let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - FileManager.default.createFile(atPath: url.path, contents: nil) - defer { try? FileManager.default.removeItem(at: url) } - let file = try FileHandle(forWritingTo: url) - defer { file.closeFile() } - fflush(stdout) - let saved = dup(STDOUT_FILENO) - try #require(saved >= 0) - defer { close(saved) } - try #require(dup2(file.fileDescriptor, STDOUT_FILENO) >= 0) - defer { - fflush(stdout) - dup2(saved, STDOUT_FILENO) - } - try body() - fflush(stdout) - return try String(contentsOf: url, encoding: .utf8) - } - @MainActor @Test("forwards layout synchronously on the calling thread and notifies on main", arguments: [false, true]) func forwardsLayout(background: Bool) async throws { diff --git a/PostHogTests/PostHogApiTest.swift b/PostHogTests/PostHogApiTest.swift index 6a5497d75..29f59d840 100644 --- a/PostHogTests/PostHogApiTest.swift +++ b/PostHogTests/PostHogApiTest.swift @@ -140,13 +140,13 @@ enum PostHogApiTests { #expect(server.flagsRequests.count == 1) } - func testBatchEndpoint(forHost host: String) async throws { + func testCaptureV1Endpoint(forHost host: String) async throws { let sut = getSut(host: host) let resp = await getApiResponse { completion in - sut.batch(events: [], completion: completion) + sut.captureV1(events: [], completion: completion) } - try requireEndpoint(server.batchRequests.last, host: host, path: "batch") + try requireEndpoint(server.batchRequests.last, host: host, path: "i/v1/analytics/events") #expect(resp.error == nil) #expect(resp.statusCode == 200) } @@ -230,41 +230,41 @@ enum PostHogApiTests { } } - @Suite("Test batch endpoint with different host paths") - class TestBatchEndpoint: BaseTestSuite { + @Suite("Test capture V1 endpoint with different host paths") + class TestCaptureV1Endpoint: BaseTestSuite { @Test("with host containing no path") func hostWithNoPath() async throws { - try await testBatchEndpoint(forHost: "http://localhost") + try await testCaptureV1Endpoint(forHost: "http://localhost") } @Test("with host containing no path and trailing slash") func hostWithNoPathAndTrailingSlash() async throws { - try await testBatchEndpoint(forHost: "http://localhost/") + try await testCaptureV1Endpoint(forHost: "http://localhost/") } @Test("with host containing path") func hostWithPath() async throws { - try await testBatchEndpoint(forHost: "http://localhost/api/v1") + try await testCaptureV1Endpoint(forHost: "http://localhost/api/v1") } @Test("with host containing path and trailing slash") func hostWithPathAndTrailingSlash() async throws { - try await testBatchEndpoint(forHost: "http://localhost/api/v1/") + try await testCaptureV1Endpoint(forHost: "http://localhost/api/v1/") } @Test("with host containing port number") func hostWithPortNumber() async throws { - try await testBatchEndpoint(forHost: "http://localhost:9000") + try await testCaptureV1Endpoint(forHost: "http://localhost:9000") } @Test("with host containing port number and path") func hostWithPortNumberAndPath() async throws { - try await testBatchEndpoint(forHost: "http://localhost:9000/api/v1") + try await testCaptureV1Endpoint(forHost: "http://localhost:9000/api/v1") } @Test("with host containing port number, path and trailing slash") func hostWithPortNumberAndTrailingSlash() async throws { - try await testBatchEndpoint(forHost: "http://localhost:9000/api/v1/") + try await testCaptureV1Endpoint(forHost: "http://localhost:9000/api/v1/") } } @@ -349,11 +349,11 @@ enum PostHogApiTests { /// session-level gzip would silently mis-label /flags on the wire. @Suite("Content-Encoding header per endpoint") class TestContentEncodingHeader: BaseTestSuite { - @Test("/batch declares gzip Content-Encoding") - func batchDeclaresGzip() async throws { + @Test("capture V1 declares gzip Content-Encoding") + func captureV1DeclaresGzip() async throws { let sut = getSut(host: "http://localhost") _ = await getApiResponse { completion in - sut.batch(events: [], completion: completion) + sut.captureV1(events: [], completion: completion) } let request = try #require(server.batchRequests.first) #expect(request.value(forHTTPHeaderField: "Content-Encoding") == "gzip") @@ -389,15 +389,15 @@ enum PostHogApiTests { #expect(request.value(forHTTPHeaderField: "Content-Encoding") == "gzip") } - @Test("/batch falls back to uncompressed when gzip fails") - func batchFallsBackToUncompressedWhenGzipFails() async throws { + @Test("capture V1 falls back to uncompressed when gzip fails") + func captureV1FallsBackToUncompressedWhenGzipFails() async throws { let originalGzipData = PostHogApi.gzipData PostHogApi.gzipData = { _ in throw NSError(domain: "PostHogApiTests", code: 1) } defer { PostHogApi.gzipData = originalGzipData } let sut = getSut(host: "http://localhost") _ = await getApiResponse { completion in - sut.batch(events: [], completion: completion) + sut.captureV1(events: [], completion: completion) } let request = try #require(server.batchRequests.first) #expect(request.value(forHTTPHeaderField: "Content-Encoding") == nil) @@ -427,11 +427,11 @@ enum PostHogApiTests { #expect(PostHogConfig(projectToken: "test_project_token", host: "http://localhost").compression == .gzip) } - @Test("/batch sends an uncompressed body when compression is none") - func batchSendsUncompressedWhenCompressionIsNone() async throws { + @Test("capture V1 sends an uncompressed body when compression is none") + func captureV1SendsUncompressedWhenCompressionIsNone() async throws { let sut = getUncompressedSut() _ = await getApiResponse { completion in - sut.batch(events: [], completion: completion) + sut.captureV1(events: [], completion: completion) } let request = try #require(server.batchRequests.first) #expect(request.value(forHTTPHeaderField: "Content-Encoding") == nil) @@ -461,8 +461,8 @@ enum PostHogApiTests { #expect(server.parseRequest(request, gzip: false)?["resourceLogs"] != nil) } - @Test("/batch drops a session-level Content-Encoding when compression is none") - func batchDropsSessionContentEncodingWhenCompressionIsNone() async throws { + @Test("capture V1 drops a session-level Content-Encoding when compression is none") + func captureV1DropsSessionContentEncodingWhenCompressionIsNone() async throws { let config = PostHogConfig(projectToken: "test_project_token", host: "http://localhost") config.compression = .none let sessionConfig = URLSessionConfiguration.default @@ -470,7 +470,7 @@ enum PostHogApiTests { config.urlSessionConfiguration = sessionConfig let sut = PostHogApi(config) _ = await getApiResponse { completion in - sut.batch(events: [], completion: completion) + sut.captureV1(events: [], completion: completion) } let request = try #require(server.batchRequests.first) #expect(request.value(forHTTPHeaderField: "Content-Encoding") == nil) @@ -497,14 +497,14 @@ enum PostHogApiTests { return PostHogApi(config) } - @Test("attaches custom headers to /batch requests") - func attachesToBatch() async throws { - let sut = getSut(host: "http://localhost", requestHeaders: ["Authorization": "Bearer test-jwt"]) + @Test("attaches custom headers to capture V1 requests") + func attachesToCaptureV1() async throws { + let sut = getSut(host: "http://localhost", requestHeaders: ["X-Proxy-Token": "test-token"]) _ = await getApiResponse { completion in - sut.batch(events: [], completion: completion) + sut.captureV1(events: [], completion: completion) } let request = try #require(server.batchRequests.first) - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-jwt") + #expect(request.value(forHTTPHeaderField: "X-Proxy-Token") == "test-token") } @Test("attaches custom headers to /flags requests") @@ -523,9 +523,11 @@ enum PostHogApiTests { func noHeaderWhenUnset() async throws { let sut = getSut(host: "http://localhost", requestHeaders: nil) _ = await getApiResponse { completion in - sut.batch(events: [], completion: completion) + sut.flags(distinctId: "x", anonymousId: nil, groups: [:], personProperties: [:]) { data, _ in + completion(data) + } } - let request = try #require(server.batchRequests.first) + let request = try #require(server.flagsRequests.first) #expect(request.value(forHTTPHeaderField: "Authorization") == nil) } @@ -533,7 +535,7 @@ enum PostHogApiTests { func doesNotOverrideSDKManagedHeaders() async throws { let sut = getSut(host: "http://localhost", requestHeaders: ["content-type": "text/plain", "User-Agent": "evil"]) _ = await getApiResponse { completion in - sut.batch(events: [], completion: completion) + sut.captureV1(events: [], completion: completion) } let request = try #require(server.batchRequests.first) #expect(request.value(forHTTPHeaderField: "Content-Type") != "text/plain") diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift new file mode 100644 index 000000000..f86adb4ac --- /dev/null +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -0,0 +1,1118 @@ +// +// PostHogCaptureV1Test.swift +// PostHogTests +// + +import Foundation +import OHHTTPStubs +import OHHTTPStubsSwift +@_spi(PostHogInternal) @testable import PostHog +import Testing + +@Suite("Capture V1 transport", .serialized, .resetsGlobalState) +final class PostHogCaptureV1Test { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + private func makeApi(requestHeaders: [String: String]? = nil) -> PostHogApi { + let config = PostHogConfig(projectToken: "phc_capture_v1", host: "http://localhost:9001/proxy") + config.requestHeaders = requestHeaders + return PostHogApi(config) + } + + private func send(_ api: PostHogApi, _ events: [PostHogEvent]) async -> PostHogUploadInfo { + await withCheckedContinuation { continuation in + api.captureV1(events: events) { continuation.resume(returning: $0) } + } + } + + private func body(_ request: URLRequest) throws -> [String: Any] { + try #require(server.parseRequest(request)) + } + + @Test("sends events to /i/v1/analytics/events with Bearer auth and V1 headers") + func sendsV1Request() async throws { + let api = makeApi(requestHeaders: [ + "Authorization": "Bearer custom", "PostHog-Attempt": "9", "PostHog-Request-Id": "custom", + ]) + _ = await send(api, [PostHogEvent(event: "test", distinctId: "user")]) + + let request = try #require(server.batchRequests.first) + #expect(request.url?.path == "/proxy/i/v1/analytics/events") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer phc_capture_v1") + #expect(request.value(forHTTPHeaderField: "PostHog-Sdk-Info") == "\(postHogSdkName)/\(postHogVersion)") + #expect(request.value(forHTTPHeaderField: "PostHog-Attempt") == "1") + let requestId = try #require(request.value(forHTTPHeaderField: "PostHog-Request-Id")) + #expect(UUID(uuidString: requestId) != nil) + let timestamp = try #require(request.value(forHTTPHeaderField: "PostHog-Request-Timestamp")) + #expect(timestamp.hasSuffix("Z")) + #expect(toISO8601Date(timestamp) != nil) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(request.value(forHTTPHeaderField: "Content-Encoding") == "gzip") + } + + @Test("builds the V1 body: created_at root, promoted session/window IDs, no $lib") + func buildsV1Body() async throws { + let event = PostHogEvent( + event: "test", + distinctId: "user", + properties: [ + "$session_id": "session-1", + "$window_id": "window-1", + "$lib": "posthog-ios", + "$lib_version": "1.0.0", + "$process_person_profile": false, + "custom": "value", + ] + ) + _ = await send(makeApi(), [event]) + + let request = try #require(server.batchRequests.first) + let body = try body(request) + #expect(Set(body.keys) == ["created_at", "batch"]) + let createdAt = try #require(body["created_at"] as? String) + #expect(toISO8601Date(createdAt) != nil) + + let events = try #require(body["batch"] as? [[String: Any]]) + let sent = try #require(events.first) + #expect(sent["event"] as? String == "test") + #expect(sent["uuid"] as? String == event.uuid.postHogUuidString) + #expect(sent["distinct_id"] as? String == "user") + #expect(sent["timestamp"] as? String == toISO8601String(event.timestamp)) + #expect(sent["session_id"] as? String == "session-1") + #expect(sent["window_id"] as? String == "window-1") + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": false]) + + let properties = try #require(sent["properties"] as? [String: Any]) + for key in ["$session_id", "$window_id", "$lib", "$lib_version", "$process_person_profile"] { + #expect(properties[key] == nil, "\(key) should not be in properties") + } + #expect(properties["custom"] as? String == "value") + + // The stored event is not reshaped. + #expect(event.properties["$session_id"] as? String == "session-1") + #expect(event.properties["$lib"] as? String == "posthog-ios") + } + + @Test("reports retry results and treats ok, warning, drop and missing results as delivered") + func reportsRetryResults() async throws { + let events = (0 ..< 5).map { PostHogEvent(event: "e\($0)", distinctId: "user") } + let uuids = events.map(\.uuid.postHogUuidString) + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: ["results": [ + uuids[0]: ["result": "ok"], + uuids[1]: ["result": "warning", "details": "truncated"], + uuids[2]: ["result": "drop", "details": "invalid"], + uuids[3]: ["result": "retry"], + ]], statusCode: 200, headers: ["Retry-After": "2"]) + } + + let result = await send(makeApi(), events) + + #expect(result.statusCode == 200) + #expect(result.retryRecordIds == [uuids[3]]) + #expect(result.retryAfter == 2) + } + + @Test("treats a 200 without a results map as delivered") + func malformedSuccessIsDelivered() async { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(data: Data("not json".utf8), statusCode: 200, headers: nil) + } + + let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + + #expect(result.statusCode == 200) + #expect(result.retryRecordIds?.isEmpty ?? true) + } + + @Test("logs each dropped event's name, UUID and reason, without its properties") + func logsDropReasons() throws { + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let uuids = [UUID().postHogUuidString, UUID().postHogUuidString, UUID().postHogUuidString] + let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") + let body = try JSONSerialization.data(withJSONObject: ["results": [ + uuids[0]: ["result": "drop", "details": "ai_event_too_big"], + uuids[1]: ["result": "drop", "details": "misrouted_event"], + uuids[2]: ["result": "ok"], + ]]) + + let output = try captureStdout { + _ = makeApi().captureV1Result( + PostHogUploadInfo(statusCode: 200, error: nil), path: PostHogApi.captureV1AiPath, data: body, + uuids: uuids, identity: identity, eventNames: [uuids[0]: "$ai_generation", uuids[1]: "signup"] + ) + } + + #expect(output.contains("Capture dropped '$ai_generation' (\(uuids[0])): ai_event_too_big.")) + #expect(output.contains("Capture dropped 'signup' (\(uuids[1])): misrouted_event.")) + #expect(!output.contains(uuids[2])) + } + + @Test("treats a 2xx whose transfer then failed as delivered, so its ok events aren't stored twice") + func failedTransferAfterSuccessIsDelivered() { + let uuids = [UUID().postHogUuidString, UUID().postHogUuidString] + let identity = CaptureV1RequestIdentity(uuids: Set(uuids), requestId: "request", attempt: 1, createdAt: "now") + let info = PostHogUploadInfo(statusCode: 200, error: URLError(.networkConnectionLost)) + + let result = makeApi().captureV1Result(info, path: PostHogApi.captureV1AnalyticsPath, data: nil, uuids: uuids, identity: identity) + + #expect(result.statusCode == 200) + #expect(result.retryRecordIds?.isEmpty ?? true) + } + + @Test("a retry reuses the request ID and increments the attempt; an independent flush starts over") + func retryIdentity() async throws { + let events = (0 ..< 2).map { PostHogEvent(event: "e\($0)", distinctId: "user") } + let retried = events[1].uuid.postHogUuidString + server.captureV1EventResult = { uuid, index in + index < 3 && uuid == retried ? "retry" : "ok" + } + server.captureV1ResponseHandler = nil + let api = makeApi() + + // 1: both events, server asks to retry the second one. + _ = await send(api, events) + // 2: the retried event alone, still marked retry. + _ = await send(api, [events[1]]) + // 3: retried again (whole batch this time via 503). + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 503, headers: nil) + } + _ = await send(api, [events[1]]) + server.captureV1ResponseHandler = nil + // 4: delivered. + _ = await send(api, [events[1]]) + // 5: an unrelated flush. + _ = await send(api, [PostHogEvent(event: "next", distinctId: "user")]) + + let requests = server.batchRequests + try #require(requests.count == 5) + let ids = requests.map { $0.value(forHTTPHeaderField: "PostHog-Request-Id") } + let attempts = requests.map { $0.value(forHTTPHeaderField: "PostHog-Attempt") } + let createdAt = try requests.map { try body($0)["created_at"] as? String } + #expect(attempts == ["1", "2", "3", "4", "1"]) + #expect(Set(ids[0 ... 3]).count == 1) + #expect(ids[4] != ids[0]) + #expect(Set(createdAt[0 ... 3]).count == 1) + } + + @Test("a 404 is retried under the same request ID and doesn't fall back to /batch") + func notFoundIsRetried() async throws { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) + } + let api = makeApi() + let event = PostHogEvent(event: "first", distinctId: "user") + + let result = await send(api, [event]) + _ = await send(api, [event]) + + #expect(result.statusCode == 404) + #expect(QueueEndpoint.batch(api: api).isRetriableStatusCode(404)) + let requests = server.batchRequests + #expect(requests.map { $0.url?.path } == ["/proxy/i/v1/analytics/events", "/proxy/i/v1/analytics/events"]) + #expect(requests.map { $0.value(forHTTPHeaderField: "PostHog-Attempt") } == ["1", "2"]) + #expect(Set(requests.map { $0.value(forHTTPHeaderField: "PostHog-Request-Id") }).count == 1) + } + + @Test("a 404 warns once with the capture URL") + func notFoundWarnsOnce() async throws { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) + } + let api = makeApi() + var warnings: [String] = [] + api.printWarning = { warnings.append($0) } + + _ = await send(api, [PostHogEvent(event: "first", distinctId: "user")]) + _ = await send(api, [PostHogEvent(event: "second", distinctId: "user")]) + + #expect(warnings == [ + "http://localhost:9001/proxy/i/v1/analytics/events returned 404. Events stay queued and are retried. " + + "If you use a reverse proxy or self-host PostHog, make sure it serves /i/v1/analytics/events.", + ]) + } + + @Test("the 404 warning leaves out credentials in the host") + func notFoundWarningRedactsCredentials() async throws { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) + } + let api = PostHogApi(PostHogConfig(projectToken: "phc_capture_v1", host: "http://proxy-user:proxy-password@localhost:9001/proxy")) + var warnings: [String] = [] + api.printWarning = { warnings.append($0) } + + _ = await send(api, [PostHogEvent(event: "first", distinctId: "user")]) + + let warning = try #require(warnings.first) + #expect(warning.hasPrefix("http://localhost:9001/proxy/i/v1/analytics/events returned 404.")) + #expect(!warning.contains("proxy-user")) + #expect(!warning.contains("proxy-password")) + } + + @Test("sends empty and whitespace session and window IDs at the root unchanged", arguments: ["", " "]) + func sendsBlankSessionIds(value: String) async throws { + let event = PostHogEvent(event: "test", distinctId: "user", properties: ["$session_id": value, "$window_id": value]) + _ = await send(makeApi(), [event]) + + let request = try #require(server.batchRequests.first) + let sent = try #require((try body(request)["batch"] as? [[String: Any]])?.first) + #expect(sent["session_id"] as? String == value) + #expect(sent["window_id"] as? String == value) + let properties = try #require(sent["properties"] as? [String: Any]) + #expect(properties["$session_id"] == nil) + #expect(properties["$window_id"] == nil) + } + + @Test("drops non-string session and window IDs from the root and properties") + func dropsNonStringSessionIds() async throws { + let event = PostHogEvent(event: "test", distinctId: "user", properties: ["$session_id": 42, "$window_id": ["w"]]) + _ = await send(makeApi(), [event]) + + let request = try #require(server.batchRequests.first) + let sent = try #require((try body(request)["batch"] as? [[String: Any]])?.first) + #expect(sent["session_id"] == nil) + #expect(sent["window_id"] == nil) + let properties = try #require(sent["properties"] as? [String: Any]) + #expect(properties["$session_id"] == nil) + #expect(properties["$window_id"] == nil) + } + + @Test("a dropped non-string ID logs its key and JSON type but not its value; null drops silently", arguments: [ + (42 as Any, "number"), + (3.5 as Any, "number"), + (true as Any, "bool"), + (["secret"] as Any, "array"), + (["k": "secret"] as Any, "object"), + (NSNull() as Any, nil), + ] as [(Any, String?)]) + func warnsOnNonStringId(value: Any, type: String?) { + var properties: [String: Any] = ["$window_id": value, "other": "kept"] + var warnings: [String] = [] + + let lifted = PostHogApi.liftStringProperty("$window_id", from: &properties) { warnings.append($0) } + + #expect(lifted == nil) + #expect(properties.keys.sorted() == ["other"]) + #expect(warnings == type.map { ["dropping $window_id: a \($0) value is not a string"] } ?? []) + } + + @Test("follows a same-origin 307 or 308 with the same body and headers", arguments: [307, 308]) + func followsSameOriginRedirect(statusCode: Int) async throws { + server.captureV1ResponseHandler = { _, index in + index == 1 + ? HTTPStubsResponse(data: Data(), statusCode: Int32(statusCode), headers: [ + "Location": "http://localhost:9001/moved/i/v1/analytics/events", + ]) + : HTTPStubsResponse(jsonObject: ["results": [:]], statusCode: 200, headers: nil) + } + + _ = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + + // No check on the result: OHHTTPStubs delivers the 3xx without waiting for the redirect + // decision, so the task can end with either response. The redirected request is what counts. + let requests = server.batchRequests + try #require(requests.count == 2) + #expect(requests[1].url?.path == "/moved/i/v1/analytics/events") + #expect(requests[1].httpMethod == "POST") + #expect(requests[1].httpBody == requests[0].httpBody) + for header in ["Authorization", "PostHog-Request-Id", "PostHog-Attempt", "PostHog-Sdk-Info", "Content-Encoding"] { + #expect(requests[1].value(forHTTPHeaderField: header) == requests[0].value(forHTTPHeaderField: header)) + } + } + + @Test("returns other redirects without following them", arguments: [ + (301, "http://localhost:9001/moved/i/v1/analytics/events"), + (302, "http://localhost:9001/moved/i/v1/analytics/events"), + (303, "http://localhost:9001/moved/i/v1/analytics/events"), + (307, "http://other.example.com/i/v1/analytics/events"), + (308, "https://localhost:9001/i/v1/analytics/events"), + (307, "http://localhost:9002/i/v1/analytics/events"), + ]) + func doesNotFollowOtherRedirects(statusCode: Int, location: String) async throws { + server.captureV1ResponseHandler = { _, _ in + HTTPStubsResponse(data: Data(), statusCode: Int32(statusCode), headers: ["Location": location]) + } + + let result = await send(makeApi(), [PostHogEvent(event: "test", distinctId: "user")]) + + #expect(result.statusCode == statusCode) + #expect(server.batchRequests.count == 1) + #expect(!QueueEndpoint.batch(api: makeApi()).isRetriableStatusCode(statusCode)) + } + + @Test("stops after 5 redirects") + func stopsAfterMaxRedirects() throws { + // Drives the delegate directly: a stubbed redirect chain races URLSession and can end + // the task without a response. + let host = try #require(URL(string: "http://localhost:9001")) + let url = host.appendingPathComponent("i/v1/analytics/events") + var original = URLRequest(url: url) + original.setValue("request-id", forHTTPHeaderField: "PostHog-Request-Id") + let task = URLSession.shared.dataTask(with: original) + let redirect = URLRequest(url: host.appendingPathComponent("proxy/i/v1/analytics/events")) + let response = try #require(HTTPURLResponse(url: url, statusCode: 307, httpVersion: nil, headerFields: nil)) + let handler = PostHogRedirectHandler(host: host, headerKeys: []) + + var followed: [Bool] = [] + for _ in 0 ... PostHogRedirectHandler.maxCaptureV1Redirects { + handler.urlSession(URLSession.shared, task: task, willPerformHTTPRedirection: response, newRequest: redirect) { + followed.append($0 != nil) + } + } + + #expect(followed == Array(repeating: true, count: PostHogRedirectHandler.maxCaptureV1Redirects) + [false]) + } + + @Test("re-sets the original headers on a followed redirect") + func redirectResetsHeaders() throws { + let host = try #require(URL(string: "https://us.i.posthog.com")) + let url = host.appendingPathComponent("i/v1/analytics/events") + var original = URLRequest(url: url) + original.httpMethod = "POST" + original.setValue("Bearer phc_test", forHTTPHeaderField: "Authorization") + original.setValue("request-id", forHTTPHeaderField: "PostHog-Request-Id") + original.setValue("2", forHTTPHeaderField: "PostHog-Attempt") + // What URLSession hands the delegate: the new URL without `Authorization`. + let stripped = try URLRequest(url: #require(URL(string: "https://US.i.posthog.com:443/next"))) + + let redirected = try #require(PostHogRedirectHandler.captureV1Redirect( + original: original, statusCode: 308, newRequest: stripped, host: host, hop: 5 + )) + #expect(redirected.url?.path == "/next") + #expect(redirected.httpMethod == "POST") + #expect(redirected.value(forHTTPHeaderField: "Authorization") == "Bearer phc_test") + #expect(redirected.value(forHTTPHeaderField: "PostHog-Request-Id") == "request-id") + #expect(redirected.value(forHTTPHeaderField: "PostHog-Attempt") == "2") + + #expect(PostHogRedirectHandler.captureV1Redirect( + original: original, statusCode: 308, newRequest: stripped, host: host, hop: 6 + ) == nil) + } + + @Test("only 404, 408, 429 and 500/502/503/504 are retried", arguments: [ + (404, true), (408, true), (429, true), (500, true), (502, true), (503, true), (504, true), + (301, false), (400, false), (401, false), (402, false), (403, false), + (413, false), (415, false), (501, false), + ]) + func statusCodeClassification(statusCode: Int, retriable: Bool) { + let endpoint = QueueEndpoint.batch(api: makeApi()) + #expect(endpoint.isRetriableStatusCode(statusCode) == retriable) + } +} + +@Suite("Capture V1 event options", .serialized, .resetsGlobalState) +final class PostHogCaptureV1OptionsTest { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + private func makeApi() -> PostHogApi { + PostHogApi(PostHogConfig(projectToken: "phc_options", host: "http://localhost:9001")) + } + + /// Sends `event` through capture V1 and returns its wire JSON. + private func sentEvent(_ event: PostHogEvent, api: PostHogApi? = nil) async throws -> [String: Any] { + let api = api ?? makeApi() + await withCheckedContinuation { continuation in + api.captureV1(events: [event]) { _ in continuation.resume() } + } + let request = try #require(server.batchRequests.last) + return try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.first) + } + + @Test("hoists each legacy property into its option", arguments: [ + ("$cookieless_mode", "cookieless_mode"), + ("$ignore_sent_at", "disable_skew_correction"), + ("$product_tour_id", "product_tour_id"), + ("$process_person_profile", "process_person_profile"), + ]) + func hoistsLegacyProperty(property: String, option: String) async throws { + let sent = try await sentEvent(PostHogEvent(event: "test", distinctId: "user", properties: [property: "value"])) + + #expect(sent["options"] as? [String: String] == [option: "value"]) + #expect((sent["properties"] as? [String: Any])?[property] == nil) + } + + @Test("an option wins over its legacy property, which is still removed; a null option is filled") + func optionWinsOverLegacyProperty() async throws { + let event = PostHogEvent( + event: "test", + distinctId: "user", + properties: ["$cookieless_mode": false, "$product_tour_id": "legacy-tour", "custom": 1], + options: ["cookieless_mode": true, "product_tour_id": NSNull(), "future_option": "kept"] + ) + + let sent = try await sentEvent(event) + + let options = try #require(sent["options"] as? [String: Any]) + #expect(options["cookieless_mode"] as? Bool == true) + #expect(options["product_tour_id"] as? String == "legacy-tour") + #expect(options["future_option"] as? String == "kept") + #expect(options.count == 3) + let properties = try #require(sent["properties"] as? [String: Any]) + #expect(Set(properties.keys) == ["custom"]) + // The stored event is not reshaped. + #expect(event.properties["$cookieless_mode"] as? Bool == false) + } + + @Test("sends empty options as {}") + func sendsEmptyOptions() async throws { + let sent = try await sentEvent(PostHogEvent(event: "test", distinctId: "user")) + + #expect((sent["options"] as? [String: Any])?.isEmpty == true) + } + + @Test("replay snapshots don't send options") + func snapshotsOmitOptions() async throws { + let event = PostHogEvent(event: "$snapshot", distinctId: "user", properties: ["$session_id": "s"], options: ["cookieless_mode": true]) + await withCheckedContinuation { continuation in + makeApi().snapshot(events: [event]) { _ in continuation.resume() } + } + + let request = try #require(server.snapshotRequests.last) + let body = try #require(request.body().flatMap { try? $0.gunzipped() }) + let sent = try #require((try JSONSerialization.jsonObject(with: body) as? [[String: Any]])?.first) + #expect(sent["options"] == nil) + #expect(sent["properties"] != nil) + } + + @Test("options survive the queue's disk format, and events stored without options still decode") + func persistsOptions() throws { + let event = PostHogEvent(event: "test", distinctId: "user", options: ["cookieless_mode": true]) + let data = try #require(toJSONData(event.toJSON())) + let decoded = try #require(PostHogEvent.fromJSON(data)) + #expect(decoded.options as? [String: Bool] == ["cookieless_mode": true]) + + var legacy = PostHogEvent(event: "old", distinctId: "user", properties: ["$cookieless_mode": true]).toJSON() + #expect(legacy["options"] == nil) + legacy.removeValue(forKey: "options") + let old = try #require(PostHogEvent.fromJSON(legacy)) + #expect(old.options.isEmpty) + #expect(old.properties["$cookieless_mode"] as? Bool == true) + } + + private func makeSDK( + personProfiles: PostHogPersonProfiles = .identifiedOnly, + flushAt: Int = 1, + beforeSend: BeforeSendBlock? = nil + ) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_options_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = flushAt + config.preloadFeatureFlags = false + config.sendFeatureFlagEvent = false + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + config.personProfiles = personProfiles + if let beforeSend { + config.setBeforeSend(beforeSend) + } + server.batchProjectToken = config.projectToken + return PostHogSDK.with(config) + } + + /// Captures one event and returns its wire JSON. + private func captureAndSend(_ sut: PostHogSDK, _ capture: (PostHogSDK) -> Void) async throws -> [String: Any] { + server.reset(batchCount: 1) + capture(sut) + _ = try await getServerEvents(server) + let request = try #require(server.batchRequests.first) + return try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.first) + } + + @Test("registered and caller process_person_profile properties and options beat the SDK's") + func processPersonProfilePrecedence() async throws { + let sut = makeSDK(personProfiles: .never) + defer { sut.close() } + + var sent = try await captureAndSend(sut) { $0.capture("test", properties: ["$process_person_profile": true]) } + #expect((sent["options"] as? [String: Any])?["process_person_profile"] as? Bool == true) + #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + + sut.register(["$process_person_profile": true]) + sent = try await captureAndSend(sut) { $0.capture("test") } + sut.unregister("$process_person_profile") + #expect((sent["options"] as? [String: Any])?["process_person_profile"] as? Bool == true) + + sent = try await captureAndSend(sut) { + $0.capture("test", options: ["process_person_profile": true, "cookieless_mode": true]) + } + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": true, "cookieless_mode": true]) + } + + @Test("the SDK sends process_person_profile only as a false option when person processing is off") + func processPersonProfileOnlyWhenOff() async throws { + let personless = makeSDK(personProfiles: .never) + var sent = try await captureAndSend(personless) { $0.capture("test") } + personless.close() + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": false]) + #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + + let processed = makeSDK(personProfiles: .always) + defer { processed.close() } + sent = try await captureAndSend(processed) { $0.capture("test") } + #expect((sent["options"] as? [String: Any])?.isEmpty == true) + #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + + // A caller property overrides the SDK's state for that event, sent as the option. + sent = try await captureAndSend(processed) { $0.capture("test", properties: ["$process_person_profile": false]) } + #expect(sent["options"] as? [String: Bool] == ["process_person_profile": false]) + #expect((sent["properties"] as? [String: Any])?["$process_person_profile"] == nil) + } + + @Test("the crash context keeps $process_person_profile false only when person processing is off") + func crashContextKeepsPersonlessProperty() { + for (personProfiles, expected) in [(PostHogPersonProfiles.never, false as Bool?), (.always, nil)] { + let sut = makeSDK(personProfiles: personProfiles) + var eventProperties: [String: Any]? + let token = sut.onEventContextChanged.subscribe { eventProperties = $0["event_properties"] as? [String: Any] } + sut.notifyContextDidChange() + withExtendedLifetime(token) {} + sut.close() + #expect(eventProperties?["$process_person_profile"] as? Bool == expected) + } + } + + @Test("beforeSend has the final say over options") + func beforeSendEditsOptions() async throws { + let sut = makeSDK(personProfiles: .never) { event in + #expect(event.options["product_tour_id"] as? String == "caller-tour") + event.options["process_person_profile"] = true + event.options["product_tour_id"] = "edited-tour" + return event + } + defer { sut.close() } + + let sent = try await captureAndSend(sut) { $0.capture("test", options: ["product_tour_id": "caller-tour"]) } + + #expect(sent["options"] as? [String: AnyHashable] == ["process_person_profile": true, "product_tour_id": "edited-tour"]) + } + + @Test("a personless $set doesn't suppress the same properties set later with person processing") + func personlessSetDoesNotDeduplicate() async throws { + let sut = makeSDK(personProfiles: .always, flushAt: 2) + defer { sut.close() } + + server.reset(batchCount: 1) + sut.setPersonProperties(userPropertiesToSet: ["plan": "pro"], options: ["process_person_profile": false]) + sut.setPersonProperties(userPropertiesToSet: ["plan": "pro"]) + let events = try await getServerEvents(server) + + #expect(events.map(\.event) == ["$set", "$set"]) + #expect(events.last?.options["process_person_profile"] == nil) + } + + @Test("a blank distinctId argument uses the current distinct ID", arguments: ["", " "]) + func blankDistinctIdFallsBack(distinctId: String) async throws { + let sut = makeSDK() + defer { sut.close() } + + let sent = try await captureAndSend(sut) { $0.capture("test", distinctId: distinctId) } + + #expect(sent["distinct_id"] as? String == sut.getDistinctId()) + } + + @Test("a personal API key disables the SDK at setup") + func personalApiKeyDisablesSdk() { + let sut = PostHogSDK.with(PostHogConfig(projectToken: "phx_personal", host: "http://localhost:9001")) + defer { sut.close() } + + // A disabled SDK has no storage, so it has no distinct ID. + #expect(sut.getDistinctId() == "") + } + + @Test("an event beforeSend returns again still gets a new UUID") + func beforeSendReturningEarlierEventGetsNewUuid() async throws { + var kept: PostHogEvent? + let sut = makeSDK(flushAt: 2) { event in + if kept == nil { kept = event } + return kept + } + defer { sut.close() } + + server.reset(batchCount: 1) + sut.capture("first") + sut.capture("second") + let events = try await getServerEvents(server) + + #expect(events.count == 2) + #expect(Set(events.map(\.uuid)).count == 2) + } + + @Test("the other event-sending methods apply options like capture does", arguments: [ + "screen", "identify", "identify existing user", "setPersonProperties", "alias", "group", + "captureException Error", "captureException NSException", + ]) + func eventSendingMethodsApplyOptions(method: String) async throws { + let options: [String: Any] = ["process_person_profile": true, "cookieless_mode": true, "product_tour_id": "caller-tour"] + // The setup identify and the call under test go out in one batch. + let sut = makeSDK(flushAt: method == "identify existing user" ? 2 : 1) { event in + if event.options["product_tour_id"] as? String == "caller-tour" { + event.options["product_tour_id"] = "edited-tour" + } + return event + } + defer { sut.close() } + // A legacy property the option must beat. + sut.register(["$cookieless_mode": false]) + + server.reset(batchCount: 1) + let expectedEvent: String + switch method { + case "screen": + sut.screen("Home", properties: ["$screen_name": "Other"], options: options) + expectedEvent = "$screen" + case "identify": + sut.identify("user", options: options) + expectedEvent = "$identify" + case "identify existing user": + sut.identify("user") + sut.identify("user", userProperties: ["plan": "pro"], options: options) + expectedEvent = "$set" + case "setPersonProperties": + sut.setPersonProperties(userPropertiesToSet: ["plan": "pro"], options: options) + expectedEvent = "$set" + case "alias": + sut.alias("other-id", options: options) + expectedEvent = "$create_alias" + case "group": + sut.group(type: "company", key: "acme", options: options) + expectedEvent = "$groupidentify" + case "captureException Error": + sut.captureException(NSError(domain: "test", code: 1), options: options) + expectedEvent = "$exception" + default: + sut.captureException(NSException(name: .genericException, reason: "boom"), options: options) + expectedEvent = "$exception" + } + _ = try await getServerEvents(server) + let request = try #require(server.batchRequests.first) + let sent = try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.last) + + #expect(sent["event"] as? String == expectedEvent) + #expect(sent["options"] as? [String: AnyHashable] == [ + "process_person_profile": true, "cookieless_mode": true, "product_tour_id": "edited-tour", + ]) + let properties = try #require(sent["properties"] as? [String: Any]) + #expect(properties["$cookieless_mode"] == nil) + if method == "screen" { + #expect(properties["$screen_name"] as? String == "Home") + } + } + + @Test("Objective-C keeps the old selectors and gains ones that take options") + func objcSelectors() { + let selectors = [ + "screenWithTitle:properties:", "screenWithTitle:properties:options:", + "identifyWithDistinctId:userProperties:userPropertiesSetOnce:", + "identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:", + "setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:", + "setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:", + "alias:", "alias:options:", + "groupWithType:key:groupProperties:", "groupWithType:key:groupProperties:options:", + "captureExceptionWithError:properties:", "captureExceptionWithError:properties:options:", + "captureExceptionWithNSException:properties:", "captureExceptionWithNSException:properties:options:", + ] + for selector in selectors { + #expect(PostHogSDK.instancesRespond(to: NSSelectorFromString(selector)), "\(selector)") + } + } +} + +@Suite("Capture V1 queue partial retry", .serialized, .resetsGlobalState) +final class PostHogCaptureV1QueueTest { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + @Test("removes ok, warning and drop events and keeps retry events for the next attempt") + func keepsOnlyRetryEvents() async throws { + try await withMockedClock { clock in + let config = PostHogConfig(projectToken: "phc_partial_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .batch(api: PostHogApi(config)), nil) + server.batchProjectToken = config.projectToken + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let events = ["ok", "warning", "drop", "retry"].map { PostHogEvent(event: $0, distinctId: "user") } + let results = Dictionary(uniqueKeysWithValues: events.map { ($0.uuid.postHogUuidString, $0.event) }) + server.captureV1EventResult = { uuid, index in index == 1 ? results[uuid] ?? "ok" : "ok" } + for event in events { + queue.add(event) + } + + queue.flush() + await waitUntil { queue.depth == 1 } + #expect(queue.depth == 1) + #expect(queue.currentRetryCountForTesting == 1) + let keptData = try #require(queue.fileQueue.peek(1).first) + let kept = try #require(PostHogEvent.fromJSON(keptData)) + #expect(kept.uuid == events[3].uuid) + + // Paused for backoff until the clock moves past it. + queue.flush() + try await Task.sleep(nanoseconds: 100_000_000) + #expect(server.batchRequests.count == 1) + + clock.date = clock.date.addingTimeInterval(retryDelay + 1) + queue.flush() + await waitUntil { queue.depth == 0 } + #expect(queue.depth == 0) + #expect(queue.currentRetryCountForTesting == 0) + + let requests = server.batchRequests + try #require(requests.count == 2) + #expect(server.parsePostHogEvents(requests[1]).map(\.uuid) == [events[3].uuid]) + #expect(requests[1].value(forHTTPHeaderField: "PostHog-Attempt") == "2") + #expect( + requests[1].value(forHTTPHeaderField: "PostHog-Request-Id") + == requests[0].value(forHTTPHeaderField: "PostHog-Request-Id") + ) + } + } + + @Test("splits analytics batches at the byte limit and sends an event over it alone") + func batchesByBytes() async throws { + let config = PostHogConfig(projectToken: "phc_bytes_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .batch(api: PostHogApi(config)), nil) + server.batchProjectToken = config.projectToken + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + // Two ~2 MiB events fit under 5 MiB; the ~6 MiB one doesn't fit with them and + // is over the limit on its own. + let small = String(repeating: "a", count: 2 * 1024 * 1024) + let big = String(repeating: "b", count: 6 * 1024 * 1024) + for blob in [small, small, big] { + queue.add(PostHogEvent(event: "large", distinctId: "user", properties: ["blob": blob])) + } + queue.flush() + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(server.batchRequests.map { server.parsePostHogEvents($0).count } == [2, 1]) + } + + @Test("flushes analytics once a batch's worth of bytes is queued, before flushAt") + func flushesAtByteLimit() async throws { + let config = PostHogConfig(projectToken: "phc_bytes_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + // Not started, so no timer: only the byte threshold can trigger a send. + let queue = PostHogQueue(config, storage, .batch(api: PostHogApi(config)), nil) + server.batchProjectToken = config.projectToken + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let blob = String(repeating: "a", count: 2 * 1024 * 1024) + for _ in 0 ..< 3 { + queue.add(PostHogEvent(event: "large", distinctId: "user", properties: ["blob": blob])) + } + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(!server.batchRequests.isEmpty) + } +} + +@Suite("Capture V1 AI lane", .serialized, .resetsGlobalState) +final class PostHogCaptureAiTest { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + private func makeSDK(configure: (PostHogConfig) -> Void = { _ in }, beforeSend: BeforeSendBlock? = nil) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_ai_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 1 + config.preloadFeatureFlags = false + config.sendFeatureFlagEvent = false + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + if let beforeSend { + config.setBeforeSend(beforeSend) + } + server.batchProjectToken = config.projectToken + configure(config) + return PostHogSDK.with(config) + } + + @Test("captureAi sends to /i/v1/ai/events through beforeSend, and capture keeps $ai_ events on analytics") + func routesByMethod() async throws { + let sut = makeSDK { event in + event.properties["edited"] = true + return event + } + defer { sut.close() } + + let uuid = sut.captureAi("$ai_generation", distinctId: "user", properties: ["$ai_model": "gpt"], options: ["cookieless_mode": true]) + await waitUntil { self.server.aiRequests.count == 1 } + + let request = try #require(server.aiRequests.first) + #expect(request.url?.path == "/i/v1/ai/events") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(sut.config.projectToken)") + #expect(request.value(forHTTPHeaderField: "PostHog-Attempt") == "1") + #expect(request.value(forHTTPHeaderField: "PostHog-Request-Id") != nil) + #expect(request.timeoutInterval == 60) + let event = try #require(server.parsePostHogEvents(request).first) + #expect(event.event == "$ai_generation") + #expect(event.uuid.postHogUuidString == uuid) + #expect(event.distinctId == "user") + #expect(event.properties["$ai_model"] as? String == "gpt") + #expect(event.properties["edited"] as? Bool == true) + #expect(event.options["cookieless_mode"] as? Bool == true) + #expect(server.batchRequests.isEmpty) + + sut.capture("$ai_generation") + await waitUntil { self.server.batchRequests.count == 1 } + let batchRequest = try #require(server.batchRequests.first) + #expect(server.parsePostHogEvents(batchRequest).map(\.event) == ["$ai_generation"]) + #expect(batchRequest.timeoutInterval < 60) + #expect(server.aiRequests.count == 1) + } + + @Test("captureAi returns nil when the event isn't queued") + func returnsNilWhenNotQueued() { + let sut = makeSDK { event in event.event == "$ai_span" ? nil : event } + defer { sut.close() } + + #expect(sut.captureAi("$ai_span") == nil) + #expect(sut.captureAi("$ai_generation", properties: ["$ai_input": String(repeating: "a", count: aiMaxEventBytes)]) == nil) + sut.optOut() + #expect(sut.captureAi("$ai_generation") == nil) + } + + @Test("AI requests use captureAiTimeout and captureAiCompression, and analytics requests don't") + func usesAiRequestSettings() async throws { + let sut = makeSDK { config in + config.captureAiTimeout = 90 + config.captureAiCompression = .none + } + defer { sut.close() } + + sut.captureAi("$ai_generation") + await waitUntil { self.server.aiRequests.count == 1 } + let request = try #require(server.aiRequests.first) + #expect(request.timeoutInterval == 90) + #expect(request.value(forHTTPHeaderField: "Content-Encoding") == nil) + let batch = server.parseRequest(request, gzip: false)?["batch"] as? [[String: Any]] + #expect(batch?.map { $0["event"] as? String } == ["$ai_generation"]) + + sut.capture("signed_up") + await waitUntil { self.server.batchRequests.count == 1 } + let batchRequest = try #require(server.batchRequests.first) + #expect(batchRequest.timeoutInterval < 90) + #expect(batchRequest.value(forHTTPHeaderField: "Content-Encoding") == "gzip") + } + + @Test("AI settings of 0 or less use their defaults, and captureAiMaxEventBytes can't exceed its default") + func aiSettingLimits() { + let config = PostHogConfig(projectToken: "phc_ai_settings") + #expect(config.effectiveCaptureAiMaxQueueSize == 100) + #expect(config.effectiveCaptureAiTimeout == 60) + #expect(config.effectiveCaptureAiMaxEventBytes == aiMaxEventBytes) + + config.captureAiMaxQueueSize = 3 + config.captureAiTimeout = 5 + config.captureAiMaxEventBytes = 1024 + #expect(QueueEndpoint.ai(api: PostHogApi(config)).maxQueueSize(config) == 3) + #expect(config.effectiveCaptureAiTimeout == 5) + #expect(config.effectiveCaptureAiMaxEventBytes == 1024) + + config.captureAiMaxQueueSize = 0 + config.captureAiTimeout = -1 + config.captureAiMaxEventBytes = 0 + #expect(config.effectiveCaptureAiMaxQueueSize == 100) + #expect(config.effectiveCaptureAiTimeout == 60) + #expect(config.effectiveCaptureAiMaxEventBytes == aiMaxEventBytes) + + config.captureAiMaxEventBytes = 100 * 1024 * 1024 + #expect(config.effectiveCaptureAiMaxEventBytes == aiMaxEventBytes) + } + + @Test("drops an AI event over a lowered captureAiMaxEventBytes") + func dropsEventOverConfiguredLimit() { + let config = PostHogConfig(projectToken: "phc_ai_lowered", host: "http://localhost:9001") + config.captureAiMaxEventBytes = 1024 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let overLimit = String(repeating: "a", count: 1024) + #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": overLimit]))) + #expect(queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": "short"]))) + #expect(queue.depth == 1) + } + + @Test("a 404 from the AI endpoint keeps the batch for retry") + func notFoundIsRetried() async throws { + server.aiResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [:], statusCode: 404, headers: nil) } + let config = PostHogConfig(projectToken: "phc_ai_404", host: "http://localhost:9001") + let storage = PostHogStorage(config) + let api = PostHogApi(config) + let queue = PostHogQueue(config, storage, .ai(api: api), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user")) + queue.flush() + await waitUntil { queue.currentRetryCountForTesting == 1 } + + #expect(queue.depth == 1) + #expect(server.aiRequests.count == 1) + #expect(server.batchRequests.isEmpty) + } + + @Test("drops an AI event over the size limit when it is queued, logging only its size") + func dropsOversizedEvent() throws { + let config = PostHogConfig(projectToken: "phc_ai_big", host: "http://localhost:9001") + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + #expect(QueueEndpoint.ai(api: PostHogApi(config)).maxQueueBytes == aiMaxQueueBytes) + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let big = String(repeating: "a", count: aiMaxEventBytes) + var added = true + let output = try captureStdout { + added = queue.add(PostHogEvent(event: "$ai_oversized", distinctId: "user", properties: ["$ai_input": big])) + } + #expect(!added) + #expect(queue.depth == 0) + // One size line, not a second "unserialisable" line. + #expect(output.contains("Dropping AI event '$ai_oversized' of ")) + #expect(output.contains("over the \(aiMaxEventBytes) byte limit.")) + #expect(!output.contains("unserialisable")) + } + + @Test("clamps captureAiMaxEventBytes above the ceiling and warns naming both values") + func warnsWhenMaxEventBytesClamped() throws { + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } + let config = PostHogConfig(projectToken: "phc_ai_clamp", host: "http://localhost:9001") + config.captureAiMaxEventBytes = aiMaxEventBytes + 1 + + var endpoint: QueueEndpoint? + let output = try captureStdout { endpoint = .ai(api: PostHogApi(config)) } + + #expect(endpoint?.maxRecordBytes == aiMaxEventBytes) + #expect(output.contains("captureAiMaxEventBytes \(aiMaxEventBytes + 1) is over the \(aiMaxEventBytes) byte ceiling")) + + config.captureAiMaxEventBytes = 1024 + let quiet = try captureStdout { endpoint = .ai(api: PostHogApi(config)) } + #expect(endpoint?.maxRecordBytes == 1024) + #expect(!quiet.contains("captureAiMaxEventBytes")) + } + + @Test("splits AI requests at the batch byte limit") + func batchesByBytes() async throws { + let config = PostHogConfig(projectToken: "phc_ai_bytes", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + // Each event is a little over 2 MiB, so two fit under 5 MiB and the third goes next. + let input = String(repeating: "a", count: 2 * 1024 * 1024) + for _ in 0 ..< 3 { + queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": input])) + } + queue.flush() + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(server.aiRequests.map { server.parsePostHogEvents($0).count } == [2, 1]) + } + + @Test("flushes once a batch's worth of bytes is queued, before flushAt is reached") + func flushesAtBatchBytes() async throws { + let config = PostHogConfig(projectToken: "phc_ai_byte_flush", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + let queue = PostHogQueue(config, storage, .ai(api: PostHogApi(config)), nil) + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let input = String(repeating: "a", count: 3 * 1024 * 1024) + queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": input])) + queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": input])) + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(server.aiRequests.map { server.parsePostHogEvents($0).count } == [1, 1]) + } +} diff --git a/PostHogTests/PostHogEventSnapshotTests.swift b/PostHogTests/PostHogEventSnapshotTests.swift index b59d7069a..664d94ca6 100644 --- a/PostHogTests/PostHogEventSnapshotTests.swift +++ b/PostHogTests/PostHogEventSnapshotTests.swift @@ -164,6 +164,18 @@ final class PostHogEventSnapshotTests { try #require(Int(contentLength) != nil) headers["content-length"] = "" } + if let sdkInfo = headers["posthog-sdk-info"] { + #expect(sdkInfo.hasPrefix("posthog-ios/")) + headers["posthog-sdk-info"] = "posthog-ios/" + } + if let requestId = headers["posthog-request-id"] { + try #require(UUID(uuidString: requestId) != nil) + headers["posthog-request-id"] = "" + } + if let requestTimestamp = headers["posthog-request-timestamp"] { + try #require(toISO8601Date(requestTimestamp) != nil) + headers["posthog-request-timestamp"] = "" + } if let userAgent = headers["user-agent"] { let prefix = "posthog-ios/" #expect(userAgent.hasPrefix(prefix)) @@ -189,9 +201,9 @@ final class PostHogEventSnapshotTests { private func normalizeBatchSnapshot(_ snapshot: inout [String: Any]) throws { var body = try #require(snapshot["body"] as? [String: Any]) - let sentAt = try #require(body["sent_at"] as? String) - try #require(toISO8601Date(sentAt) != nil) - body["sent_at"] = "" + let createdAt = try #require(body["created_at"] as? String) + try #require(toISO8601Date(createdAt) != nil) + body["created_at"] = "" var events = try #require(body["batch"] as? [[String: Any]]) for index in events.indices { diff --git a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index a6b262957..01a12fc68 100644 --- a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift +++ b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift @@ -57,7 +57,7 @@ struct PostHogFileBackedQueueAlignmentTest { let first = queue.add(Data("A".utf8), maxSize: 2) #expect(first.success) - #expect(first.evicted == nil) + #expect(first.evicted.isEmpty) queue.add(Data("B".utf8), maxSize: 2) let originalIds = queue.peekEntries(2).map(\.id) try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: dir.path) @@ -67,7 +67,7 @@ struct PostHogFileBackedQueueAlignmentTest { let failed = queue.add(Data("C".utf8), maxSize: 2) #expect(!failed.success) - #expect(failed.evicted == nil) + #expect(failed.evicted.isEmpty) #expect(queue.depth == 2) #expect(queue.peekEntries(2).map(\.id) == originalIds) #expect(decode(queue.peek(2)) == ["A", "B"]) @@ -76,7 +76,7 @@ struct PostHogFileBackedQueueAlignmentTest { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dir.path) let retried = queue.add(Data("C".utf8), maxSize: 2) #expect(retried.success) - #expect(retried.evicted == originalIds.first) + #expect(retried.evicted == [originalIds[0]]) #expect(queue.depth == 2) #expect(decode(queue.peek(2)) == ["B", "C"]) let reloaded = PostHogFileBackedQueue(queue: dir, maxSize: 2) @@ -84,6 +84,81 @@ struct PostHogFileBackedQueueAlignmentTest { #expect(reloaded.depth == 2) } + @Test("evicts the oldest records to stay under the byte limit") + func evictsOldestOverByteLimit() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-bytes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let queue = PostHogFileBackedQueue(queue: dir, maxBytes: 10) + + queue.add(Data("AAAA".utf8)) + let first = queue.peekEntries(1).map(\.id) + queue.add(Data("BBBB".utf8)) + let added = queue.add(Data("CCCC".utf8)) + + #expect(added.evicted == first) + #expect(decode(queue.peek(10)) == ["BBBB", "CCCC"]) + #expect(try FileManager.default.contentsOfDirectory(atPath: dir.path).count == 2) + + queue.pop(1) + queue.add(Data("DDDD".utf8)) + #expect(decode(queue.peek(10)) == ["CCCC", "DDDD"]) + } + + @Test("tracks the byte count without a byte limit and evicts nothing") + func tracksBytesWithoutLimit() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-bytes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let queue = PostHogFileBackedQueue(queue: dir, trackBytes: true) + + #expect(queue.add(Data("AAAA".utf8)).evicted.isEmpty) + #expect(queue.add(Data("BBBBBB".utf8)).evicted.isEmpty) + #expect(queue.byteCount == 10) + #expect(PostHogFileBackedQueue(queue: dir, trackBytes: true).byteCount == 10) + + queue.pop(1) + #expect(queue.byteCount == 6) + #expect(decode(queue.peek(10)) == ["BBBBBB"]) + } + + @Test("keeps the newest record even when it is over the byte limit alone") + func keepsOversizedNewestRecord() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-bytes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let queue = PostHogFileBackedQueue(queue: dir, maxBytes: 4) + + queue.add(Data("AA".utf8)) + let added = queue.add(Data("BBBBBB".utf8)) + + #expect(added.evicted.count == 1) + #expect(decode(queue.peek(10)) == ["BBBBBB"]) + } + + @Test("trims the oldest records over the byte limit when reloading from disk") + func trimsByteLimitOnLoad() throws { + let (queue, dir) = makeQueue() + defer { try? FileManager.default.removeItem(at: dir) } + enqueue(["AAAA", "BBBB", "CCCC"], into: queue) + + let reloaded = PostHogFileBackedQueue(queue: dir, maxBytes: 8) + + #expect(decode(reloaded.peek(10)) == ["BBBB", "CCCC"]) + #expect(try FileManager.default.contentsOfDirectory(atPath: dir.path).count == 2) + } + + @Test("a byte-bounded peek stops after the record that reaches the budget") + func peekStopsAtByteBudget() throws { + let (queue, dir) = makeQueue() + defer { try? FileManager.default.removeItem(at: dir) } + enqueue(["AAAA", "BBBB", "CCCC"], into: queue) + + #expect(queue.peekEntries(10, maxBytes: 5).map { String(decoding: $0.data, as: UTF8.self) } == ["AAAA", "BBBB"]) + #expect(queue.peekEntries(10, maxBytes: 1).count == 1) + #expect(queue.peekEntries(10).count == 3) + } + @Test("delivers every record once in FIFO order on the happy path") func happyPath() throws { let (queue, dir) = makeQueue() diff --git a/PostHogTests/PostHogGeoIpTest.swift b/PostHogTests/PostHogGeoIpTest.swift index bcbaa1a35..5edb215a9 100644 --- a/PostHogTests/PostHogGeoIpTest.swift +++ b/PostHogTests/PostHogGeoIpTest.swift @@ -15,10 +15,10 @@ class PostHogGeoIpTests { server.stop() } - func getSut(disableGeoIp: Bool = false) -> PostHogSDK { + func getSut(disableGeoIp: Bool = false, flushAt: Int = 1) -> PostHogSDK { // A token per test keeps its storage and requests apart from other suites' SDK instances let config = PostHogConfig(projectToken: "geoip_\(UUID().uuidString)", host: "http://localhost:9001") - config.flushAt = 1 + config.flushAt = flushAt config.captureApplicationLifecycleEvents = false config.disableReachabilityForTesting = true config.disableQueueTimerForTesting = true @@ -64,6 +64,23 @@ class PostHogGeoIpTests { sut.close() } + @Test("registered and caller $geoip_disable values override disableGeoIp") + func registeredAndCallerOverrideGeoipDisable() { + // flushAt 2 so both events share one batch + let sut = getSut(disableGeoIp: true, flushAt: 2) + + sut.register(["$geoip_disable": false]) + sut.capture("registered") + sut.capture("caller", properties: ["$geoip_disable": true]) + + let events = getBatchedEvents(server) + #expect(events.first { $0.event == "registered" }?.properties["$geoip_disable"] as? Bool == false) + #expect(events.first { $0.event == "caller" }?.properties["$geoip_disable"] as? Bool == true) + + sut.reset() + sut.close() + } + @Test("flags requests send geoip_disable false by default") func flagsRequestsSendGeoipDisableFalseByDefault() async throws { let sut = getSut() diff --git a/PostHogTests/PostHogLogsQueueTest.swift b/PostHogTests/PostHogLogsQueueTest.swift index 6a3022c00..f928b8d3d 100644 --- a/PostHogTests/PostHogLogsQueueTest.swift +++ b/PostHogTests/PostHogLogsQueueTest.swift @@ -328,7 +328,7 @@ final class PostHogLogsQueueTests { } @Test("Retry-After floors the backoff, clamped to the documented maximum", arguments: [ - ("3600", logsMaxRetryAfterSeconds), + ("3600", min(logsMaxRetryAfterSeconds, maxRetryDelay)), ("30", 30.0), ]) func retryAfterIsClampedThenFloored(header: String, expectedWait: TimeInterval) async throws { diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index 403e715f0..a71ea8723 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -117,7 +117,7 @@ resetStorage: Bool = true ) -> (handler: PostHogPushSubscriptionHandler, storage: PostHogStorage, config: PostHogConfig) { let config = PostHogConfig(projectToken: projectToken, host: "http://localhost:9001") - config.maxRetries = maxRetries + config.pushNotificationSubscriptionMaxRetries = maxRetries config.disableReachabilityForTesting = true let api = PostHogApi(config) let storage = PostHogStorage(config) @@ -726,7 +726,7 @@ #expect(server.pushSubscriptionRequests.count == 2) } - @Test("gives up after maxRetries, keeps the record, and retries once on relaunch (vector 4)") + @Test("gives up after pushNotificationSubscriptionMaxRetries, keeps the record, and retries once on relaunch (vector 4)") func givesUpKeepsRecordThenRetriesOnRelaunch() async throws { server.pushSubscriptionStatusCode = 500 let maxRetries = 3 diff --git a/PostHogTests/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index c79f8d364..02bfb02ba 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -78,12 +78,14 @@ final class PostHogQueueTest { #expect(latch.isSignaled, sourceLocation: sourceLocation) } - private func getSut(flushAt: Int = 1, maxQueueSize: Int = 1000, maxBatchSize: Int = 50, maxRetries: Int = 3, sender: ControlledBatchSender? = nil, afterUpload: (() -> Void)? = nil) -> PostHogQueue { + private func getSut(flushAt: Int = 1, maxQueueSize: Int = 1000, maxBatchSize: Int = 50, maxRetries: Int = 3, sender: ControlledBatchSender? = nil, + maxBatchBytes: Int? = nil, jitterUnit: @escaping () -> Double = { 0.5 }, afterUpload: (() -> Void)? = nil) -> PostHogQueue + { let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9001") config.flushAt = flushAt config.maxQueueSize = maxQueueSize config.maxBatchSize = maxBatchSize - config.maxRetries = maxRetries + config.pushNotificationSubscriptionMaxRetries = maxRetries config.sendFeatureFlagEvent = false let storage = PostHogStorage(config) let api = PostHogApi(config) @@ -101,6 +103,8 @@ final class PostHogQueueTest { encode: base.encode, decode: base.decode, describe: base.describe, + recordId: base.recordId, + maxBatchBytes: maxBatchBytes, send: { events, completion in let send = sender?.send ?? base.send send(events) { result in @@ -110,8 +114,9 @@ final class PostHogQueueTest { }, isRetriableStatusCode: base.isRetriableStatusCode ) - let sut = PostHogQueue(config, storage, endpoint, nil) - // Only count /batch requests from this fixture's queue: the stub and the activation hook are + // 0.5 is a jitter factor of 1, so backoff waits are exact unless a test injects its own. + let sut = PostHogQueue(config, storage, endpoint, nil, jitterUnit: jitterUnit) + // Only count capture requests from this fixture's queue: the stub and the activation hook are // process-global, so a stray request from another queue/SDK instance would otherwise shift // batchRequests.count and the request number the batchResponseHandler keys on. server.batchProjectToken = config.projectToken @@ -182,6 +187,20 @@ final class PostHogQueueTest { sut.clear() } + @Test("flushes below flushAt once queued bytes reach the batch byte limit") + func flushesOnBatchByteThresholdWithoutQueueByteCap() async { + let sender = ControlledBatchSender() + let sut = getSut(flushAt: 100, sender: sender, maxBatchBytes: 1000) + + sut.add(PostHogEvent(event: "small", distinctId: "distinctId")) + #expect(sender.requestCount == 0) + + sut.add(PostHogEvent(event: "large", distinctId: "distinctId", properties: ["blob": String(repeating: "x", count: 2000)])) + await expectEventually({ sender.requestCount }, 1) + + sut.clear() + } + @Test("add item to queue and rotate queue") func addItemToQueueAndRotateQueue() throws { let sut = getSut(flushAt: 3, maxQueueSize: 2) @@ -311,29 +330,88 @@ final class PostHogQueueTest { sut.clear() } - @Test("retains batch on HTTP 429 and does not change cap") - func retainsBatchOnHTTP429AndDoesNotChangeCap() async { - let sut = getSut(flushAt: 2, maxBatchSize: 4) - server.batchResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 429, headers: nil) + @Test("caps Retry-After at the maximum retry delay", arguments: [("5", 5.0), ("3600", maxRetryDelay)]) + func capsRetryAfter(retryAfter: String, expectedDelay: TimeInterval) async throws { + try await withMockedClock { clock in + let uploaded = AsyncLatch() + let sut = getSut(flushAt: 100) { uploaded.signal() } + server.batchResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 503, headers: ["Retry-After": retryAfter]) + } + + sut.add(PostHogEvent(event: "event", distinctId: "id")) + sut.flush() + await expectSignaled(uploaded) + + let pausedUntil = try #require(sut.pausedUntilForTesting) + #expect(abs(pausedUntil.timeIntervalSince(clock.date) - expectedDelay) < 0.001) + sut.clear() } + } - sut.add(PostHogEvent(event: "event1", distinctId: "id1")) - sut.add(PostHogEvent(event: "event2", distinctId: "id2")) + @Test("backoff doubles from the base delay up to the ceiling", arguments: [ + (1, 1.0), (2, 2.0), (3, 4.0), (4, 8.0), (5, 16.0), (6, maxRetryDelay), (50, maxRetryDelay), + ]) + func backoffDoublesUpToCeiling(attempt: Int, expected: TimeInterval) { + let delay = queueRetryDelay(attempt: attempt, jitterUnit: 0.5, retryAfter: nil, retryAfterCap: maxRetryDelay) + #expect(delay == expected) + } - _ = getBatchedEvents(server) + @Test("jitter scales the backoff within 25% and never exceeds the ceiling", arguments: [ + (1, 0.0, 0.75), (1, 0.75, 1.125), (3, 0.0, 3.0), (3, 0.75, 4.5), (6, 0.0, 22.5), (6, 0.999, maxRetryDelay), + ]) + func jitterScalesBackoff(attempt: Int, jitterUnit: Double, expected: TimeInterval) { + let delay = queueRetryDelay(attempt: attempt, jitterUnit: jitterUnit, retryAfter: nil, retryAfterCap: maxRetryDelay) + #expect(abs(delay - expected) < 0.000_001) + } - await expectEventually({ sut.depth }, 2) - await expectEventually({ sut.currentBatchCapForTesting }, 4) + @Test("Retry-After is a floor applied after jitter, clamped to its cap", arguments: [ + (10.0, 10.0), (0.5, 0.75), (3600.0, maxRetryDelay), (-5.0, 0.75), + ]) + func retryAfterFloorsJitteredBackoff(retryAfter: TimeInterval, expected: TimeInterval) { + let delay = queueRetryDelay(attempt: 1, jitterUnit: 0, retryAfter: retryAfter, retryAfterCap: maxRetryDelay) + #expect(delay == expected) + } - sut.clear() + @Test("unusable randomness falls back to the unjittered backoff", arguments: [Double.nan, 1.0, -0.1, Double.infinity]) + func unusableJitterFallsBack(jitterUnit: Double) { + let delay = queueRetryDelay(attempt: 3, jitterUnit: jitterUnit, retryAfter: nil, retryAfterCap: maxRetryDelay) + #expect(delay == 4.0) + } + + @Test("pauses for the jittered exponential backoff after consecutive failures") + func pausesForJitteredExponentialBackoff() async throws { + try await withMockedClock { clock in + let uploads = (1 ... 3).map { _ in AsyncLatch() } + var uploadIndex = 0 + let sut = getSut(flushAt: 100, jitterUnit: { 0 }) { + guard uploadIndex < uploads.count else { return } + uploads[uploadIndex].signal() + uploadIndex += 1 + } + server.batchResponseHandler = { _, _ in + HTTPStubsResponse(jsonObject: [], statusCode: 503, headers: nil) + } + + sut.add(PostHogEvent(event: "event", distinctId: "id")) + // Jitter factor 0.75 on 1s, 2s and 4s. + for (index, expectedDelay) in [0.75, 1.5, 3.0].enumerated() { + sut.flush() + await expectSignaled(uploads[index]) + let pausedUntil = try #require(sut.pausedUntilForTesting) + #expect(abs(pausedUntil.timeIntervalSince(clock.date) - expectedDelay) < 0.001) + #expect(sut.depth == 1) + clock.date = pausedUntil.addingTimeInterval(0.001) + } + sut.clear() + } } - @Test("retains batch on HTTP 408 (request timeout is retriable) and does not change cap") - func retainsBatchOnHTTP408AndDoesNotChangeCap() async { + @Test("retains batch on a retriable HTTP status and does not change cap", arguments: [404, 408, 429]) + func retainsBatchOnRetriableStatusAndDoesNotChangeCap(statusCode: Int32) async { let sut = getSut(flushAt: 2, maxBatchSize: 4) server.batchResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 408, headers: nil) + HTTPStubsResponse(jsonObject: [], statusCode: statusCode, headers: nil) } sut.add(PostHogEvent(event: "event1", distinctId: "id1")) @@ -633,7 +711,7 @@ struct PostHogQueueUploadDispositionTest { let config = PostHogConfig(projectToken: "queue_disposition_\(UUID().uuidString)", host: "http://localhost:9001") config.flushAt = 100 config.maxBatchSize = 4 - config.maxRetries = 0 + config.pushNotificationSubscriptionMaxRetries = 0 let api = PostHogApi(config) let base: QueueEndpoint = snapshot ? .snapshot(api: api) : .batch(api: api) let endpoint = QueueEndpoint( @@ -649,6 +727,7 @@ struct PostHogQueueUploadDispositionTest { encode: base.encode, decode: base.decode, describe: base.describe, + recordId: base.recordId, canBatchTogether: base.canBatchTogether, send: sender.send, isRetriableStatusCode: base.isRetriableStatusCode @@ -661,7 +740,7 @@ struct PostHogQueueUploadDispositionTest { try #require(sender.requestCount == count) } - @Test("received HTTP disposition wins over an accompanying transport error", arguments: [-1, 200, 400, 408, 429, 503], [false, true]) + @Test("received HTTP disposition wins over an accompanying transport error", arguments: [-1, 200, 400, 404, 408, 429, 503], [false, true]) func receivedHTTPDisposition(statusCode: Int, snapshot: Bool) async throws { let sender = ControlledBatchSender() let queue = makeQueue(snapshot: snapshot, sender: sender) @@ -680,7 +759,7 @@ struct PostHogQueueUploadDispositionTest { sender.completeRequest(at: 0, with: $0) } - let retryable = [-1, 408, 429, 503].contains(statusCode) + let retryable = [-1, 408, 429, 503].contains(statusCode) || (!snapshot && statusCode == 404) let expectedIds = retryable ? allIds : allIds.filter { !sentIds.contains($0) } #expect(queue.fileQueue.peekEntries(2).map(\.id) == expectedIds) #expect(queue.currentRetryCountForTesting == (retryable ? 1 : 0)) diff --git a/PostHogTests/PostHogSDKPersonProfilesTest.swift b/PostHogTests/PostHogSDKPersonProfilesTest.swift index f28792d7d..2307e137b 100644 --- a/PostHogTests/PostHogSDKPersonProfilesTest.swift +++ b/PostHogTests/PostHogSDKPersonProfilesTest.swift @@ -48,7 +48,7 @@ final class PostHogSDKPersonProfilesTest { return PostHogSDK.with(config) } - @Test("capture sets process person to false if identified only and not identified") + @Test("capture sets the process person option to false if identified only and not identified") func captureSetsProcessPersonFalseIfIdentifiedOnlyAndNotIdentified() throws { let sut = getSut() @@ -60,27 +60,27 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() } - @Test("caller can't override $process_person_profile") - func callerCannotOverrideProcessPersonProfile() throws { + @Test("a caller $process_person_profile property overrides the SDK") + func callerOverridesProcessPersonProfile() throws { let sut = getSut(personProfiles: .never) sut.capture("test event", properties: ["$process_person_profile": true]) let event = try #require(getBatchedEvents(server).first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == true) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with user props") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithUserProps() throws { + @Test("capture sends no process person option if identified only and with user props") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithUserProps() throws { let sut = getSut() sut.capture("test event", @@ -92,14 +92,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with user set once props") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithUserSetOnceProps() throws { + @Test("capture sends no process person option if identified only and with user set once props") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithUserSetOnceProps() throws { let sut = getSut() sut.capture("test event", @@ -111,14 +111,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with group props") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithGroupProps() throws { + @Test("capture sends no process person option if identified only and with group props") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithGroupProps() throws { let sut = getSut() sut.capture("test event", @@ -130,14 +130,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and identified") - func captureSetsProcessPersonTrueIfIdentifiedOnlyAndIdentified() throws { + @Test("capture sends no process person option if identified only and identified") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyAndIdentified() throws { let sut = getSut(flushAt: 2) sut.identify("distinctId") @@ -150,14 +150,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.last) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with alias") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithAlias() throws { + @Test("capture sends no process person option if identified only and with alias") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithAlias() throws { let sut = getSut(flushAt: 2) sut.alias("distinctId") @@ -170,14 +170,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.last) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if identified only and with groups") - func captureSetsProcessPersonTrueIfIdentifiedOnlyWithGroups() throws { + @Test("capture sends no process person option if identified only and with groups") + func captureSendsNoProcessPersonOptionIfIdentifiedOnlyWithGroups() throws { let sut = getSut(flushAt: 2) sut.group(type: "theType", key: "theKey") @@ -190,14 +190,14 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.last) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to true if always") - func captureSetsProcessPersonTrueIfAlways() throws { + @Test("capture sends no process person option if always") + func captureSendsNoProcessPersonOptionIfAlways() throws { let sut = getSut(personProfiles: .always) sut.capture("test event") @@ -208,13 +208,13 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) sut.reset() sut.close() } - @Test("capture sets process person to false if never and identify called") + @Test("capture sets the process person option to false if never and identify called") func captureSetsProcessPersonFalseIfNeverAndIdentifyCalled() throws { let sut = getSut(personProfiles: .never) @@ -229,13 +229,13 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() } - @Test("capture sets process person to false if never and alias called") + @Test("capture sets the process person option to false if never and alias called") func captureSetsProcessPersonFalseIfNeverAndAliasCalled() throws { let sut = getSut(personProfiles: .never) @@ -250,13 +250,13 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() } - @Test("capture sets process person to false if never and group called") + @Test("capture sets the process person option to false if never and group called") func captureSetsProcessPersonFalseIfNeverAndGroupCalled() throws { let sut = getSut(personProfiles: .never) @@ -271,7 +271,7 @@ final class PostHogSDKPersonProfilesTest { let event = try #require(events.first) - #expect(event.properties["$process_person_profile"] as? Bool == false) + #expect(event.options["process_person_profile"] as? Bool == false) sut.reset() sut.close() diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index 74b586853..3c7409279 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -321,7 +321,7 @@ final class PostHogSDKTests { let events = getBatchedEvents(server) #expect(events.count == 1) #expect(events.first?.event == "$set") - #expect(events.first?.properties["$process_person_profile"] as? Bool == true) + #expect(events.first?.options["process_person_profile"] == nil) #expect(config.storageManager?.isIdentified() == true) } @@ -360,7 +360,7 @@ final class PostHogSDKTests { let event = try #require(events.first) #expect(event.event == "$set") - #expect(event.properties["$process_person_profile"] as? Bool == true) + #expect(event.options["process_person_profile"] == nil) let set = event.properties["$set"] as? [String: Any] ?? [:] #expect(set["foo"] as? String == "bar") @@ -492,7 +492,7 @@ final class PostHogSDKTests { @Test("excludes $recording_status and $sdk_debug_* properties from $snapshot events") func excludesRecordingStatusAndDebugPropertiesFromSnapshotEvents() throws { - // $snapshot events route to the replay queue and the /s/ endpoint, not /batch — + // $snapshot events route to the replay queue and the /s/ endpoint, not capture V1 — // read the raw request body rather than getBatchedEvents. server.reset(batchCount: 0, snapshotCount: 1) let sut = getSut() @@ -525,24 +525,6 @@ final class PostHogSDKTests { sut.close() } - @Test("SDK-computed debug keys win over a same-named registered super property") - func sdkComputedDebugKeysWinOverRegisteredSuperProperty() throws { - server.reset(batchCount: 1) - let sut = getSut() - sut.register(["$recording_status": "bogus", "$sdk_debug_pending_queue_size": -1]) - - sut.capture("test event") - - let events = getBatchedEvents(server) - let props = try #require(events.first).properties - #expect(props["$recording_status"] as? String == "disabled") - let pendingQueueSize = try #require(props["$sdk_debug_pending_queue_size"] as? Int) - #expect(pendingQueueSize != -1) - - sut.reset() - sut.close() - } - #else @Test("reports disabled recording status with no replay keys on non-iOS platforms") func reportsDisabledRecordingStatusOnNonIOSPlatforms() throws { @@ -914,6 +896,28 @@ final class PostHogSDKTests { sut.close() } + @Test("keeps legacy option properties on minimal feature flag events") + func keepsLegacyOptionPropertiesOnMinimalFeatureFlagEvents() throws { + server.minimalFlagCalledEvents = true + let sut = getSut(preloadFeatureFlags: true, sendFeatureFlagEvent: true) + sut.register(["$cookieless_mode": true, "$ignore_sent_at": true, "$product_tour_id": "tour"]) + + waitForFeatureFlagsLoaded(server, sut) + #expect(sut.isFeatureEnabled("string-value") == true) + + let events = getBatchedEvents(server) + + let event = try #require(events.first) + #expect(event.event == "$feature_flag_called") + // They reach capture V1 as options. + #expect(event.options["cookieless_mode"] as? Bool == true) + #expect(event.options["disable_skew_correction"] as? Bool == true) + #expect(event.options["product_tour_id"] as? String == "tour") + + sut.reset() + sut.close() + } + @Test("keeps $groups on minimal feature flag events") func keepsGroupsOnMinimalFeatureFlagEvents() throws { server.minimalFlagCalledEvents = true @@ -933,7 +937,8 @@ final class PostHogSDKTests { let event = try #require(events.last) #expect(event.event == "$feature_flag_called") // $groups is correctness-required (ingestion dedup key + personful routing for group - // flags), so it must survive minimization when groups are registered. + // flags), so it must survive minimization when groups are registered. group() turns person + // processing on, so there's no process_person_profile option or property. #expect(Set(event.properties.keys) == Set([ "$feature_flag", "$feature_flag_response", @@ -944,7 +949,6 @@ final class PostHogSDKTests { "$feature_flag_request_id", "$feature_flag_evaluated_at", "$groups", - "$process_person_profile", "$session_id", "$lib", "$lib_version", @@ -1191,11 +1195,11 @@ final class PostHogSDKTests { func callerPropertiesWinOverSdkContext() throws { let sut = getSut() - sut.capture("event", properties: ["$os_name": "caller-os", "$lib": "caller-lib"]) + sut.capture("event", properties: ["$os_name": "caller-os", "$app_version": "caller-version"]) let event = try #require(getBatchedEvents(server).first) #expect(event.properties["$os_name"] as? String == "caller-os") - #expect(event.properties["$lib"] as? String == "caller-lib") + #expect(event.properties["$app_version"] as? String == "caller-version") sut.reset() sut.close() @@ -1231,6 +1235,30 @@ final class PostHogSDKTests { sut.close() } + @Test("per-call userProperties, userPropertiesSetOnce and groups, and group() groups, win over registered ones") + func perCallTypedValuesWinOverRegistered() throws { + let sut = getSut(flushAt: 2) + + sut.group(type: "project", key: "stored") + sut.register([ + "$set": ["plan": "registered"], + "$set_once": ["source": "registered"], + "$groups": ["company": "registered", "team": "registered"], + ]) + sut.capture("event", + userProperties: ["plan": "caller"], + userPropertiesSetOnce: ["source": "caller"], + groups: ["company": "caller"]) + + let event = try #require(getBatchedEvents(server).first { $0.event == "event" }) + #expect(event.properties["$set"] as? [String: String] == ["plan": "caller"]) + #expect(event.properties["$set_once"] as? [String: String] == ["source": "caller"]) + #expect(event.properties["$groups"] as? [String: String] == ["project": "stored", "company": "caller"]) + + sut.reset() + sut.close() + } + @Test("blank or non-string caller $session_id keeps the SDK session", arguments: ["", " ", 1] as [AnyHashable]) func invalidCallerSessionIdKeepsSdkSession(sessionId: AnyHashable) throws { let sut = getSut() @@ -1245,31 +1273,38 @@ final class PostHogSDKTests { sut.close() } - @Test("caller can't override $is_identified") - func callerCannotOverrideIsIdentified() throws { - let sut = getSut(flushAt: 2) + @Test("a registered $is_identified overrides the SDK's, and a caller one overrides both") + func registeredAndCallerOverrideIsIdentified() throws { + let sut = getSut(flushAt: 3) sut.identify("user") - sut.capture("event", properties: ["$is_identified": false]) + sut.register(["$is_identified": false]) + sut.capture("registered") + sut.capture("caller", properties: ["$is_identified": true]) - let event = try #require(getBatchedEvents(server).first { $0.event == "event" }) - #expect(event.properties["$is_identified"] as? Bool == true) + let events = getBatchedEvents(server) + #expect(events.first { $0.event == "registered" }?.properties["$is_identified"] as? Bool == false) + #expect(events.first { $0.event == "caller" }?.properties["$is_identified"] as? Bool == true) sut.reset() sut.close() } - @Test("caller can't override SDK debug properties") - func callerCannotOverrideSdkDebugProperties() throws { - let sut = getSut() + @Test("registered debug properties override the SDK's, and caller ones override both") + func registeredAndCallerOverrideSdkDebugProperties() throws { + let sut = getSut(flushAt: 2) - sut.capture("event", properties: ["$recording_status": "bogus", "$sdk_debug_pending_queue_size": -1]) + sut.register(["$recording_status": "registered", "$sdk_debug_pending_queue_size": -1]) + sut.capture("registered") + sut.capture("caller", properties: ["$recording_status": "caller", "$sdk_debug_pending_queue_size": -2]) - let event = try #require(getBatchedEvents(server).first) - let recordingStatus = try #require(event.properties["$recording_status"] as? String) - let pendingQueueSize = try #require(event.properties["$sdk_debug_pending_queue_size"] as? Int) - #expect(recordingStatus != "bogus") - #expect(pendingQueueSize != -1) + let events = getBatchedEvents(server) + let registered = try #require(events.first { $0.event == "registered" }) + #expect(registered.properties["$recording_status"] as? String == "registered") + #expect(registered.properties["$sdk_debug_pending_queue_size"] as? Int == -1) + let caller = try #require(events.first { $0.event == "caller" }) + #expect(caller.properties["$recording_status"] as? String == "caller") + #expect(caller.properties["$sdk_debug_pending_queue_size"] as? Int == -2) sut.reset() sut.close() diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index 148c3f049..55b493b36 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -152,6 +152,13 @@ snapshotEndpoint: "/s/", expected: true ), + IgnoredNetworkRequestCase( + name: "ignores capture V1 ingestion on the configured host", + requestURL: "https://us.i.posthog.com/i/v1/analytics/events", + apiHost: "https://us.i.posthog.com", + snapshotEndpoint: "/s/", + expected: true + ), IgnoredNetworkRequestCase( name: "ignores batch ingestion on the configured host", requestURL: "https://us.i.posthog.com/batch", @@ -166,6 +173,13 @@ snapshotEndpoint: "/s/", expected: true ), + IgnoredNetworkRequestCase( + name: "ignores capture V1 ingestion behind a reverse proxy path", + requestURL: "https://app.posthog.com/ingest/i/v1/analytics/events", + apiHost: "https://app.posthog.com/ingest", + snapshotEndpoint: "/s/", + expected: true + ), IgnoredNetworkRequestCase( name: "ignores batch ingestion behind a reverse proxy path", requestURL: "https://app.posthog.com/ingest/batch", diff --git a/PostHogTests/PostHogWrapperEventTest.swift b/PostHogTests/PostHogWrapperEventTest.swift new file mode 100644 index 000000000..7066156ec --- /dev/null +++ b/PostHogTests/PostHogWrapperEventTest.swift @@ -0,0 +1,353 @@ +// +// PostHogWrapperEventTest.swift +// PostHogTests +// + +import Foundation +@_spi(PostHogInternal) @testable import PostHog +import Testing + +@Suite("Wrapper build and send", .serialized, .resetsGlobalState) +final class PostHogWrapperEventTest { + private let server: MockPostHogServer + + init() { + deleteSafely(applicationSupportDirectoryURL()) + server = MockPostHogServer() + server.start(batchCount: 0) + } + + deinit { + server.stop() + } + + private func makeSDK(beforeSend: BeforeSendBlock? = nil, reuseAnonymousId: Bool = false) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_wrapper_\(UUID().uuidString)", host: "http://localhost:9001") + config.reuseAnonymousId = reuseAnonymousId + config.flushAt = 1 + config.preloadFeatureFlags = false + config.sendFeatureFlagEvent = false + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + if let beforeSend { + config.setBeforeSend(beforeSend) + } + server.batchProjectToken = config.projectToken + return PostHogSDK.with(config) + } + + private func build(_ sut: PostHogSDK, _ event: String = "test", properties: [String: Any]? = nil, ai: Bool = false) -> PostHogEvent? { + sut.buildEventForWrapper(event: event, distinctId: nil, properties: properties, userProperties: ["plan": "pro"], + userPropertiesSetOnce: nil, groups: nil, options: ["cookieless_mode": true], ai: ai) + } + + @Test("build returns the filled event without running beforeSend or queueing it") + func buildFillsWithoutSending() async throws { + var hookCalls = 0 + let sut = makeSDK { event in + hookCalls += 1 + return event + } + defer { sut.close() } + sut.register(["registered": "yes"]) + + let built = try #require(build(sut, "built", properties: ["call": 1])) + + #expect(built.properties["registered"] as? String == "yes") + #expect(built.properties["call"] as? Int == 1) + #expect(built.properties["$set"] as? [String: String] == ["plan": "pro"]) + #expect(built.properties["$session_id"] != nil) + #expect(built.properties["$lib"] != nil) + #expect(built.options["cookieless_mode"] as? Bool == true) + #expect(built.distinctId == sut.getDistinctId()) + #expect(hookCalls == 0) + + server.reset(batchCount: 1) + sut.capture("other") + #expect(try await getServerEvents(server).map(\.event) == ["other"]) + } + + @Test("send runs beforeSend, adds no properties, and keeps the uuid and timestamp") + func sendKeepsBuiltEvent() async throws { + var hookCalls = 0 + let sut = makeSDK { event in + hookCalls += 1 + event.properties["native"] = true + return event + } + defer { sut.close() } + sut.register(["registered": "yes"]) + let built = try #require(build(sut)) + built.properties.removeValue(forKey: "registered") + built.properties["wrapper"] = true + + server.reset(batchCount: 1) + let uuid = sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(try await getServerEvents(server).first) + + #expect(hookCalls == 1) + #expect(uuid == built.uuid.postHogUuidString) + #expect(sent.uuid == built.uuid) + #expect(toISO8601String(sent.timestamp) == toISO8601String(built.timestamp)) + #expect(sent.properties["registered"] == nil) + #expect(sent.properties["wrapper"] as? Bool == true) + #expect(sent.properties["native"] as? Bool == true) + } + + @Test("an AI event is sent on the AI lane") + func sendsAiOnAiLane() async throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$ai_generation", ai: true)) + + sut.sendBuiltEventForWrapper(built, ai: true) + await waitUntil { self.server.aiRequests.count == 1 } + + let request = try #require(server.aiRequests.first) + let sent = server.parsePostHogEvents(request) + #expect(sent.map(\.uuid) == [built.uuid]) + #expect(sent.first?.properties["$sdk_debug_session_start"] != nil) + #expect(server.batchRequests.isEmpty) + } + + @Test("build and send return nil when the SDK is opted out") + func optedOutReturnsNil() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + sut.optOut() + + #expect(build(sut) == nil) + #expect(build(sut, "$screen", properties: ["$screen_name": "Home"]) == nil) + #expect(sut.sendBuiltEventForWrapper(built, ai: false) == nil) + } + + @Test("send returns nil when native beforeSend drops the event") + func nativeDropReturnsNil() throws { + let sut = makeSDK { _ in nil } + defer { sut.close() } + + #expect(try sut.sendBuiltEventForWrapper(#require(build(sut)), ai: false) == nil) + } + + @Test("a sent $screen event becomes the current screen; a built one doesn't") + func screenRecordedAtSend() async throws { + let sut = makeSDK() + defer { sut.close() } + + let screen = try #require(build(sut, "$screen", properties: ["$screen_name": "Home", "tab": 1])) + #expect(screen.properties["$screen_name"] as? String == "Home") + #expect(screen.properties["tab"] as? Int == 1) + #expect(build(sut)?.properties["$screen_name"] == nil) + + sut.sendBuiltEventForWrapper(screen, ai: false) + + #expect(build(sut)?.properties["$screen_name"] as? String == "Home") + } + + @Test("a $screen the wrapper renames isn't recorded as a screen") + func renamedScreenNotRecorded() async throws { + let sut = makeSDK() + defer { sut.close() } + let screen = try #require(build(sut, "$screen", properties: ["$screen_name": "Home"])) + screen.event = "custom" + + sut.sendBuiltEventForWrapper(screen, ai: false) + + #expect(build(sut)?.properties["$screen_name"] == nil) + } + + @Test("a $screen sent after reset doesn't restore its screen name") + func screenNotRestoredAfterReset() throws { + let sut = makeSDK() + defer { sut.close() } + let screen = try #require(build(sut, "$screen", properties: ["$screen_name": "Home"])) + + sut.reset() + #expect(sut.sendBuiltEventForWrapper(screen, ai: false) != nil) + + #expect(build(sut)?.properties["$screen_name"] == nil) + } + + @Test("a $screen build without a $screen_name returns nil", arguments: [nil, "", " "] as [String?]) + func screenNeedsName(name: String?) { + let sut = makeSDK() + defer { sut.close() } + + #expect(build(sut, "$screen", properties: name.map { ["$screen_name": $0] }) == nil) + } + + @Test("an event is sent once, on the lane given at send") + func sendsOnceOnSendLane() async throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$ai_generation", ai: true)) + + let uuid = sut.sendBuiltEventForWrapper(built, ai: false) + await waitUntil { self.server.batchRequests.count == 1 } + + #expect(uuid == built.uuid.postHogUuidString) + #expect(sut.sendBuiltEventForWrapper(built, ai: true) == nil) + #expect(server.aiRequests.isEmpty) + } + + @Test("feature flags get the person properties the wrapper left on the sent event") + func flagsUseSentPersonProperties() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + built.properties["$set"] = ["kept": "yes"] + + sut.sendBuiltEventForWrapper(built, ai: false) + + let flagProperties = try #require(sut.remoteConfig).getPersonPropertiesForFlags() + #expect(flagProperties["kept"] as? String == "yes") + #expect(flagProperties["plan"] == nil) + } + + @Test("an event sent after reset doesn't give its person properties to the new user's flags") + func flagsSkipPersonPropertiesAfterReset() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + sut.reset() + sut.sendBuiltEventForWrapper(built, ai: false) + + #expect(try #require(sut.remoteConfig).getPersonPropertiesForFlags()["plan"] == nil) + } + + @Test("a reset that keeps the distinct ID still counts as a new identity at send") + func resetKeepingDistinctIdIsNewIdentity() throws { + let sut = makeSDK(reuseAnonymousId: true) + defer { sut.close() } + let built = try #require(build(sut)) + let distinctId = sut.getDistinctId() + + sut.reset() + #expect(sut.getDistinctId() == distinctId) + sut.sendBuiltEventForWrapper(built, ai: false) + + #expect(try #require(sut.remoteConfig).getPersonPropertiesForFlags()["plan"] == nil) + #expect(sut.config.storageManager?.isPersonProcessing() == false) + } + + @Test("a build turns on person processing only when it's sent") + func personProcessingDeferredToSend() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + #expect(sut.config.storageManager?.isPersonProcessing() == false) + #expect(built.options["process_person_profile"] == nil) + + sut.sendBuiltEventForWrapper(built, ai: false) + + #expect(sut.config.storageManager?.isPersonProcessing() == true) + } + + @Test("an event built before close and setup isn't sent through the new setup") + func sendAfterReconfigureDrops() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + sut.close() + let config = PostHogConfig(projectToken: "phc_wrapper_\(UUID().uuidString)", host: "http://localhost:9001") + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + sut.setup(config) + + #expect(sut.sendBuiltEventForWrapper(built, ai: false) == nil) + } + + @Test("an event built on one SDK instance isn't sent through another") + func sendOnOtherInstanceDrops() throws { + let sut = makeSDK() + let other = makeSDK() + defer { + sut.close() + other.close() + } + let built = try #require(build(sut)) + + #expect(other.sendBuiltEventForWrapper(built, ai: false) == nil) + } + + @Test("an event sent after reset doesn't turn on person processing for the new user") + func personProcessingSkippedAfterReset() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut)) + + sut.reset() + sut.sendBuiltEventForWrapper(built, ai: false) + + #expect(sut.config.storageManager?.isPersonProcessing() == false) + } + + @Test("the replay debug values sent are the ones from build, even if the session changed") + func replayDebugSnapshotAtBuild() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$wrapped")) + let builtStart = try #require(sut.sessionManager.sessionStartTimestampSnapshot) + + sut.endSession() + server.reset(batchCount: 1) + sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(getBatchedEvents(server).first) + + #expect(sent.properties["$sdk_debug_session_start"] as? Int64 == Int64(builtStart * 1000)) + } + + #if os(iOS) + #if !SWIFT_PACKAGE || SessionReplay + @Test("the replay debug bundle is claimed when a wrapper event is sent, not when it's built") + func wrapperClaimsReplayDebugBundleAtSend() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$wrapped")) + #expect(built.properties["$sdk_debug_replay_capture_mode"] == nil) + + server.reset(batchCount: 1) + sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(getBatchedEvents(server).first) + + #expect(sent.properties["$sdk_debug_replay_capture_mode"] != nil) + } + + @Test("a wrapper event renamed by the hook keeps the replay debug eligibility of its built name") + func wrapperReplayEligibilityFromBuiltName() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$wrapped")) + built.event = "renamed" + + server.reset(batchCount: 1) + sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(getBatchedEvents(server).first) + + #expect(sent.properties["$sdk_debug_replay_capture_mode"] != nil) + } + + @Test("send doesn't add back a required replay property the wrapper removed") + func wrapperRemovalOfRequiredReplayPropertyKept() throws { + let sut = makeSDK() + defer { sut.close() } + let built = try #require(build(sut, "$wrapped")) + #expect(built.properties["$recording_status"] != nil) + built.properties.removeValue(forKey: "$recording_status") + + server.reset(batchCount: 1) + sut.sendBuiltEventForWrapper(built, ai: false) + let sent = try #require(getBatchedEvents(server).first) + + #expect(sent.properties["$recording_status"] == nil) + } + #endif + #endif +} diff --git a/PostHogTests/Resources/event-shapes-batch.json b/PostHogTests/Resources/event-shapes-batch.json index 2474ce3a3..983e7afea 100644 --- a/PostHogTests/Resources/event-shapes-batch.json +++ b/PostHogTests/Resources/event-shapes-batch.json @@ -1,10 +1,12 @@ { "body" : { - "api_key" : "snapshot-project-token", "batch" : [ { "distinct_id" : "anonymous-123", "event" : "order completed", + "options" : { + + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -26,17 +28,13 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 0, - "$session_id" : "00000000-0000-7000-8000-000000000001", "$set" : { "plan" : "pro" }, @@ -54,12 +52,16 @@ }, "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" }, { "distinct_id" : "user-123", "event" : "$identify", + "options" : { + + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -79,18 +81,14 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 1, "$sdk_debug_session_start" : "", - "$session_id" : "00000000-0000-7000-8000-000000000001", "$set" : { "email" : "person@example.com" }, @@ -101,12 +99,16 @@ "distinct_id" : "user-123", "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" }, { "distinct_id" : "user-123", "event" : "$groupidentify", + "options" : { + + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -134,26 +136,26 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 2, - "$session_id" : "00000000-0000-7000-8000-000000000001", "$timezone" : "", "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" }, { "distinct_id" : "user-123", "event" : "$feature_interaction", + "options" : { + + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -175,17 +177,13 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 3, - "$session_id" : "00000000-0000-7000-8000-000000000001", "$set" : { "$feature_interaction\/checkout-redesign" : "test" }, @@ -194,12 +192,16 @@ "feature_flag_variant" : "test", "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" }, { "distinct_id" : "user-123", "event" : "$exception", + "options" : { + + }, "properties" : { "$active_feature_flags" : [ "beta", @@ -244,38 +246,40 @@ "$is_mac_catalyst_app" : "", "$is_sideloaded" : "", "$is_testflight" : "", - "$lib" : "posthog-ios", - "$lib_version" : "", "$locale" : "", "$network_cellular" : "", "$network_wifi" : "", "$os_name" : "", "$os_version" : "", - "$process_person_profile" : true, "$recording_status" : "disabled", "$sdk_debug_pending_queue_size" : 4, - "$session_id" : "00000000-0000-7000-8000-000000000001", "$timezone" : "", "handled_by" : "snapshot-test", "registered_property" : "registered-value" }, + "session_id" : "00000000-0000-7000-8000-000000000001", "timestamp" : "", "uuid" : "" } ], - "sent_at" : "" + "created_at" : "" }, "headers" : { "accept-encoding" : "gzip", + "authorization" : "Bearer snapshot-project-token", "content-encoding" : "gzip", "content-length" : "", - "content-type" : "application\/json; charset=utf-8", + "content-type" : "application\/json", + "posthog-attempt" : "1", + "posthog-request-id" : "", + "posthog-request-timestamp" : "", + "posthog-sdk-info" : "posthog-ios\/", "user-agent" : "posthog-ios\/" }, "method" : "POST", "url" : { "host" : "localhost", - "path" : "\/batch", + "path" : "\/i\/v1\/analytics\/events", "port" : 9001, "query" : [ diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index 44cd35cfc..d4a64a31d 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -17,6 +17,7 @@ class MockPostHogServer { private var recordedSnapshotRequests = [URLRequest]() private var recordedLogsRequests = [URLRequest]() private var recordedFlagsRequests = [URLRequest]() + private var recordedAiRequests = [URLRequest]() var batchRequests: [URLRequest] { get { requestsLock.withLock { recordedBatchRequests } } @@ -34,6 +35,14 @@ class MockPostHogServer { get { requestsLock.withLock { recordedFlagsRequests } } set { requestsLock.withLock { recordedFlagsRequests = newValue } } } + /// Requests to the capture V1 AI endpoint (`/i/v1/ai/events`), with buffered bodies. + var aiRequests: [URLRequest] { + get { requestsLock.withLock { recordedAiRequests } } + set { requestsLock.withLock { recordedAiRequests = newValue } } + } + /// Optional override for `/i/v1/ai/events` responses, given the request and its + /// 1-based number. When `nil`, every event gets an `ok` result. + var aiResponseHandler: ((URLRequest, Int) -> HTTPStubsResponse)? var batchExpectation: XCTestExpectation? var snapshotExpectation: XCTestExpectation? var logsExpectation: XCTestExpectation? @@ -75,7 +84,7 @@ class MockPostHogServer { var logsResponseHandler: ((URLRequest, Int) -> HTTPStubsResponse)? var version: Int = 3 - /// When set, `/batch` requests whose `api_key` differs are not recorded, so batches from other + /// When set, capture V1 requests whose Bearer token differs are not recorded, so batches from other /// tests' still-running SDK instances can't satisfy or inflate this server's expectations. var batchProjectToken: String? /// When set, `/flags` requests whose `api_key` differs are not recorded, for the same reason. @@ -86,7 +95,8 @@ class MockPostHogServer { if let batchProjectToken { // Buffer the body: a stream-backed body can only be read once. request.httpBody = request.body() - guard parseRequest(request)?["api_key"] as? String == batchProjectToken else { return } + let token = request.value(forHTTPHeaderField: "Authorization").map { String($0.dropFirst("Bearer ".count)) } + guard token == batchProjectToken else { return } } requestsLock.lock() @@ -141,11 +151,20 @@ class MockPostHogServer { var errorsWhileComputingFlags = false var return500 = false - /// Optional override for `/batch` responses. When set, takes precedence + /// Optional override for capture V1 responses. When set, takes precedence /// over `return500`. The closure receives the incoming request and the /// 1-based index of that request in the batch sequence so callers can /// vary the response (e.g. 413 then 200). var batchResponseHandler: ((URLRequest, Int) -> HTTPStubsResponse)? + /// Optional override for capture V1 (`/i/v1/analytics/events`) responses, + /// with the same arguments as `batchResponseHandler`. When `nil`, V1 uses + /// `batchResponseHandler`, then `return500`, then a 200 with per-event + /// results from `captureV1EventResult`. + var captureV1ResponseHandler: ((URLRequest, Int) -> HTTPStubsResponse)? + /// Per-event result (`ok`, `warning`, `drop`, `retry`) for the default V1 + /// 200 response, given the event UUID and the 1-based request index. + /// `nil` returns `ok` for every event. + var captureV1EventResult: ((String, Int) -> String)? var returnReplay = false var returnReplayWithVariant = false var returnReplayWithMultiVariant = false @@ -404,15 +423,40 @@ class MockPostHogServer { return response }) - stubDescriptors.append(stub(condition: pathEndsWith("/batch")) { request in - if let handler = self.batchResponseHandler { - let index = self.batchRequests.count + 1 + stubDescriptors.append(stub(condition: pathEndsWith("/i/v1/analytics/events")) { request in + // Buffer the body and record here: a stream-backed body can only be + // read once, and the default response needs the event UUIDs. + var request = request + request.httpBody = request.body() + let index = self.batchRequests.count + 1 + defer { self.trackBatchRequest(request) } + if let handler = self.captureV1ResponseHandler ?? self.batchResponseHandler { return handler(request, index) } if self.return500 { return HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) } - return HTTPStubsResponse(jsonObject: ["status": "ok"], statusCode: 200, headers: nil) + let uuids = (self.parseRequest(request)?["batch"] as? [[String: Any]] ?? []).compactMap { $0["uuid"] as? String } + var results: [String: Any] = [:] + for uuid in uuids { + results[uuid] = ["result": self.captureV1EventResult?(uuid, index) ?? "ok"] + } + return HTTPStubsResponse(jsonObject: ["results": results], statusCode: 200, headers: nil) + }) + + stubDescriptors.append(stub(condition: pathEndsWith("/i/v1/ai/events")) { request in + var request = request + request.httpBody = request.body() + let index = self.requestsLock.withLock { () -> Int in + self.recordedAiRequests.append(request) + return self.recordedAiRequests.count + } + if let handler = self.aiResponseHandler { + return handler(request, index) + } + let uuids = (self.parseRequest(request)?["batch"] as? [[String: Any]] ?? []).compactMap { $0["uuid"] as? String } + let results = Dictionary(uniqueKeysWithValues: uuids.map { ($0, ["result": "ok"]) }) + return HTTPStubsResponse(jsonObject: ["results": results], statusCode: 200, headers: nil) }) stubDescriptors.append(stub(condition: pathEndsWith("/s")) { _ in @@ -572,9 +616,8 @@ class MockPostHogServer { }) HTTPStubs.onStubActivation { request, _, _ in - if request.url?.lastPathComponent == "batch" { - self.trackBatchRequest(request) - } else if request.url?.lastPathComponent == "s" { + // Capture V1 requests are recorded by their stub. + if request.url?.lastPathComponent == "s" { self.trackSnapshotRequest(request) } else if request.url?.lastPathComponent == "logs" { self.trackLogsRequest(request) @@ -606,6 +649,8 @@ class MockPostHogServer { snapshotRequests = [] logsRequests = [] flagsRequests = [] + aiRequests = [] + aiResponseHandler = nil pushSubscriptionRequests = [] batchExpectation = XCTestExpectation(description: "\(batchCount) batch requests to occur") snapshotExpectation = XCTestExpectation(description: "\(snapshotCount) snapshot requests to occur") @@ -621,6 +666,8 @@ class MockPostHogServer { errorsWhileComputingFlags = false return500 = false batchResponseHandler = nil + captureV1ResponseHandler = nil + captureV1EventResult = nil returnPushSubscription500 = false pushSubscriptionStatusCode = nil pushSubscriptionStatusHandler = nil @@ -635,15 +682,45 @@ class MockPostHogServer { return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] } + static func isCaptureV1(_ request: URLRequest) -> Bool { + request.url.map { $0.path.hasSuffix("/i/v1/analytics/events") || $0.path.hasSuffix("/i/v1/ai/events") } == true + } + + /// Decodes the events of a capture V1 request. It puts + /// back what the server injects (`$session_id`, `$window_id`, `$lib`, + /// `$lib_version` and the legacy property of each known option), so event + /// assertions don't depend on the transport. V1 events keep `options` too. func parsePostHogEvents(_ context: URLRequest) -> [PostHogEvent] { let data = parseRequest(context) guard let batchEvents = data?["batch"] as? [[String: Any]] else { return [] } + var sdkInfo: (lib: String, version: String)? + if Self.isCaptureV1(context), + let header = context.value(forHTTPHeaderField: "PostHog-Sdk-Info"), + let slash = header.lastIndex(of: "/") + { + sdkInfo = (String(header[.. Void) throws -> String { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + FileManager.default.createFile(atPath: url.path, contents: nil) + defer { try? FileManager.default.removeItem(at: url) } + let file = try FileHandle(forWritingTo: url) + defer { file.closeFile() } + fflush(stdout) + let saved = dup(STDOUT_FILENO) + try #require(saved >= 0) + defer { close(saved) } + try #require(dup2(file.fileDescriptor, STDOUT_FILENO) >= 0) + defer { + fflush(stdout) + dup2(saved, STDOUT_FILENO) + } + try body() + fflush(stdout) + return try String(contentsOf: url, encoding: .utf8) +} + /// Adaptive poll that returns the instant `condition` holds (or after `timeout`). Yields the thread /// between checks instead of spinning. Use this only when the state you're asserting isn't tied to an /// awaitable signal (e.g. an async storage write with no completion callback) — when a callback exists, diff --git a/api/posthog-ios.public-api.no-traits.txt b/api/posthog-ios.public-api.no-traits.txt index 6fec67dd2..b7e932b9b 100644 --- a/api/posthog-ios.public-api.no-traits.txt +++ b/api/posthog-ios.public-api.no-traits.txt @@ -31,6 +31,10 @@ PostHog | PostHogConfig.PostHogDataMode.init(rawValue:) | init | init?(rawValue: PostHog | PostHogConfig.PostHogDataMode.wifi | enum.case | case wifi | s:7PostHog0aB6ConfigC0aB8DataModeO4wifiyA2EmF PostHog | PostHogConfig.appGroupIdentifier | property | @objc var appGroupIdentifier: String? | c:@M@PostHog@objc(cs)PostHogConfig(py)appGroupIdentifier PostHog | PostHogConfig.bootstrap | property | @objc var bootstrap: PostHogBootstrapConfig? | c:@M@PostHog@objc(cs)PostHogConfig(py)bootstrap +PostHog | PostHogConfig.captureAiCompression | property | @objc var captureAiCompression: PostHogCompression | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiCompression +PostHog | PostHogConfig.captureAiMaxEventBytes | property | @objc var captureAiMaxEventBytes: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiMaxEventBytes +PostHog | PostHogConfig.captureAiMaxQueueSize | property | @objc var captureAiMaxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiMaxQueueSize +PostHog | PostHogConfig.captureAiTimeout | property | @objc var captureAiTimeout: TimeInterval | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiTimeout PostHog | PostHogConfig.captureApplicationLifecycleEvents | property | @objc var captureApplicationLifecycleEvents: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureApplicationLifecycleEvents PostHog | PostHogConfig.captureAutocaptureElementText | property | @objc var captureAutocaptureElementText: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAutocaptureElementText PostHog | PostHogConfig.captureElementInteractions | property | @objc var captureElementInteractions: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureElementInteractions @@ -56,12 +60,12 @@ PostHog | PostHogConfig.init(projectToken:host:) | init | @objc(projectToken:hos PostHog | PostHogConfig.logs | property | @objc let logs: PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)logs PostHog | PostHogConfig.maxBatchSize | property | @objc var maxBatchSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxBatchSize PostHog | PostHogConfig.maxQueueSize | property | @objc var maxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxQueueSize -PostHog | PostHogConfig.maxRetries | property | @objc var maxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxRetries PostHog | PostHogConfig.optOut | property | @objc var optOut: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)optOut PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: PostHogPersonProfiles | c:@M@PostHog@objc(cs)PostHogConfig(py)personProfiles PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider +PostHog | PostHogConfig.pushNotificationSubscriptionMaxRetries | property | @objc var pushNotificationSubscriptionMaxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)pushNotificationSubscriptionMaxRetries PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders PostHog | PostHogConfig.reuseAnonymousId | property | @objc var reuseAnonymousId: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)reuseAnonymousId @@ -83,6 +87,7 @@ PostHog | PostHogErrorTrackingConfig.init() | init | override init() | c:@M@Post PostHog | PostHogEvent | class | @objc(PostHogEvent) class PostHogEvent | c:@M@PostHog@objc(cs)PostHogEvent PostHog | PostHogEvent.distinctId | property | @objc var distinctId: String | c:@M@PostHog@objc(cs)PostHogEvent(py)distinctId PostHog | PostHogEvent.event | property | @objc var event: String | c:@M@PostHog@objc(cs)PostHogEvent(py)event +PostHog | PostHogEvent.options | property | @objc var options: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)options PostHog | PostHogEvent.properties | property | @objc var properties: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)properties PostHog | PostHogEvent.timestamp | property | @objc var timestamp: Date | c:@M@PostHog@objc(cs)PostHogEvent(py)timestamp PostHog | PostHogEvent.uuid | property | @objc var uuid: UUID { get } | c:@M@PostHog@objc(cs)PostHogEvent(py)uuid @@ -163,13 +168,15 @@ PostHog | PostHogSDK | class | @objc class PostHogSDK | c:@M@PostHog@objc(cs)Pos PostHog | PostHogSDK.addExceptionStep(_:) | method | @objc(addExceptionStep:) func addExceptionStep(_ message: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep: PostHog | PostHogSDK.addExceptionStep(_:properties:) | method | @objc(addExceptionStep:properties:) func addExceptionStep(_ message: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep:properties: PostHog | PostHogSDK.alias(_:) | method | @objc func alias(_ alias: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias: +PostHog | PostHogSDK.alias(_:options:) | method | @objc(alias:options:) func alias(_ alias: String, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias:options: PostHog | PostHogSDK.capture(_:) | method | @objc func capture(_ event: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)capture: PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups: -PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: +PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.capture(_:properties:) | method | @objc(captureWithEvent:properties:) func capture(_ event: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties: PostHog | PostHogSDK.capture(_:properties:userProperties:) | method | @objc(captureWithEvent:properties:userProperties:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups: +PostHog | PostHogSDK.captureAi(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @discardableResult @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func captureAi(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.captureDeepLink(openURLContexts:) | method | @objc func captureDeepLink(openURLContexts: Set) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithOpenURLContexts: PostHog | PostHogSDK.captureDeepLink(url:) | method | @objc func captureDeepLink(url: URL) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl: PostHog | PostHogSDK.captureDeepLink(url:options:) | method | @objc func captureDeepLink(url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl:options: @@ -177,8 +184,10 @@ PostHog | PostHogSDK.captureDeepLink(urls:) | method | @objc func captureDeepLin PostHog | PostHogSDK.captureDeepLink(userActivity:) | method | @objc func captureDeepLink(userActivity: NSUserActivity) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUserActivity: PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithError:) func captureException(_ error: any Error) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError: PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithNSException:) func captureException(_ exception: NSException) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException: -PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureException(_ error: any Error, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: -PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureException(_ exception: NSException, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: +PostHog | PostHogSDK.captureException(_:properties:options:) | method | @objc(captureExceptionWithError:properties:options:) func captureException(_ error: any Error, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties:options: +PostHog | PostHogSDK.captureException(_:properties:options:) | method | @objc(captureExceptionWithNSException:properties:options:) func captureException(_ exception: NSException, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties:options: +PostHog | PostHogSDK.captureExceptionObjC(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureExceptionObjC(_ error: any Error, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: +PostHog | PostHogSDK.captureExceptionObjC(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureExceptionObjC(_ exception: NSException, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: PostHog | PostHogSDK.captureFeatureInteraction(flag:flagVariant:) | method | @objc func captureFeatureInteraction(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureInteractionWithFlag:flagVariant: PostHog | PostHogSDK.captureFeatureView(flag:flagVariant:) | method | @objc func captureFeatureView(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureViewWithFlag:flagVariant: PostHog | PostHogSDK.captureLog(_:) | method | @objc(captureLogWithBody:) func captureLog(_ body: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody: @@ -186,6 +195,7 @@ PostHog | PostHogSDK.captureLog(_:level:) | method | @objc(captureLogWithBody:le PostHog | PostHogSDK.captureLog(_:level:attributes:) | method | @objc(captureLogWithBody:level:attributes:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes: PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | @objc(captureLogWithBody:level:attributes:traceId:spanId:traceFlags:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?, traceId: String?, spanId: String?, traceFlags: NSNumber?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes:traceId:spanId:traceFlags: PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | func captureLog(_ body: String, level: PostHogLogSeverity = .info, attributes: [String : Any]? = nil, traceId: String? = nil, spanId: String? = nil, traceFlags: Int? = nil) | s:7PostHog0aB3SDKC10captureLog_5level10attributes7traceId04spanI00H5FlagsySS_AA0abE8SeverityOSDySSypGSgSSSgANSiSgtF +PostHog | PostHogSDK.captureObjC(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func captureObjC(_ event: String, distinctId: String?, properties: [String : Any]?, userProperties: [String : Any]?, userPropertiesSetOnce: [String : Any]?, groups: [String : String]?, timestamp: Date?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: PostHog | PostHogSDK.capturePushNotificationOpened(response:) | method | @objc func capturePushNotificationOpened(response: UNNotificationResponse) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithResponse: PostHog | PostHogSDK.capturePushNotificationOpened(title:subtitle:body:payload:action:) | method | @objc func capturePushNotificationOpened(title: String? = nil, subtitle: String? = nil, body: String? = nil, payload: [AnyHashable : Any]? = nil, action: String? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithTitle:subtitle:body:payload:action: PostHog | PostHogSDK.close() | method | @objc func close() | c:@M@PostHog@objc(cs)PostHogSDK(im)close @@ -203,10 +213,12 @@ PostHog | PostHogSDK.getFeatureFlagResult(_:) | method | @objc func getFeatureFl PostHog | PostHogSDK.getFeatureFlagResult(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagResultWithKey:sendFeatureFlagEvent:) func getFeatureFlagResult(_ key: String, sendFeatureFlagEvent: Bool) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResultWithKey:sendFeatureFlagEvent: PostHog | PostHogSDK.getSessionId() | method | @objc func getSessionId() -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)getSessionId PostHog | PostHogSDK.group(type:key:) | method | @objc(groupWithType:key:) func group(type: String, key: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key: -PostHog | PostHogSDK.group(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func group(type: String, key: String, groupProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: +PostHog | PostHogSDK.group(type:key:groupProperties:options:) | method | @objc(groupWithType:key:groupProperties:options:) func group(type: String, key: String, groupProperties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties:options: +PostHog | PostHogSDK.groupObjC(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func groupObjC(type: String, key: String, groupProperties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: PostHog | PostHogSDK.identify(_:) | method | @objc func identify(_ distinctId: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)identify: PostHog | PostHogSDK.identify(_:userProperties:) | method | @objc(identifyWithDistinctId:userProperties:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties: -PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: +PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:options:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce:options: +PostHog | PostHogSDK.identifyObjC(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identifyObjC(_ distinctId: String, userProperties: [String : Any]?, userPropertiesSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.isAutocaptureActive() | method | @objc func isAutocaptureActive() -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isAutocaptureActive PostHog | PostHogSDK.isFeatureEnabled(_:) | method | @objc func isFeatureEnabled(_ key: String) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabled: PostHog | PostHogSDK.isFeatureEnabled(_:sendFeatureFlagEvent:) | method | @objc(isFeatureEnabledWithKey:sendFeatureFlagEvent:) func isFeatureEnabled(_ key: String, sendFeatureFlagEvent: Bool) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabledWithKey:sendFeatureFlagEvent: @@ -230,13 +242,15 @@ PostHog | PostHogSDK.resetGroupPropertiesForFlags(reloadFeatureFlags:) | method PostHog | PostHogSDK.resetPersonPropertiesForFlags() | method | @objc func resetPersonPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlags PostHog | PostHogSDK.resetPersonPropertiesForFlags(reloadFeatureFlags:) | method | @objc(resetPersonPropertiesForFlagsWithReloadFeatureFlags:) func resetPersonPropertiesForFlags(reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlagsWithReloadFeatureFlags: PostHog | PostHogSDK.screen(_:) | method | @objc func screen(_ screenTitle: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)screen: -PostHog | PostHogSDK.screen(_:properties:) | method | @objc(screenWithTitle:properties:) func screen(_ screenTitle: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: +PostHog | PostHogSDK.screen(_:properties:options:) | method | @objc(screenWithTitle:properties:options:) func screen(_ screenTitle: String, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties:options: +PostHog | PostHogSDK.screenObjC(_:properties:) | method | @objc(screenWithTitle:properties:) func screenObjC(_ screenTitle: String, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:) | method | @objc(setGroupPropertiesForFlags:properties:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties: PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:reloadFeatureFlags:) | method | @objc(setGroupPropertiesForFlags:properties:reloadFeatureFlags:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties:reloadFeatureFlags: PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:) func setPersonProperties(userPropertiesToSet: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet: -PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: +PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:options:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options: PostHog | PostHogSDK.setPersonPropertiesForFlags(_:) | method | @objc func setPersonPropertiesForFlags(_ properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlags: PostHog | PostHogSDK.setPersonPropertiesForFlags(_:reloadFeatureFlags:) | method | @objc(setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags:) func setPersonPropertiesForFlags(_ properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags: +PostHog | PostHogSDK.setPersonPropertiesObjC(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonPropertiesObjC(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: PostHog | PostHogSDK.setup(_:) | method | @objc func setup(_ config: PostHogConfig) | c:@M@PostHog@objc(cs)PostHogSDK(im)setup: PostHog | PostHogSDK.shared | type.property | @objc nonisolated(unsafe) static let shared: PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK(cpy)shared PostHog | PostHogSDK.startSession() | method | @objc func startSession() | c:@M@PostHog@objc(cs)PostHogSDK(im)startSession diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index a01b4135d..a79d3d4e0 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -34,6 +34,10 @@ PostHog | PostHogConfig.PostHogDataMode.init(rawValue:) | init | init?(rawValue: PostHog | PostHogConfig.PostHogDataMode.wifi | enum.case | case wifi | s:7PostHog0aB6ConfigC0aB8DataModeO4wifiyA2EmF PostHog | PostHogConfig.appGroupIdentifier | property | @objc var appGroupIdentifier: String? | c:@M@PostHog@objc(cs)PostHogConfig(py)appGroupIdentifier PostHog | PostHogConfig.bootstrap | property | @objc var bootstrap: PostHogBootstrapConfig? | c:@M@PostHog@objc(cs)PostHogConfig(py)bootstrap +PostHog | PostHogConfig.captureAiCompression | property | @objc var captureAiCompression: PostHogCompression | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiCompression +PostHog | PostHogConfig.captureAiMaxEventBytes | property | @objc var captureAiMaxEventBytes: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiMaxEventBytes +PostHog | PostHogConfig.captureAiMaxQueueSize | property | @objc var captureAiMaxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiMaxQueueSize +PostHog | PostHogConfig.captureAiTimeout | property | @objc var captureAiTimeout: TimeInterval | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAiTimeout PostHog | PostHogConfig.captureApplicationLifecycleEvents | property | @objc var captureApplicationLifecycleEvents: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureApplicationLifecycleEvents PostHog | PostHogConfig.captureAutocaptureElementText | property | @objc var captureAutocaptureElementText: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAutocaptureElementText PostHog | PostHogConfig.captureElementInteractions | property | @objc var captureElementInteractions: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureElementInteractions @@ -59,12 +63,12 @@ PostHog | PostHogConfig.init(projectToken:host:) | init | @objc(projectToken:hos PostHog | PostHogConfig.logs | property | @objc let logs: PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)logs PostHog | PostHogConfig.maxBatchSize | property | @objc var maxBatchSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxBatchSize PostHog | PostHogConfig.maxQueueSize | property | @objc var maxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxQueueSize -PostHog | PostHogConfig.maxRetries | property | @objc var maxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxRetries PostHog | PostHogConfig.optOut | property | @objc var optOut: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)optOut PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: PostHogPersonProfiles | c:@M@PostHog@objc(cs)PostHogConfig(py)personProfiles PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider +PostHog | PostHogConfig.pushNotificationSubscriptionMaxRetries | property | @objc var pushNotificationSubscriptionMaxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)pushNotificationSubscriptionMaxRetries PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders PostHog | PostHogConfig.reuseAnonymousId | property | @objc var reuseAnonymousId: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)reuseAnonymousId @@ -168,6 +172,7 @@ PostHog | PostHogErrorTrackingConfig.init() | init | override init() | c:@M@Post PostHog | PostHogEvent | class | @objc(PostHogEvent) class PostHogEvent | c:@M@PostHog@objc(cs)PostHogEvent PostHog | PostHogEvent.distinctId | property | @objc var distinctId: String | c:@M@PostHog@objc(cs)PostHogEvent(py)distinctId PostHog | PostHogEvent.event | property | @objc var event: String | c:@M@PostHog@objc(cs)PostHogEvent(py)event +PostHog | PostHogEvent.options | property | @objc var options: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)options PostHog | PostHogEvent.properties | property | @objc var properties: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)properties PostHog | PostHogEvent.timestamp | property | @objc var timestamp: Date | c:@M@PostHog@objc(cs)PostHogEvent(py)timestamp PostHog | PostHogEvent.uuid | property | @objc var uuid: UUID { get } | c:@M@PostHog@objc(cs)PostHogEvent(py)uuid @@ -251,13 +256,15 @@ PostHog | PostHogSDK | class | @objc class PostHogSDK | c:@M@PostHog@objc(cs)Pos PostHog | PostHogSDK.addExceptionStep(_:) | method | @objc(addExceptionStep:) func addExceptionStep(_ message: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep: PostHog | PostHogSDK.addExceptionStep(_:properties:) | method | @objc(addExceptionStep:properties:) func addExceptionStep(_ message: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep:properties: PostHog | PostHogSDK.alias(_:) | method | @objc func alias(_ alias: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias: +PostHog | PostHogSDK.alias(_:options:) | method | @objc(alias:options:) func alias(_ alias: String, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias:options: PostHog | PostHogSDK.capture(_:) | method | @objc func capture(_ event: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)capture: PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups: -PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: +PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.capture(_:properties:) | method | @objc(captureWithEvent:properties:) func capture(_ event: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties: PostHog | PostHogSDK.capture(_:properties:userProperties:) | method | @objc(captureWithEvent:properties:userProperties:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups: +PostHog | PostHogSDK.captureAi(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) | method | @discardableResult @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) func captureAi(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil, options: [String : Any]? = nil) -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: PostHog | PostHogSDK.captureDeepLink(openURLContexts:) | method | @objc func captureDeepLink(openURLContexts: Set) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithOpenURLContexts: PostHog | PostHogSDK.captureDeepLink(url:) | method | @objc func captureDeepLink(url: URL) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl: PostHog | PostHogSDK.captureDeepLink(url:options:) | method | @objc func captureDeepLink(url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl:options: @@ -265,8 +272,10 @@ PostHog | PostHogSDK.captureDeepLink(urls:) | method | @objc func captureDeepLin PostHog | PostHogSDK.captureDeepLink(userActivity:) | method | @objc func captureDeepLink(userActivity: NSUserActivity) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUserActivity: PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithError:) func captureException(_ error: any Error) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError: PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithNSException:) func captureException(_ exception: NSException) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException: -PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureException(_ error: any Error, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: -PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureException(_ exception: NSException, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: +PostHog | PostHogSDK.captureException(_:properties:options:) | method | @objc(captureExceptionWithError:properties:options:) func captureException(_ error: any Error, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties:options: +PostHog | PostHogSDK.captureException(_:properties:options:) | method | @objc(captureExceptionWithNSException:properties:options:) func captureException(_ exception: NSException, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties:options: +PostHog | PostHogSDK.captureExceptionObjC(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureExceptionObjC(_ error: any Error, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: +PostHog | PostHogSDK.captureExceptionObjC(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureExceptionObjC(_ exception: NSException, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: PostHog | PostHogSDK.captureFeatureInteraction(flag:flagVariant:) | method | @objc func captureFeatureInteraction(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureInteractionWithFlag:flagVariant: PostHog | PostHogSDK.captureFeatureView(flag:flagVariant:) | method | @objc func captureFeatureView(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureViewWithFlag:flagVariant: PostHog | PostHogSDK.captureLog(_:) | method | @objc(captureLogWithBody:) func captureLog(_ body: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody: @@ -274,6 +283,7 @@ PostHog | PostHogSDK.captureLog(_:level:) | method | @objc(captureLogWithBody:le PostHog | PostHogSDK.captureLog(_:level:attributes:) | method | @objc(captureLogWithBody:level:attributes:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes: PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | @objc(captureLogWithBody:level:attributes:traceId:spanId:traceFlags:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?, traceId: String?, spanId: String?, traceFlags: NSNumber?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes:traceId:spanId:traceFlags: PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | func captureLog(_ body: String, level: PostHogLogSeverity = .info, attributes: [String : Any]? = nil, traceId: String? = nil, spanId: String? = nil, traceFlags: Int? = nil) | s:7PostHog0aB3SDKC10captureLog_5level10attributes7traceId04spanI00H5FlagsySS_AA0abE8SeverityOSDySSypGSgSSSgANSiSgtF +PostHog | PostHogSDK.captureObjC(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func captureObjC(_ event: String, distinctId: String?, properties: [String : Any]?, userProperties: [String : Any]?, userPropertiesSetOnce: [String : Any]?, groups: [String : String]?, timestamp: Date?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: PostHog | PostHogSDK.capturePushNotificationOpened(response:) | method | @objc func capturePushNotificationOpened(response: UNNotificationResponse) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithResponse: PostHog | PostHogSDK.capturePushNotificationOpened(title:subtitle:body:payload:action:) | method | @objc func capturePushNotificationOpened(title: String? = nil, subtitle: String? = nil, body: String? = nil, payload: [AnyHashable : Any]? = nil, action: String? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithTitle:subtitle:body:payload:action: PostHog | PostHogSDK.close() | method | @objc func close() | c:@M@PostHog@objc(cs)PostHogSDK(im)close @@ -291,10 +301,12 @@ PostHog | PostHogSDK.getFeatureFlagResult(_:) | method | @objc func getFeatureFl PostHog | PostHogSDK.getFeatureFlagResult(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagResultWithKey:sendFeatureFlagEvent:) func getFeatureFlagResult(_ key: String, sendFeatureFlagEvent: Bool) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResultWithKey:sendFeatureFlagEvent: PostHog | PostHogSDK.getSessionId() | method | @objc func getSessionId() -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)getSessionId PostHog | PostHogSDK.group(type:key:) | method | @objc(groupWithType:key:) func group(type: String, key: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key: -PostHog | PostHogSDK.group(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func group(type: String, key: String, groupProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: +PostHog | PostHogSDK.group(type:key:groupProperties:options:) | method | @objc(groupWithType:key:groupProperties:options:) func group(type: String, key: String, groupProperties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties:options: +PostHog | PostHogSDK.groupObjC(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func groupObjC(type: String, key: String, groupProperties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: PostHog | PostHogSDK.identify(_:) | method | @objc func identify(_ distinctId: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)identify: PostHog | PostHogSDK.identify(_:userProperties:) | method | @objc(identifyWithDistinctId:userProperties:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties: -PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: +PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:options:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:options:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce:options: +PostHog | PostHogSDK.identifyObjC(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identifyObjC(_ distinctId: String, userProperties: [String : Any]?, userPropertiesSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: PostHog | PostHogSDK.isAutocaptureActive() | method | @objc func isAutocaptureActive() -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isAutocaptureActive PostHog | PostHogSDK.isFeatureEnabled(_:) | method | @objc func isFeatureEnabled(_ key: String) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabled: PostHog | PostHogSDK.isFeatureEnabled(_:sendFeatureFlagEvent:) | method | @objc(isFeatureEnabledWithKey:sendFeatureFlagEvent:) func isFeatureEnabled(_ key: String, sendFeatureFlagEvent: Bool) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabledWithKey:sendFeatureFlagEvent: @@ -319,13 +331,15 @@ PostHog | PostHogSDK.resetGroupPropertiesForFlags(reloadFeatureFlags:) | method PostHog | PostHogSDK.resetPersonPropertiesForFlags() | method | @objc func resetPersonPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlags PostHog | PostHogSDK.resetPersonPropertiesForFlags(reloadFeatureFlags:) | method | @objc(resetPersonPropertiesForFlagsWithReloadFeatureFlags:) func resetPersonPropertiesForFlags(reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlagsWithReloadFeatureFlags: PostHog | PostHogSDK.screen(_:) | method | @objc func screen(_ screenTitle: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)screen: -PostHog | PostHogSDK.screen(_:properties:) | method | @objc(screenWithTitle:properties:) func screen(_ screenTitle: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: +PostHog | PostHogSDK.screen(_:properties:options:) | method | @objc(screenWithTitle:properties:options:) func screen(_ screenTitle: String, properties: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties:options: +PostHog | PostHogSDK.screenObjC(_:properties:) | method | @objc(screenWithTitle:properties:) func screenObjC(_ screenTitle: String, properties: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:) | method | @objc(setGroupPropertiesForFlags:properties:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties: PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:reloadFeatureFlags:) | method | @objc(setGroupPropertiesForFlags:properties:reloadFeatureFlags:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties:reloadFeatureFlags: PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:) func setPersonProperties(userPropertiesToSet: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet: -PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: +PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:options:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]? = nil, options: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:options: PostHog | PostHogSDK.setPersonPropertiesForFlags(_:) | method | @objc func setPersonPropertiesForFlags(_ properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlags: PostHog | PostHogSDK.setPersonPropertiesForFlags(_:reloadFeatureFlags:) | method | @objc(setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags:) func setPersonPropertiesForFlags(_ properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags: +PostHog | PostHogSDK.setPersonPropertiesObjC(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonPropertiesObjC(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: PostHog | PostHogSDK.setup(_:) | method | @objc func setup(_ config: PostHogConfig) | c:@M@PostHog@objc(cs)PostHogSDK(im)setup: PostHog | PostHogSDK.shared | type.property | @objc nonisolated(unsafe) static let shared: PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK(cpy)shared PostHog | PostHogSDK.startSession() | method | @objc func startSession() | c:@M@PostHog@objc(cs)PostHogSDK(im)startSession diff --git a/sdk_compliance_adapter/Sources/RequestInterceptor.swift b/sdk_compliance_adapter/Sources/RequestInterceptor.swift index d012ce687..778b829c8 100644 --- a/sdk_compliance_adapter/Sources/RequestInterceptor.swift +++ b/sdk_compliance_adapter/Sources/RequestInterceptor.swift @@ -103,11 +103,12 @@ class RequestInterceptor: URLProtocol { // Only intercept requests to the mock server (not to real PostHog endpoints) guard let url = request.url else { return false } - // Intercept /batch and /e/ endpoints, but not /flags/ or /config + // Intercept capture endpoints, but not /flags/ or /config let urlString = url.absoluteString - if urlString.contains("/batch") || urlString.contains("/e/") || urlString.contains("/s/"), - !urlString.contains("/flags"), - !urlString.contains("/config") + if urlString.contains("/i/v1/analytics/events") || urlString.contains("/i/v1/ai/events") + || urlString.contains("/e/") || urlString.contains("/s/"), + !urlString.contains("/flags"), + !urlString.contains("/config") { print("[INTERCEPTOR] Can handle: \(urlString)") return true @@ -228,7 +229,7 @@ class RequestInterceptor: URLProtocol { } if let json = try JSONSerialization.jsonObject(with: decompressed) as? [String: Any] { - // Server SDK format: {"api_key": "...", "batch": [...]} where api_key carries the project token. + // Capture V1 format: {"created_at": "...", "batch": [...]}. if let batch = json["batch"] as? [[String: Any]] { events = batch print("[INTERCEPTOR] Found batch with \(events.count) events") diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 1f74f01e2..6e2fa9986 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -53,11 +53,13 @@ app.get("health") { req async throws -> Response in "sdk_name": postHogSdkName, "sdk_version": postHogVersion, "adapter_version": "1.0.0", - // Declares which test suites apply. The iOS SDK posts events to /batch - // (capture_v0) with gzip; it does not implement the /i/v1/e capture_v1 - // protocol. Without this, the harness skips the capability-gated capture - // suites entirely. - "capabilities": ["capture_v0", "encoding_gzip"], + // Declares which test suites apply. The iOS SDK posts events to + // /i/v1/analytics/events (capture_v1) with gzip. Without this, the + // harness skips the capability-gated capture suites entirely. + "capabilities": [ + "capture_v1", "capture_ai_v1", "encoding_gzip", "event_options", + "disable_geoip", "generated_payloads", "property_layers", "fail_open", + ], ] print("[ADAPTER] GET /health") @@ -72,6 +74,11 @@ app.post("init") { req async throws -> Response in let flushAt: Int? let flushIntervalMs: Int? let maxRetries: Int? + let enableCompression: Bool? + let disableGeoip: Bool? + let superProperties: [String: AnyCodable]? + // Declarative hook: {"set_properties": {...}} sets those keys on every event. + let beforeSend: [String: AnyCodable]? enum CodingKeys: String, CodingKey { // Wire field name remains api_key, but it carries the PostHog project token. @@ -80,16 +87,17 @@ app.post("init") { req async throws -> Response in case flushAt = "flush_at" case flushIntervalMs = "flush_interval_ms" case maxRetries = "max_retries" + case enableCompression = "enable_compression" + case disableGeoip = "disable_geoip" + case superProperties = "super_properties" + case beforeSend = "before_send" } } let initReq = try req.content.decode(InitRequest.self) - // Empty token: setup() stays disabled but the singleton is non-nil, hanging a later - // reloadFeatureFlags() (callback never fires when disabled). Fail fast. - guard !initReq.apiKey.isEmpty else { - throw Abort(.badRequest, reason: "Empty project token; SDK would not enable.") - } + // An empty or personal (phx_) token is passed through as given: setup() leaves the SDK + // disabled, so captures send nothing (fail_open). // Rewrite host.docker.internal to localhost since adapter runs on macOS host let host = initReq.host.replacingOccurrences(of: "host.docker.internal", with: "localhost") @@ -115,7 +123,18 @@ app.post("init") { req async throws -> Response in config.flushAt = initReq.flushAt ?? 1 let defaultFlushIntervalMs = config.flushAt > 1 ? 5000 : 500 config.flushIntervalSeconds = TimeInterval(initReq.flushIntervalMs ?? defaultFlushIntervalMs) / 1000.0 - config.maxRetries = initReq.maxRetries ?? config.maxRetries + config.pushNotificationSubscriptionMaxRetries = initReq.maxRetries ?? config.pushNotificationSubscriptionMaxRetries + if initReq.enableCompression == false { + config.compression = .none + config.captureAiCompression = .none + } + config.disableGeoIp = initReq.disableGeoip ?? config.disableGeoIp + if let setProperties = initReq.beforeSend?["set_properties"]?.value as? [String: Any] { + config.setBeforeSend { event in + event.properties.merge(setProperties) { _, new in new } + return event + } + } // Disable features for testing config.captureApplicationLifecycleEvents = false @@ -123,6 +142,9 @@ app.post("init") { req async throws -> Response in config.preloadFeatureFlags = false config.sendFeatureFlagEvent = false config.enableSwizzling = false + // The harness models a server SDK: it never identifies, and expects person + // processing on unless an event opts out. + config.personProfiles = .always #if os(iOS) config.sessionReplay = false @@ -139,6 +161,9 @@ app.post("init") { req async throws -> Response in // Initialize PostHog SDK PostHogSDK.shared.setup(config) + if let superProperties = initReq.superProperties { + PostHogSDK.shared.register(superProperties.mapValues(\.value)) + } state.posthogSDK = PostHogSDK.shared state.apiKey = initReq.apiKey state.host = host @@ -167,12 +192,14 @@ app.post("capture") { req async throws -> Response in let distinctId: String? let properties: [String: AnyCodable]? let timestamp: String? + let options: [String: AnyCodable]? enum CodingKeys: String, CodingKey { case event case distinctId = "distinct_id" case properties case timestamp + case options } } @@ -195,7 +222,13 @@ app.post("capture") { req async throws -> Response in // This ensures the distinct_id is set for THIS event, not globally // The harness may send a non-UTC offset; parsing keeps the instant, and the SDK serializes it as UTC. let timestamp = captureReq.timestamp.flatMap(parseHarnessTimestamp) - sdk.capture(captureReq.event, distinctId: captureReq.distinctId, properties: props, timestamp: timestamp) + sdk.capture( + captureReq.event, + distinctId: captureReq.distinctId, + properties: props, + timestamp: timestamp, + options: captureReq.options?.mapValues(\.value) + ) print("[ADAPTER] Event captured: \(captureReq.event)") @@ -203,6 +236,72 @@ app.post("capture") { req async throws -> Response in return try await result.encodeResponse(for: req) } +// AI capture endpoint: same request as /capture, sent with captureAi to /i/v1/ai/events. +// AI events can be up to 8 MiB, over Vapor's default 16 KB body limit. +app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws -> Response in + struct CaptureAiRequest: Content { + let event: String + let distinctId: String? + let properties: [String: AnyCodable]? + let timestamp: String? + let options: [String: AnyCodable]? + let padPropertiesToBytes: Int? + let marker: String? + + enum CodingKeys: String, CodingKey { + case event + case distinctId = "distinct_id" + case properties + case timestamp + case options + case padPropertiesToBytes = "pad_properties_to_bytes" + case marker + } + } + + let captureReq = try req.content.decode(CaptureAiRequest.self) + print("[ADAPTER] POST /capture_ai - event: \(captureReq.event), distinct_id: \(captureReq.distinctId ?? "nil")") + + guard let sdk = state.posthogSDK else { + throw Abort(.badRequest, reason: "SDK not initialized. Call /init first.") + } + + var properties = captureReq.properties?.mapValues(\.value) + if let targetBytes = captureReq.padPropertiesToBytes { + properties = try paddedProperties(properties ?? [:], toBytes: targetBytes, marker: captureReq.marker ?? "") + } + + let uuid = sdk.captureAi( + captureReq.event, + distinctId: captureReq.distinctId, + properties: properties, + timestamp: captureReq.timestamp.flatMap(parseHarnessTimestamp), + options: captureReq.options?.mapValues(\.value) + ) + + var result: [String: Any] = ["success": true] + result["uuid"] = uuid + return try await result.encodeResponse(for: req) +} + +/// Adds a `$test_padding` string so `properties`, as compact UTF-8 JSON, is exactly `targetBytes` +/// long (generated_payloads). The padding starts with `marker`. +func paddedProperties(_ properties: [String: Any], toBytes targetBytes: Int, marker: String) throws -> [String: Any] { + // The harness asks for at most ~9 MiB; refuse anything far beyond that instead of allocating it. + let maxTargetBytes = 64 * 1024 * 1024 + guard targetBytes <= maxTargetBytes else { + throw Abort(.badRequest, reason: "pad_properties_to_bytes \(targetBytes) is over the \(maxTargetBytes)-byte limit.") + } + var padded = properties + padded["$test_padding"] = marker + let baseBytes = try JSONSerialization.data(withJSONObject: padded).count + guard baseBytes <= targetBytes else { + throw Abort(.badRequest, reason: "Properties are already \(baseBytes) bytes, over \(targetBytes).") + } + padded["$test_padding"] = marker + String(repeating: "x", count: targetBytes - baseBytes) + return padded +} + func fetchFlagsWithRetry(flagsURL: URL, payload: [String: Any]) async throws -> Data { let body = try JSONSerialization.data(withJSONObject: payload) var lastStatus = 0 diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index 4a5b520ca..6f0a6e80e 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -15,7 +15,7 @@ version: '3.8' services: test-harness: - image: ${TEST_HARNESS_IMAGE:-ghcr.io/posthog/sdk-test-harness:1.14.0} + image: ${TEST_HARNESS_IMAGE:-ghcr.io/posthog/sdk-test-harness:1.26.0} command: ["run", "--adapter-url", "http://host.docker.internal:8080", "--mock-url", "http://host.docker.internal:8081", "--sdk-type", "server", "--output", "text", "--report", "/report/sdk-compliance-report.md", "--debug"] ports: - "8081:8081"