Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
042b7bf
feat: add captureAi on the capture V1 AI endpoint
turnipdabeets Oct 10, 2026
3d8bec7
feat: cap AI queue disk use and return the event UUID from captureAi
turnipdabeets Oct 10, 2026
af822d8
fix: flush and bound AI queue reads by batch bytes, and flush it on c…
turnipdabeets Oct 20, 2026
d384209
chore: return success from the compliance adapter's /capture_ai
turnipdabeets Oct 20, 2026
27ec307
feat(queue): cap analytics batches at 5 MiB
turnipdabeets Oct 22, 2026
800e920
feat: give AI requests a 60 s timeout and log non-$ai_ captureAi names
turnipdabeets Oct 22, 2026
a0a729c
docs: note the 5 MiB analytics batch cap in the migration guide
turnipdabeets Oct 23, 2026
cf6c2ae
docs: add an AI events section to the migration guide
turnipdabeets Oct 23, 2026
a58da3c
feat: add captureAi settings to PostHogConfig
turnipdabeets Oct 23, 2026
ee92eab
fix: make captureAiMaxEventBytes limit the whole serialized event
turnipdabeets Oct 26, 2026
8ac03d0
feat!: remove the /batch fallback from capture V1
turnipdabeets Oct 10, 2026
a35f65c
chore: stop ignoring /batch in replay network capture
turnipdabeets Oct 21, 2026
9c7b166
docs: drop the /batch fallback from the migration guide
turnipdabeets Oct 23, 2026
65b2cc3
fix: keep ignoring /batch in replay network capture
turnipdabeets Oct 26, 2026
3479164
docs: list the reverse proxy requirements for capture v1
turnipdabeets Oct 27, 2026
949225e
test(compliance): disable AI compression when enable_compression is f…
turnipdabeets Oct 27, 2026
b6c4c91
fix: log capture V1 drop reasons and the captureAiMaxEventBytes clamp
turnipdabeets Oct 27, 2026
5a584cb
docs: note the captureAiMaxEventBytes clamp warning
turnipdabeets Oct 27, 2026
eb60169
Merge remote-tracking branch 'origin/feat/v4-capture-ai' into feat/v4…
turnipdabeets Oct 27, 2026
9881b2e
Merge branch 'feat/v4-capture-v1' into feat/v4-capture-ai
turnipdabeets Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/v4-capture-ai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'posthog-ios': minor
---

Add `captureAi(...)` to send AI events such as `$ai_generation` to PostHog's AI endpoint (`/i/v1/ai/events`), which accepts events up to 8 MiB. It returns the event UUID.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 4.0 guide has no section for captureAi yet. Can this PR add one covering the AI endpoint, the 8 MiB event limit, the 60-second timeout and the new 5 MiB analytics batch cap? How wrapper SDKs such as Flutter expose AI capture is still being worked out for all of them at once, so for now the guide can describe only the native iOS API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added in 5880463


Analytics batches are now also capped at 5 MiB per request, so a batch of large events stays under capture V1's request limit; an event larger than that is sent on its own.

AI requests time out after 60 seconds without network activity, instead of 10, since they can carry several MB.

Add `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` to `PostHogConfig` to tune AI requests separately from other events. The defaults keep the behavior above: gzip, 100 queued events, 60 seconds and 8 MiB plus 64 KiB per serialized event.

