sitepong 0.2.7 → 0.2.9
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.
|
@@ -146,6 +146,32 @@ public class SitePongScreenRecorderModule: Module {
|
|
|
146
146
|
Function("watchtowerGetSessionId") { () -> String? in
|
|
147
147
|
return Watchtower.sessionId
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
// MARK: - Structural replay thin bridge
|
|
151
|
+
// Forwards to SitePongStructuralCapture (UIView-tree structural capture →
|
|
152
|
+
// POST /api/replay-stream/native → server-side differ). Complements the
|
|
153
|
+
// screenshot-based Watchtower engine above.
|
|
154
|
+
|
|
155
|
+
Function("structuralStart") { (config: [String: Any]) in
|
|
156
|
+
let apiKey = config["apiKey"] as? String ?? ""
|
|
157
|
+
let projectId = config["projectId"] as? String
|
|
158
|
+
let endpoint = config["endpoint"] as? String ?? ""
|
|
159
|
+
let sessionId = config["sessionId"] as? String ?? "wt-\(Int(Date().timeIntervalSince1970 * 1000))"
|
|
160
|
+
let platform = config["platform"] as? String ?? "ios"
|
|
161
|
+
guard !endpoint.isEmpty, !apiKey.isEmpty else { return }
|
|
162
|
+
SitePongStructuralCapture.shared.start(
|
|
163
|
+
endpoint: endpoint, apiKey: apiKey, projectId: projectId,
|
|
164
|
+
sessionId: sessionId, platform: platform
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
Function("structuralStop") {
|
|
169
|
+
SitePongStructuralCapture.shared.stop()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
Function("structuralSetScreen") { (name: String) in
|
|
173
|
+
SitePongStructuralCapture.shared.setScreen(name)
|
|
174
|
+
}
|
|
149
175
|
}
|
|
150
176
|
|
|
151
177
|
/// Find a UIView by React tag across all key windows (mirrors
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// SitePong — NATIVE (UIKit/Swift) structural session-replay capture.
|
|
2
|
+
//
|
|
3
|
+
// The RN adapter sources its tree from the React fiber tree; this native adapter
|
|
4
|
+
// sources it from the UIView hierarchy — the same traversal WatchtowerCore
|
|
5
|
+
// already does for redaction (Screenshotter walks rootViewController's subviews).
|
|
6
|
+
// Each UIView is serialized to a node with its REAL frame (native gives exact
|
|
7
|
+
// geometry, unlike RN where the dashboard replays flexbox), its class-derived
|
|
8
|
+
// tag, text, and accessibilityIdentifier (== the RN testID). Snapshots + taps
|
|
9
|
+
// are POSTed to the ingest server, where the SAME @sitepong/watchtower-replay
|
|
10
|
+
// core diffs them into the SAME rrweb-style message stream the RN adapter posts
|
|
11
|
+
// directly (POST /api/replay-stream/native → server-side differ).
|
|
12
|
+
//
|
|
13
|
+
// One platform-neutral core, two capture adapters (fiber / UIView), one stored
|
|
14
|
+
// stream, one dashboard Player.
|
|
15
|
+
import UIKit
|
|
16
|
+
|
|
17
|
+
public final class SitePongStructuralCapture {
|
|
18
|
+
public static let shared = SitePongStructuralCapture()
|
|
19
|
+
|
|
20
|
+
private var endpoint = ""
|
|
21
|
+
private var apiKey = ""
|
|
22
|
+
private var projectId: String?
|
|
23
|
+
private var platform = "ios"
|
|
24
|
+
private var sessionId = ""
|
|
25
|
+
private var startedAt = Date()
|
|
26
|
+
private var ids: [ObjectIdentifier: Int] = [:]
|
|
27
|
+
private var nextId = 1
|
|
28
|
+
private var events: [[String: Any]] = []
|
|
29
|
+
private var screen = ""
|
|
30
|
+
private var sampleTimer: Timer?
|
|
31
|
+
private var flushTimer: Timer?
|
|
32
|
+
private var running = false
|
|
33
|
+
|
|
34
|
+
// Incremental delivery: count of events CONFIRMED stored (== next seq_start).
|
|
35
|
+
// The device ships only the delta each flush and clears its buffer, so memory
|
|
36
|
+
// stays bounded and the uplink never re-sends the whole session. Node ids
|
|
37
|
+
// (`ids`/`nextId`) stay PERSISTENT across flushes — the server stitches the
|
|
38
|
+
// deltas by id, so resetting them would corrupt the tree.
|
|
39
|
+
private var flushedEventCount = 0
|
|
40
|
+
private var sending = false
|
|
41
|
+
|
|
42
|
+
private func now() -> Int { Int(Date().timeIntervalSince(startedAt) * 1000) }
|
|
43
|
+
|
|
44
|
+
/// Start capturing. `endpoint` is the ingest base URL (e.g.
|
|
45
|
+
/// https://ingest.sitepong.com); `apiKey` is the project key.
|
|
46
|
+
public func start(endpoint: String, apiKey: String, projectId: String?, sessionId: String, platform: String = "ios") {
|
|
47
|
+
guard !running else { return }
|
|
48
|
+
self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint
|
|
49
|
+
self.apiKey = apiKey
|
|
50
|
+
self.projectId = projectId
|
|
51
|
+
self.sessionId = sessionId
|
|
52
|
+
self.platform = platform
|
|
53
|
+
self.startedAt = Date()
|
|
54
|
+
self.running = true
|
|
55
|
+
|
|
56
|
+
// Global tap capture: a recognizer on the key window that never steals the
|
|
57
|
+
// touch (cancelsTouchesInView=false, simultaneous) — the native analogue
|
|
58
|
+
// of the RN touch wrapper.
|
|
59
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
|
60
|
+
guard let self = self, let w = self.keyWindow() else { return }
|
|
61
|
+
let g = UITapGestureRecognizer(target: self, action: #selector(self.onTap(_:)))
|
|
62
|
+
g.cancelsTouchesInView = false
|
|
63
|
+
g.delegate = SPStructuralTapDelegate.shared
|
|
64
|
+
w.addGestureRecognizer(g)
|
|
65
|
+
}
|
|
66
|
+
sampleTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in self?.sample() }
|
|
67
|
+
flushTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in self?.flush() }
|
|
68
|
+
|
|
69
|
+
// Flush on background so the tail isn't stranded when timers suspend.
|
|
70
|
+
NotificationCenter.default.addObserver(
|
|
71
|
+
self, selector: #selector(onBackground),
|
|
72
|
+
name: UIApplication.didEnterBackgroundNotification, object: nil
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@objc private func onBackground() {
|
|
77
|
+
guard running else { return }
|
|
78
|
+
flush()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public func setScreen(_ name: String) {
|
|
82
|
+
guard running, name != screen else { return }
|
|
83
|
+
sample()
|
|
84
|
+
events.append(["t": now(), "type": "screen", "name": name])
|
|
85
|
+
screen = name
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public func stop() {
|
|
89
|
+
guard running else { return }
|
|
90
|
+
running = false
|
|
91
|
+
sampleTimer?.invalidate(); sampleTimer = nil
|
|
92
|
+
flushTimer?.invalidate(); flushTimer = nil
|
|
93
|
+
NotificationCenter.default.removeObserver(
|
|
94
|
+
self, name: UIApplication.didEnterBackgroundNotification, object: nil
|
|
95
|
+
)
|
|
96
|
+
flush()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private func idFor(_ v: UIView) -> Int {
|
|
100
|
+
let k = ObjectIdentifier(v)
|
|
101
|
+
if let id = ids[k] { return id }
|
|
102
|
+
let id = nextId; nextId += 1; ids[k] = id
|
|
103
|
+
return id
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func keyWindow() -> UIWindow? {
|
|
107
|
+
UIApplication.shared.connectedScenes
|
|
108
|
+
.compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }
|
|
109
|
+
.first(where: { $0.isKeyWindow }) ??
|
|
110
|
+
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }.first
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// MARK: - tree serialization
|
|
114
|
+
private func tagFor(_ v: UIView) -> String {
|
|
115
|
+
switch v {
|
|
116
|
+
case is UILabel: return "Text"
|
|
117
|
+
case is UIImageView: return "Image"
|
|
118
|
+
case is UITextField, is UITextView: return "TextInput"
|
|
119
|
+
case is UIScrollView: return "ScrollView"
|
|
120
|
+
case is UIButton, is UIControl: return "Pressable"
|
|
121
|
+
default: return "View"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private func hex(_ c: UIColor?) -> String? {
|
|
126
|
+
guard let c = c else { return nil }
|
|
127
|
+
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
128
|
+
guard c.getRed(&r, green: &g, blue: &b, alpha: &a), a > 0.01 else { return nil }
|
|
129
|
+
return String(format: "#%02x%02x%02x", Int(r * 255), Int(g * 255), Int(b * 255))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func textFor(_ v: UIView) -> String? {
|
|
133
|
+
if let l = v as? UILabel { return l.text }
|
|
134
|
+
if let b = v as? UIButton { return b.title(for: .normal) ?? b.titleLabel?.text }
|
|
135
|
+
if let t = v as? UITextField { return t.isSecureTextEntry ? nil : t.text }
|
|
136
|
+
return nil
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func serialize(_ v: UIView, in window: UIWindow) -> [String: Any]? {
|
|
140
|
+
if v.isHidden || v.alpha < 0.02 { return nil }
|
|
141
|
+
let f = v.convert(v.bounds, to: window)
|
|
142
|
+
var attrs: [String: String] = [:]
|
|
143
|
+
if let aid = v.accessibilityIdentifier, !aid.isEmpty { attrs["testID"] = aid }
|
|
144
|
+
if let al = v.accessibilityLabel, !al.isEmpty { attrs["accessibilityLabel"] = al }
|
|
145
|
+
if let bg = hex(v.backgroundColor) { attrs["backgroundColor"] = bg }
|
|
146
|
+
if v.layer.cornerRadius > 0 { attrs["borderRadius"] = String(Int(v.layer.cornerRadius)) }
|
|
147
|
+
if let secure = (v as? UITextField)?.isSecureTextEntry, secure { attrs["wtSensitive"] = "true" }
|
|
148
|
+
if let l = v as? UILabel {
|
|
149
|
+
attrs["color"] = hex(l.textColor) ?? "#111111"
|
|
150
|
+
attrs["fontSize"] = String(Int(l.font.pointSize))
|
|
151
|
+
}
|
|
152
|
+
attrs["frame"] = "\(Int(f.origin.x)),\(Int(f.origin.y)),\(Int(f.size.width)),\(Int(f.size.height))"
|
|
153
|
+
let tag = tagFor(v)
|
|
154
|
+
var node: [String: Any] = ["id": idFor(v), "tag": tag, "attrs": attrs]
|
|
155
|
+
if let t = textFor(v), !t.isEmpty { node["text"] = t }
|
|
156
|
+
// Don't descend into text/controls' internal subviews (keeps the tree clean).
|
|
157
|
+
if !(v is UILabel) && !(v is UIButton) {
|
|
158
|
+
var kids: [[String: Any]] = []
|
|
159
|
+
for sub in v.subviews { if let s = serialize(sub, in: window) { kids.append(s) } }
|
|
160
|
+
node["children"] = kids
|
|
161
|
+
} else {
|
|
162
|
+
node["children"] = []
|
|
163
|
+
}
|
|
164
|
+
return node
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private func sample() {
|
|
168
|
+
guard running, let w = keyWindow(), let root = serialize(w, in: w) else { return }
|
|
169
|
+
events.append(["t": now(), "type": "snapshot", "tree": root])
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@objc private func onTap(_ g: UITapGestureRecognizer) {
|
|
173
|
+
guard running, let w = keyWindow() else { return }
|
|
174
|
+
let p = g.location(in: w)
|
|
175
|
+
let hit = w.hitTest(p, with: nil)
|
|
176
|
+
sample()
|
|
177
|
+
var nodeId = 0
|
|
178
|
+
if let hit = hit { nodeId = idFor(deepestInteresting(hit)) }
|
|
179
|
+
let bounds = w.bounds
|
|
180
|
+
events.append([
|
|
181
|
+
"t": now(), "type": "tap", "nodeId": nodeId,
|
|
182
|
+
"x": Double(p.x / max(bounds.width, 1)), "y": Double(p.y / max(bounds.height, 1)),
|
|
183
|
+
])
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private func deepestInteresting(_ v: UIView) -> UIView {
|
|
187
|
+
var cur: UIView? = v
|
|
188
|
+
while let c = cur {
|
|
189
|
+
if c.accessibilityIdentifier?.isEmpty == false { return c }
|
|
190
|
+
if c is UIControl { return c }
|
|
191
|
+
cur = c.superview
|
|
192
|
+
}
|
|
193
|
+
return v
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Ship the pending event delta (only what's accumulated since the last
|
|
197
|
+
/// confirmed flush), then clear the device buffer. The batch carries its
|
|
198
|
+
/// `seq_start` so the server dedups retries; a failed batch is re-queued so
|
|
199
|
+
/// nothing is lost offline. All buffer mutation stays on the main thread.
|
|
200
|
+
private func flush() {
|
|
201
|
+
guard !endpoint.isEmpty, !sending, !events.isEmpty, let w = keyWindow() else { return }
|
|
202
|
+
let batch = events
|
|
203
|
+
let seqStart = flushedEventCount
|
|
204
|
+
events.removeAll(keepingCapacity: true) // drain the device buffer
|
|
205
|
+
sending = true
|
|
206
|
+
post(batch: batch, seqStart: seqStart,
|
|
207
|
+
viewport: (Int(w.bounds.width), Int(w.bounds.height)), attempt: 0)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private func post(batch: [[String: Any]], seqStart: Int, viewport: (Int, Int), attempt: Int) {
|
|
211
|
+
let payload: [String: Any] = [
|
|
212
|
+
"session_id": sessionId,
|
|
213
|
+
"platform": platform,
|
|
214
|
+
"viewport": ["w": viewport.0, "h": viewport.1],
|
|
215
|
+
"seq_start": seqStart,
|
|
216
|
+
"events": batch,
|
|
217
|
+
]
|
|
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
|
+
}
|
|
223
|
+
var req = URLRequest(url: url)
|
|
224
|
+
req.httpMethod = "POST"
|
|
225
|
+
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
226
|
+
req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
|
|
227
|
+
if let pid = projectId { req.setValue(pid, forHTTPHeaderField: "X-Project-ID") }
|
|
228
|
+
req.httpBody = body
|
|
229
|
+
|
|
230
|
+
URLSession.shared.dataTask(with: req) { [weak self] _, resp, err in
|
|
231
|
+
guard let self = self else { return }
|
|
232
|
+
let ok = (resp as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } ?? false
|
|
233
|
+
DispatchQueue.main.async {
|
|
234
|
+
if ok && err == nil {
|
|
235
|
+
self.flushedEventCount = seqStart + batch.count
|
|
236
|
+
self.sending = false
|
|
237
|
+
} else if attempt < 3 {
|
|
238
|
+
let delay = pow(2.0, Double(attempt)) // 1,2,4s
|
|
239
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
|
240
|
+
self.post(batch: batch, seqStart: seqStart, viewport: viewport, attempt: attempt + 1)
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
// Give up on this attempt: re-queue at the front so the seq
|
|
244
|
+
// stays contiguous and the delta re-sends on the next flush.
|
|
245
|
+
self.events.insert(contentsOf: batch, at: 0)
|
|
246
|
+
self.sending = false
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}.resume()
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/// Allow the capture recognizer to run alongside buttons/scroll gestures.
|
|
254
|
+
final class SPStructuralTapDelegate: NSObject, UIGestureRecognizerDelegate {
|
|
255
|
+
static let shared = SPStructuralTapDelegate()
|
|
256
|
+
func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
|
|
257
|
+
func gestureRecognizer(_ g: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { true }
|
|
258
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sitepong",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "Official SitePong SDK for error tracking and monitoring",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -157,22 +157,23 @@
|
|
|
157
157
|
"web-vitals": "^4.2.4"
|
|
158
158
|
},
|
|
159
159
|
"devDependencies": {
|
|
160
|
+
"@react-native-async-storage/async-storage": "^2.2.0",
|
|
161
|
+
"@sitepong/capture-core": "*",
|
|
162
|
+
"@sitepong/watchtower-recorder": "*",
|
|
160
163
|
"@types/express": "^5.0.6",
|
|
161
164
|
"@types/node": "^20.10.0",
|
|
162
165
|
"@types/react": "^19.2.9",
|
|
163
166
|
"@typescript-eslint/eslint-plugin": "^6.13.0",
|
|
164
167
|
"@typescript-eslint/parser": "^6.13.0",
|
|
165
168
|
"eslint": "^8.55.0",
|
|
169
|
+
"expo-modules-core": "~3.0.0",
|
|
170
|
+
"expo-sqlite": "~16.0.4",
|
|
166
171
|
"next": "^16.1.4",
|
|
167
172
|
"react": "^19.2.3",
|
|
173
|
+
"react-native": "0.83.2",
|
|
168
174
|
"tsup": "^8.0.1",
|
|
169
175
|
"typescript": "^5.3.0",
|
|
170
|
-
"vitest": "^1.0.0"
|
|
171
|
-
"react-native": "0.83.2",
|
|
172
|
-
"@react-native-async-storage/async-storage": "^2.2.0",
|
|
173
|
-
"expo-sqlite": "~16.0.4",
|
|
174
|
-
"expo-modules-core": "~3.0.0",
|
|
175
|
-
"@sitepong/capture-core": "*"
|
|
176
|
+
"vitest": "^1.0.0"
|
|
176
177
|
},
|
|
177
178
|
"engines": {
|
|
178
179
|
"node": ">=16.0.0"
|