sitepong 0.2.13 → 0.2.14

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.
@@ -141,8 +141,8 @@ public class SitePongScreenRecorderModule: Module {
141
141
  Watchtower.captureScreenshot()
142
142
  }
143
143
 
144
- Function("watchtowerSetUser") { (distinctId: String?) in
145
- Watchtower.setUser(distinctId)
144
+ Function("watchtowerSetUser") { (distinctId: String?, email: String?, name: String?) in
145
+ Watchtower.setUser(distinctId, email: email, name: name)
146
146
  }
147
147
 
148
148
  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.
@@ -113,6 +113,13 @@ final class WatchtowerEngine {
113
113
  observeBackground()
114
114
  isRunning = true
115
115
  emit(baseEvent(type: "session_start"))
116
+ // Announce the session so the structural recorder can share this id and
117
+ // roll in lockstep (the native analogue of the RN provider adopting the
118
+ // native session id).
119
+ NotificationCenter.default.post(
120
+ name: .sitePongWatchtowerSessionDidRoll,
121
+ object: nil, userInfo: ["sessionId": sessionId]
122
+ )
116
123
  }
117
124
 
118
125
  func stop() {
@@ -254,6 +261,11 @@ final class WatchtowerEngine {
254
261
  emit(baseEvent(type: "session_start"))
255
262
  pendingPreviousSessionId = nil
256
263
  pendingSessionGapMs = nil
264
+ // Structural recorder re-keys onto the rolled session id.
265
+ NotificationCenter.default.post(
266
+ name: .sitePongWatchtowerSessionDidRoll,
267
+ object: nil, userInfo: ["sessionId": sessionId]
268
+ )
257
269
  }
258
270
 
259
271
  // 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.14",
4
4
  "description": "Official SitePong SDK for error tracking and monitoring",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",