With `debug` on, the SDK logs the name, UUID and reason of each event PostHog drops, and warns when `captureAiMaxEventBytes` is set above its 8 MiB plus 64 KiB ceiling, which it uses instead.
2 changes: 1 addition & 1 deletion .changeset/v4-capture-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
139 changes: 51 additions & 88 deletions PostHog/PostHogApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -51,7 +51,7 @@ func processUploadResponse(
class PostHogApi {
static var gzipData: (Data) throws -> Data = { try $0.gzipped() }

private let config: PostHogConfig
let config: PostHogConfig

/// Snapshot at init; treated as immutable after setup.
private let customRequestHeaders: [String: String]
Expand All @@ -68,10 +68,8 @@ class PostHogApi {
/// Guards the capture V1 state below. In memory only: a new request ID
/// after an app restart is fine, since the server only logs it.
private let captureV1Lock = NSLock()
private var captureV1PendingRetry: CaptureV1RequestIdentity?
// Set when V1 returns 404 so the rest of this session uses `/batch`.
// Remove with /batch.
private var captureV1Unavailable = false
// Keyed by endpoint path, so analytics and AI retries keep separate identities.
private var captureV1PendingRetry: [String: CaptureV1RequestIdentity] = [:]

init(_ config: PostHogConfig) {
self.config = config
Expand Down Expand Up @@ -106,11 +104,11 @@ class PostHogApi {
}

/// `gzipped: true` adds `Content-Encoding: gzip` for upload endpoints
/// (/batch, /s/, /i/v1/logs) whose bodies are gzipped.
private func getURLRequest(_ url: URL, gzipped: Bool = false, httpMethod: String = "POST") -> URLRequest {
/// (capture V1, /s/, /i/v1/logs) whose bodies are gzipped.
private func getURLRequest(_ url: URL, gzipped: Bool = false, httpMethod: String = "POST", timeout: TimeInterval? = nil) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = httpMethod
request.timeoutInterval = defaultTimeout
request.timeoutInterval = timeout ?? defaultTimeout
applyCustomHeaders(&request)
if gzipped {
request.setValue("gzip", forHTTPHeaderField: "Content-Encoding")
Expand All @@ -137,15 +135,18 @@ class PostHogApi {
"posthog-sdk-info", "posthog-attempt", "posthog-request-id", "posthog-request-timestamp",
]

private func requestAndPayload(url: URL, data: Data, endpointName: String, httpMethod: String = "POST") -> (URLRequest, Data) {
guard config.compression == .gzip else {
return (getURLRequest(url, httpMethod: httpMethod), data)
private func requestAndPayload(
url: URL, data: Data, endpointName: String, httpMethod: String = "POST", timeout: TimeInterval? = nil,
compression: PostHogCompression? = nil
) -> (URLRequest, Data) {
guard (compression ?? config.compression) == .gzip else {
return (getURLRequest(url, httpMethod: httpMethod, timeout: timeout), data)
}
do {
return (getURLRequest(url, gzipped: true, httpMethod: httpMethod), try Self.gzipData(data))
return (getURLRequest(url, gzipped: true, httpMethod: httpMethod, timeout: timeout), try Self.gzipData(data))
} catch {
hedgeLog("Error gzipping the \(endpointName) body, sending it uncompressed: \(error).")
return (getURLRequest(url, httpMethod: httpMethod), data)
return (getURLRequest(url, httpMethod: httpMethod, timeout: timeout), data)
}
}

Expand Down Expand Up @@ -188,53 +189,32 @@ class PostHogApi {
return request
}

func batch(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) {
guard let url = getEndpointURL("/batch", relativeTo: config.host) else {
hedgeLog("Malformed batch URL error.")
return completion(PostHogUploadInfo(statusCode: nil, error: nil))
}

let toSend: [String: Any] = [
// Wire field name remains api_key, but it carries the PostHog project token.
"api_key": config.projectToken,
"batch": events.map(Self.batchJSON),
"sent_at": toISO8601String(Date()),
]

guard let data = try? JSONSerialization.data(withJSONObject: toSend) else {
hedgeLog("Error parsing the batch body")
return completion(PostHogUploadInfo(statusCode: nil, error: nil))
}

let (request, payload) = requestAndPayload(url: url, data: data, endpointName: "batch")

session.uploadTask(with: request, from: payload) { data, response, error in
processUploadResponse(endpointName: "batch", data: data, response: response, error: error, completion: completion)
}.resume()
}

/// Whether analytics events still go to capture V1 this session.
var usesCaptureV1: Bool {
captureV1Lock.withLock { !captureV1Unavailable }
}

/// POSTs analytics events to capture V1 (`/i/v1/analytics/events`).
///
/// The completion's `retryRecordIds` holds the UUIDs of events a 200 asked
/// to retry. A 404 resends the batch through `/batch` and keeps using it for
/// the rest of the session.
/// to retry.
func captureV1(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) {
guard usesCaptureV1 else {
return batch(events: events, completion: completion)
}
postCaptureV1(path: Self.captureV1AnalyticsPath, events: events, completion: completion)
}

/// POSTs AI events to the capture V1 AI endpoint (`/i/v1/ai/events`).
/// Same wire format, per-event results and retry policy as `captureV1`.
func captureAi(events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) {
postCaptureV1(path: Self.captureV1AiPath, events: events, completion: completion)
}

guard let url = getEndpointURL("/i/v1/analytics/events", relativeTo: config.host) else {
static let captureV1AnalyticsPath = "/i/v1/analytics/events"
static let captureV1AiPath = "/i/v1/ai/events"

private func postCaptureV1(path: String, events: [PostHogEvent], completion: @escaping (PostHogUploadInfo) -> Void) {
guard let url = getEndpointURL(path, relativeTo: config.host) else {
hedgeLog("Malformed capture URL error.")
return completion(PostHogUploadInfo(statusCode: nil, error: nil))
}

let uuids = events.map(\.uuid.postHogUuidString)
let identity = nextCaptureV1Identity(uuids: Set(uuids))
let eventNames = Dictionary(events.map { ($0.uuid.postHogUuidString, $0.event) }, uniquingKeysWith: { first, _ in first })
let identity = nextCaptureV1Identity(path: path, uuids: Set(uuids))

let toSend: [String: Any] = [
"created_at": identity.createdAt,
Expand All @@ -246,7 +226,13 @@ class PostHogApi {
return completion(PostHogUploadInfo(statusCode: nil, error: nil))
}

var (request, payload) = requestAndPayload(url: url, data: data, endpointName: "capture")
// AI batches can be several MB, so they get their own timeout and compression.
let isAi = path == Self.captureV1AiPath
var (request, payload) = requestAndPayload(
url: url, data: data, endpointName: "capture",
timeout: isAi ? config.effectiveCaptureAiTimeout : nil,
compression: isAi ? config.captureAiCompression : nil
)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(config.projectToken)", forHTTPHeaderField: "Authorization")
request.setValue("\(postHogSdkName)/\(postHogVersion)", forHTTPHeaderField: "PostHog-Sdk-Info")
Expand All @@ -257,31 +243,17 @@ class PostHogApi {
session.uploadTask(with: request, from: payload) { [weak self] data, response, error in
processUploadResponse(endpointName: "capture", data: data, response: response, error: error) { info in
guard let self else { return completion(info) }

// iOS-only fallback for hosts without capture V1. Remove with /batch.
if info.statusCode == 404 {
let firstFallback = self.captureV1Lock.withLock {
defer { self.captureV1Unavailable = true }
self.captureV1PendingRetry = nil
return !self.captureV1Unavailable
}
if firstFallback {
hedgeLog("Capture V1 returned 404, using /batch for the rest of this session.")
}
return self.batch(events: events, completion: completion)
}

completion(self.captureV1Result(info, data: data, uuids: uuids, identity: identity))
completion(self.captureV1Result(info, path: path, data: data, uuids: uuids, identity: identity, eventNames: eventNames))
}
}.resume()
}

/// 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<String>) -> CaptureV1RequestIdentity {
private func nextCaptureV1Identity(path: String, uuids: Set<String>) -> 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
)
Expand All @@ -293,12 +265,15 @@ class PostHogApi {
}

/// Reads the per-event results of a 2xx and records which events, if any,
/// the next request retries under the same identity.
/// the next request retries under the same identity. `eventNames` maps
/// UUIDs to event names for the per-event drop log.
func captureV1Result(
_ info: PostHogUploadInfo,
path: String,
data: Data?,
uuids: [String],
identity: CaptureV1RequestIdentity
identity: CaptureV1RequestIdentity,
eventNames: [String: String] = [:]
) -> PostHogUploadInfo {
var retryUuids: Set<String> = []
var dropped = 0
Expand All @@ -320,6 +295,10 @@ class PostHogApi {
retryUuids.insert(uuid)
case "drop":
dropped += 1
// Debug only. Name, UUID, and the server's reason; never content or properties.
let name = eventNames[uuid].map { "'\($0)'" } ?? "event"
let details = entry["details"] as? String ?? "no details"
hedgeLog("Capture dropped \(name) (\(uuid)): \(details).")
default:
break
}
Expand All @@ -329,13 +308,12 @@ class PostHogApi {
retryUuids = Set(uuids)
}

// Counts only: event UUIDs and server details can identify users.
if dropped > 0 || !retryUuids.isEmpty {
hedgeLog("Capture: \(dropped) dropped, \(retryUuids.count) to retry.")
}

captureV1Lock.withLock {
captureV1PendingRetry = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity(
captureV1PendingRetry[path] = retryUuids.isEmpty ? nil : CaptureV1RequestIdentity(
uuids: retryUuids, requestId: identity.requestId, attempt: identity.attempt, createdAt: identity.createdAt
)
}
Expand All @@ -357,21 +335,6 @@ class PostHogApi {
("$process_person_profile", "process_person_profile"),
]

/// Shapes a stored event for `/batch`, which has no options: each option
/// goes back into its legacy property and wins over it. Remove with /batch.
private static func batchJSON(_ event: PostHogEvent) -> [String: Any] {
var json = event.toJSON()
guard let options = json.removeValue(forKey: "options") as? [String: Any] else { return json }
var properties = event.properties
for (property, option) in legacyOptionProperties {
if let value = options[option], !(value is NSNull) {
properties[property] = value
}
}
json["properties"] = properties
return json
}

/// Shapes a stored event for capture V1. `$session_id` and `$window_id`
/// move to the event root, and `$lib`/`$lib_version` are dropped because
/// the server reads them from `PostHog-Sdk-Info`.
Expand Down
31 changes: 31 additions & 0 deletions PostHog/PostHogConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent?
static let flushIntervalSeconds: TimeInterval = 30
static let pushNotificationSubscriptionMaxRetries: Int = 3
static let featureFlagRequestMaxRetries: Int = 1
static let captureAiMaxQueueSize: Int = 100
static let captureAiTimeout: TimeInterval = 60
/// PostHog's 8 MiB AI properties limit, plus 64 KiB for the rest of the event.
static let captureAiMaxEventBytes: Int = 8 * 1024 * 1024 + 64 * 1024
}

/// Network connectivity mode required before queued data may be flushed.
Expand Down Expand Up @@ -475,6 +479,33 @@ public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent?
/// Default: `.gzip`.
@objc public var compression: PostHogCompression = .gzip

/// How the SDK compresses `captureAi` request bodies, separately from `compression`.
///
/// Default: `.gzip`.
@objc public var captureAiCompression: PostHogCompression = .gzip

/// Maximum number of `captureAi` events kept in their on-disk queue before older events are dropped.
///
/// AI events can be megabytes, so this is lower than `maxQueueSize`. The queue also keeps at most
/// 50 MiB of AI events, whatever this is set to. A value of `0` or less uses the default.
/// Default: `100`.
@objc public var captureAiMaxQueueSize: Int = Defaults.captureAiMaxQueueSize

/// How long, in seconds, a `captureAi` request can go without network activity before it times out.
///
/// AI requests can carry several MB, so this is longer than for other requests, which time out
/// after 10 seconds. A value of `0` or less uses the default.
/// Default: `60`.
@objc public var captureAiTimeout: TimeInterval = Defaults.captureAiTimeout

/// Maximum size, in bytes, of a serialized `captureAi` event. The SDK drops a larger event.
///
/// Use it to lower the limit. PostHog drops AI events whose properties are over 8 MiB, so the default
/// allows 8 MiB plus 64 KiB for the rest of the event. A larger value uses the default and logs a
/// warning when `debug` is on. A value of `0` or less uses the default.
/// Default: `8454144` (8 MiB + 64 KiB).
@objc public var captureAiMaxEventBytes: Int = Defaults.captureAiMaxEventBytes

// only internal
var disableReachabilityForTesting: Bool = false
var disableQueueTimerForTesting: Bool = false
Expand Down
Loading
Loading