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.
@@ -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
- sampleTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in self?.sample() }
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 let secure = (v as? UITextField)?.isSecureTextEntry, secure { attrs["wtSensitive"] = "true" }
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
- if let t = textFor(v), !t.isEmpty { node["text"] = t }
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(), let root = serialize(w, in: w) else { return }
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 post(batch: [[String: Any]], seqStart: Int, viewport: (Int, Int), attempt: Int) {
211
- let payload: [String: Any] = [
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
- guard let body = try? JSONSerialization.data(withJSONObject: payload),
219
- let url = URL(string: "\(endpoint)/api/replay-stream/native") else {
220
- DispatchQueue.main.async { self.sending = false }
221
- return
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitepong",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Official SitePong SDK for error tracking and monitoring",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",