Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/v4-capture-ai.md
Original file line number Diff line number Diff line change
@@ -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. It returns the event UUID.
34 changes: 25 additions & 9 deletions PostHog/PostHogApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -222,8 +223,21 @@ 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))
}
Expand All @@ -232,7 +246,7 @@ class PostHogApi {
var seenUuids = Set<String>()
let uniqueEvents = events.filter { seenUuids.insert($0.uuid.postHogUuidString).inserted }
let uuids = uniqueEvents.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,
Expand All @@ -257,10 +271,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 {
Expand All @@ -270,7 +284,8 @@ class PostHogApi {
}

completion(self.captureV1Result(
info, data: data, uuids: uuids, identity: identity, duplicates: events.count - uniqueEvents.count
info, path: path, data: data, uuids: uuids, identity: identity,
duplicates: events.count - uniqueEvents.count
))
}
}.resume()
Expand All @@ -279,9 +294,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<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 @@ -296,6 +311,7 @@ class PostHogApi {
/// the next request retries under the same identity.
private func captureV1Result(
_ info: PostHogUploadInfo,
path: String,
data: Data?,
uuids: [String],
identity: CaptureV1RequestIdentity,
Expand Down Expand Up @@ -335,7 +351,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
)
}
Expand Down
74 changes: 61 additions & 13 deletions PostHog/PostHogFileBackedQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand All @@ -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, [])
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand Down
17 changes: 13 additions & 4 deletions PostHog/PostHogQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ class PostHogQueue<Record> {
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))
}
Expand All @@ -134,7 +135,8 @@ class PostHogQueue<Record> {
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))
}
Expand Down Expand Up @@ -363,8 +365,8 @@ class PostHogQueue<Record> {

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()
Expand Down Expand Up @@ -461,6 +463,7 @@ class PostHogQueue<Record> {
var selectedIds: [String] = []
// Entry ID -> `endpoint.recordId`, for removing all but retried records.
var recordIds: [String: String] = [:]
var batchBytes = 0
var next = start
while next < entries.count {
let entry = entries[next]
Expand All @@ -471,6 +474,12 @@ class PostHogQueue<Record> {
{
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)
}
Expand Down
Loading
Loading