@apex-inc/capacitor-plugin 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ //
2
+ // BatchSender.swift
3
+ // Apex Capacitor Plugin
4
+ //
5
+ // Drains the NativeOfflineQueue by POSTing batches of events to
6
+ // `${apiBaseUrl}/api/events`. Mirrors the JS-side BatchSender so the
7
+ // on-disk queue isn't stranded.
8
+ //
9
+ // Before this file shipped (plugin <= 0.3.5), `track()` on iOS
10
+ // enqueued events to the native file-backed queue and `flushQueue()`
11
+ // was a no-op — events accumulated forever and never reached Apex.
12
+ // See MMP-205 friction-log F6 for the bug write-up.
13
+ //
14
+ // Concurrency model:
15
+ // - Serial DispatchQueue gates send attempts so two flushes don't
16
+ // race over the same batch.
17
+ // - URLSession callbacks come in on the URLSession's delegate
18
+ // queue; we hop back to the serial queue to mutate the offline
19
+ // queue (markSent / markFailed).
20
+ //
21
+
22
+ import Foundation
23
+
24
+ public final class NativeBatchSender {
25
+
26
+ public enum SendOutcome {
27
+ case success
28
+ case nonRetryable(Int)
29
+ case retryableExhausted(String)
30
+ }
31
+
32
+ private let apiBaseUrl: String
33
+ private let projectKey: String
34
+ private let platformHeader: String
35
+ private let batchSize: Int
36
+ private let maxRetries: Int
37
+ private let baseBackoffMs: Int
38
+ private let debug: Bool
39
+ private let urlSession: URLSession
40
+
41
+ private let serial = DispatchQueue(label: "inc.apex.batch-sender")
42
+ private var inFlight = false
43
+
44
+ public init(
45
+ apiBaseUrl: String,
46
+ projectKey: String,
47
+ platformHeader: String = "ios",
48
+ batchSize: Int = 50,
49
+ maxRetries: Int = 3,
50
+ baseBackoffMs: Int = 1000,
51
+ debug: Bool = false,
52
+ urlSession: URLSession = .shared
53
+ ) {
54
+ // Strip trailing slashes; URL joining below assumes none.
55
+ var trimmed = apiBaseUrl
56
+ while trimmed.hasSuffix("/") { trimmed.removeLast() }
57
+ self.apiBaseUrl = trimmed
58
+ self.projectKey = projectKey
59
+ self.platformHeader = platformHeader
60
+ self.batchSize = max(1, batchSize)
61
+ self.maxRetries = max(1, maxRetries)
62
+ self.baseBackoffMs = max(0, baseBackoffMs)
63
+ self.debug = debug
64
+ self.urlSession = urlSession
65
+ }
66
+
67
+ /// Drains the queue, one batch at a time, with exponential backoff
68
+ /// on retryable failures. Safe to call concurrently — only one
69
+ /// flush actually runs at a time; concurrent callers no-op.
70
+ public func flush(queue: NativeOfflineQueue, completion: @escaping (Int, Int) -> Void) {
71
+ serial.async { [weak self] in
72
+ guard let self = self else { completion(0, 0); return }
73
+ if self.inFlight {
74
+ // Caller will be picked up by the in-flight flush.
75
+ let remaining = (try? queue.size()) ?? 0
76
+ completion(0, remaining)
77
+ return
78
+ }
79
+ self.inFlight = true
80
+
81
+ var totalFlushed = 0
82
+ self.drainLoop(queue: queue, flushed: &totalFlushed) { [weak self] in
83
+ self?.inFlight = false
84
+ let remaining = (try? queue.size()) ?? 0
85
+ completion(totalFlushed, remaining)
86
+ }
87
+ }
88
+ }
89
+
90
+ private func drainLoop(
91
+ queue: NativeOfflineQueue,
92
+ flushed: inout Int,
93
+ done: @escaping () -> Void
94
+ ) {
95
+ let batch: [NativeQueuedEvent]
96
+ do {
97
+ batch = try queue.peek(batchSize: batchSize)
98
+ } catch {
99
+ if debug { print("[apex-capacitor] peek failed: \(error)") }
100
+ done()
101
+ return
102
+ }
103
+ if batch.isEmpty {
104
+ done()
105
+ return
106
+ }
107
+
108
+ let localFlushed = flushed
109
+ sendBatchWithRetry(batch, attempt: 0) { [weak self] outcome in
110
+ guard let self = self else { done(); return }
111
+ self.serial.async {
112
+ let ids = batch.map { $0.id }
113
+ switch outcome {
114
+ case .success:
115
+ try? queue.markSent(ids: ids)
116
+ if self.debug { print("[apex-capacitor] flushed batch of \(batch.count)") }
117
+ var nextFlushed = localFlushed + batch.count
118
+ self.drainLoop(queue: queue, flushed: &nextFlushed, done: done)
119
+ // After the recursive call returns the outer flushed
120
+ // is no longer relevant — drain handles its own.
121
+ case .nonRetryable(let status):
122
+ if self.debug { print("[apex-capacitor] non-retryable HTTP \(status); leaving events queued") }
123
+ done()
124
+ case .retryableExhausted(let err):
125
+ try? queue.markFailed(ids: ids)
126
+ if self.debug { print("[apex-capacitor] retries exhausted: \(err)") }
127
+ done()
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ private func sendBatchWithRetry(
134
+ _ batch: [NativeQueuedEvent],
135
+ attempt: Int,
136
+ completion: @escaping (SendOutcome) -> Void
137
+ ) {
138
+ guard !batch.isEmpty else { completion(.success); return }
139
+ guard let url = URL(string: "\(apiBaseUrl)/api/events") else {
140
+ completion(.nonRetryable(0))
141
+ return
142
+ }
143
+
144
+ // The server accepts `{ projectKey, events: [{ id, ...payload }] }`.
145
+ // We unwrap the queued envelope so the `id` lives at the top
146
+ // level of each event object (matches what /api/events expects
147
+ // and what the JS-side BatchSender produces).
148
+ let body: [String: Any] = [
149
+ "projectKey": projectKey,
150
+ "events": batch.map { ev -> [String: Any] in
151
+ var flat = ev.payload.mapValues { $0.value }
152
+ flat["id"] = ev.id
153
+ return flat
154
+ },
155
+ ]
156
+
157
+ var request = URLRequest(url: url)
158
+ request.httpMethod = "POST"
159
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
160
+ request.setValue(projectKey, forHTTPHeaderField: "X-Apex-Project-Key")
161
+ request.setValue(platformHeader, forHTTPHeaderField: "X-Apex-Platform")
162
+ request.timeoutInterval = 20
163
+
164
+ do {
165
+ request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
166
+ } catch {
167
+ completion(.nonRetryable(0))
168
+ return
169
+ }
170
+
171
+ if debug { print("[apex-capacitor] POST \(url.absoluteString) (\(batch.count) events, attempt \(attempt + 1)/\(maxRetries))") }
172
+
173
+ let task = urlSession.dataTask(with: request) { [weak self] _, response, error in
174
+ guard let self = self else { return }
175
+
176
+ if let error = error {
177
+ self.retryOrFail(
178
+ batch: batch,
179
+ attempt: attempt,
180
+ errorMessage: error.localizedDescription,
181
+ completion: completion
182
+ )
183
+ return
184
+ }
185
+
186
+ guard let http = response as? HTTPURLResponse else {
187
+ self.retryOrFail(
188
+ batch: batch,
189
+ attempt: attempt,
190
+ errorMessage: "non-HTTP response",
191
+ completion: completion
192
+ )
193
+ return
194
+ }
195
+
196
+ if (200..<300).contains(http.statusCode) {
197
+ completion(.success)
198
+ return
199
+ }
200
+ if (400..<500).contains(http.statusCode) {
201
+ // Client error — don't retry; preserves events for
202
+ // inspection but stops hammering the server.
203
+ completion(.nonRetryable(http.statusCode))
204
+ return
205
+ }
206
+ // 5xx / unknown — retryable.
207
+ self.retryOrFail(
208
+ batch: batch,
209
+ attempt: attempt,
210
+ errorMessage: "HTTP \(http.statusCode)",
211
+ completion: completion
212
+ )
213
+ }
214
+ task.resume()
215
+ }
216
+
217
+ private func retryOrFail(
218
+ batch: [NativeQueuedEvent],
219
+ attempt: Int,
220
+ errorMessage: String,
221
+ completion: @escaping (SendOutcome) -> Void
222
+ ) {
223
+ if attempt + 1 >= maxRetries {
224
+ completion(.retryableExhausted(errorMessage))
225
+ return
226
+ }
227
+ let backoffMs = baseBackoffMs * Int(pow(2.0, Double(attempt)))
228
+ if debug { print("[apex-capacitor] retry in \(backoffMs)ms (\(errorMessage))") }
229
+ DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(backoffMs)) { [weak self] in
230
+ self?.sendBatchWithRetry(batch, attempt: attempt + 1, completion: completion)
231
+ }
232
+ }
233
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import Foundation
14
14
  import Capacitor
15
+ import UIKit
15
16
  import UserNotifications
16
17
 
17
18
  @objc(ApexCapacitorPlugin)
@@ -70,6 +71,12 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
70
71
  private var apiBaseUrl: String = ""
71
72
  private var projectKey: String = ""
72
73
 
74
+ // MMP-205 — native batch sender. Drains offlineQueue to /api/events.
75
+ // Until 0.3.6 this was a no-op; events accumulated on disk and never
76
+ // reached the server. See friction-log F6.
77
+ private var batchSender: NativeBatchSender?
78
+ private var flushTimer: Timer?
79
+
73
80
  public override func load() {
74
81
  let defaults = UserDefaults.standard
75
82
  visitorId = defaults.string(forKey: "apex.visitorId") ?? UUID().uuidString.lowercased()
@@ -175,9 +182,56 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
175
182
  fileURL: queueFile,
176
183
  maxSize: call.getInt("offlineQueueMaxSize") ?? 1000
177
184
  )
185
+
186
+ // MMP-205 — wire up the native batch sender so events actually
187
+ // leave the device. Kicks an immediate flush + starts a 30s
188
+ // periodic flush + flushes on every foreground transition.
189
+ batchSender = NativeBatchSender(
190
+ apiBaseUrl: apiBaseUrl,
191
+ projectKey: projectKey,
192
+ platformHeader: "ios",
193
+ debug: debug
194
+ )
195
+ scheduleFlushTimer()
196
+ observeForegroundForFlush()
197
+ kickFlush(reason: "initialize")
198
+
178
199
  call.resolve()
179
200
  }
180
201
 
202
+ /// Schedules a periodic 30s flush so events that arrive in bursts
203
+ /// (or during background) leave the device on a steady cadence.
204
+ private func scheduleFlushTimer() {
205
+ flushTimer?.invalidate()
206
+ DispatchQueue.main.async { [weak self] in
207
+ self?.flushTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
208
+ self?.kickFlush(reason: "timer")
209
+ }
210
+ }
211
+ }
212
+
213
+ private func observeForegroundForFlush() {
214
+ NotificationCenter.default.addObserver(
215
+ forName: UIApplication.willEnterForegroundNotification,
216
+ object: nil,
217
+ queue: .main
218
+ ) { [weak self] _ in
219
+ self?.kickFlush(reason: "foreground")
220
+ }
221
+ }
222
+
223
+ /// Fire-and-forget flush. Safe to call freely; the sender's serial
224
+ /// queue ensures only one drain runs at a time.
225
+ private func kickFlush(reason: String) {
226
+ guard let queue = offlineQueue, let sender = batchSender else { return }
227
+ if debug { print("[apex-capacitor] flush kicked (\(reason))") }
228
+ sender.flush(queue: queue) { [weak self] flushed, remaining in
229
+ if self?.debug == true && (flushed > 0 || remaining > 0) {
230
+ print("[apex-capacitor] flush done — sent \(flushed), remaining \(remaining)")
231
+ }
232
+ }
233
+ }
234
+
181
235
  // MARK: - ATT
182
236
 
183
237
  @objc public func requestTrackingAuthorization(_ call: CAPPluginCall) {
@@ -361,6 +415,15 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
361
415
  codablePayload[key] = AnyCodable(v)
362
416
  }
363
417
  }
418
+
419
+ // MMP-205 — stamp testMode + platform on every event so the
420
+ // server-side dashboards' filters work. Mirrors the JS-side
421
+ // BatchSender which does the same in web.ts.
422
+ if testMode {
423
+ codablePayload["testMode"] = AnyCodable(true)
424
+ }
425
+ codablePayload["platform"] = AnyCodable("ios")
426
+ codablePayload["timestamp"] = AnyCodable(ISO8601DateFormatter().string(from: Date()))
364
427
  let event = NativeQueuedEvent(
365
428
  id: id,
366
429
  payload: codablePayload,
@@ -369,6 +432,10 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
369
432
  )
370
433
  do {
371
434
  try queue.enqueue(event)
435
+ // MMP-205 — kick a flush after every track so single-event
436
+ // sessions don't sit in the queue until the periodic timer.
437
+ // The sender's serial gate makes this safe to spam.
438
+ kickFlush(reason: "track")
372
439
  call.resolve()
373
440
  } catch {
374
441
  call.reject("Failed to enqueue event: \(error.localizedDescription)")
@@ -393,10 +460,16 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
393
460
  }
394
461
 
395
462
  @objc public func flushQueue(_ call: CAPPluginCall) {
396
- // Flush is handled by the JS side through the same offline queue
397
- // protocol. Native queue is drained by the batch sender which runs
398
- // in JS. Here we just report no-op success.
399
- call.resolve(["flushed": 0, "remaining": (try? offlineQueue?.size()) ?? 0])
463
+ // MMP-205 properly drain the native queue via the native
464
+ // BatchSender. The previous no-op (events stranded on disk)
465
+ // is friction-log F6.
466
+ guard let queue = offlineQueue, let sender = batchSender else {
467
+ call.resolve(["flushed": 0, "remaining": 0])
468
+ return
469
+ }
470
+ sender.flush(queue: queue) { flushed, remaining in
471
+ call.resolve(["flushed": flushed, "remaining": remaining])
472
+ }
400
473
  }
401
474
 
402
475
  @objc public func setTestMode(_ call: CAPPluginCall) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-inc/capacitor-plugin",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Apex Capacitor plugin — iOS/Android attribution, events, deep linking, SKAN, and offline-tolerant tracking for Capacitor apps.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/esm/index.js",