diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md new file mode 100644 index 000000000..ab38dbb58 --- /dev/null +++ b/.changeset/v4-capture-ai.md @@ -0,0 +1,13 @@ +--- +'posthog-ios': minor +--- + +Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. It returns the event UUID. + +Analytics batches are now also capped at 5 MiB per request, so a batch of large events stays under capture V1's request limit; an event larger than that is sent on its own. + +AI requests time out after 60 seconds without network activity, instead of 10, since they can carry several MB. + +Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults keep the behavior above: gzip, 100 queued events, 60 seconds and 8 MiB plus 64 KiB per serialized event. + +With `debug` on, the SDK logs the name, UUID and reason of each event PostHog drops, and warns when `captureAiMaxEventBytes` is set above its 8 MiB plus 64 KiB ceiling, which it uses instead. diff --git a/.changeset/v4-capture-v1.md b/.changeset/v4-capture-v1.md index df983dcc6..121a537f3 100644 --- a/.changeset/v4-capture-v1.md +++ b/.changeset/v4-capture-v1.md @@ -4,7 +4,7 @@ **Breaking:** send events to capture V1 (`/i/v1/analytics/events`) with the project token in an `Authorization: Bearer` header. -- If you use a reverse proxy, forward `/i/v1/analytics/events`. A 404 falls back to `/batch` until the app restarts; other errors such as 403 or 405 drop the events. +- If you use a reverse proxy or self-host PostHog, make sure `/i/v1/analytics/events` is served: a 404 or other non-retriable response drops the events. - Capture requests replace any `Authorization` header from `requestHeaders`, so authenticate your proxy with a different header. - Only a 307 or 308 redirect to the same origin as `host` is followed, at most 5 times. Other redirects drop the events, so point `host` at the final host. - 429 responses are no longer retried, and `Retry-After` pauses sending for at most 30 seconds, including session replay and logs. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index cae13ecb4..314dc73ac 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] @@ -68,10 +68,8 @@ class PostHogApi { /// Guards the capture V1 state below. In memory only: a new request ID /// after an app restart is fine, since the server only logs it. private let captureV1Lock = NSLock() - private var captureV1PendingRetry: CaptureV1RequestIdentity? - // Set when V1 returns 404 so the rest of this session uses `/batch`. - // Remove with /batch. - private var captureV1Unavailable = false + // Keyed by endpoint path, so analytics and AI retries keep separate identities. + private var captureV1PendingRetry: [String: CaptureV1RequestIdentity] = [:] init(_ config: PostHogConfig) { self.config = config @@ -106,11 +104,11 @@ class PostHogApi { } /// `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") @@ -137,15 +135,18 @@ class PostHogApi { "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) } } @@ -188,53 +189,32 @@ 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.") - return completion(PostHogUploadInfo(statusCode: nil, error: nil)) - } - - let toSend: [String: Any] = [ - // Wire field name remains api_key, but it carries the PostHog project token. - "api_key": config.projectToken, - "batch": events.map(Self.batchJSON), - "sent_at": toISO8601String(Date()), - ] - - guard let data = try? JSONSerialization.data(withJSONObject: toSend) else { - hedgeLog("Error parsing the batch 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) - }.resume() - } - - /// Whether analytics events still go to capture V1 this session. - var usesCaptureV1: Bool { - captureV1Lock.withLock { !captureV1Unavailable } - } - /// POSTs analytics events to capture V1 (`/i/v1/analytics/events`). /// /// The completion's `retryRecordIds` holds the UUIDs of events a 200 asked - /// to retry. A 404 resends the batch through `/batch` and keeps using it for - /// the rest of the session. + /// to retry. func captureV1(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { - guard usesCaptureV1 else { - return batch(events: events, completion: completion) - } + 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) + } - guard let url = getEndpointURL("/i/v1/analytics/events", relativeTo: config.host) else { + 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 identity = nextCaptureV1Identity(uuids: Set(uuids)) + 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] = [ "created_at": identity.createdAt, @@ -246,7 +226,13 @@ class PostHogApi { return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture") + // 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") @@ -257,21 +243,7 @@ class PostHogApi { session.uploadTask(with: request, from: payload) { [weak self] data, response, error in processUploadResponse(endpointName: "capture", data: data, response: response, error: error) { info in guard let self else { return completion(info) } - - // iOS-only fallback for hosts without capture V1. Remove with /batch. - if info.statusCode == 404 { - let firstFallback = self.captureV1Lock.withLock { - defer { self.captureV1Unavailable = true } - self.captureV1PendingRetry = nil - return !self.captureV1Unavailable - } - if firstFallback { - hedgeLog("Capture V1 returned 404, using /batch for the rest of this session.") - } - return self.batch(events: events, completion: completion) - } - - completion(self.captureV1Result(info, data: data, uuids: uuids, identity: identity)) + completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity, eventNames: eventNames)) } }.resume() } @@ -279,9 +251,9 @@ class PostHogApi { /// Reuses the request ID with the next attempt number when `uuids` is the /// set of events the previous request left to retry. Otherwise starts a new /// request at attempt 1. - private func nextCaptureV1Identity(uuids: Set) -> CaptureV1RequestIdentity { + private func nextCaptureV1Identity(path: String, uuids: Set) -> CaptureV1RequestIdentity { captureV1Lock.withLock { - if let pending = captureV1PendingRetry, pending.uuids == uuids { + if let pending = captureV1PendingRetry[path], pending.uuids == uuids { return CaptureV1RequestIdentity( uuids: uuids, requestId: pending.requestId, attempt: pending.attempt + 1, createdAt: pending.createdAt ) @@ -293,12 +265,15 @@ class PostHogApi { } /// Reads the per-event results of a 2xx and records which events, if any, - /// the next request retries under the same identity. + /// 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 + identity: CaptureV1RequestIdentity, + eventNames: [String: String] = [:] ) -> PostHogUploadInfo { var retryUuids: Set = [] var dropped = 0 @@ -320,6 +295,10 @@ class PostHogApi { retryUuids.insert(uuid) case "drop": dropped += 1 + // Debug only. Name, UUID, and the server's reason; never content or properties. + let name = eventNames[uuid].map { "'\($0)'" } ?? "event" + let details = entry["details"] as? String ?? "no details" + hedgeLog("Capture dropped \(name) (\(uuid)): \(details).") default: break } @@ -329,13 +308,12 @@ class PostHogApi { retryUuids = Set(uuids) } - // Counts only: event UUIDs and server details can identify users. if dropped > 0 || !retryUuids.isEmpty { hedgeLog("Capture: \(dropped) dropped, \(retryUuids.count) to retry.") } captureV1Lock.withLock { - captureV1PendingRetry = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( + captureV1PendingRetry[path] = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( uuids: retryUuids, requestId: identity.requestId, attempt: identity.attempt, createdAt: identity.createdAt ) } @@ -357,21 +335,6 @@ class PostHogApi { ("$process_person_profile", "process_person_profile"), ] - /// Shapes a stored event for `/batch`, which has no options: each option - /// goes back into its legacy property and wins over it. Remove with /batch. - private static func batchJSON(_ event: PostHogEvent) -> [String: Any] { - var json = event.toJSON() - guard let options = json.removeValue(forKey: "options") as? [String: Any] else { return json } - var properties = event.properties - for (property, option) in legacyOptionProperties { - if let value = options[option], !(value is NSNull) { - properties[property] = value - } - } - json["properties"] = properties - return json - } - /// Shapes a stored event for capture V1. `$session_id` and `$window_id` /// move to the event root, and `$lib`/`$lib_version` are dropped because /// the server reads them from `PostHog-Sdk-Info`. diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 6bfa8260a..ae324128d 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -34,6 +34,10 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? static let flushIntervalSeconds: TimeInterval = 30 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. @@ -475,6 +479,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/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index 9206ca0a0..ed0367801 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -15,16 +15,28 @@ 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? private var items = [String]() + /// File sizes by id, tracked only when `maxBytes` is set. 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` is set, 0 otherwise. + var byteCount: Int { + itemsLock.withLock { totalBytes } + } + + init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil, maxBytes: Int? = nil) { self.queue = queue self.maxSize = maxSize.map { max(1, $0) } + self.maxBytes = maxBytes.map { max(1, $0) } setup(oldQueues: oldQueues) } @@ -60,14 +72,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 +98,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 +108,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 +165,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 maxBytes != nil { + 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 +184,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 +202,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 +215,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 +246,38 @@ class PostHogFileBackedQueue { return false } + /// Caller must hold `itemsLock`. + private func track(_ id: String, size: Int) { + guard maxBytes != nil 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/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 2698d588b..4b3343ffb 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -154,7 +154,8 @@ 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 ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } @@ -173,7 +174,8 @@ 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 ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } @@ -374,7 +376,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() } } @@ -400,11 +405,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() @@ -480,7 +490,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) } } @@ -501,6 +512,7 @@ class PostHogQueue { 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] @@ -511,6 +523,12 @@ 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) } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 8a0b8f445..936fea4a2 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -85,6 +85,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) @@ -288,10 +293,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, @@ -303,6 +310,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() @@ -853,8 +863,8 @@ let maxRetryDelay = 30.0 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 @@ -863,6 +873,7 @@ let maxRetryDelay = 30.0 queue?.flush() replayQueue?.flush() logsQueue?.flush() + aiQueue?.flush() pushSubscriptionHandler?.retryIfNeeded() } @@ -1519,6 +1530,64 @@ let maxRetryDelay = 30.0 ) } + /// 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? + { + if !event.hasPrefix("$ai_") { + hedgeLog("captureAi event '\(event)' doesn't start with '$ai_', so PostHog will drop it. Use capture for non-AI events.") + } + + 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. @@ -1695,6 +1764,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, @@ -1706,18 +1777,19 @@ let maxRetryDelay = 30.0 options: [String: Any]? = nil, skipBuildProperties: Bool = false, propertyAllowlist: Set? = nil, - deduplicatePersonProperties: Bool = false - ) { + deduplicatePersonProperties: Bool = false, + aiLane: Bool = false + ) -> String? { if !isEnabled() { - return + return nil } if isOptOutState() { - return + return nil } - guard let queue else { - return + guard let queue = aiLane ? aiQueue : queue else { + return nil } // $exception_list only ever comes from the caller, so raw properties are sufficient here @@ -1727,11 +1799,11 @@ 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" + var isSnapshotEvent = !aiLane && event == "$snapshot" let eventTimestamp = timestamp ?? now() // 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() @@ -1798,16 +1870,17 @@ let maxRetryDelay = 30.0 ) guard let posthogEvent else { - return + 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. + 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 @@ -1820,9 +1893,10 @@ let maxRetryDelay = 30.0 releaseClaimIfCarried(posthogEvent) replayQueue?.add(posthogEvent) onEventCaptured.invoke(posthogEvent) - } else { - queueEvent(posthogEvent, queue: queue, deduplicatePersonProperties: deduplicatePersonProperties) + return replayQueue == nil ? nil : posthogEvent.uuid.postHogUuidString } + let stored = queueEvent(posthogEvent, queue: queue, deduplicatePersonProperties: deduplicatePersonProperties) + return stored ? posthogEvent.uuid.postHogUuidString : nil } /// Records a screen view by capturing a `$screen` event. @@ -2172,7 +2246,9 @@ 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] @@ -2205,7 +2281,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 { @@ -2219,6 +2295,7 @@ let maxRetryDelay = 30.0 } } onEventCaptured.invoke(event) + return storedInQueue } /// Associates subsequent events with a group. @@ -3104,7 +3181,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() { @@ -3117,16 +3194,19 @@ let maxRetryDelay = 30.0 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) startSessionRecordingOnOptIn = true 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/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 028e3b16d..a9b0b7b23 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -5,9 +5,8 @@ import Foundation -/// Retry policy shared by `/batch` (events fallback) 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) } @@ -23,9 +22,18 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { [408, 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 { - /// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`), or - /// `/batch` after V1 returned 404 this session. + /// 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, @@ -41,18 +49,48 @@ extension QueueEndpoint where Record == PostHogEvent { 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.captureV1(events: events, completion: completion) }, - isRetriableStatusCode: { code in - // The /batch fallback keeps the /batch policy. Remove with /batch. - api.usesCaptureV1 ? isCaptureV1RetriableStatusCode(code) : isEventsRetriableStatusCode(code) - } + 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, @@ -75,7 +113,7 @@ extension QueueEndpoint where Record == PostHogEvent { send: { events, completion in api.snapshot(events: events, completion: completion) }, - isRetriableStatusCode: isEventsRetriableStatusCode + isRetriableStatusCode: isSnapshotRetriableStatusCode ) } } @@ -128,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 944e12b65..3886d84ca 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -67,6 +67,19 @@ struct QueueEndpoint { /// 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 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/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 index 95c0bcbae..ee1bd6625 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -136,13 +136,38 @@ final class PostHogCaptureV1Test { #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, data: nil, uuids: uuids, identity: identity) + let result = makeApi().captureV1Result(info, path: PostHogApi.captureV1AnalyticsPath, data: nil, uuids: uuids, identity: identity) #expect(result.statusCode == 200) #expect(result.retryRecordIds?.isEmpty ?? true) @@ -184,58 +209,23 @@ final class PostHogCaptureV1Test { #expect(Set(createdAt[0 ... 3]).count == 1) } - @Test("falls back to /batch on 404 for the rest of the session") - func fallsBackToBatchOn404() async throws { - server.captureV1ResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) - } - let api = makeApi() - let endpoint = QueueEndpoint.batch(api: api) - let event = PostHogEvent(event: "first", distinctId: "user") - #expect(!endpoint.isRetriableStatusCode(429)) - - let result = await send(api, [event]) - - #expect(result.statusCode == 200) - #expect(!api.usesCaptureV1) - // The fallback uses the /batch retry policy. - #expect(endpoint.isRetriableStatusCode(429)) - var paths = server.batchRequests.map { $0.url?.path } - #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/batch"]) - let legacyRequest = try #require(server.batchRequests.last) - #expect(try body(legacyRequest)["api_key"] as? String == "phc_capture_v1") - #expect(server.parsePostHogEvents(legacyRequest).map(\.uuid) == [event.uuid]) - - _ = await send(api, [PostHogEvent(event: "second", distinctId: "user")]) - - paths = server.batchRequests.map { $0.url?.path } - #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/batch", "/proxy/batch"]) - // A new API instance (next launch) tries V1 again. - #expect(makeApi().usesCaptureV1) - } - - @Test("a failing /batch fallback is retried on /batch") - func failingFallbackIsRetriedOnBatch() async throws { + @Test("a 404 is terminal and doesn't fall back to /batch") + func notFoundIsTerminal() async throws { server.captureV1ResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) } - server.batchResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 503, headers: nil) - } let api = makeApi() - let endpoint = QueueEndpoint.batch(api: api) let result = await send(api, [PostHogEvent(event: "first", distinctId: "user")]) - let statusCode = try #require(result.statusCode) - #expect(statusCode == 503) - #expect(endpoint.isRetriableStatusCode(statusCode)) + #expect(result.statusCode == 404) #expect(result.retryRecordIds == nil) + #expect(!QueueEndpoint.batch(api: api).isRetriableStatusCode(404)) _ = await send(api, [PostHogEvent(event: "second", distinctId: "user")]) let paths = server.batchRequests.map { $0.url?.path } - #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/batch", "/proxy/batch"]) + #expect(paths == ["/proxy/i/v1/analytics/events", "/proxy/i/v1/analytics/events"]) } @Test("sends empty and whitespace session and window IDs at the root unchanged", arguments: ["", " "]) @@ -459,34 +449,6 @@ final class PostHogCaptureV1OptionsTest { #expect((sent["options"] as? [String: Any])?.isEmpty == true) } - @Test("the /batch fallback folds options into their legacy properties and sends no options") - func batchFallbackFoldsOptions() async throws { - server.captureV1ResponseHandler = { _, _ in - HTTPStubsResponse(jsonObject: [], statusCode: 404, headers: nil) - } - let api = makeApi() - let event = PostHogEvent( - event: "test", - distinctId: "user", - properties: ["$process_person_profile": false, "$ignore_sent_at": true, "$product_tour_id": "tour"], - options: ["process_person_profile": true, "cookieless_mode": true, "product_tour_id": NSNull(), "future_option": 1] - ) - await withCheckedContinuation { continuation in - api.captureV1(events: [event]) { _ in continuation.resume() } - } - - let request = try #require(server.batchRequests.last) - #expect(request.url?.path == "/batch") - let sent = try #require((server.parseRequest(request)?["batch"] as? [[String: Any]])?.first) - #expect(sent["options"] == nil) - let properties = try #require(sent["properties"] as? [String: Any]) - #expect(properties["$process_person_profile"] as? Bool == true) - #expect(properties["$cookieless_mode"] as? Bool == true) - #expect(properties["$ignore_sent_at"] as? Bool == true) - #expect(properties["$product_tour_id"] as? String == "tour") - #expect(properties["future_option"] == nil) - } - @Test("replay snapshots don't send options") func snapshotsOmitOptions() async throws { let event = PostHogEvent(event: "$snapshot", distinctId: "user", properties: ["$session_id": "s"], options: ["cookieless_mode": true]) @@ -809,4 +771,289 @@ final class PostHogCaptureV1QueueTest { ) } } + + @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]) + } +} + +@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 drops the batch") + func notFoundIsTerminal() 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.depth == 0 } + + #expect(queue.depth == 0) + #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/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index a6b262957..07ec910c6 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,64 @@ 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("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/PostHogQueueTest.swift b/PostHogTests/PostHogQueueTest.swift index c35076905..1d2f2f856 100644 --- a/PostHogTests/PostHogQueueTest.swift +++ b/PostHogTests/PostHogQueueTest.swift @@ -115,7 +115,7 @@ final class PostHogQueueTest { ) // 0.5 is a jitter factor of 1, so backoff waits are exact unless a test injects its own. let sut = PostHogQueue(config, storage, endpoint, nil, jitterUnit: jitterUnit) - // Only count /batch requests from this fixture's queue: the stub and the activation hook are + // 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 diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index b348d0dd8..3c7409279 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -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() 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/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index c2c422cec..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,9 +95,7 @@ class MockPostHogServer { if let batchProjectToken { // Buffer the body: a stream-backed body can only be read once. request.httpBody = request.body() - let token = Self.isCaptureV1(request) - ? request.value(forHTTPHeaderField: "Authorization").map { String($0.dropFirst("Bearer ".count)) } - : parseRequest(request)?["api_key"] as? String + let token = request.value(forHTTPHeaderField: "Authorization").map { String($0.dropFirst("Bearer ".count)) } guard token == batchProjectToken else { return } } @@ -144,7 +151,7 @@ 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). @@ -416,17 +423,6 @@ class MockPostHogServer { return response }) - stubDescriptors.append(stub(condition: pathEndsWith("/batch")) { request in - if let handler = self.batchResponseHandler { - let index = self.batchRequests.count + 1 - return handler(request, index) - } - if self.return500 { - return HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) - } - return HTTPStubsResponse(jsonObject: ["status": "ok"], statusCode: 200, headers: nil) - }) - stubDescriptors.append(stub(condition: pathEndsWith("/i/v1/analytics/events")) { request in // Buffer the body and record here: a stream-backed body can only be // read once, and the default response needs the event UUIDs. @@ -448,6 +444,21 @@ class MockPostHogServer { 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 if self.return500 { HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) @@ -606,9 +617,7 @@ class MockPostHogServer { HTTPStubs.onStubActivation { request, _, _ in // Capture V1 requests are recorded by their stub. - if request.url?.lastPathComponent == "batch" { - self.trackBatchRequest(request) - } else if request.url?.lastPathComponent == "s" { + if request.url?.lastPathComponent == "s" { self.trackSnapshotRequest(request) } else if request.url?.lastPathComponent == "logs" { self.trackLogsRequest(request) @@ -640,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") @@ -672,10 +683,10 @@ class MockPostHogServer { } static func isCaptureV1(_ request: URLRequest) -> Bool { - request.url?.path.hasSuffix("/i/v1/analytics/events") == true + request.url.map { $0.path.hasSuffix("/i/v1/analytics/events") || $0.path.hasSuffix("/i/v1/ai/events") } == true } - /// Decodes the events of a `/batch` or capture V1 request. For V1 it puts + /// 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. diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 1cebef4df..40d4e4f4b 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -159,6 +159,28 @@ extension Trait where Self == ResetGlobalStateTrait { static var resetsGlobalState: ResetGlobalStateTrait { ResetGlobalStateTrait() } } +/// Returns what `body` printed to stdout, such as `hedgeLog` lines. Stdout is process-wide, so +/// match on something unique to the test rather than asserting the whole output. +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) +} + /// 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.txt b/api/posthog-ios.public-api.txt index fb3eda0f7..00cf41301 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 @@ -258,6 +262,7 @@ PostHog | PostHogSDK.capture(_:properties:) | method | @objc(captureWithEvent:pr 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: diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 3b69a4ed2..3a71f5bb7 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -28,8 +28,9 @@ You need to change something if your app does any of these: | Data | 3.x | 4.0 | | --- | --- | --- | -| Analytics events | `/batch` | `/i/v1/analytics/events`, or `/batch` after a 404 (see below) | +| 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 | @@ -42,11 +43,18 @@ In 3.x, the SDK started and PostHog rejected every event with a 401. Use your pr ### Hosts without capture v1 -If you use a reverse proxy, forward `/i/v1/analytics/events` before you upgrade. -PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves it. +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. -When `/i/v1/analytics/events` returns 404, the SDK resends that batch to `/batch` and keeps using `/batch` until the app restarts. -Any other error status, such as 403 or 405, drops the events. +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. +- 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, or any other error status that isn't retried, such as 403 or 405, drops the events. ### Redirects @@ -136,7 +144,25 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - The SDK sends option values as given. PostHog reads common forms of a value, such as `"false"` or `0` for a boolean, and drops an event whose option value it can't read. - Other option keys are sent unchanged, and PostHog ignores keys it doesn't know. - Events still queued on disk by 3.x are sent to capture v1 after the update, with their legacy properties moved to options. -- After a 404 fallback to `/batch`, the SDK puts each option back into its legacy property. + +## 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)). +- 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 @@ -146,5 +172,5 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - 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. -- After a 404 fallback, `/batch` keeps the 3.x retry rules. diff --git a/sdk_compliance_adapter/Sources/RequestInterceptor.swift b/sdk_compliance_adapter/Sources/RequestInterceptor.swift index fe0cabbd2..778b829c8 100644 --- a/sdk_compliance_adapter/Sources/RequestInterceptor.swift +++ b/sdk_compliance_adapter/Sources/RequestInterceptor.swift @@ -105,7 +105,7 @@ class RequestInterceptor: URLProtocol { // Intercept capture endpoints, but not /flags/ or /config let urlString = url.absoluteString - if urlString.contains("/i/v1/analytics/events") || urlString.contains("/batch") + 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") @@ -229,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 6087b5c8b..ccbbd65e3 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -56,7 +56,7 @@ app.get("health") { req async throws -> Response in // Declares which test suites apply. The iOS SDK posts events to // /i/v1/analytics/events (capture_v1) with gzip. Without this, the // harness skips the capability-gated capture suites entirely. - "capabilities": ["capture_v1", "encoding_gzip", "event_options"], + "capabilities": ["capture_v1", "capture_ai_v1", "encoding_gzip", "event_options"], ] print("[ADAPTER] GET /health") @@ -121,6 +121,7 @@ app.post("init") { req async throws -> Response in config.pushNotificationSubscriptionMaxRetries = initReq.maxRetries ?? config.pushNotificationSubscriptionMaxRetries if initReq.enableCompression == false { config.compression = .none + config.captureAiCompression = .none } config.disableGeoIp = initReq.disableGeoip ?? config.disableGeoIp @@ -221,6 +222,45 @@ 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]? + + enum CodingKeys: String, CodingKey { + case event + case distinctId = "distinct_id" + case properties + case timestamp + case options + } + } + + 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.") + } + + let uuid = sdk.captureAi( + captureReq.event, + distinctId: captureReq.distinctId, + properties: captureReq.properties?.mapValues(\.value), + 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) +} + func fetchFlagsWithRetry(flagsURL: URL, payload: [String: Any]) async throws -> Data { let body = try JSONSerialization.data(withJSONObject: payload) var lastStatus = 0