@apex-inc/capacitor-plugin 0.3.5 → 0.3.7
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,241 @@
|
|
|
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
|
+
///
|
|
71
|
+
/// Accumulator is passed by value through the recursion (no inout).
|
|
72
|
+
/// The previous inout-based design tripped Swift's exclusivity
|
|
73
|
+
/// runtime check at line 85 because the same `totalFlushed` local
|
|
74
|
+
/// was both captured by the `done` closure and passed inout — a
|
|
75
|
+
/// classic simultaneous-access violation that traps in debug builds.
|
|
76
|
+
public func flush(queue: NativeOfflineQueue, completion: @escaping (Int, Int) -> Void) {
|
|
77
|
+
serial.async { [weak self] in
|
|
78
|
+
guard let self = self else { completion(0, 0); return }
|
|
79
|
+
if self.inFlight {
|
|
80
|
+
// Caller will be picked up by the in-flight flush.
|
|
81
|
+
let remaining = (try? queue.size()) ?? 0
|
|
82
|
+
completion(0, remaining)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
self.inFlight = true
|
|
86
|
+
self.drainLoop(queue: queue, accumulated: 0) { [weak self] flushed in
|
|
87
|
+
self?.serial.async {
|
|
88
|
+
self?.inFlight = false
|
|
89
|
+
let remaining = (try? queue.size()) ?? 0
|
|
90
|
+
completion(flushed, remaining)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Recursive drain. `accumulated` is the running total of successfully
|
|
97
|
+
/// flushed events; `done` is invoked exactly once with the final count.
|
|
98
|
+
private func drainLoop(
|
|
99
|
+
queue: NativeOfflineQueue,
|
|
100
|
+
accumulated: Int,
|
|
101
|
+
done: @escaping (Int) -> Void
|
|
102
|
+
) {
|
|
103
|
+
let batch: [NativeQueuedEvent]
|
|
104
|
+
do {
|
|
105
|
+
batch = try queue.peek(batchSize: batchSize)
|
|
106
|
+
} catch {
|
|
107
|
+
if debug { print("[apex-capacitor] peek failed: \(error)") }
|
|
108
|
+
done(accumulated)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if batch.isEmpty {
|
|
112
|
+
done(accumulated)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
sendBatchWithRetry(batch, attempt: 0) { [weak self] outcome in
|
|
117
|
+
guard let self = self else { done(accumulated); return }
|
|
118
|
+
self.serial.async {
|
|
119
|
+
let ids = batch.map { $0.id }
|
|
120
|
+
switch outcome {
|
|
121
|
+
case .success:
|
|
122
|
+
try? queue.markSent(ids: ids)
|
|
123
|
+
if self.debug { print("[apex-capacitor] flushed batch of \(batch.count)") }
|
|
124
|
+
self.drainLoop(
|
|
125
|
+
queue: queue,
|
|
126
|
+
accumulated: accumulated + batch.count,
|
|
127
|
+
done: done
|
|
128
|
+
)
|
|
129
|
+
case .nonRetryable(let status):
|
|
130
|
+
if self.debug { print("[apex-capacitor] non-retryable HTTP \(status); leaving events queued") }
|
|
131
|
+
done(accumulated)
|
|
132
|
+
case .retryableExhausted(let err):
|
|
133
|
+
try? queue.markFailed(ids: ids)
|
|
134
|
+
if self.debug { print("[apex-capacitor] retries exhausted: \(err)") }
|
|
135
|
+
done(accumulated)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private func sendBatchWithRetry(
|
|
142
|
+
_ batch: [NativeQueuedEvent],
|
|
143
|
+
attempt: Int,
|
|
144
|
+
completion: @escaping (SendOutcome) -> Void
|
|
145
|
+
) {
|
|
146
|
+
guard !batch.isEmpty else { completion(.success); return }
|
|
147
|
+
guard let url = URL(string: "\(apiBaseUrl)/api/events") else {
|
|
148
|
+
completion(.nonRetryable(0))
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The server accepts `{ projectKey, events: [{ id, ...payload }] }`.
|
|
153
|
+
// We unwrap the queued envelope so the `id` lives at the top
|
|
154
|
+
// level of each event object (matches what /api/events expects
|
|
155
|
+
// and what the JS-side BatchSender produces).
|
|
156
|
+
let body: [String: Any] = [
|
|
157
|
+
"projectKey": projectKey,
|
|
158
|
+
"events": batch.map { ev -> [String: Any] in
|
|
159
|
+
var flat = ev.payload.mapValues { $0.value }
|
|
160
|
+
flat["id"] = ev.id
|
|
161
|
+
return flat
|
|
162
|
+
},
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
var request = URLRequest(url: url)
|
|
166
|
+
request.httpMethod = "POST"
|
|
167
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
168
|
+
request.setValue(projectKey, forHTTPHeaderField: "X-Apex-Project-Key")
|
|
169
|
+
request.setValue(platformHeader, forHTTPHeaderField: "X-Apex-Platform")
|
|
170
|
+
request.timeoutInterval = 20
|
|
171
|
+
|
|
172
|
+
do {
|
|
173
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
|
|
174
|
+
} catch {
|
|
175
|
+
completion(.nonRetryable(0))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if debug { print("[apex-capacitor] POST \(url.absoluteString) (\(batch.count) events, attempt \(attempt + 1)/\(maxRetries))") }
|
|
180
|
+
|
|
181
|
+
let task = urlSession.dataTask(with: request) { [weak self] _, response, error in
|
|
182
|
+
guard let self = self else { return }
|
|
183
|
+
|
|
184
|
+
if let error = error {
|
|
185
|
+
self.retryOrFail(
|
|
186
|
+
batch: batch,
|
|
187
|
+
attempt: attempt,
|
|
188
|
+
errorMessage: error.localizedDescription,
|
|
189
|
+
completion: completion
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
guard let http = response as? HTTPURLResponse else {
|
|
195
|
+
self.retryOrFail(
|
|
196
|
+
batch: batch,
|
|
197
|
+
attempt: attempt,
|
|
198
|
+
errorMessage: "non-HTTP response",
|
|
199
|
+
completion: completion
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (200..<300).contains(http.statusCode) {
|
|
205
|
+
completion(.success)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
if (400..<500).contains(http.statusCode) {
|
|
209
|
+
// Client error — don't retry; preserves events for
|
|
210
|
+
// inspection but stops hammering the server.
|
|
211
|
+
completion(.nonRetryable(http.statusCode))
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
// 5xx / unknown — retryable.
|
|
215
|
+
self.retryOrFail(
|
|
216
|
+
batch: batch,
|
|
217
|
+
attempt: attempt,
|
|
218
|
+
errorMessage: "HTTP \(http.statusCode)",
|
|
219
|
+
completion: completion
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
task.resume()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private func retryOrFail(
|
|
226
|
+
batch: [NativeQueuedEvent],
|
|
227
|
+
attempt: Int,
|
|
228
|
+
errorMessage: String,
|
|
229
|
+
completion: @escaping (SendOutcome) -> Void
|
|
230
|
+
) {
|
|
231
|
+
if attempt + 1 >= maxRetries {
|
|
232
|
+
completion(.retryableExhausted(errorMessage))
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
let backoffMs = baseBackoffMs * Int(pow(2.0, Double(attempt)))
|
|
236
|
+
if debug { print("[apex-capacitor] retry in \(backoffMs)ms (\(errorMessage))") }
|
|
237
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(backoffMs)) { [weak self] in
|
|
238
|
+
self?.sendBatchWithRetry(batch, attempt: attempt + 1, completion: completion)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -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
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.7",
|
|
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",
|