From 3579b928237101eb2343f637d428a0a627b3fd94 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 12:04:45 -0400 Subject: [PATCH] fix: flush analytics once a batch's worth of bytes is queued The early flush at maxBatchBytes read the file queue's byteCount, which was only tracked when a disk byte cap (maxQueueBytes) was set. Analytics has a batch byte limit but no disk cap, so byteCount stayed 0 and large events waited for flushAt or the timer. Track file sizes whenever the endpoint has a batch byte limit, without adding eviction. Co-Authored-By: Claude Opus 5.5 --- PostHog/PostHogFileBackedQueue.swift | 13 ++++++---- PostHog/PostHogQueue.swift | 6 +++-- PostHogTests/PostHogCaptureV1Test.swift | 24 +++++++++++++++++++ .../PostHogFileBackedQueueAlignmentTest.swift | 17 +++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index ed0367801..9ecb3db68 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -18,8 +18,10 @@ class PostHogFileBackedQueue { /// FIFO limit on the total size of the stored files, on top of `maxSize`. /// The newest entry is always kept, even when it's larger on its own. private let maxBytes: Int? + /// Whether file sizes are tracked: always with `maxBytes`, or on request. + private let trackBytes: Bool private var items = [String]() - /// File sizes by id, tracked only when `maxBytes` is set. Guarded by `itemsLock`. + /// File sizes by id, tracked only when `trackBytes` is set. Guarded by `itemsLock`. private var sizes = [String: Int]() private var totalBytes = 0 private let itemsLock = NSLock() @@ -28,15 +30,16 @@ class PostHogFileBackedQueue { itemsLock.withLock { items.count } } - /// Total size of the stored files. Tracked only when `maxBytes` is set, 0 otherwise. + /// Total size of the stored files. Tracked only when `trackBytes` is set, 0 otherwise. var byteCount: Int { itemsLock.withLock { totalBytes } } - init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil, maxBytes: Int? = nil) { + init(queue: URL, oldQueues: [URL] = [], maxSize: Int? = nil, maxBytes: Int? = nil, trackBytes: Bool = false) { self.queue = queue self.maxSize = maxSize.map { max(1, $0) } self.maxBytes = maxBytes.map { max(1, $0) } + self.trackBytes = trackBytes || maxBytes != nil setup(oldQueues: oldQueues) } @@ -167,7 +170,7 @@ class PostHogFileBackedQueue { items = Array(sortedItems.dropFirst(overflow)) sizes = [:] totalBytes = 0 - if maxBytes != nil { + if trackBytes { for item in items { let attributes = try? FileManager.default.attributesOfItem(atPath: queue.appendingPathComponent(item).path) track(item, size: (attributes?[.size] as? NSNumber)?.intValue ?? 0) @@ -248,7 +251,7 @@ class PostHogFileBackedQueue { /// Caller must hold `itemsLock`. private func track(_ id: String, size: Int) { - guard maxBytes != nil else { return } + guard trackBytes else { return } sizes[id] = size totalBytes += size } diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 4b3343ffb..8f4f8c02d 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -155,7 +155,8 @@ class PostHogQueue { queue: storage.url(forKey: endpoint.storageKey), oldQueues: endpoint.oldStorageKeys.map { storage.url(forKey: $0) }, maxSize: configuredMaxQueueSize, - maxBytes: endpoint.maxQueueBytes + maxBytes: endpoint.maxQueueBytes, + trackBytes: endpoint.maxBatchBytes != nil ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } @@ -175,7 +176,8 @@ class PostHogQueue { queue: storage.url(forKey: endpoint.storageKey), oldQueues: endpoint.oldStorageKeys.map { storage.url(forKey: $0) }, maxSize: configuredMaxQueueSize, - maxBytes: endpoint.maxQueueBytes + maxBytes: endpoint.maxQueueBytes, + trackBytes: endpoint.maxBatchBytes != nil ) dispatchQueue = DispatchQueue(label: endpoint.dispatchQueueLabel, target: .global(qos: .utility)) } diff --git a/PostHogTests/PostHogCaptureV1Test.swift b/PostHogTests/PostHogCaptureV1Test.swift index ee1bd6625..632d8abf0 100644 --- a/PostHogTests/PostHogCaptureV1Test.swift +++ b/PostHogTests/PostHogCaptureV1Test.swift @@ -798,6 +798,30 @@ final class PostHogCaptureV1QueueTest { #expect(queue.depth == 0) #expect(server.batchRequests.map { server.parsePostHogEvents($0).count } == [2, 1]) } + + @Test("flushes analytics once a batch's worth of bytes is queued, before flushAt") + func flushesAtByteLimit() async throws { + let config = PostHogConfig(projectToken: "phc_bytes_\(UUID().uuidString)", host: "http://localhost:9001") + config.flushAt = 100 + let storage = PostHogStorage(config) + // Not started, so no timer: only the byte threshold can trigger a send. + let queue = PostHogQueue(config, storage, .batch(api: PostHogApi(config)), nil) + server.batchProjectToken = config.projectToken + defer { + queue.stop() + queue.clear() + deleteSafely(storage.appFolderUrl) + } + + let blob = String(repeating: "a", count: 2 * 1024 * 1024) + for _ in 0 ..< 3 { + queue.add(PostHogEvent(event: "large", distinctId: "user", properties: ["blob": blob])) + } + await waitUntil(timeout: 10) { queue.depth == 0 } + + #expect(queue.depth == 0) + #expect(!server.batchRequests.isEmpty) + } } @Suite("Capture V1 AI lane", .serialized, .resetsGlobalState) diff --git a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index 07ec910c6..01a12fc68 100644 --- a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift +++ b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift @@ -105,6 +105,23 @@ struct PostHogFileBackedQueueAlignmentTest { #expect(decode(queue.peek(10)) == ["CCCC", "DDDD"]) } + @Test("tracks the byte count without a byte limit and evicts nothing") + func tracksBytesWithoutLimit() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-bytes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let queue = PostHogFileBackedQueue(queue: dir, trackBytes: true) + + #expect(queue.add(Data("AAAA".utf8)).evicted.isEmpty) + #expect(queue.add(Data("BBBBBB".utf8)).evicted.isEmpty) + #expect(queue.byteCount == 10) + #expect(PostHogFileBackedQueue(queue: dir, trackBytes: true).byteCount == 10) + + queue.pop(1) + #expect(queue.byteCount == 6) + #expect(decode(queue.peek(10)) == ["BBBBBB"]) + } + @Test("keeps the newest record even when it is over the byte limit alone") func keepsOversizedNewestRecord() throws { let dir = FileManager.default.temporaryDirectory