sitepong 0.2.13 → 0.2.15
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.
- package/dist/cdn/sitepong.min.js +5 -5
- package/dist/cdn/sitepong.min.js.map +1 -1
- package/dist/entries/rn.d.ts +28 -1
- package/dist/entries/rn.js +44 -5
- package/dist/entries/rn.js.map +1 -1
- package/dist/entries/web.js +30 -2
- package/dist/entries/web.js.map +1 -1
- package/dist/entries/web.mjs +30 -2
- package/dist/entries/web.mjs.map +1 -1
- package/dist/index.d.mts +25 -2
- package/dist/index.d.ts +25 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.js +5 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +5 -1
- package/dist/react/index.mjs.map +1 -1
- package/ios/ScreenRecorder/ScreenRecorderModule.swift +9 -3
- package/ios/WatchtowerCore/StructuralRecorder.swift +163 -19
- package/ios/WatchtowerCore/TapEvent.swift +5 -0
- package/ios/WatchtowerCore/Watchtower.swift +48 -3
- package/ios/WatchtowerCore/WatchtowerEngine.swift +26 -1
- package/package.json +1 -1
|
@@ -123,10 +123,16 @@ public class SitePongScreenRecorderModule: Module {
|
|
|
123
123
|
let platform = config["platform"] as? String ?? "react-native-ios"
|
|
124
124
|
// Idle grace before a reopen rolls a new session (default 30s).
|
|
125
125
|
let sessionGraceMs = config["sessionGraceMs"] as? Double ?? 30_000
|
|
126
|
+
// Release channel + suppression list forwarded from the JS SDK config.
|
|
127
|
+
// nil channel → WatchtowerCore auto-resolves (debug→development,
|
|
128
|
+
// release→production); ignoreChannels defaults to ["development"].
|
|
129
|
+
let channel = config["channel"] as? String
|
|
130
|
+
let ignoreChannels = config["ignoreChannels"] as? [String] ?? ["development"]
|
|
126
131
|
guard let endpoint = URL(string: endpointStr) else { return }
|
|
127
132
|
Watchtower.start(apiKey: apiKey, projectId: projectId,
|
|
128
133
|
endpoint: endpoint, sampleRate: sampleRate,
|
|
129
|
-
platform: platform, sessionGraceMs: sessionGraceMs
|
|
134
|
+
platform: platform, sessionGraceMs: sessionGraceMs,
|
|
135
|
+
channel: channel, ignoreChannels: ignoreChannels)
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
Function("watchtowerStop") {
|
|
@@ -141,8 +147,8 @@ public class SitePongScreenRecorderModule: Module {
|
|
|
141
147
|
Watchtower.captureScreenshot()
|
|
142
148
|
}
|
|
143
149
|
|
|
144
|
-
Function("watchtowerSetUser") { (distinctId: String?) in
|
|
145
|
-
Watchtower.setUser(distinctId)
|
|
150
|
+
Function("watchtowerSetUser") { (distinctId: String?, email: String?, name: String?) in
|
|
151
|
+
Watchtower.setUser(distinctId, email: email, name: name)
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
Function("watchtowerGetSessionId") { () -> String? in
|
|
@@ -12,8 +12,27 @@
|
|
|
12
12
|
//
|
|
13
13
|
// One platform-neutral core, two capture adapters (fiber / UIView), one stored
|
|
14
14
|
// stream, one dashboard Player.
|
|
15
|
+
//
|
|
16
|
+
// Parity with the RN structural SDK (all implemented here):
|
|
17
|
+
// • Sensitive masking — honors watchtowerSensitive UIViews + SwiftUI
|
|
18
|
+
// .watchtowerSensitive() regions (via WatchtowerRegistry), not just secure fields.
|
|
19
|
+
// • Scroll fidelity — native nodes carry ABSOLUTE window frames, so scroll shows
|
|
20
|
+
// up as frame deltas; we densify sampling while a UIScrollView is moving so fast
|
|
21
|
+
// scrolls aren't aliased (no SetNodeScroll — that would double-translate here).
|
|
22
|
+
// • Session follow — re-keys onto WatchtowerEngine's rolled session id (shared id,
|
|
23
|
+
// roll-in-lockstep) via .sitePongWatchtowerSessionDidRoll, the native analogue
|
|
24
|
+
// of the RN provider's watchForRoll.
|
|
25
|
+
// • Crash-flush — a best-effort synchronous flush on uncaught exception.
|
|
26
|
+
// • Adaptive sampling — a heavy tree walk backs the sampler off for a few ticks
|
|
27
|
+
// so capture never pins the main thread.
|
|
15
28
|
import UIKit
|
|
16
29
|
|
|
30
|
+
extension Notification.Name {
|
|
31
|
+
/// Posted by WatchtowerEngine on session start/roll so the structural recorder
|
|
32
|
+
/// can share the session id and roll in lockstep.
|
|
33
|
+
static let sitePongWatchtowerSessionDidRoll = Notification.Name("SitePongWatchtowerSessionDidRoll")
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
public final class SitePongStructuralCapture {
|
|
18
37
|
public static let shared = SitePongStructuralCapture()
|
|
19
38
|
|
|
@@ -22,12 +41,12 @@ public final class SitePongStructuralCapture {
|
|
|
22
41
|
private var projectId: String?
|
|
23
42
|
private var platform = "ios"
|
|
24
43
|
private var sessionId = ""
|
|
44
|
+
private var installId: String?
|
|
25
45
|
private var startedAt = Date()
|
|
26
46
|
private var ids: [ObjectIdentifier: Int] = [:]
|
|
27
47
|
private var nextId = 1
|
|
28
48
|
private var events: [[String: Any]] = []
|
|
29
49
|
private var screen = ""
|
|
30
|
-
private var sampleTimer: Timer?
|
|
31
50
|
private var flushTimer: Timer?
|
|
32
51
|
private var running = false
|
|
33
52
|
|
|
@@ -39,6 +58,20 @@ public final class SitePongStructuralCapture {
|
|
|
39
58
|
private var flushedEventCount = 0
|
|
40
59
|
private var sending = false
|
|
41
60
|
|
|
61
|
+
// Scroll fidelity: last seen content offset per node id; while any scroll view
|
|
62
|
+
// moves we sample faster so the frame-delta capture isn't aliased.
|
|
63
|
+
private var scrollOffsets: [Int: CGPoint] = [:]
|
|
64
|
+
private var scanScrolls: [(id: Int, off: CGPoint)] = []
|
|
65
|
+
private var scrollQuietTicks = 3
|
|
66
|
+
|
|
67
|
+
// Adaptive sampling: a walk over `walkBudgetMs` backs off proportionally.
|
|
68
|
+
private let walkBudgetMs: Double = 24
|
|
69
|
+
private let maxOverloadSkip = 8
|
|
70
|
+
private var overloadSkip = 0
|
|
71
|
+
|
|
72
|
+
// Crash-flush chains any pre-existing uncaught-exception handler.
|
|
73
|
+
private static var previousExceptionHandler: (@convention(c) (NSException) -> Void)?
|
|
74
|
+
|
|
42
75
|
private func now() -> Int { Int(Date().timeIntervalSince(startedAt) * 1000) }
|
|
43
76
|
|
|
44
77
|
/// Start capturing. `endpoint` is the ingest base URL (e.g.
|
|
@@ -50,6 +83,7 @@ public final class SitePongStructuralCapture {
|
|
|
50
83
|
self.projectId = projectId
|
|
51
84
|
self.sessionId = sessionId
|
|
52
85
|
self.platform = platform
|
|
86
|
+
self.installId = UIDevice.current.identifierForVendor?.uuidString
|
|
53
87
|
self.startedAt = Date()
|
|
54
88
|
self.running = true
|
|
55
89
|
|
|
@@ -63,7 +97,7 @@ public final class SitePongStructuralCapture {
|
|
|
63
97
|
g.delegate = SPStructuralTapDelegate.shared
|
|
64
98
|
w.addGestureRecognizer(g)
|
|
65
99
|
}
|
|
66
|
-
|
|
100
|
+
scheduleSample()
|
|
67
101
|
flushTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in self?.flush() }
|
|
68
102
|
|
|
69
103
|
// Flush on background so the tail isn't stranded when timers suspend.
|
|
@@ -71,6 +105,12 @@ public final class SitePongStructuralCapture {
|
|
|
71
105
|
self, selector: #selector(onBackground),
|
|
72
106
|
name: UIApplication.didEnterBackgroundNotification, object: nil
|
|
73
107
|
)
|
|
108
|
+
// Follow WatchtowerEngine's session so structural + tap streams share one id.
|
|
109
|
+
NotificationCenter.default.addObserver(
|
|
110
|
+
self, selector: #selector(onSessionRoll(_:)),
|
|
111
|
+
name: .sitePongWatchtowerSessionDidRoll, object: nil
|
|
112
|
+
)
|
|
113
|
+
installCrashFlush()
|
|
74
114
|
}
|
|
75
115
|
|
|
76
116
|
@objc private func onBackground() {
|
|
@@ -78,6 +118,29 @@ public final class SitePongStructuralCapture {
|
|
|
78
118
|
flush()
|
|
79
119
|
}
|
|
80
120
|
|
|
121
|
+
/// WatchtowerEngine rolled (or started) a session — re-key onto it so the
|
|
122
|
+
/// structural stream follows the same (linked) session id. Mirrors the RN
|
|
123
|
+
/// provider's stop()+begin() on a session roll.
|
|
124
|
+
@objc private func onSessionRoll(_ note: Notification) {
|
|
125
|
+
guard running, let newId = note.userInfo?["sessionId"] as? String, newId != sessionId else { return }
|
|
126
|
+
// Best-effort ship the old-session tail under the OLD id (fire-and-forget,
|
|
127
|
+
// decoupled from the flush cursor so it can't corrupt the new session).
|
|
128
|
+
if !events.isEmpty, let w = keyWindow() {
|
|
129
|
+
sendBestEffort(batch: events, seqStart: flushedEventCount, sessionId: sessionId,
|
|
130
|
+
viewport: (Int(w.bounds.width), Int(w.bounds.height)))
|
|
131
|
+
}
|
|
132
|
+
// Reset to a clean new session and take a fresh full snapshot.
|
|
133
|
+
sessionId = newId
|
|
134
|
+
events.removeAll(keepingCapacity: true)
|
|
135
|
+
ids.removeAll(keepingCapacity: true)
|
|
136
|
+
nextId = 1
|
|
137
|
+
flushedEventCount = 0
|
|
138
|
+
scrollOffsets.removeAll(keepingCapacity: true)
|
|
139
|
+
screen = ""
|
|
140
|
+
startedAt = Date()
|
|
141
|
+
sample()
|
|
142
|
+
}
|
|
143
|
+
|
|
81
144
|
public func setScreen(_ name: String) {
|
|
82
145
|
guard running, name != screen else { return }
|
|
83
146
|
sample()
|
|
@@ -88,11 +151,8 @@ public final class SitePongStructuralCapture {
|
|
|
88
151
|
public func stop() {
|
|
89
152
|
guard running else { return }
|
|
90
153
|
running = false
|
|
91
|
-
sampleTimer?.invalidate(); sampleTimer = nil
|
|
92
154
|
flushTimer?.invalidate(); flushTimer = nil
|
|
93
|
-
NotificationCenter.default.removeObserver(
|
|
94
|
-
self, name: UIApplication.didEnterBackgroundNotification, object: nil
|
|
95
|
-
)
|
|
155
|
+
NotificationCenter.default.removeObserver(self)
|
|
96
156
|
flush()
|
|
97
157
|
}
|
|
98
158
|
|
|
@@ -136,27 +196,39 @@ public final class SitePongStructuralCapture {
|
|
|
136
196
|
return nil
|
|
137
197
|
}
|
|
138
198
|
|
|
139
|
-
private func serialize(_ v: UIView, in window: UIWindow) -> [String: Any]? {
|
|
199
|
+
private func serialize(_ v: UIView, in window: UIWindow, sensitiveRects: [CGRect]) -> [String: Any]? {
|
|
140
200
|
if v.isHidden || v.alpha < 0.02 { return nil }
|
|
141
201
|
let f = v.convert(v.bounds, to: window)
|
|
202
|
+
// Sensitive if the view opts in (UIKit flag), is a secure field, or its
|
|
203
|
+
// center falls inside a SwiftUI .watchtowerSensitive() region — the same
|
|
204
|
+
// sources Screenshotter blurs, so structural replay masks them too.
|
|
205
|
+
let center = CGPoint(x: f.midX, y: f.midY)
|
|
206
|
+
let inSensitive = v.watchtowerSensitive
|
|
207
|
+
|| ((v as? UITextField)?.isSecureTextEntry ?? false)
|
|
208
|
+
|| sensitiveRects.contains { $0.contains(center) }
|
|
209
|
+
|
|
142
210
|
var attrs: [String: String] = [:]
|
|
143
211
|
if let aid = v.accessibilityIdentifier, !aid.isEmpty { attrs["testID"] = aid }
|
|
144
|
-
if let al = v.accessibilityLabel, !al.isEmpty { attrs["accessibilityLabel"] = al }
|
|
212
|
+
if let al = v.accessibilityLabel, !al.isEmpty, !inSensitive { attrs["accessibilityLabel"] = al }
|
|
145
213
|
if let bg = hex(v.backgroundColor) { attrs["backgroundColor"] = bg }
|
|
146
214
|
if v.layer.cornerRadius > 0 { attrs["borderRadius"] = String(Int(v.layer.cornerRadius)) }
|
|
147
|
-
if
|
|
215
|
+
if inSensitive { attrs["wtSensitive"] = "true" }
|
|
148
216
|
if let l = v as? UILabel {
|
|
149
217
|
attrs["color"] = hex(l.textColor) ?? "#111111"
|
|
150
218
|
attrs["fontSize"] = String(Int(l.font.pointSize))
|
|
151
219
|
}
|
|
152
220
|
attrs["frame"] = "\(Int(f.origin.x)),\(Int(f.origin.y)),\(Int(f.size.width)),\(Int(f.size.height))"
|
|
221
|
+
|
|
222
|
+
if let sv = v as? UIScrollView { scanScrolls.append((idFor(v), sv.contentOffset)) }
|
|
223
|
+
|
|
153
224
|
let tag = tagFor(v)
|
|
154
225
|
var node: [String: Any] = ["id": idFor(v), "tag": tag, "attrs": attrs]
|
|
155
|
-
|
|
226
|
+
// Never serialize text inside a sensitive region.
|
|
227
|
+
if !inSensitive, let t = textFor(v), !t.isEmpty { node["text"] = t }
|
|
156
228
|
// Don't descend into text/controls' internal subviews (keeps the tree clean).
|
|
157
229
|
if !(v is UILabel) && !(v is UIButton) {
|
|
158
230
|
var kids: [[String: Any]] = []
|
|
159
|
-
for sub in v.subviews { if let s = serialize(sub, in: window) { kids.append(s) } }
|
|
231
|
+
for sub in v.subviews { if let s = serialize(sub, in: window, sensitiveRects: sensitiveRects) { kids.append(s) } }
|
|
160
232
|
node["children"] = kids
|
|
161
233
|
} else {
|
|
162
234
|
node["children"] = []
|
|
@@ -165,8 +237,38 @@ public final class SitePongStructuralCapture {
|
|
|
165
237
|
}
|
|
166
238
|
|
|
167
239
|
private func sample() {
|
|
168
|
-
guard running, let w = keyWindow()
|
|
240
|
+
guard running, let w = keyWindow() else { return }
|
|
241
|
+
// Adaptive back-off: while recovering from a heavy walk, skip the body.
|
|
242
|
+
if overloadSkip > 0 { overloadSkip -= 1; return }
|
|
243
|
+
|
|
244
|
+
scanScrolls.removeAll(keepingCapacity: true)
|
|
245
|
+
let sensitiveRects = WatchtowerRegistry.shared.sensitiveRects(in: w)
|
|
246
|
+
let t0 = Date()
|
|
247
|
+
guard let root = serialize(w, in: w, sensitiveRects: sensitiveRects) else { return }
|
|
248
|
+
let walkMs = Date().timeIntervalSince(t0) * 1000
|
|
249
|
+
if walkMs > walkBudgetMs { overloadSkip = min(maxOverloadSkip, Int(walkMs / walkBudgetMs)) }
|
|
250
|
+
|
|
169
251
|
events.append(["t": now(), "type": "snapshot", "tree": root])
|
|
252
|
+
|
|
253
|
+
// Scroll detection: did any scroll view's offset move? Densify sampling
|
|
254
|
+
// for a few ticks so fast scrolls (captured as frame deltas) aren't aliased.
|
|
255
|
+
var scrolled = false
|
|
256
|
+
for (id, off) in scanScrolls {
|
|
257
|
+
if let last = scrollOffsets[id], abs(last.x - off.x) > 0.5 || abs(last.y - off.y) > 0.5 { scrolled = true }
|
|
258
|
+
scrollOffsets[id] = off
|
|
259
|
+
}
|
|
260
|
+
scrollQuietTicks = scrolled ? 0 : scrollQuietTicks + 1
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Self-scheduling sampler: 60ms while scrolling, 120ms at rest.
|
|
264
|
+
private func scheduleSample() {
|
|
265
|
+
guard running else { return }
|
|
266
|
+
let interval = scrollQuietTicks < 3 ? 0.06 : 0.12
|
|
267
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + interval) { [weak self] in
|
|
268
|
+
guard let self = self, self.running else { return }
|
|
269
|
+
self.sample()
|
|
270
|
+
self.scheduleSample()
|
|
271
|
+
}
|
|
170
272
|
}
|
|
171
273
|
|
|
172
274
|
@objc private func onTap(_ g: UITapGestureRecognizer) {
|
|
@@ -207,26 +309,35 @@ public final class SitePongStructuralCapture {
|
|
|
207
309
|
viewport: (Int(w.bounds.width), Int(w.bounds.height)), attempt: 0)
|
|
208
310
|
}
|
|
209
311
|
|
|
210
|
-
private func
|
|
211
|
-
|
|
312
|
+
private func requestBody(batch: [[String: Any]], seqStart: Int, sessionId: String, viewport: (Int, Int)) -> Data? {
|
|
313
|
+
var payload: [String: Any] = [
|
|
212
314
|
"session_id": sessionId,
|
|
213
315
|
"platform": platform,
|
|
214
316
|
"viewport": ["w": viewport.0, "h": viewport.1],
|
|
215
317
|
"seq_start": seqStart,
|
|
216
318
|
"events": batch,
|
|
217
319
|
]
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
320
|
+
if let iid = installId { payload["install_id"] = iid }
|
|
321
|
+
return try? JSONSerialization.data(withJSONObject: payload)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func makeRequest(body: Data) -> URLRequest? {
|
|
325
|
+
guard let url = URL(string: "\(endpoint)/api/replay-stream/native") else { return nil }
|
|
223
326
|
var req = URLRequest(url: url)
|
|
224
327
|
req.httpMethod = "POST"
|
|
225
328
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
226
329
|
req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
|
|
227
330
|
if let pid = projectId { req.setValue(pid, forHTTPHeaderField: "X-Project-ID") }
|
|
228
331
|
req.httpBody = body
|
|
332
|
+
return req
|
|
333
|
+
}
|
|
229
334
|
|
|
335
|
+
private func post(batch: [[String: Any]], seqStart: Int, viewport: (Int, Int), attempt: Int) {
|
|
336
|
+
guard let body = requestBody(batch: batch, seqStart: seqStart, sessionId: sessionId, viewport: viewport),
|
|
337
|
+
let req = makeRequest(body: body) else {
|
|
338
|
+
DispatchQueue.main.async { self.sending = false }
|
|
339
|
+
return
|
|
340
|
+
}
|
|
230
341
|
URLSession.shared.dataTask(with: req) { [weak self] _, resp, err in
|
|
231
342
|
guard let self = self else { return }
|
|
232
343
|
let ok = (resp as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } ?? false
|
|
@@ -248,6 +359,39 @@ public final class SitePongStructuralCapture {
|
|
|
248
359
|
}
|
|
249
360
|
}.resume()
|
|
250
361
|
}
|
|
362
|
+
|
|
363
|
+
/// Fire-and-forget send under an explicit session id — used for the old-session
|
|
364
|
+
/// tail on a roll, decoupled from the flush cursor so it can't corrupt the new
|
|
365
|
+
/// session's seq. Best-effort (no retry / no re-queue).
|
|
366
|
+
private func sendBestEffort(batch: [[String: Any]], seqStart: Int, sessionId: String, viewport: (Int, Int)) {
|
|
367
|
+
guard !endpoint.isEmpty,
|
|
368
|
+
let body = requestBody(batch: batch, seqStart: seqStart, sessionId: sessionId, viewport: viewport),
|
|
369
|
+
let req = makeRequest(body: body) else { return }
|
|
370
|
+
URLSession.shared.dataTask(with: req).resume()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// MARK: - crash flush
|
|
374
|
+
private func installCrashFlush() {
|
|
375
|
+
SitePongStructuralCapture.previousExceptionHandler = NSGetUncaughtExceptionHandler()
|
|
376
|
+
NSSetUncaughtExceptionHandler { ex in
|
|
377
|
+
SitePongStructuralCapture.shared.crashFlush()
|
|
378
|
+
SitePongStructuralCapture.previousExceptionHandler?(ex)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Synchronous best-effort flush from an uncaught-exception handler (the run
|
|
383
|
+
/// loop is dead, so we block briefly on URLSession's own queue). Mirrors the
|
|
384
|
+
/// JS ErrorUtils crash-flush.
|
|
385
|
+
func crashFlush() {
|
|
386
|
+
guard running, !events.isEmpty,
|
|
387
|
+
let w = keyWindow(),
|
|
388
|
+
let body = requestBody(batch: events, seqStart: flushedEventCount, sessionId: sessionId,
|
|
389
|
+
viewport: (Int(w.bounds.width), Int(w.bounds.height))),
|
|
390
|
+
let req = makeRequest(body: body) else { return }
|
|
391
|
+
let sem = DispatchSemaphore(value: 0)
|
|
392
|
+
URLSession.shared.dataTask(with: req) { _, _, _ in sem.signal() }.resume()
|
|
393
|
+
_ = sem.wait(timeout: .now() + 1.5)
|
|
394
|
+
}
|
|
251
395
|
}
|
|
252
396
|
|
|
253
397
|
/// Allow the capture recognizer to run alongside buttons/scroll gestures.
|
|
@@ -14,6 +14,11 @@ public struct CaptureEvent: Codable {
|
|
|
14
14
|
public var distinct_id: String?
|
|
15
15
|
public var device_model: String?
|
|
16
16
|
public var os_version: String?
|
|
17
|
+
/// Release channel / environment this event was captured in (dev | preview |
|
|
18
|
+
/// production | …). Stamped on every event so the dashboard can keep dev
|
|
19
|
+
/// noise out of prod analytics. Defaults to "production"; the engine sets it
|
|
20
|
+
/// from the resolved channel. Non-optional so it is always on the wire.
|
|
21
|
+
public var channel: String = "production"
|
|
17
22
|
/// Persistent per-install id (identifierForVendor) — stamped on every event
|
|
18
23
|
/// so the dashboard can stitch consecutive sessions from the same install
|
|
19
24
|
/// into a journey at read time, even for anonymous users.
|
|
@@ -11,25 +11,70 @@ public enum Watchtower {
|
|
|
11
11
|
/// §1.1). It defaults to `"ios"` for native hosts; the React Native bridge
|
|
12
12
|
/// (§4) passes `"react-native-ios"`. This is the only host-overridable
|
|
13
13
|
/// identity knob — capture logic is identical regardless of platform.
|
|
14
|
+
///
|
|
15
|
+
/// `channel` tags every event with a release channel / environment so dev
|
|
16
|
+
/// traffic can be kept out of prod analytics. When omitted it is resolved
|
|
17
|
+
/// automatically: **debug builds → `"development"`, release builds →
|
|
18
|
+
/// `"production"`**. Any channel listed in `ignoreChannels` is *suppressed*
|
|
19
|
+
/// — `start` becomes a complete no-op (no swizzles, no observers, zero
|
|
20
|
+
/// network). The default `["development"]` means debug builds capture
|
|
21
|
+
/// nothing unless you opt in (pass `ignoreChannels: []` or an explicit
|
|
22
|
+
/// `channel`). Release builds send `"production"` and are never suppressed
|
|
23
|
+
/// by the default.
|
|
14
24
|
public static func start(apiKey: String, projectId: String,
|
|
15
25
|
endpoint: URL, sampleRate: Double = 0.1,
|
|
16
|
-
platform: String = "ios", sessionGraceMs: Double = 30_000
|
|
26
|
+
platform: String = "ios", sessionGraceMs: Double = 30_000,
|
|
27
|
+
channel: String? = nil,
|
|
28
|
+
ignoreChannels: [String] = ["development"]) {
|
|
29
|
+
let resolved = resolveChannel(channel)
|
|
30
|
+
// Suppressed channel → don't start at all. This is the "ignore dev,
|
|
31
|
+
// listen to prod" gate: zero capture and zero ingest cost for dev.
|
|
32
|
+
let ignored = Set(ignoreChannels.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() })
|
|
33
|
+
if ignored.contains(resolved) { return }
|
|
34
|
+
|
|
17
35
|
#if canImport(UIKit)
|
|
18
36
|
// Ensure UIKit work happens on the main thread.
|
|
19
37
|
if Thread.isMainThread {
|
|
20
38
|
WatchtowerEngine.shared.start(apiKey: apiKey, projectId: projectId,
|
|
21
39
|
endpoint: endpoint, sampleRate: sampleRate,
|
|
22
|
-
platform: platform, sessionGraceMs: sessionGraceMs
|
|
40
|
+
platform: platform, sessionGraceMs: sessionGraceMs,
|
|
41
|
+
channel: resolved)
|
|
23
42
|
} else {
|
|
24
43
|
DispatchQueue.main.async {
|
|
25
44
|
WatchtowerEngine.shared.start(apiKey: apiKey, projectId: projectId,
|
|
26
45
|
endpoint: endpoint, sampleRate: sampleRate,
|
|
27
|
-
platform: platform, sessionGraceMs: sessionGraceMs
|
|
46
|
+
platform: platform, sessionGraceMs: sessionGraceMs,
|
|
47
|
+
channel: resolved)
|
|
28
48
|
}
|
|
29
49
|
}
|
|
30
50
|
#endif
|
|
31
51
|
}
|
|
32
52
|
|
|
53
|
+
/// Resolve the effective channel: an explicit, non-empty override wins
|
|
54
|
+
/// (lowercased); otherwise debug builds are `"development"` and release
|
|
55
|
+
/// builds `"production"`. Exposed at module scope so tests can assert the
|
|
56
|
+
/// build-config default without a running engine.
|
|
57
|
+
static func resolveChannel(_ explicit: String?) -> String {
|
|
58
|
+
if let c = explicit?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !c.isEmpty {
|
|
59
|
+
return c
|
|
60
|
+
}
|
|
61
|
+
#if DEBUG
|
|
62
|
+
return "development"
|
|
63
|
+
#else
|
|
64
|
+
return "production"
|
|
65
|
+
#endif
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// The channel the engine is currently tagging events with, or nil when not
|
|
69
|
+
/// running (e.g. suppressed). Useful for diagnostics and tests.
|
|
70
|
+
public static var channel: String? {
|
|
71
|
+
#if canImport(UIKit)
|
|
72
|
+
return WatchtowerEngine.shared.currentChannel
|
|
73
|
+
#else
|
|
74
|
+
return nil
|
|
75
|
+
#endif
|
|
76
|
+
}
|
|
77
|
+
|
|
33
78
|
public static func stop() {
|
|
34
79
|
#if canImport(UIKit)
|
|
35
80
|
WatchtowerEngine.shared.stop()
|
|
@@ -37,6 +37,13 @@ final class WatchtowerEngine {
|
|
|
37
37
|
/// overrides to "react-native-ios" via start(platform:).
|
|
38
38
|
private var platform: String = "ios"
|
|
39
39
|
|
|
40
|
+
/// Release channel / environment stamped on every event (dev | preview |
|
|
41
|
+
/// production | …). Resolved by Watchtower.start (debug→"development",
|
|
42
|
+
/// release→"production", or an explicit override) and passed in here. The
|
|
43
|
+
/// suppression decision (don't run at all on an ignored channel) is made in
|
|
44
|
+
/// Watchtower.start before this engine is ever started.
|
|
45
|
+
private var channel: String = "production"
|
|
46
|
+
|
|
40
47
|
/// Persistent per-install id (identifierForVendor), resolved once at start
|
|
41
48
|
/// and stamped on every event so the dashboard can stitch consecutive
|
|
42
49
|
/// sessions from the same install into a journey at read time.
|
|
@@ -83,13 +90,15 @@ final class WatchtowerEngine {
|
|
|
83
90
|
// MARK: - Lifecycle
|
|
84
91
|
|
|
85
92
|
func start(apiKey: String, projectId: String, endpoint: URL, sampleRate: Double,
|
|
86
|
-
platform: String = "ios", sessionGraceMs: Double = 30_000
|
|
93
|
+
platform: String = "ios", sessionGraceMs: Double = 30_000,
|
|
94
|
+
channel: String = "production") {
|
|
87
95
|
guard !isRunning else { return }
|
|
88
96
|
self.transport = Transport(apiKey: apiKey, projectId: projectId, endpoint: endpoint)
|
|
89
97
|
self.sessionId = UUID().uuidString
|
|
90
98
|
self.sequence = 0
|
|
91
99
|
self.sampleRate = sampleRate
|
|
92
100
|
self.platform = platform
|
|
101
|
+
self.channel = channel
|
|
93
102
|
self.sessionGraceMs = sessionGraceMs
|
|
94
103
|
self.installId = UIDevice.current.identifierForVendor?.uuidString
|
|
95
104
|
self.lastBackgroundedAt = nil
|
|
@@ -113,6 +122,13 @@ final class WatchtowerEngine {
|
|
|
113
122
|
observeBackground()
|
|
114
123
|
isRunning = true
|
|
115
124
|
emit(baseEvent(type: "session_start"))
|
|
125
|
+
// Announce the session so the structural recorder can share this id and
|
|
126
|
+
// roll in lockstep (the native analogue of the RN provider adopting the
|
|
127
|
+
// native session id).
|
|
128
|
+
NotificationCenter.default.post(
|
|
129
|
+
name: .sitePongWatchtowerSessionDidRoll,
|
|
130
|
+
object: nil, userInfo: ["sessionId": sessionId]
|
|
131
|
+
)
|
|
116
132
|
}
|
|
117
133
|
|
|
118
134
|
func stop() {
|
|
@@ -142,6 +158,9 @@ final class WatchtowerEngine {
|
|
|
142
158
|
|
|
143
159
|
var currentSessionId: String? { isRunning ? sessionId : nil }
|
|
144
160
|
|
|
161
|
+
/// The channel events are being tagged with, or nil when not running.
|
|
162
|
+
var currentChannel: String? { isRunning ? channel : nil }
|
|
163
|
+
|
|
145
164
|
private func durationMs() -> UInt64 {
|
|
146
165
|
guard let started = sessionStartedAt else { return 0 }
|
|
147
166
|
return UInt64(max(0, Date().timeIntervalSince(started) * 1000))
|
|
@@ -159,6 +178,7 @@ final class WatchtowerEngine {
|
|
|
159
178
|
os_version: osVersion
|
|
160
179
|
)
|
|
161
180
|
event.install_id = installId
|
|
181
|
+
event.channel = channel
|
|
162
182
|
// Link hints ride only the session_start of a rolled session.
|
|
163
183
|
if type == "session_start" {
|
|
164
184
|
event.previous_session_id = pendingPreviousSessionId
|
|
@@ -254,6 +274,11 @@ final class WatchtowerEngine {
|
|
|
254
274
|
emit(baseEvent(type: "session_start"))
|
|
255
275
|
pendingPreviousSessionId = nil
|
|
256
276
|
pendingSessionGapMs = nil
|
|
277
|
+
// Structural recorder re-keys onto the rolled session id.
|
|
278
|
+
NotificationCenter.default.post(
|
|
279
|
+
name: .sitePongWatchtowerSessionDidRoll,
|
|
280
|
+
object: nil, userInfo: ["sessionId": sessionId]
|
|
281
|
+
)
|
|
257
282
|
}
|
|
258
283
|
|
|
259
284
|
// MARK: - Screen views (§1.1 screen_view)
|