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.
@@ -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
- public static func setUser(_ distinctId: String?) {
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 { WatchtowerEngine.shared.setUser(distinctId) }
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.6",
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"