From 042b7bf5f93a6f6e4781f303fd1eaf0aeed2139f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 22:44:38 -0400 Subject: [PATCH 01/18] feat: add captureAi on the capture V1 AI endpoint captureAi(...) takes capture's arguments, runs the same enrichment and beforeSend, and queues the event on its own disk queue for /i/v1/ai/events. It reuses the capture V1 transport (headers, per-event results, retry identity per endpoint, redirects, Retry-After), with no /batch fallback. The AI queue batches by bytes (5 MiB) as well as count, keeps at most 100 events, and drops events over 8 MiB + 64 KiB logging only the name and size. capture() never reroutes $ai_ events. The compliance adapter gains /capture_ai and the capture_ai_v1 capability. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 5 + PostHog/PostHogApi.swift | 33 +++-- PostHog/PostHogQueue.swift | 7 + PostHog/PostHogSDK.swift | 68 +++++++++- PostHog/PostHogStorage.swift | 3 +- PostHog/QueueEndpoint+Factories.swift | 43 ++++++ PostHog/QueueEndpoint.swift | 5 + PostHogTests/PostHogCaptureV1Test.swift | 126 +++++++++++++++++- .../TestUtils/MockPostHogServer.swift | 28 +++- api/posthog-ios.public-api.txt | 1 + .../Sources/RequestInterceptor.swift | 3 +- sdk_compliance_adapter/Sources/main.swift | 36 ++++- 12 files changed, 337 insertions(+), 21 deletions(-) create mode 100644 .changeset/v4-capture-ai.md diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md new file mode 100644 index 000000000..92e292bdb --- /dev/null +++ b/.changeset/v4-capture-ai.md @@ -0,0 +1,5 @@ +--- +'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. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index cae13ecb4..aa47c8cfa 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -68,7 +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? + // Keyed by endpoint path, so analytics and AI retries keep separate identities. + private var captureV1PendingRetry: [String: CaptureV1RequestIdentity] = [:] // Set when V1 returns 404 so the rest of this session uses `/batch`. // Remove with /batch. private var captureV1Unavailable = false @@ -227,14 +228,27 @@ class PostHogApi { 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 and per-event results as `captureV1`, but a 404 is + /// terminal: AI events never fall back to `/batch`. + func captureAi(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) { + postCaptureV1(path: Self.captureV1AiPath, events: events, completion: completion) + } + + static let captureV1AnalyticsPath = "/i/v1/analytics/events" + static let captureV1AiPath = "/i/v1/ai/events" - guard let url = getEndpointURL("/i/v1/analytics/events", relativeTo: config.host) else { + 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 identity = nextCaptureV1Identity(path: path, uuids: Set(uuids)) let toSend: [String: Any] = [ "created_at": identity.createdAt, @@ -259,10 +273,10 @@ class PostHogApi { guard let self else { return completion(info) } // iOS-only fallback for hosts without capture V1. Remove with /batch. - if info.statusCode == 404 { + if info.statusCode == 404, path == Self.captureV1AnalyticsPath { let firstFallback = self.captureV1Lock.withLock { defer { self.captureV1Unavailable = true } - self.captureV1PendingRetry = nil + self.captureV1PendingRetry[path] = nil return !self.captureV1Unavailable } if firstFallback { @@ -271,7 +285,7 @@ class PostHogApi { 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)) } }.resume() } @@ -279,9 +293,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 ) @@ -296,6 +310,7 @@ class PostHogApi { /// the next request retries under the same identity. func captureV1Result( _ info: PostHogUploadInfo, + path: String, data: Data?, uuids: [String], identity: CaptureV1RequestIdentity @@ -335,7 +350,7 @@ class PostHogApi { } captureV1Lock.withLock { - captureV1PendingRetry = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( + captureV1PendingRetry[path] = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity( uuids: retryUuids, requestId: identity.requestId, attempt: identity.attempt, createdAt: identity.createdAt ) } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 2698d588b..1989e141f 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -501,6 +501,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 +512,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..55b205496 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -85,6 +85,7 @@ let maxRetryDelay = 30.0 } private(set) var logsQueue: PostHogQueue? + private(set) var aiQueue: PostHogQueue? private(set) var storage: PostHogStorage? private var surveyIdentityGeneration: String? #if !os(watchOS) @@ -288,10 +289,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 +306,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 +859,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 +869,7 @@ let maxRetryDelay = 30.0 queue?.flush() replayQueue?.flush() logsQueue?.flush() + aiQueue?.flush() pushSubscriptionHandler?.retryIfNeeded() } @@ -1519,6 +1526,49 @@ 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 8 MiB is dropped. PostHog drops an event sent here whose name doesn't start + /// with `$ai_`. `capture` never sends events here, even ones named `$ai_*`. + /// + /// - 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`. + @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) + { + 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. @@ -1706,7 +1756,8 @@ let maxRetryDelay = 30.0 options: [String: Any]? = nil, skipBuildProperties: Bool = false, propertyAllowlist: Set? = nil, - deduplicatePersonProperties: Bool = false + deduplicatePersonProperties: Bool = false, + aiLane: Bool = false ) { if !isEnabled() { return @@ -1716,7 +1767,7 @@ let maxRetryDelay = 30.0 return } - guard let queue else { + guard let queue = aiLane ? aiQueue : queue else { return } @@ -1731,7 +1782,7 @@ let maxRetryDelay = 30.0 } } - 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() @@ -1801,8 +1852,9 @@ let maxRetryDelay = 30.0 return } - // 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 { @@ -3123,10 +3175,12 @@ let maxRetryDelay = 30.0 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..8304c2de6 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -23,6 +23,15 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { [408, 500, 502, 503, 504].contains(code) } +/// PostHog's AI endpoint drops events whose properties exceed 8 MiB. The +/// stored event also holds its envelope, so allow 64 KiB on top, as +/// posthog-python and posthog-go do. +let aiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024 +/// Soft request size for the AI endpoint, matching the other PostHog SDKs. +let aiMaxBatchBytes = 5 * 1024 * 1024 +/// AI events can be megabytes, so keep fewer of them on disk than analytics events. +let aiMaxQueueSize = 100 + extension QueueEndpoint where Record == PostHogEvent { /// Analytics events endpoint: capture V1 (`/i/v1/analytics/events`), or /// `/batch` after V1 returned 404 this session. @@ -51,6 +60,40 @@ extension QueueEndpoint where Record == PostHogEvent { ) } + /// Capture V1 AI endpoint (`/i/v1/ai/events`) for events sent with + /// `captureAi`. Same retry policy as capture V1, with no `/batch` fallback. + /// Batches by bytes as well as count, since AI events can be megabytes. + static func ai(api: PostHogApi) -> QueueEndpoint { + QueueEndpoint( + storageKey: .aiQueue, + oldStorageKeys: [], + dispatchQueueLabel: "com.posthog.AiQueue", + initialCap: { $0.maxBatchSize }, + initialFlushAt: { $0.flushAt }, + maxQueueSize: { _ in aiMaxQueueSize }, + flushIntervalSeconds: { $0.flushIntervalSeconds }, + rateCapMax: { _ in 0 }, + rateCapWindowSeconds: { _ in 0 }, + encode: { event in + guard let data = toJSONData(event.toJSON()) else { return nil } + // Name and size only: AI properties can hold prompts and media. + if data.count > aiMaxEventBytes { + hedgeLog("Dropping AI event '\(event.event)' of \(data.count) bytes, over the \(aiMaxEventBytes) byte limit.") + return nil + } + return data + }, + decode: { data in PostHogEvent.fromJSON(data) }, + describe: { event in "AI event '\(event.event)'" }, + recordId: { event in event.uuid.postHogUuidString }, + maxBatchBytes: aiMaxBatchBytes, + send: { events, completion in + api.captureAi(events: events, completion: completion) + }, + isRetriableStatusCode: isCaptureV1RetriableStatusCode + ) + } + /// `/snapshot` endpoint for session-replay snapshots. Shares its retry /// policy with `/batch`. static func snapshot(api: PostHogApi) -> QueueEndpoint { diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index 944e12b65..567283d42 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -67,6 +67,11 @@ 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? + // MARK: Send /// Build the wire payload from a list of records and POST it. The queue diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 95c0bcbae..ce8236229 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -142,7 +142,7 @@ final class PostHogCaptureV1Test { 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) @@ -810,3 +810,127 @@ final class PostHogCaptureV1QueueTest { } } } + +@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(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 + 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() } + + 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) + let event = try #require(server.parsePostHogEvents(request).first) + #expect(event.event == "$ai_generation") + #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 } + #expect(server.parsePostHogEvents(try #require(server.batchRequests.first)).map(\.event) == ["$ai_generation"]) + #expect(server.aiRequests.count == 1) + } + + @Test("a 404 from the AI endpoint drops the batch without falling back to /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) + #expect(api.usesCaptureV1) + } + + @Test("drops an AI event over the size limit when it is queued") + func dropsOversizedEvent() { + 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) + } + + let big = String(repeating: "a", count: aiMaxEventBytes) + #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": big]))) + #expect(queue.depth == 0) + } + + @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]) + } +} diff --git a/PostHogTests/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index c2c422cec..63988a277 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? @@ -448,6 +457,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) @@ -640,6 +664,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,7 +698,7 @@ 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 diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index fb3eda0f7..98647893c 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -258,6 +258,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 | @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) | 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/sdk_compliance_adapter/Sources/RequestInterceptor.swift b/sdk_compliance_adapter/Sources/RequestInterceptor.swift index fe0cabbd2..416a9cea3 100644 --- a/sdk_compliance_adapter/Sources/RequestInterceptor.swift +++ b/sdk_compliance_adapter/Sources/RequestInterceptor.swift @@ -105,7 +105,8 @@ 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("/batch") || urlString.contains("/e/") || urlString.contains("/s/"), !urlString.contains("/flags"), !urlString.contains("/config") diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 6087b5c8b..a14e02905 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") @@ -221,6 +221,40 @@ 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. +app.post("capture_ai") { req async throws -> Response in + struct CaptureAiRequest: Content { + let event: String + let distinctId: String? + let properties: [String: AnyCodable]? + let options: [String: AnyCodable]? + + enum CodingKeys: String, CodingKey { + case event + case distinctId = "distinct_id" + case properties + 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.") + } + + sdk.captureAi( + captureReq.event, + distinctId: captureReq.distinctId, + properties: captureReq.properties?.mapValues(\.value), + options: captureReq.options?.mapValues(\.value) + ) + + let result = ["status": "ok"] + 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 From 3d8bec73c32a07726ba5213fe379a1d15be4fbff Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sat, 10 Oct 2026 11:35:06 -0400 Subject: [PATCH 02/18] feat: cap AI queue disk use and return the event UUID from captureAi The AI queue now drops its oldest events once they take more than 50 MiB on disk, on top of the 100-event limit. captureAi returns the queued event's UUID, or nil when the event isn't queued. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 2 +- PostHog/PostHogFileBackedQueue.swift | 74 +++++++++++++++---- PostHog/PostHogQueue.swift | 10 ++- PostHog/PostHogSDK.swift | 34 ++++++--- PostHog/QueueEndpoint+Factories.swift | 3 + PostHog/QueueEndpoint.swift | 4 + PostHogTests/PostHogCaptureV1Test.swift | 15 +++- .../PostHogFileBackedQueueAlignmentTest.swift | 53 ++++++++++++- api/posthog-ios.public-api.txt | 2 +- sdk_compliance_adapter/Sources/main.swift | 5 +- 10 files changed, 165 insertions(+), 37 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index 92e292bdb..cc73f66f7 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -2,4 +2,4 @@ '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. +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. diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index 9206ca0a0..4a4d4ee3b 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -15,16 +15,23 @@ 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) { + 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) } @@ -67,7 +74,7 @@ class PostHogFileBackedQueue { 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 +91,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 +101,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 +158,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 { @@ -194,7 +212,10 @@ class PostHogFileBackedQueue { } 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 +237,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 1989e141f..3e755ff0e 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)) } @@ -403,8 +405,8 @@ class PostHogQueue { 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() diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 55b205496..0af984a8f 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1546,6 +1546,10 @@ let maxRetryDelay = 30.0 /// - 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 8 MiB. + @discardableResult @objc(captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:) public func captureAi(_ event: String, distinctId: String? = nil, @@ -1554,7 +1558,7 @@ let maxRetryDelay = 30.0 userPropertiesSetOnce: [String: Any]? = nil, groups: [String: String]? = nil, timestamp: Date? = nil, - options: [String: Any]? = nil) + options: [String: Any]? = nil) -> String? { captureInternal( event, @@ -1745,6 +1749,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, @@ -1758,17 +1764,17 @@ let maxRetryDelay = 30.0 propertyAllowlist: Set? = nil, deduplicatePersonProperties: Bool = false, aiLane: Bool = false - ) { + ) -> String? { if !isEnabled() { - return + return nil } if isOptOutState() { - return + return nil } guard let queue = aiLane ? aiQueue : queue else { - return + return nil } // $exception_list only ever comes from the caller, so raw properties are sufficient here @@ -1778,7 +1784,7 @@ let maxRetryDelay = 30.0 PostHogErrorTrackingAutoCaptureIntegration.exceptionListMatchesIgnoredTypes(properties ?? [:], ignoredTypes: ignored) { hedgeLog("$exception skipped: exception type is in errorTrackingConfig.ignoredExceptionTypes") - return + return nil } } @@ -1849,7 +1855,7 @@ 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. @@ -1859,7 +1865,7 @@ let maxRetryDelay = 30.0 // 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 @@ -1872,9 +1878,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. @@ -2224,7 +2231,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] @@ -2257,7 +2266,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 { @@ -2271,6 +2280,7 @@ let maxRetryDelay = 30.0 } } onEventCaptured.invoke(event) + return storedInQueue } /// Associates subsequent events with a group. diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 8304c2de6..31bb45815 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -31,6 +31,8 @@ let aiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024 let aiMaxBatchBytes = 5 * 1024 * 1024 /// AI events can be megabytes, so keep fewer of them on disk than analytics events. let aiMaxQueueSize = 100 +/// 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 @@ -87,6 +89,7 @@ extension QueueEndpoint where Record == PostHogEvent { describe: { event in "AI event '\(event.event)'" }, recordId: { event in event.uuid.postHogUuidString }, maxBatchBytes: aiMaxBatchBytes, + maxQueueBytes: aiMaxQueueBytes, send: { events, completion in api.captureAi(events: events, completion: completion) }, diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index 567283d42..e64c7f31e 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -72,6 +72,10 @@ struct QueueEndpoint { /// the limit goes alone. `nil` batches by count only. var maxBatchBytes: 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/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index ce8236229..25434cd45 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -849,7 +849,7 @@ final class PostHogCaptureAiTest { } defer { sut.close() } - sut.captureAi("$ai_generation", distinctId: "user", properties: ["$ai_model": "gpt"], options: ["cookieless_mode": true]) + 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) @@ -859,6 +859,7 @@ final class PostHogCaptureAiTest { #expect(request.value(forHTTPHeaderField: "PostHog-Request-Id") != nil) 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) @@ -871,6 +872,17 @@ final class PostHogCaptureAiTest { #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("a 404 from the AI endpoint drops the batch without falling back to /batch") func notFoundIsTerminal() async throws { server.aiResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [:], statusCode: 404, headers: nil) } @@ -905,6 +917,7 @@ final class PostHogCaptureAiTest { deleteSafely(storage.appFolderUrl) } + #expect(QueueEndpoint.ai(api: PostHogApi(config)).maxQueueBytes == aiMaxQueueBytes) let big = String(repeating: "a", count: aiMaxEventBytes) #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": big]))) #expect(queue.depth == 0) diff --git a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index a6b262957..27a60fa2c 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,53 @@ 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("delivers every record once in FIFO order on the happy path") func happyPath() throws { let (queue, dir) = makeQueue() diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 98647893c..96c6bc0ac 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -258,7 +258,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 | @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) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options: +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/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index a14e02905..34187a1fc 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -244,14 +244,15 @@ app.post("capture_ai") { req async throws -> Response in throw Abort(.badRequest, reason: "SDK not initialized. Call /init first.") } - sdk.captureAi( + let uuid = sdk.captureAi( captureReq.event, distinctId: captureReq.distinctId, properties: captureReq.properties?.mapValues(\.value), options: captureReq.options?.mapValues(\.value) ) - let result = ["status": "ok"] + var result = ["status": "ok"] + result["uuid"] = uuid return try await result.encodeResponse(for: req) } From af822d8f24e157baf86924fbfffc092fef93b710 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 20 Oct 2026 12:04:16 -0400 Subject: [PATCH 03/18] fix: flush and bound AI queue reads by batch bytes, and flush it on close() - close() flushes the AI queue along with the others, and aiQueue is guarded by setupLock like the other queues. - The AI queue flushes once a batch's worth of bytes (5 MiB) is queued, so multi-MB events are sent before the 50 MiB cap evicts them. - A flush reads about one byte-sized batch from disk instead of the whole window, so large AI events aren't all held in memory at once. - The compliance adapter's /capture_ai accepts bodies up to 9 MB and passes the harness timestamp. Co-Authored-By: Claude Opus 5.5 (1M context) --- PostHog/PostHogFileBackedQueue.swift | 17 +++++++++++---- PostHog/PostHogQueue.swift | 8 +++++-- PostHog/PostHogSDK.swift | 9 ++++++-- PostHogTests/PostHogCaptureV1Test.swift | 21 +++++++++++++++++++ .../PostHogFileBackedQueueAlignmentTest.swift | 11 ++++++++++ sdk_compliance_adapter/Sources/main.swift | 6 +++++- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index 4a4d4ee3b..ed0367801 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -28,6 +28,11 @@ class PostHogFileBackedQueue { itemsLock.withLock { items.count } } + /// 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) } @@ -67,8 +72,10 @@ 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) { @@ -177,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 } @@ -194,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)") @@ -206,7 +215,7 @@ class PostHogFileBackedQueue { skipped.insert(item) } - if results.count == count { + if results.count == count || maxBytes.map({ loadedBytes >= $0 }) == true { break } } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 3e755ff0e..722e62377 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -376,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() } } @@ -482,7 +485,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) } } diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 0af984a8f..599860f51 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -85,7 +85,11 @@ let maxRetryDelay = 30.0 } private(set) var logsQueue: PostHogQueue? - private(set) var aiQueue: 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) @@ -3166,7 +3170,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() { @@ -3179,6 +3183,7 @@ let maxRetryDelay = 30.0 PostHogSDK.projectTokens.remove(config.projectToken) queue?.flush() + aiQueue?.flush() replayQueue?.flush() logsQueue?.flush() diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 25434cd45..87c3d91fe 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -946,4 +946,25 @@ final class PostHogCaptureAiTest { #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 27a60fa2c..07ec910c6 100644 --- a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift +++ b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift @@ -131,6 +131,17 @@ struct PostHogFileBackedQueueAlignmentTest { #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/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 34187a1fc..8db1ddfbb 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -222,17 +222,20 @@ app.post("capture") { req async throws -> Response in } // AI capture endpoint: same request as /capture, sent with captureAi to /i/v1/ai/events. -app.post("capture_ai") { req async throws -> Response in +// 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 } } @@ -248,6 +251,7 @@ app.post("capture_ai") { req async throws -> Response in captureReq.event, distinctId: captureReq.distinctId, properties: captureReq.properties?.mapValues(\.value), + timestamp: captureReq.timestamp.flatMap(parseHarnessTimestamp), options: captureReq.options?.mapValues(\.value) ) From d3842094f73f4ad7cdbf23a40e5f3b3a28c14b43 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 20 Oct 2026 13:01:18 -0400 Subject: [PATCH 04/18] chore: return success from the compliance adapter's /capture_ai The harness reads the action result's success field, as posthog-python's adapter returns it. Co-Authored-By: Claude Opus 5.5 (1M context) --- sdk_compliance_adapter/Sources/main.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 8db1ddfbb..11df198fa 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -255,7 +255,7 @@ app.on(.POST, "capture_ai", body: .collect(maxSize: "9mb")) { req async throws - options: captureReq.options?.mapValues(\.value) ) - var result = ["status": "ok"] + var result: [String: Any] = ["success": true] result["uuid"] = uuid return try await result.encodeResponse(for: req) } From 27ec3072a557f9f1ba399aa35efa0c90c564accf Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 16:37:03 -0400 Subject: [PATCH 05/18] feat(queue): cap analytics batches at 5 MiB Capture V1 rejects requests over 10 MiB, and analytics batches were capped by count only. Give the analytics endpoint the same 5 MiB soft byte limit as the AI lane (renamed to captureV1MaxBatchBytes). An event over the limit is still sent alone, and 413 halving is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 2 ++ PostHog/QueueEndpoint+Factories.swift | 11 ++++++---- PostHogTests/PostHogCaptureV1Test.swift | 27 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index cc73f66f7..0b1d1316e 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -3,3 +3,5 @@ --- 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. diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 31bb45815..4ce0424f8 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -27,8 +27,9 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { /// stored event also holds its envelope, so allow 64 KiB on top, as /// posthog-python and posthog-go do. let aiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024 -/// Soft request size for the AI endpoint, matching the other PostHog SDKs. -let aiMaxBatchBytes = 5 * 1024 * 1024 +/// 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 /// AI events can be megabytes, so keep fewer of them on disk than analytics events. let aiMaxQueueSize = 100 /// Disk budget for queued AI events; the oldest are dropped first. @@ -36,7 +37,8 @@ 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. + /// `/batch` after V1 returned 404 this session. 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, @@ -52,6 +54,7 @@ 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) }, @@ -88,7 +91,7 @@ extension QueueEndpoint where Record == PostHogEvent { decode: { data in PostHogEvent.fromJSON(data) }, describe: { event in "AI event '\(event.event)'" }, recordId: { event in event.uuid.postHogUuidString }, - maxBatchBytes: aiMaxBatchBytes, + maxBatchBytes: captureV1MaxBatchBytes, maxQueueBytes: aiMaxQueueBytes, send: { events, completion in api.captureAi(events: events, completion: completion) diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 87c3d91fe..fa35761d5 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -809,6 +809,33 @@ 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) From 800e9204936752ba6efd060f981ee01ee9fc057f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 22 Oct 2026 17:03:45 -0400 Subject: [PATCH 06/18] feat: give AI requests a 60 s timeout and log non-$ai_ captureAi names Document the AI lane's fixed limits on captureAi. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 2 ++ PostHog/PostHogApi.swift | 19 ++++++++++++------- PostHog/PostHogSDK.swift | 15 +++++++++++++-- PostHogTests/PostHogCaptureV1Test.swift | 5 ++++- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index 0b1d1316e..d561a607a 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -5,3 +5,5 @@ 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. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index aa47c8cfa..772db9d17 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -58,6 +58,8 @@ class PostHogApi { // default is 60s but we do 10s private let defaultTimeout: TimeInterval = 10 + /// AI batches can be several MB, so give them longer than other requests. + static let aiTimeout: TimeInterval = 60 /// Shared so connection pool, TLS state, and HTTP/2 streams survive /// between calls instead of being torn down per request. @@ -108,10 +110,10 @@ 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 { + 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") @@ -138,15 +140,17 @@ 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) { + private func requestAndPayload( + url: URL, data: Data, endpointName: String, httpMethod: String = "POST", timeout: TimeInterval? = nil + ) -> (URLRequest, Data) { guard config.compression == .gzip else { - return (getURLRequest(url, httpMethod: httpMethod), data) + 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) } } @@ -260,7 +264,8 @@ class PostHogApi { return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture") + let timeout = path == Self.captureV1AiPath ? Self.aiTimeout : nil + var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture", timeout: timeout) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(config.projectToken)", forHTTPHeaderField: "Authorization") request.setValue("\(postHogSdkName)/\(postHogVersion)", forHTTPHeaderField: "PostHog-Sdk-Info") diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 599860f51..2391e2d64 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1538,7 +1538,14 @@ let maxRetryDelay = 30.0 /// 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 8 MiB is dropped. PostHog drops an event sent here whose name doesn't start - /// with `$ai_`. `capture` never sends events here, even ones named `$ai_*`. + /// 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`, `flushIntervalSeconds` and `compression` from + /// `PostHogConfig`, like other events. These limits are fixed and can't be configured: + /// - Requests carry up to about 5 MiB of events, and the SDK sends as soon as that much is queued. + /// - Requests time out after 60 seconds without network activity, instead of 10. + /// - Up to 100 events and 50 MiB are kept on disk. Beyond that, the oldest are dropped. /// /// - Parameters: /// - event: AI event name, such as `$ai_generation`. @@ -1564,7 +1571,11 @@ let maxRetryDelay = 30.0 timestamp: Date? = nil, options: [String: Any]? = nil) -> String? { - captureInternal( + 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, diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index fa35761d5..cbcf374e4 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -884,6 +884,7 @@ final class PostHogCaptureAiTest { #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 == PostHogApi.aiTimeout) let event = try #require(server.parsePostHogEvents(request).first) #expect(event.event == "$ai_generation") #expect(event.uuid.postHogUuidString == uuid) @@ -895,7 +896,9 @@ final class PostHogCaptureAiTest { sut.capture("$ai_generation") await waitUntil { self.server.batchRequests.count == 1 } - #expect(server.parsePostHogEvents(try #require(server.batchRequests.first)).map(\.event) == ["$ai_generation"]) + let batchRequest = try #require(server.batchRequests.first) + #expect(server.parsePostHogEvents(batchRequest).map(\.event) == ["$ai_generation"]) + #expect(batchRequest.timeoutInterval < PostHogApi.aiTimeout) #expect(server.aiRequests.count == 1) } From a0a729ced5a44300781d9a65c0a4e1635029a459 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 14:36:49 -0400 Subject: [PATCH 07/18] docs: note the 5 MiB analytics batch cap in the migration guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 3b69a4ed2..3d30234da 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -146,5 +146,6 @@ 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. From cf6c2ae4694cac8a274479f256d0aa4efa76757e Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 15:40:29 -0400 Subject: [PATCH 08/18] docs: add an AI events section to the migration guide Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 3d30234da..5fefd76bd 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -30,6 +30,7 @@ You need to change something if your app does any of these: | --- | --- | --- | | Analytics events | `/batch` | `/i/v1/analytics/events`, or `/batch` after a 404 (see below) | | 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 | @@ -138,6 +139,24 @@ When the event is sent, the SDK removes each one from `properties` and uses it f - 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 can be up to 8 MiB. The SDK drops a larger event, and `captureAi` returns `nil`. +- 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. Other requests still time out after 10 seconds. +- AI events are queued on disk separately from other events. Up to 100 events and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. +- AI events use `flushAt`, `maxBatchSize`, `flushIntervalSeconds` and `compression`, 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 - The SDK retries network errors and 408, 500, 502, 503 and 504 responses. Other statuses drop the events, including 429. 3.x also retried 429 and 3xx responses. Capture itself doesn't return 429, but a proxy in front of it can. From a58da3cfbd086cc39e3b60346c1d75b46e5fda0a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 15:53:52 -0400 Subject: [PATCH 09/18] feat: add captureAi settings to PostHogConfig Add captureAiCompression, captureAiMaxQueueSize, captureAiTimeout and captureAiMaxEventBytes, matching the AI lane settings in the backend v1 SDKs. Defaults keep the current behavior: gzip, 100 events, 60 s, 8 MiB. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-ai.md | 2 + PostHog/PostHogApi.swift | 18 +++--- PostHog/PostHogConfig.swift | 29 ++++++++++ PostHog/PostHogSDK.swift | 16 +++--- PostHog/QueueEndpoint+Factories.swift | 40 ++++++++++---- PostHogTests/PostHogCaptureV1Test.swift | 73 ++++++++++++++++++++++++- api/posthog-ios.public-api.txt | 4 ++ docs/migration-3.x-to-4.0.md | 9 +-- 8 files changed, 159 insertions(+), 32 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index d561a607a..b46089d20 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -7,3 +7,5 @@ Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI 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. diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index 772db9d17..f17a9fd87 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -51,15 +51,13 @@ 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] // default is 60s but we do 10s private let defaultTimeout: TimeInterval = 10 - /// AI batches can be several MB, so give them longer than other requests. - static let aiTimeout: TimeInterval = 60 /// Shared so connection pool, TLS state, and HTTP/2 streams survive /// between calls instead of being torn down per request. @@ -141,9 +139,10 @@ class PostHogApi { ] private func requestAndPayload( - url: URL, data: Data, endpointName: String, httpMethod: String = "POST", timeout: TimeInterval? = nil + url: URL, data: Data, endpointName: String, httpMethod: String = "POST", timeout: TimeInterval? = nil, + compression: PostHogCompression? = nil ) -> (URLRequest, Data) { - guard config.compression == .gzip else { + guard (compression ?? config.compression) == .gzip else { return (getURLRequest(url, httpMethod: httpMethod, timeout: timeout), data) } do { @@ -264,8 +263,13 @@ class PostHogApi { return completion(PostHogUploadInfo(statusCode: nil, error: nil)) } - let timeout = path == Self.captureV1AiPath ? Self.aiTimeout : nil - var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture", timeout: timeout) + // 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") diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 6bfa8260a..d7d0aaedb 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -34,6 +34,9 @@ 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 + static let captureAiMaxEventBytes: Int = 8 * 1024 * 1024 } /// Network connectivity mode required before queued data may be flushed. @@ -475,6 +478,32 @@ 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 `captureAi` event's properties. The SDK drops a larger event. + /// + /// Use it to lower the limit. PostHog drops AI events over 8 MiB, so a larger value, or a value of + /// `0` or less, uses 8 MiB. + /// Default: `8388608` (8 MiB). + @objc public var captureAiMaxEventBytes: Int = Defaults.captureAiMaxEventBytes + // only internal var disableReachabilityForTesting: Bool = false var disableQueueTimerForTesting: Bool = false diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 2391e2d64..6b85a57d6 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1537,15 +1537,15 @@ let maxRetryDelay = 30.0 /// 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 8 MiB 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_*`. + /// An event over `captureAiMaxEventBytes` (8 MiB 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`, `flushIntervalSeconds` and `compression` from - /// `PostHogConfig`, like other events. These limits are fixed and can't be configured: + /// 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. - /// - Requests time out after 60 seconds without network activity, instead of 10. - /// - Up to 100 events and 50 MiB are kept on disk. Beyond that, the oldest are dropped. + /// - 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`. @@ -1559,7 +1559,7 @@ let maxRetryDelay = 30.0 /// `capture`'s `options`. /// /// - Returns: The event's UUID, or `nil` if the event wasn't queued: for example, the SDK is - /// opted out or disabled, `beforeSend` dropped it, or it's over 8 MiB. + /// 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, diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 4ce0424f8..7b50de1bd 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -23,15 +23,14 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { [408, 500, 502, 503, 504].contains(code) } -/// PostHog's AI endpoint drops events whose properties exceed 8 MiB. The -/// stored event also holds its envelope, so allow 64 KiB on top, as -/// posthog-python and posthog-go do. -let aiMaxEventBytes = 8 * 1024 * 1024 + 64 * 1024 +/// The stored event also holds its envelope, so allow 64 KiB on top of the +/// properties limit, as posthog-python and posthog-go do. +let aiEventEnvelopeBytes = 64 * 1024 +/// Stored-size limit for an AI event at the default `captureAiMaxEventBytes`. +let aiMaxEventBytes = PostHogConfig.Defaults.captureAiMaxEventBytes + aiEventEnvelopeBytes /// 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 -/// AI events can be megabytes, so keep fewer of them on disk than analytics events. -let aiMaxQueueSize = 100 /// Disk budget for queued AI events; the oldest are dropped first. let aiMaxQueueBytes = 50 * 1024 * 1024 @@ -69,21 +68,22 @@ extension QueueEndpoint where Record == PostHogEvent { /// `captureAi`. Same retry policy as capture V1, with no `/batch` fallback. /// Batches by bytes as well as count, since AI events can be megabytes. static func ai(api: PostHogApi) -> QueueEndpoint { - QueueEndpoint( + let maxEventBytes = api.config.captureAiMaxStoredEventBytes + return QueueEndpoint( storageKey: .aiQueue, oldStorageKeys: [], dispatchQueueLabel: "com.posthog.AiQueue", initialCap: { $0.maxBatchSize }, initialFlushAt: { $0.flushAt }, - maxQueueSize: { _ in aiMaxQueueSize }, + maxQueueSize: { $0.effectiveCaptureAiMaxQueueSize }, flushIntervalSeconds: { $0.flushIntervalSeconds }, rateCapMax: { _ in 0 }, rateCapWindowSeconds: { _ in 0 }, encode: { event in guard let data = toJSONData(event.toJSON()) else { return nil } // Name and size only: AI properties can hold prompts and media. - if data.count > aiMaxEventBytes { - hedgeLog("Dropping AI event '\(event.event)' of \(data.count) bytes, over the \(aiMaxEventBytes) byte limit.") + if data.count > maxEventBytes { + hedgeLog("Dropping AI event '\(event.event)' of \(data.count) bytes, over the \(maxEventBytes) byte limit.") return nil } return data @@ -177,3 +177,23 @@ 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 + } + + /// Stored-size limit for an AI event: `captureAiMaxEventBytes`, capped at + /// PostHog's 8 MiB, plus the envelope allowance. + var captureAiMaxStoredEventBytes: Int { + let limit = Defaults.captureAiMaxEventBytes + let bytes = captureAiMaxEventBytes > 0 ? min(captureAiMaxEventBytes, limit) : limit + return bytes + aiEventEnvelopeBytes + } +} diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index cbcf374e4..f45553402 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -852,7 +852,7 @@ final class PostHogCaptureAiTest { server.stop() } - private func makeSDK(beforeSend: BeforeSendBlock? = nil) -> PostHogSDK { + 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 @@ -865,6 +865,7 @@ final class PostHogCaptureAiTest { config.setBeforeSend(beforeSend) } server.batchProjectToken = config.projectToken + configure(config) return PostHogSDK.with(config) } @@ -884,7 +885,7 @@ final class PostHogCaptureAiTest { #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 == PostHogApi.aiTimeout) + #expect(request.timeoutInterval == 60) let event = try #require(server.parsePostHogEvents(request).first) #expect(event.event == "$ai_generation") #expect(event.uuid.postHogUuidString == uuid) @@ -898,7 +899,7 @@ final class PostHogCaptureAiTest { 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 < PostHogApi.aiTimeout) + #expect(batchRequest.timeoutInterval < 60) #expect(server.aiRequests.count == 1) } @@ -913,6 +914,72 @@ final class PostHogCaptureAiTest { #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 8 MiB") + func aiSettingLimits() { + let config = PostHogConfig(projectToken: "phc_ai_settings") + #expect(config.effectiveCaptureAiMaxQueueSize == 100) + #expect(config.effectiveCaptureAiTimeout == 60) + #expect(config.captureAiMaxStoredEventBytes == 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.captureAiMaxStoredEventBytes == 1024 + aiEventEnvelopeBytes) + + config.captureAiMaxQueueSize = 0 + config.captureAiTimeout = -1 + config.captureAiMaxEventBytes = 0 + #expect(config.effectiveCaptureAiMaxQueueSize == 100) + #expect(config.effectiveCaptureAiTimeout == 60) + #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + + config.captureAiMaxEventBytes = 100 * 1024 * 1024 + #expect(config.captureAiMaxStoredEventBytes == 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 + aiEventEnvelopeBytes) + #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 without falling back to /batch") func notFoundIsTerminal() async throws { server.aiResponseHandler = { _, _ in HTTPStubsResponse(jsonObject: [:], statusCode: 404, headers: nil) } diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 96c6bc0ac..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 diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 5fefd76bd..3e4d44d4b 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -150,11 +150,12 @@ PostHogSDK.shared.captureAi("$ai_generation", properties: ["$ai_model": "gpt-5", In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`. -- An AI event can be up to 8 MiB. The SDK drops a larger event, and `captureAi` returns `nil`. +- An AI event can be up to 8 MiB. The SDK drops a larger event, and `captureAi` returns `nil`. Set `captureAiMaxEventBytes` to lower the limit. - 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. Other requests still time out after 10 seconds. -- AI events are queued on disk separately from other events. Up to 100 events and 50 MiB are kept, and the oldest are dropped first. `maxQueueSize` doesn't apply. -- AI events use `flushAt`, `maxBatchSize`, `flushIntervalSeconds` and `compression`, like other events. A request carries up to about 5 MiB, as for analytics events (see [Retries](#retries)). +- 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 From ee92eab7c2ceb32aa4e4c86b6d86d74c92294c3f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 14:54:16 -0400 Subject: [PATCH 10/18] fix: make captureAiMaxEventBytes limit the whole serialized event It capped properties but the SDK added 64 KiB on top of any value, so a lowered limit let much larger events through. Like posthog-python's capture_ai_max_event_bytes, it now limits the whole serialized event and defaults to 8 MiB plus 64 KiB. Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-capture-ai.md | 2 +- PostHog/PostHogConfig.swift | 12 +++++++----- PostHog/PostHogSDK.swift | 2 +- PostHog/QueueEndpoint+Factories.swift | 17 ++++++----------- PostHogTests/PostHogCaptureV1Test.swift | 12 ++++++------ docs/migration-3.x-to-4.0.md | 2 +- 6 files changed, 22 insertions(+), 25 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index b46089d20..4a24f8be0 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -8,4 +8,4 @@ Analytics batches are now also capped at 5 MiB per request, so a batch of large 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. +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. diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index d7d0aaedb..957a58d49 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -36,7 +36,8 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? static let featureFlagRequestMaxRetries: Int = 1 static let captureAiMaxQueueSize: Int = 100 static let captureAiTimeout: TimeInterval = 60 - static let captureAiMaxEventBytes: Int = 8 * 1024 * 1024 + /// 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. @@ -497,11 +498,12 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Default: `60`. @objc public var captureAiTimeout: TimeInterval = Defaults.captureAiTimeout - /// Maximum size, in bytes, of a `captureAi` event's properties. The SDK drops a larger event. + /// 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 over 8 MiB, so a larger value, or a value of - /// `0` or less, uses 8 MiB. - /// Default: `8388608` (8 MiB). + /// 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, or a value of `0` or less, + /// uses the default. + /// Default: `8454144` (8 MiB + 64 KiB). @objc public var captureAiMaxEventBytes: Int = Defaults.captureAiMaxEventBytes // only internal diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index 6b85a57d6..936fea4a2 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -1537,7 +1537,7 @@ let maxRetryDelay = 30.0 /// 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 by default) is dropped. PostHog drops an event + /// 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_*`. /// diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 7b50de1bd..41a67d741 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -23,11 +23,8 @@ func isCaptureV1RetriableStatusCode(_ code: Int) -> Bool { [408, 500, 502, 503, 504].contains(code) } -/// The stored event also holds its envelope, so allow 64 KiB on top of the -/// properties limit, as posthog-python and posthog-go do. -let aiEventEnvelopeBytes = 64 * 1024 -/// Stored-size limit for an AI event at the default `captureAiMaxEventBytes`. -let aiMaxEventBytes = PostHogConfig.Defaults.captureAiMaxEventBytes + aiEventEnvelopeBytes +/// 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 @@ -68,7 +65,7 @@ extension QueueEndpoint where Record == PostHogEvent { /// `captureAi`. Same retry policy as capture V1, with no `/batch` fallback. /// Batches by bytes as well as count, since AI events can be megabytes. static func ai(api: PostHogApi) -> QueueEndpoint { - let maxEventBytes = api.config.captureAiMaxStoredEventBytes + let maxEventBytes = api.config.effectiveCaptureAiMaxEventBytes return QueueEndpoint( storageKey: .aiQueue, oldStorageKeys: [], @@ -189,11 +186,9 @@ extension PostHogConfig { captureAiTimeout > 0 ? captureAiTimeout : Defaults.captureAiTimeout } - /// Stored-size limit for an AI event: `captureAiMaxEventBytes`, capped at - /// PostHog's 8 MiB, plus the envelope allowance. - var captureAiMaxStoredEventBytes: Int { + /// `captureAiMaxEventBytes`, capped at its default, or the default when it isn't positive. + var effectiveCaptureAiMaxEventBytes: Int { let limit = Defaults.captureAiMaxEventBytes - let bytes = captureAiMaxEventBytes > 0 ? min(captureAiMaxEventBytes, limit) : limit - return bytes + aiEventEnvelopeBytes + return captureAiMaxEventBytes > 0 ? min(captureAiMaxEventBytes, limit) : limit } } diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index f45553402..5a0d5d13c 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -937,29 +937,29 @@ final class PostHogCaptureAiTest { #expect(batchRequest.value(forHTTPHeaderField: "Content-Encoding") == "gzip") } - @Test("AI settings of 0 or less use their defaults, and captureAiMaxEventBytes can't exceed 8 MiB") + @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.captureAiMaxStoredEventBytes == aiMaxEventBytes) + #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.captureAiMaxStoredEventBytes == 1024 + aiEventEnvelopeBytes) + #expect(config.effectiveCaptureAiMaxEventBytes == 1024) config.captureAiMaxQueueSize = 0 config.captureAiTimeout = -1 config.captureAiMaxEventBytes = 0 #expect(config.effectiveCaptureAiMaxQueueSize == 100) #expect(config.effectiveCaptureAiTimeout == 60) - #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + #expect(config.effectiveCaptureAiMaxEventBytes == aiMaxEventBytes) config.captureAiMaxEventBytes = 100 * 1024 * 1024 - #expect(config.captureAiMaxStoredEventBytes == aiMaxEventBytes) + #expect(config.effectiveCaptureAiMaxEventBytes == aiMaxEventBytes) } @Test("drops an AI event over a lowered captureAiMaxEventBytes") @@ -974,7 +974,7 @@ final class PostHogCaptureAiTest { deleteSafely(storage.appFolderUrl) } - let overLimit = String(repeating: "a", count: 1024 + aiEventEnvelopeBytes) + 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) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 3e4d44d4b..8ce87d88b 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -150,7 +150,7 @@ PostHogSDK.shared.captureAi("$ai_generation", properties: ["$ai_model": "gpt-5", In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:options:`. -- An AI event can be up to 8 MiB. The SDK drops a larger event, and `captureAi` returns `nil`. Set `captureAiMaxEventBytes` to lower the limit. +- 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. From 8ac03d030f70bdaff8a08c11bb7d41cce31e9480 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 9 Oct 2026 22:15:10 -0400 Subject: [PATCH 11/18] feat!: remove the /batch fallback from capture V1 Analytics events now only go to /i/v1/analytics/events. A 404 is terminal like any other non-retriable status, matching posthog-python, posthog-go and posthog-rs, instead of resending the batch to /batch for the session. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v4-capture-v1.md | 2 +- PostHog/PostHogApi.swift | 75 +----------------- PostHog/QueueEndpoint+Factories.swift | 23 +++--- PostHogTests/PostHogApiTest.swift | 66 ++++++++-------- PostHogTests/PostHogCaptureV1Test.swift | 76 ++----------------- PostHogTests/PostHogQueueTest.swift | 2 +- PostHogTests/PostHogSDKTest.swift | 2 +- .../TestUtils/MockPostHogServer.swift | 25 ++---- .../Sources/RequestInterceptor.swift | 3 +- 9 files changed, 62 insertions(+), 212 deletions(-) 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 f17a9fd87..eb92a8551 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( @@ -70,9 +70,6 @@ class PostHogApi { private let captureV1Lock = NSLock() // Keyed by endpoint path, so analytics and AI retries keep separate identities. private var captureV1PendingRetry: [String: CaptureV1RequestIdentity] = [:] - // Set when V1 returns 404 so the rest of this session uses `/batch`. - // Remove with /batch. - private var captureV1Unavailable = false init(_ config: PostHogConfig) { self.config = config @@ -107,7 +104,7 @@ class PostHogApi { } /// `gzipped: true` adds `Content-Encoding: gzip` for upload endpoints - /// (/batch, /s/, /i/v1/logs) whose bodies are gzipped. + /// (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 @@ -192,51 +189,16 @@ 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 and per-event results as `captureV1`, but a 404 is - /// terminal: AI events never fall back to `/batch`. + /// 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) } @@ -280,20 +242,6 @@ 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, path == Self.captureV1AnalyticsPath { - let firstFallback = self.captureV1Lock.withLock { - defer { self.captureV1Unavailable = true } - self.captureV1PendingRetry[path] = 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, path: path, data: data, uuids: uuids, identity: identity)) } }.resume() @@ -381,21 +329,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/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 41a67d741..83205c4e0 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) } @@ -32,9 +31,9 @@ let captureV1MaxBatchBytes = 5 * 1024 * 1024 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. Batches by bytes as well - /// as count, so a batch of large events stays under the request limit. + /// 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, @@ -54,15 +53,12 @@ extension QueueEndpoint where Record == PostHogEvent { 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 ) } /// Capture V1 AI endpoint (`/i/v1/ai/events`) for events sent with - /// `captureAi`. Same retry policy as capture V1, with no `/batch` fallback. + /// `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 @@ -97,8 +93,7 @@ extension QueueEndpoint where Record == PostHogEvent { ) } - /// `/snapshot` endpoint for session-replay snapshots. Shares its retry - /// policy with `/batch`. + /// `/snapshot` endpoint for session-replay snapshots. static func snapshot(api: PostHogApi) -> QueueEndpoint { QueueEndpoint( storageKey: .replayQeueue, @@ -121,7 +116,7 @@ extension QueueEndpoint where Record == PostHogEvent { send: { events, completion in api.snapshot(events: events, completion: completion) }, - isRetriableStatusCode: isEventsRetriableStatusCode + isRetriableStatusCode: isSnapshotRetriableStatusCode ) } } 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 5a0d5d13c..c1f5b662e 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -184,58 +184,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 +424,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]) @@ -980,7 +917,7 @@ final class PostHogCaptureAiTest { #expect(queue.depth == 1) } - @Test("a 404 from the AI endpoint drops the batch without falling back to /batch") + @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") @@ -1000,7 +937,6 @@ final class PostHogCaptureAiTest { #expect(queue.depth == 0) #expect(server.aiRequests.count == 1) #expect(server.batchRequests.isEmpty) - #expect(api.usesCaptureV1) } @Test("drops an AI event over the size limit when it is queued") 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/TestUtils/MockPostHogServer.swift b/PostHogTests/TestUtils/MockPostHogServer.swift index 63988a277..d4a64a31d 100644 --- a/PostHogTests/TestUtils/MockPostHogServer.swift +++ b/PostHogTests/TestUtils/MockPostHogServer.swift @@ -84,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. @@ -95,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 } } @@ -153,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). @@ -425,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. @@ -630,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) @@ -701,7 +686,7 @@ class MockPostHogServer { 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/sdk_compliance_adapter/Sources/RequestInterceptor.swift b/sdk_compliance_adapter/Sources/RequestInterceptor.swift index 416a9cea3..778b829c8 100644 --- a/sdk_compliance_adapter/Sources/RequestInterceptor.swift +++ b/sdk_compliance_adapter/Sources/RequestInterceptor.swift @@ -106,7 +106,6 @@ class RequestInterceptor: URLProtocol { // Intercept capture endpoints, but not /flags/ or /config let urlString = url.absoluteString if urlString.contains("/i/v1/analytics/events") || urlString.contains("/i/v1/ai/events") - || urlString.contains("/batch") || urlString.contains("/e/") || urlString.contains("/s/"), !urlString.contains("/flags"), !urlString.contains("/config") @@ -230,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") From a35f65cc879c39511957d60556afce04f9f82192 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 20 Oct 2026 22:49:14 -0400 Subject: [PATCH 12/18] chore: stop ignoring /batch in replay network capture The SDK no longer sends to /batch, so replay doesn't need to filter it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Network/PostHogSessionReplayNetworkPlugin.swift | 2 +- PostHogTests/PostHogSessionReplayPluginTest.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift index 3934c29f9..477c4fa01 100644 --- a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift +++ b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift @@ -101,7 +101,7 @@ } let path = strippingAPIHostPathPrefix(from: url.path, apiHostPath: apiHost.path) - let ignoredPaths = [snapshotEndpoint, "/batch", "/e/", "/i/"] + let ignoredPaths = [snapshotEndpoint, "/e/", "/i/"] return ignoredPaths.contains { ignoredPath in pathMatches(path, ignoredPath: ignoredPath) diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index 148c3f049..943e49767 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -153,8 +153,8 @@ expected: true ), IgnoredNetworkRequestCase( - name: "ignores batch ingestion on the configured host", - requestURL: "https://us.i.posthog.com/batch", + 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 @@ -167,8 +167,8 @@ expected: true ), IgnoredNetworkRequestCase( - name: "ignores batch ingestion behind a reverse proxy path", - requestURL: "https://app.posthog.com/ingest/batch", + 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 From 9c7b166053799fca58b61cf7798b5cd99264406d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 13:37:54 -0400 Subject: [PATCH 13/18] docs: drop the /batch fallback from the migration guide A 404 from capture v1 now drops the events, as the changeset says. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/migration-3.x-to-4.0.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 8ce87d88b..543c4ef6a 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -28,7 +28,7 @@ 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 | @@ -43,11 +43,10 @@ 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. +If you use a reverse proxy or self-host PostHog, make sure `/i/v1/analytics/events` is served before you upgrade. PostHog Cloud (`us.i.posthog.com`, `eu.i.posthog.com`) serves it. -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. +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 @@ -137,7 +136,6 @@ 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 @@ -168,4 +166,3 @@ In Objective-C, use `captureAiWithEvent:distinctId:properties:userProperties:use - `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. From 65b2cc38ce5e8f7614c0d4d2065cc6b43b214a7d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 14:52:20 -0400 Subject: [PATCH 14/18] fix: keep ignoring /batch in replay network capture React Native apps record replay through posthog-ios while their JS SDK still posts to /batch, so those requests would show up in recordings. Co-Authored-By: Claude Opus 5.5 --- .../PostHogSessionReplayNetworkPlugin.swift | 4 +++- PostHogTests/PostHogSessionReplayPluginTest.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift index 477c4fa01..58f21bd38 100644 --- a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift +++ b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift @@ -101,7 +101,9 @@ } let path = strippingAPIHostPathPrefix(from: url.path, apiHostPath: apiHost.path) - let ignoredPaths = [snapshotEndpoint, "/e/", "/i/"] + // 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 pathMatches(path, ignoredPath: ignoredPath) diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index 943e49767..55b493b36 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -159,6 +159,13 @@ snapshotEndpoint: "/s/", expected: true ), + IgnoredNetworkRequestCase( + name: "ignores batch ingestion on the configured host", + requestURL: "https://us.i.posthog.com/batch", + apiHost: "https://us.i.posthog.com", + snapshotEndpoint: "/s/", + expected: true + ), IgnoredNetworkRequestCase( name: "ignores replay ingestion behind a reverse proxy path", requestURL: "https://app.posthog.com/ingest/s/?ip=1", @@ -173,6 +180,13 @@ snapshotEndpoint: "/s/", expected: true ), + IgnoredNetworkRequestCase( + name: "ignores batch ingestion behind a reverse proxy path", + requestURL: "https://app.posthog.com/ingest/batch", + apiHost: "https://app.posthog.com/ingest", + snapshotEndpoint: "/s/", + expected: true + ), IgnoredNetworkRequestCase( name: "ignores a remotely configured snapshot endpoint", requestURL: "https://us.i.posthog.com/newS/?ip=1", From 34791640f8c8a61659278d66baaa16fe310a3ad1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 09:49:46 -0400 Subject: [PATCH 15/18] docs: list the reverse proxy requirements for capture v1 Co-Authored-By: Claude Opus 5.5 --- docs/migration-3.x-to-4.0.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/migration-3.x-to-4.0.md b/docs/migration-3.x-to-4.0.md index 543c4ef6a..3a71f5bb7 100644 --- a/docs/migration-3.x-to-4.0.md +++ b/docs/migration-3.x-to-4.0.md @@ -43,8 +43,16 @@ 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 or self-host PostHog, make sure `/i/v1/analytics/events` is served 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. + +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. From 949225e494cef977391ed169b4faf541d1258e72 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 09:55:38 -0400 Subject: [PATCH 16/18] test(compliance): disable AI compression when enable_compression is false Co-Authored-By: Claude Opus 5.5 --- sdk_compliance_adapter/Sources/main.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 11df198fa..ccbbd65e3 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -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 From b6c4c9114e15d73792ddedf4688ba756c132696d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:47:52 -0400 Subject: [PATCH 17/18] fix: log capture V1 drop reasons and the captureAiMaxEventBytes clamp - Log each event a 2xx drops at debug level with its name, UUID and the server's details (e.g. ai_event_too_big), never its properties. - Check the AI event size limit in the queue via maxRecordBytes, so an oversized event logs only the size line, not "unserialisable record". - Warn once at setup when captureAiMaxEventBytes is over the ceiling. Co-Authored-By: Claude Opus 5.5 --- .changeset/v4-capture-ai.md | 2 + PostHog/PostHogApi.swift | 14 +++-- PostHog/PostHogQueue.swift | 5 ++ PostHog/QueueEndpoint+Factories.swift | 15 ++--- PostHog/QueueEndpoint.swift | 4 ++ .../ApplicationViewLayoutPublisherTest.swift | 20 ------ PostHogTests/PostHogCaptureV1Test.swift | 62 ++++++++++++++++++- PostHogTests/TestUtils/TestPostHog.swift | 22 +++++++ 8 files changed, 108 insertions(+), 36 deletions(-) diff --git a/.changeset/v4-capture-ai.md b/.changeset/v4-capture-ai.md index 4a24f8be0..ab38dbb58 100644 --- a/.changeset/v4-capture-ai.md +++ b/.changeset/v4-capture-ai.md @@ -9,3 +9,5 @@ Analytics batches are now also capped at 5 MiB per request, so a batch of large 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/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index f17a9fd87..3443234f7 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -251,6 +251,7 @@ class PostHogApi { } let uuids = events.map(\.uuid.postHogUuidString) + let eventNames = Dictionary(events.map { ($0.uuid.postHogUuidString, $0.event) }, uniquingKeysWith: { first, _ in first }) let identity = nextCaptureV1Identity(path: path, uuids: Set(uuids)) let toSend: [String: Any] = [ @@ -294,7 +295,7 @@ class PostHogApi { return self.batch(events: events, completion: completion) } - completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity)) + completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity, eventNames: eventNames)) } }.resume() } @@ -316,13 +317,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 @@ -344,6 +347,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 } @@ -353,7 +360,6 @@ 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.") } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 722e62377..4b3343ffb 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -405,6 +405,11 @@ 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 } diff --git a/PostHog/QueueEndpoint+Factories.swift b/PostHog/QueueEndpoint+Factories.swift index 41a67d741..7776fa3d9 100644 --- a/PostHog/QueueEndpoint+Factories.swift +++ b/PostHog/QueueEndpoint+Factories.swift @@ -66,6 +66,10 @@ extension QueueEndpoint where Record == PostHogEvent { /// 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: [], @@ -76,19 +80,12 @@ extension QueueEndpoint where Record == PostHogEvent { flushIntervalSeconds: { $0.flushIntervalSeconds }, rateCapMax: { _ in 0 }, rateCapWindowSeconds: { _ in 0 }, - encode: { event in - guard let data = toJSONData(event.toJSON()) else { return nil } - // Name and size only: AI properties can hold prompts and media. - if data.count > maxEventBytes { - hedgeLog("Dropping AI event '\(event.event)' of \(data.count) bytes, over the \(maxEventBytes) byte limit.") - return nil - } - return data - }, + 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) diff --git a/PostHog/QueueEndpoint.swift b/PostHog/QueueEndpoint.swift index e64c7f31e..3886d84ca 100644 --- a/PostHog/QueueEndpoint.swift +++ b/PostHog/QueueEndpoint.swift @@ -72,6 +72,10 @@ struct QueueEndpoint { /// 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? 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/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index 5a0d5d13c..aec4c1f55 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -136,6 +136,31 @@ 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] @@ -1003,8 +1028,8 @@ final class PostHogCaptureAiTest { #expect(api.usesCaptureV1) } - @Test("drops an AI event over the size limit when it is queued") - func dropsOversizedEvent() { + @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) @@ -1015,9 +1040,40 @@ final class PostHogCaptureAiTest { } #expect(QueueEndpoint.ai(api: PostHogApi(config)).maxQueueBytes == aiMaxQueueBytes) + let wasLogging = hedgeLogEnabled + hedgeLogEnabled = true + defer { hedgeLogEnabled = wasLogging } let big = String(repeating: "a", count: aiMaxEventBytes) - #expect(!queue.add(PostHogEvent(event: "$ai_generation", distinctId: "user", properties: ["$ai_input": big]))) + 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") 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, From 5a584cb80509082b1ba29c3a7a66f37ef6f594db Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 11:48:26 -0400 Subject: [PATCH 18/18] docs: note the captureAiMaxEventBytes clamp warning Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogConfig.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 957a58d49..ae324128d 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -501,8 +501,8 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// 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, or a value of `0` or less, - /// uses 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