sitepong 0.2.6 → 0.2.8
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/entries/rn.d.ts +54 -1
- package/dist/entries/rn.js +782 -48
- package/dist/entries/rn.js.map +1 -1
- package/ios/ScreenRecorder/ScreenRecorderModule.swift +26 -0
- package/ios/WatchtowerCore/StructuralRecorder.swift +199 -0
- package/ios/WatchtowerCore/TapEvent.swift +5 -0
- package/ios/WatchtowerCore/Watchtower.swift +9 -3
- package/ios/WatchtowerCore/WatchtowerEngine.swift +13 -1
- package/package.json +8 -7
|
@@ -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,199 @@
|
|
|
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
|
+
private func now() -> Int { Int(Date().timeIntervalSince(startedAt) * 1000) }
|
|
35
|
+
|
|
36
|
+
/// Start capturing. `endpoint` is the ingest base URL (e.g.
|
|
37
|
+
/// https://ingest.sitepong.com); `apiKey` is the project key.
|
|
38
|
+
public func start(endpoint: String, apiKey: String, projectId: String?, sessionId: String, platform: String = "ios") {
|
|
39
|
+
guard !running else { return }
|
|
40
|
+
self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint
|
|
41
|
+
self.apiKey = apiKey
|
|
42
|
+
self.projectId = projectId
|
|
43
|
+
self.sessionId = sessionId
|
|
44
|
+
self.platform = platform
|
|
45
|
+
self.startedAt = Date()
|
|
46
|
+
self.running = true
|
|
47
|
+
|
|
48
|
+
// Global tap capture: a recognizer on the key window that never steals the
|
|
49
|
+
// touch (cancelsTouchesInView=false, simultaneous) — the native analogue
|
|
50
|
+
// of the RN touch wrapper.
|
|
51
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
|
52
|
+
guard let self = self, let w = self.keyWindow() else { return }
|
|
53
|
+
let g = UITapGestureRecognizer(target: self, action: #selector(self.onTap(_:)))
|
|
54
|
+
g.cancelsTouchesInView = false
|
|
55
|
+
g.delegate = SPStructuralTapDelegate.shared
|
|
56
|
+
w.addGestureRecognizer(g)
|
|
57
|
+
}
|
|
58
|
+
sampleTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in self?.sample() }
|
|
59
|
+
flushTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in self?.flush() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public func setScreen(_ name: String) {
|
|
63
|
+
guard running, name != screen else { return }
|
|
64
|
+
sample()
|
|
65
|
+
events.append(["t": now(), "type": "screen", "name": name])
|
|
66
|
+
screen = name
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public func stop() {
|
|
70
|
+
guard running else { return }
|
|
71
|
+
running = false
|
|
72
|
+
sampleTimer?.invalidate(); sampleTimer = nil
|
|
73
|
+
flushTimer?.invalidate(); flushTimer = nil
|
|
74
|
+
flush()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func idFor(_ v: UIView) -> Int {
|
|
78
|
+
let k = ObjectIdentifier(v)
|
|
79
|
+
if let id = ids[k] { return id }
|
|
80
|
+
let id = nextId; nextId += 1; ids[k] = id
|
|
81
|
+
return id
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func keyWindow() -> UIWindow? {
|
|
85
|
+
UIApplication.shared.connectedScenes
|
|
86
|
+
.compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }
|
|
87
|
+
.first(where: { $0.isKeyWindow }) ??
|
|
88
|
+
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }.first
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MARK: - tree serialization
|
|
92
|
+
private func tagFor(_ v: UIView) -> String {
|
|
93
|
+
switch v {
|
|
94
|
+
case is UILabel: return "Text"
|
|
95
|
+
case is UIImageView: return "Image"
|
|
96
|
+
case is UITextField, is UITextView: return "TextInput"
|
|
97
|
+
case is UIScrollView: return "ScrollView"
|
|
98
|
+
case is UIButton, is UIControl: return "Pressable"
|
|
99
|
+
default: return "View"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func hex(_ c: UIColor?) -> String? {
|
|
104
|
+
guard let c = c else { return nil }
|
|
105
|
+
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
|
106
|
+
guard c.getRed(&r, green: &g, blue: &b, alpha: &a), a > 0.01 else { return nil }
|
|
107
|
+
return String(format: "#%02x%02x%02x", Int(r * 255), Int(g * 255), Int(b * 255))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func textFor(_ v: UIView) -> String? {
|
|
111
|
+
if let l = v as? UILabel { return l.text }
|
|
112
|
+
if let b = v as? UIButton { return b.title(for: .normal) ?? b.titleLabel?.text }
|
|
113
|
+
if let t = v as? UITextField { return t.isSecureTextEntry ? nil : t.text }
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private func serialize(_ v: UIView, in window: UIWindow) -> [String: Any]? {
|
|
118
|
+
if v.isHidden || v.alpha < 0.02 { return nil }
|
|
119
|
+
let f = v.convert(v.bounds, to: window)
|
|
120
|
+
var attrs: [String: String] = [:]
|
|
121
|
+
if let aid = v.accessibilityIdentifier, !aid.isEmpty { attrs["testID"] = aid }
|
|
122
|
+
if let al = v.accessibilityLabel, !al.isEmpty { attrs["accessibilityLabel"] = al }
|
|
123
|
+
if let bg = hex(v.backgroundColor) { attrs["backgroundColor"] = bg }
|
|
124
|
+
if v.layer.cornerRadius > 0 { attrs["borderRadius"] = String(Int(v.layer.cornerRadius)) }
|
|
125
|
+
if let secure = (v as? UITextField)?.isSecureTextEntry, secure { attrs["wtSensitive"] = "true" }
|
|
126
|
+
if let l = v as? UILabel {
|
|
127
|
+
attrs["color"] = hex(l.textColor) ?? "#111111"
|
|
128
|
+
attrs["fontSize"] = String(Int(l.font.pointSize))
|
|
129
|
+
}
|
|
130
|
+
attrs["frame"] = "\(Int(f.origin.x)),\(Int(f.origin.y)),\(Int(f.size.width)),\(Int(f.size.height))"
|
|
131
|
+
let tag = tagFor(v)
|
|
132
|
+
var node: [String: Any] = ["id": idFor(v), "tag": tag, "attrs": attrs]
|
|
133
|
+
if let t = textFor(v), !t.isEmpty { node["text"] = t }
|
|
134
|
+
// Don't descend into text/controls' internal subviews (keeps the tree clean).
|
|
135
|
+
if !(v is UILabel) && !(v is UIButton) {
|
|
136
|
+
var kids: [[String: Any]] = []
|
|
137
|
+
for sub in v.subviews { if let s = serialize(sub, in: window) { kids.append(s) } }
|
|
138
|
+
node["children"] = kids
|
|
139
|
+
} else {
|
|
140
|
+
node["children"] = []
|
|
141
|
+
}
|
|
142
|
+
return node
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private func sample() {
|
|
146
|
+
guard running, let w = keyWindow(), let root = serialize(w, in: w) else { return }
|
|
147
|
+
events.append(["t": now(), "type": "snapshot", "tree": root])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@objc private func onTap(_ g: UITapGestureRecognizer) {
|
|
151
|
+
guard running, let w = keyWindow() else { return }
|
|
152
|
+
let p = g.location(in: w)
|
|
153
|
+
let hit = w.hitTest(p, with: nil)
|
|
154
|
+
sample()
|
|
155
|
+
var nodeId = 0
|
|
156
|
+
if let hit = hit { nodeId = idFor(deepestInteresting(hit)) }
|
|
157
|
+
let bounds = w.bounds
|
|
158
|
+
events.append([
|
|
159
|
+
"t": now(), "type": "tap", "nodeId": nodeId,
|
|
160
|
+
"x": Double(p.x / max(bounds.width, 1)), "y": Double(p.y / max(bounds.height, 1)),
|
|
161
|
+
])
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private func deepestInteresting(_ v: UIView) -> UIView {
|
|
165
|
+
var cur: UIView? = v
|
|
166
|
+
while let c = cur {
|
|
167
|
+
if c.accessibilityIdentifier?.isEmpty == false { return c }
|
|
168
|
+
if c is UIControl { return c }
|
|
169
|
+
cur = c.superview
|
|
170
|
+
}
|
|
171
|
+
return v
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private func flush() {
|
|
175
|
+
guard !endpoint.isEmpty, !events.isEmpty, let w = keyWindow() else { return }
|
|
176
|
+
let payload: [String: Any] = [
|
|
177
|
+
"session_id": sessionId,
|
|
178
|
+
"platform": platform,
|
|
179
|
+
"viewport": ["w": Int(w.bounds.width), "h": Int(w.bounds.height)],
|
|
180
|
+
"events": events,
|
|
181
|
+
]
|
|
182
|
+
guard let body = try? JSONSerialization.data(withJSONObject: payload),
|
|
183
|
+
let url = URL(string: "\(endpoint)/api/replay-stream/native") else { return }
|
|
184
|
+
var req = URLRequest(url: url)
|
|
185
|
+
req.httpMethod = "POST"
|
|
186
|
+
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
187
|
+
req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
|
|
188
|
+
if let pid = projectId { req.setValue(pid, forHTTPHeaderField: "X-Project-ID") }
|
|
189
|
+
req.httpBody = body
|
|
190
|
+
URLSession.shared.dataTask(with: req).resume()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// Allow the capture recognizer to run alongside buttons/scroll gestures.
|
|
195
|
+
final class SPStructuralTapDelegate: NSObject, UIGestureRecognizerDelegate {
|
|
196
|
+
static let shared = SPStructuralTapDelegate()
|
|
197
|
+
func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
|
|
198
|
+
func gestureRecognizer(_ g: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { true }
|
|
199
|
+
}
|
|
@@ -15,6 +15,11 @@ public struct CaptureEvent: Codable {
|
|
|
15
15
|
public var device_model: String?
|
|
16
16
|
public var os_version: String?
|
|
17
17
|
|
|
18
|
+
// identity traits — only populated on an "identify" event (Watchtower.setUser
|
|
19
|
+
// with email/name), so PII is sent once per identify, not on every tap.
|
|
20
|
+
public var email: String?
|
|
21
|
+
public var name: String?
|
|
22
|
+
|
|
18
23
|
// screen_view
|
|
19
24
|
public var screen_name: String?
|
|
20
25
|
public var prev_screen_name: String?
|
|
@@ -58,12 +58,18 @@ public enum Watchtower {
|
|
|
58
58
|
/// Identity (§1.1 distinct_id): stamped on every subsequent event. Pass nil
|
|
59
59
|
/// to return to anonymous. The RN layer feeds the SDK's identify() through
|
|
60
60
|
/// this; native hosts call it directly.
|
|
61
|
-
|
|
61
|
+
///
|
|
62
|
+
/// Optionally pass `email` / `name` traits: they are sent once as an
|
|
63
|
+
/// `identify` event (not stamped on every tap) so the dashboard can label
|
|
64
|
+
/// the user by name/email instead of a raw id.
|
|
65
|
+
public static func setUser(_ distinctId: String?, email: String? = nil, name: String? = nil) {
|
|
62
66
|
#if canImport(UIKit)
|
|
63
67
|
if Thread.isMainThread {
|
|
64
|
-
WatchtowerEngine.shared.setUser(distinctId)
|
|
68
|
+
WatchtowerEngine.shared.setUser(distinctId, email: email, name: name)
|
|
65
69
|
} else {
|
|
66
|
-
DispatchQueue.main.async {
|
|
70
|
+
DispatchQueue.main.async {
|
|
71
|
+
WatchtowerEngine.shared.setUser(distinctId, email: email, name: name)
|
|
72
|
+
}
|
|
67
73
|
}
|
|
68
74
|
#endif
|
|
69
75
|
}
|
|
@@ -15,6 +15,9 @@ final class WatchtowerEngine {
|
|
|
15
15
|
private var appVersion: String?
|
|
16
16
|
/// Identity from Watchtower.setUser (§1.1 distinct_id). nil = anonymous.
|
|
17
17
|
private var distinctId: String?
|
|
18
|
+
/// Optional identity traits, sent once via an "identify" event.
|
|
19
|
+
private var userEmail: String?
|
|
20
|
+
private var userName: String?
|
|
18
21
|
/// Hardware identifier (utsname.machine, e.g. "iPhone17,1") + OS version,
|
|
19
22
|
/// stamped on every event (§1.1).
|
|
20
23
|
private var deviceModel: String?
|
|
@@ -103,8 +106,17 @@ final class WatchtowerEngine {
|
|
|
103
106
|
transport = nil
|
|
104
107
|
}
|
|
105
108
|
|
|
106
|
-
func setUser(_ id: String?) {
|
|
109
|
+
func setUser(_ id: String?, email: String? = nil, name: String? = nil) {
|
|
107
110
|
distinctId = id
|
|
111
|
+
userEmail = email
|
|
112
|
+
userName = name
|
|
113
|
+
// Emit a one-shot identify so downstream can attach name/email to this
|
|
114
|
+
// distinct_id without stamping PII on every tap. No-op until running.
|
|
115
|
+
guard isRunning, id != nil || email != nil || name != nil else { return }
|
|
116
|
+
var ev = baseEvent(type: "identify")
|
|
117
|
+
ev.email = email
|
|
118
|
+
ev.name = name
|
|
119
|
+
emit(ev)
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
var currentSessionId: String? { isRunning ? sessionId : nil }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sitepong",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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"
|