@goliapkg/sentori-react-native 0.9.0 → 0.9.2

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.
@@ -47,6 +47,9 @@ object SentoriCrashHandler {
47
47
  // foreground Activity so it knows which Window to PixelCopy.
48
48
  (appCtx as? android.app.Application)?.let {
49
49
  SentoriScreenshotCapture.register(it)
50
+ // v0.9.6 #2 — replay capture also tracks the activity for
51
+ // wireframe view-tree walks.
52
+ SentoriReplayCapture.register(it)
50
53
  }
51
54
  }
52
55
 
@@ -119,6 +122,16 @@ object SentoriCrashHandler {
119
122
 
120
123
  val error = errorToJson(throwable)
121
124
 
125
+ // v0.9.5 #7 — detect crashes originating from native code (JNI
126
+ // / .so libs). Pure SIGSEGV in a stripped .so won't reach us
127
+ // without breakpad (queued for v1.1), but throws that surface
128
+ // as UnsatisfiedLinkError or have native frames in the stack
129
+ // are tagged so the dashboard can split them out.
130
+ val tags = JSONObject()
131
+ if (SentoriNativeOrigin.looksNative(throwable)) {
132
+ tags.put("native_signal", "true")
133
+ }
134
+
122
135
  val event = JSONObject().apply {
123
136
  put("id", uuidLower())
124
137
  put("timestamp", iso8601Now())
@@ -129,7 +142,7 @@ object SentoriCrashHandler {
129
142
  put("device", device)
130
143
  put("app", app)
131
144
  put("user", JSONObject.NULL)
132
- put("tags", JSONObject())
145
+ put("tags", tags)
133
146
  put("breadcrumbs", JSONArray())
134
147
  put("error", error)
135
148
  put("fingerprint", JSONArray())
@@ -24,6 +24,16 @@ class SentoriModule : Module() {
24
24
  SentoriMobileVitals.startFrameWatch()
25
25
  }
26
26
 
27
+ // v0.9.5 #8 — TurboModule exception bridge readout.
28
+ Function("getRecentNativeException") {
29
+ SentoriNativeExceptionBridge.getRecent()
30
+ }
31
+
32
+ // v0.9.6 #2 — wireframe session replay capture.
33
+ Function("captureWireframe") { maskedIds: List<String> ->
34
+ SentoriReplayCapture.captureWireframe(maskedIds)
35
+ }
36
+
27
37
  // v0.9.4 #1 — Mobile Vitals exposure.
28
38
  Function("markJsBridgeReady") {
29
39
  SentoriMobileVitals.markJsBridgeReady()
@@ -0,0 +1,75 @@
1
+ package com.sentori
2
+
3
+ import java.util.concurrent.ConcurrentLinkedDeque
4
+ import java.util.concurrent.atomic.AtomicLong
5
+
6
+ /**
7
+ * v0.9.5 #8 — partial fix for TurboModule swallowing native
8
+ * exceptions into a generic JSError.
9
+ *
10
+ * Android side mirrors the iOS implementation. Host TurboModule code:
11
+ *
12
+ * try {
13
+ * riskyOperation()
14
+ * } catch (e: Exception) {
15
+ * SentoriNativeExceptionBridge.record(e)
16
+ * throw e
17
+ * }
18
+ *
19
+ * JS-side coerceError calls `getRecent()` and, if an exception
20
+ * within the last 1 s exists, attaches its stack to the resulting
21
+ * sentori event.
22
+ */
23
+ object SentoriNativeExceptionBridge {
24
+
25
+ private const val RING_SIZE = 8
26
+ private const val WINDOW_MS = 1_000L
27
+
28
+ private data class Stash(
29
+ val timestamp: Long,
30
+ val name: String,
31
+ val reason: String,
32
+ val stack: List<String>,
33
+ )
34
+
35
+ private val ring = ConcurrentLinkedDeque<Stash>()
36
+ private val lastPurgeAt = AtomicLong(0)
37
+
38
+ @JvmStatic
39
+ fun record(t: Throwable) {
40
+ val frames = t.stackTrace.take(48).map { it.toString() }
41
+ val stash = Stash(
42
+ timestamp = System.currentTimeMillis(),
43
+ name = t.javaClass.simpleName,
44
+ reason = t.message ?: "",
45
+ stack = frames,
46
+ )
47
+ ring.addLast(stash)
48
+ while (ring.size > RING_SIZE) ring.pollFirst()
49
+ purgeIfDue()
50
+ }
51
+
52
+ @JvmStatic
53
+ fun getRecent(): Map<String, Any?>? {
54
+ purgeIfDue()
55
+ val latest = ring.peekLast() ?: return null
56
+ return mapOf(
57
+ "name" to latest.name,
58
+ "reason" to latest.reason,
59
+ "stack" to latest.stack,
60
+ "ageMs" to (System.currentTimeMillis() - latest.timestamp).toInt(),
61
+ )
62
+ }
63
+
64
+ private fun purgeIfDue() {
65
+ val now = System.currentTimeMillis()
66
+ val last = lastPurgeAt.get()
67
+ if (now - last < 100) return
68
+ lastPurgeAt.set(now)
69
+ val cutoff = now - WINDOW_MS
70
+ val it = ring.iterator()
71
+ while (it.hasNext()) {
72
+ if (it.next().timestamp < cutoff) it.remove()
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,32 @@
1
+ package com.sentori
2
+
3
+ /**
4
+ * v0.9.5 #7 — Android NDK origin detection (stub).
5
+ *
6
+ * Pure-Kotlin heuristics to tag a Throwable as "originated in native
7
+ * code" so the dashboard can show NDK crashes separately from JVM
8
+ * crashes. Real breakpad/crashpad integration (with minidump +
9
+ * dump_syms symbolicator) is queued for v1.1 — see
10
+ * `docs/design/v1-roadmap.md` #7.
11
+ */
12
+ object SentoriNativeOrigin {
13
+
14
+ /** Returns true iff this throwable likely originated in native
15
+ * (NDK / .so / JNI) code. Used by `SentoriCrashHandler.write` to
16
+ * flip the `native_signal` tag on the event. */
17
+ @JvmStatic
18
+ fun looksNative(t: Throwable): Boolean {
19
+ val name = t.javaClass.simpleName
20
+ if (name == "UnsatisfiedLinkError") return true
21
+ // OutOfMemoryError can be either JVM or native allocator;
22
+ // bias toward native since pure-JVM OOM is rare on modern
23
+ // Android with heap auto-growth.
24
+ if (name == "OutOfMemoryError") return true
25
+ return t.stackTrace.any { f ->
26
+ val cls = f.className
27
+ cls.contains("jni", ignoreCase = true) ||
28
+ cls.contains("native", ignoreCase = true) ||
29
+ f.fileName?.endsWith(".so") == true
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,153 @@
1
+ package com.sentori
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Rect
5
+ import android.view.View
6
+ import android.view.ViewGroup
7
+ import android.widget.EditText
8
+ import android.widget.ImageView
9
+ import android.widget.TextView
10
+ import org.json.JSONArray
11
+ import org.json.JSONObject
12
+ import java.lang.ref.WeakReference
13
+
14
+ /**
15
+ * v0.9.6 #2 — wireframe session replay (Android side).
16
+ *
17
+ * Mirrors SentoriReplayCapture.swift. Walks View hierarchy from the
18
+ * current activity's decor view at 1 Hz. Each visible node becomes
19
+ * a JSON dict { kind, x, y, w, h, text?, color? }. Returns one JSON
20
+ * object string per snapshot.
21
+ *
22
+ * Mask: nodes whose `View.tag` (cast to String) matches `maskedIds`
23
+ * render as a single black "mask" rect. Descendants of a masked
24
+ * node are not emitted.
25
+ */
26
+ object SentoriReplayCapture {
27
+
28
+ private const val MAX_NODES = 800
29
+
30
+ @Volatile private var lastActivity: WeakReference<Activity>? = null
31
+
32
+ @JvmStatic
33
+ fun setActivity(activity: Activity?) {
34
+ lastActivity = activity?.let { WeakReference(it) }
35
+ }
36
+
37
+ /** Attach an ActivityLifecycleCallbacks so future
38
+ * `captureWireframe()` calls know which Activity to walk. */
39
+ @JvmStatic
40
+ fun register(application: android.app.Application) {
41
+ application.registerActivityLifecycleCallbacks(object :
42
+ android.app.Application.ActivityLifecycleCallbacks {
43
+ override fun onActivityCreated(a: Activity, b: android.os.Bundle?) { setActivity(a) }
44
+ override fun onActivityStarted(a: Activity) { setActivity(a) }
45
+ override fun onActivityResumed(a: Activity) { setActivity(a) }
46
+ override fun onActivityPaused(a: Activity) {}
47
+ override fun onActivityStopped(a: Activity) {}
48
+ override fun onActivitySaveInstanceState(a: Activity, b: android.os.Bundle) {}
49
+ override fun onActivityDestroyed(a: Activity) {}
50
+ })
51
+ }
52
+
53
+ @JvmStatic
54
+ fun captureWireframe(maskedIds: List<String>): String? {
55
+ val activity = lastActivity?.get() ?: return null
56
+ val root = activity.window?.decorView ?: return null
57
+ if (root.width <= 0 || root.height <= 0) return null
58
+
59
+ val maskedSet = maskedIds.toHashSet()
60
+ val nodes = JSONArray()
61
+ val rect = Rect()
62
+ val rootLoc = IntArray(2).also { root.getLocationInWindow(it) }
63
+ walk(root, false, maskedSet, rootLoc, rect, nodes)
64
+
65
+ val payload = JSONObject().apply {
66
+ put("ts", System.currentTimeMillis())
67
+ put("width", root.width)
68
+ put("height", root.height)
69
+ put("nodes", nodes)
70
+ }
71
+ return payload.toString()
72
+ }
73
+
74
+ private fun walk(
75
+ view: View,
76
+ parentMasked: Boolean,
77
+ maskedSet: Set<String>,
78
+ rootLoc: IntArray,
79
+ scratch: Rect,
80
+ nodes: JSONArray,
81
+ ) {
82
+ if (nodes.length() >= MAX_NODES) return
83
+ if (view.visibility != View.VISIBLE || view.alpha < 0.01) return
84
+
85
+ val viewTag = view.tag as? String
86
+ val isThisMasked = viewTag != null && maskedSet.contains(viewTag)
87
+ val masked = parentMasked || isThisMasked
88
+
89
+ val loc = IntArray(2)
90
+ view.getLocationInWindow(loc)
91
+ val x = loc[0] - rootLoc[0]
92
+ val y = loc[1] - rootLoc[1]
93
+ val w = view.width
94
+ val h = view.height
95
+ if (w <= 0 || h <= 0) return
96
+
97
+ val node = JSONObject().apply {
98
+ put("x", x)
99
+ put("y", y)
100
+ put("w", w)
101
+ put("h", h)
102
+ }
103
+
104
+ var kindEmitted = false
105
+ when {
106
+ masked -> {
107
+ node.put("kind", "mask")
108
+ kindEmitted = true
109
+ }
110
+ view is TextView && !view.text.isNullOrEmpty() -> {
111
+ node.put("kind", "text")
112
+ val text = view.text.toString().let { if (it.length > 200) it.substring(0, 200) else it }
113
+ node.put("text", text)
114
+ node.put("color", colorToHex(view.currentTextColor))
115
+ kindEmitted = true
116
+ }
117
+ view is EditText -> {
118
+ node.put("kind", "text")
119
+ val text = (view.text ?: "").toString().let { if (it.length > 200) it.substring(0, 200) else it }
120
+ node.put("text", text)
121
+ kindEmitted = true
122
+ }
123
+ view is ImageView -> {
124
+ node.put("kind", "image")
125
+ kindEmitted = true
126
+ }
127
+ view.background != null -> {
128
+ node.put("kind", "rect")
129
+ // Background drawables don't always expose color directly.
130
+ // Skip color for non-ColorDrawable; renderer falls back to neutral.
131
+ kindEmitted = true
132
+ }
133
+ }
134
+
135
+ if (kindEmitted) {
136
+ nodes.put(node)
137
+ }
138
+
139
+ if (!masked && view is ViewGroup) {
140
+ for (i in 0 until view.childCount) {
141
+ walk(view.getChildAt(i), masked, maskedSet, rootLoc, scratch, nodes)
142
+ }
143
+ }
144
+ }
145
+
146
+ private fun colorToHex(c: Int): String {
147
+ val a = (c shr 24) and 0xff
148
+ val r = (c shr 16) and 0xff
149
+ val g = (c shr 8) and 0xff
150
+ val b = c and 0xff
151
+ return String.format("#%02X%02X%02X%02X", r, g, b, a)
152
+ }
153
+ }
@@ -19,6 +19,17 @@ public class SentoriModule: Module {
19
19
  SentoriMobileVitals.startFrameWatch()
20
20
  }
21
21
 
22
+ // v0.9.5 #8 — TurboModule exception bridge readout for
23
+ // coerceError to attach native stack to wrapped JSError.
24
+ Function("getRecentNativeException") { () -> [String: Any]? in
25
+ return SentoriNativeExceptionBridge.getRecentException()
26
+ }
27
+
28
+ // v0.9.6 #2 — wireframe session replay capture.
29
+ Function("captureWireframe") { (maskedIds: [String]) -> String? in
30
+ return SentoriReplayCapture.captureWireframe(maskedIds: maskedIds)
31
+ }
32
+
22
33
  // v0.9.4 #1 — Mobile Vitals exposure.
23
34
  Function("markJsBridgeReady") {
24
35
  SentoriMobileVitals.markJsBridgeReady()
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+
3
+ /// v0.9.5 #8 — partial-fix for the "TurboModule swallows NSException
4
+ /// into a generic JSError" gap.
5
+ ///
6
+ /// We can't easily swizzle the C++ ObjCTurboModule call site that does
7
+ /// the swallowing. What we *can* offer is an escape hatch: host code
8
+ /// inside a TurboModule method wraps its native call in `@try @catch`
9
+ /// and calls `SentoriNativeExceptionBridge.record(exception)` from the
10
+ /// catch block. We stash the exception (name + reason + callStackSymbols)
11
+ /// in a static ring with timestamps. When the JS side then receives
12
+ /// the generic JSError that RN wraps it into, `coerceError` checks the
13
+ /// ring for an exception within the last 1 s and attaches the native
14
+ /// stack to the JS error event.
15
+ ///
16
+ /// Usage from a host TurboModule (Swift example):
17
+ ///
18
+ /// @objc func mySensitiveMethod() {
19
+ /// do {
20
+ /// try riskyNativeOperation()
21
+ /// } catch let nsException as NSException {
22
+ /// SentoriNativeExceptionBridge.record(nsException)
23
+ /// throw nsException
24
+ /// }
25
+ /// }
26
+ ///
27
+ /// Or Objective-C:
28
+ ///
29
+ /// @try {
30
+ /// riskyOp();
31
+ /// } @catch (NSException *e) {
32
+ /// [SentoriNativeExceptionBridge recordException:e];
33
+ /// @throw;
34
+ /// }
35
+
36
+ @objc public final class SentoriNativeExceptionBridge: NSObject {
37
+
38
+ private static let RING_SIZE = 8
39
+ private static let WINDOW_MS: Double = 1000
40
+
41
+ private struct Stash {
42
+ let timestamp: Date
43
+ let name: String
44
+ let reason: String
45
+ let stack: [String]
46
+ }
47
+
48
+ private static var ring: [Stash] = []
49
+ private static let lock = NSLock()
50
+
51
+ /// Called from a `@catch` inside a TurboModule method. Records
52
+ /// the exception's name + reason + callStackSymbols for ~1 s so
53
+ /// the JS-side coerceError can pick it up.
54
+ @objc public static func recordException(_ exception: NSException) {
55
+ let stash = Stash(
56
+ timestamp: Date(),
57
+ name: exception.name.rawValue,
58
+ reason: exception.reason ?? "",
59
+ stack: exception.callStackSymbols
60
+ )
61
+ lock.lock()
62
+ defer { lock.unlock() }
63
+ ring.append(stash)
64
+ while ring.count > RING_SIZE {
65
+ ring.removeFirst()
66
+ }
67
+ }
68
+
69
+ /// Called by JS-side bridge. Returns the most recent exception
70
+ /// within the last 1 s, or nil. Does NOT remove from the ring —
71
+ /// the same NSException may surface as multiple JSError frames
72
+ /// across the bridge. Ring is cleared by `purge()` on a timer.
73
+ @objc public static func getRecentException() -> [String: Any]? {
74
+ lock.lock()
75
+ defer { lock.unlock() }
76
+ purgeLocked()
77
+ guard let latest = ring.last else { return nil }
78
+ return [
79
+ "name": latest.name,
80
+ "reason": latest.reason,
81
+ "stack": latest.stack,
82
+ "ageMs": Int(Date().timeIntervalSince(latest.timestamp) * 1000),
83
+ ]
84
+ }
85
+
86
+ private static func purgeLocked() {
87
+ let now = Date()
88
+ ring.removeAll { now.timeIntervalSince($0.timestamp) * 1000 > WINDOW_MS }
89
+ }
90
+ }
@@ -0,0 +1,147 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ /// v0.9.6 #2 — wireframe session replay (iOS side).
5
+ ///
6
+ /// Walks the UIView hierarchy at 1 Hz and serializes each visible
7
+ /// node as a compact JSON dict:
8
+ /// { kind, x, y, w, h, text?, color? }
9
+ ///
10
+ /// Mask: nodes whose `accessibilityIdentifier` matches the JS-side
11
+ /// mask registry (passed in as `maskedIds`) have their text replaced
12
+ /// with "***" and the masked flag set so descendants render as
13
+ /// black-filled rects in the dashboard player.
14
+ ///
15
+ /// Output: one JSON object per snapshot, returned as a string. The
16
+ /// JS side appends each snapshot to a 60-slot ring buffer; on
17
+ /// `captureException` the ring is uploaded as a `replay` attachment
18
+ /// (NDJSON: one snapshot per line).
19
+ @objc public final class SentoriReplayCapture: NSObject {
20
+
21
+ @objc public static func captureWireframe(maskedIds: [String]) -> String? {
22
+ if Thread.isMainThread {
23
+ return captureSync(maskedIds: Set(maskedIds))
24
+ }
25
+ var result: String?
26
+ DispatchQueue.main.sync {
27
+ result = captureSync(maskedIds: Set(maskedIds))
28
+ }
29
+ return result
30
+ }
31
+
32
+ private static func captureSync(maskedIds: Set<String>) -> String? {
33
+ guard let window = keyWindow() else { return nil }
34
+ var nodes: [[String: Any]] = []
35
+ walk(
36
+ view: window,
37
+ parentMasked: false,
38
+ maskedIds: maskedIds,
39
+ window: window,
40
+ nodes: &nodes
41
+ )
42
+ let payload: [String: Any] = [
43
+ "ts": Int(Date().timeIntervalSince1970 * 1000),
44
+ "width": Double(window.bounds.width),
45
+ "height": Double(window.bounds.height),
46
+ "nodes": nodes,
47
+ ]
48
+ if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) {
49
+ return String(data: data, encoding: .utf8)
50
+ }
51
+ return nil
52
+ }
53
+
54
+ private static func keyWindow() -> UIWindow? {
55
+ if #available(iOS 13.0, *) {
56
+ for scene in UIApplication.shared.connectedScenes {
57
+ guard let ws = scene as? UIWindowScene else { continue }
58
+ if let key = ws.windows.first(where: { $0.isKeyWindow }) {
59
+ return key
60
+ }
61
+ if let first = ws.windows.first {
62
+ return first
63
+ }
64
+ }
65
+ }
66
+ return UIApplication.shared.windows.first
67
+ }
68
+
69
+ /// Cap on nodes per snapshot — extremely deep / wide trees can
70
+ /// have thousands of subviews (UICollectionView recyclers).
71
+ private static let MAX_NODES = 800
72
+
73
+ private static func walk(
74
+ view: UIView,
75
+ parentMasked: Bool,
76
+ maskedIds: Set<String>,
77
+ window: UIWindow,
78
+ nodes: inout [[String: Any]]
79
+ ) {
80
+ if nodes.count >= MAX_NODES { return }
81
+ if view.isHidden || view.alpha < 0.01 { return }
82
+
83
+ let isThisMasked = view.accessibilityIdentifier
84
+ .map { maskedIds.contains($0) } ?? false
85
+ let masked = parentMasked || isThisMasked
86
+
87
+ let frame = view.convert(view.bounds, to: window)
88
+ // Skip nodes outside the window bounds (off-screen recyclers).
89
+ if !frame.intersects(window.bounds) {
90
+ return
91
+ }
92
+
93
+ var node: [String: Any] = [
94
+ "x": Double(frame.origin.x),
95
+ "y": Double(frame.origin.y),
96
+ "w": Double(frame.size.width),
97
+ "h": Double(frame.size.height),
98
+ ]
99
+
100
+ if masked {
101
+ node["kind"] = "mask"
102
+ } else if let label = view as? UILabel, let text = label.text, !text.isEmpty {
103
+ node["kind"] = "text"
104
+ node["text"] = text.count > 200 ? String(text.prefix(200)) : text
105
+ if let color = label.textColor.flatMap(colorToHex) {
106
+ node["color"] = color
107
+ }
108
+ } else if let textView = view as? UITextView, let text = textView.text, !text.isEmpty {
109
+ node["kind"] = "text"
110
+ node["text"] = text.count > 200 ? String(text.prefix(200)) : text
111
+ } else if view is UIImageView {
112
+ node["kind"] = "image"
113
+ } else if let bg = view.backgroundColor, let hex = colorToHex(bg), hex != "#00000000" {
114
+ node["kind"] = "rect"
115
+ node["color"] = hex
116
+ }
117
+ // else: invisible container — skip emitting but recurse.
118
+
119
+ if node["kind"] != nil {
120
+ nodes.append(node)
121
+ }
122
+
123
+ if !masked {
124
+ // Don't expose internals of masked subtrees.
125
+ for sub in view.subviews {
126
+ walk(
127
+ view: sub,
128
+ parentMasked: masked,
129
+ maskedIds: maskedIds,
130
+ window: window,
131
+ nodes: &nodes
132
+ )
133
+ }
134
+ }
135
+ }
136
+
137
+ private static func colorToHex(_ color: UIColor?) -> String? {
138
+ guard let c = color else { return nil }
139
+ var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
140
+ c.getRed(&r, green: &g, blue: &b, alpha: &a)
141
+ let ri = max(0, min(255, Int(r * 255)))
142
+ let gi = max(0, min(255, Int(g * 255)))
143
+ let bi = max(0, min(255, Int(b * 255)))
144
+ let ai = max(0, min(255, Int(a * 255)))
145
+ return String(format: "#%02X%02X%02X%02X", ri, gi, bi, ai)
146
+ }
147
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAoD,IAAI,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5F,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAgB5D,eAAO,MAAM,+BAA+B,QAAO,IAElD,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,IAAI,GAAG,IAAI,KAAG,IAE3C,CAAC;AAEF,eAAO,MAAM,OAAO,QAAO,IAAI,GAAG,IAAa,CAAC;AAEhD,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;qDAGiD;IACjD,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAU,OAAO;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,KAAG,OAAO,CAAC,IAAI,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,IAAI,GAAG,MAAM,CAAA;CAAE,CAKxD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,OAAO,KAAK,EAAE,SAAS,aAAa,KAAG,IAqEnE,CAAC;AAiEF,eAAO,MAAM,gBAAgB,UAtIO,KAAK,WAAW,aAAa,KAAG,IAsIxB,CAAC"}
1
+ {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAoD,IAAI,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5F,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAgB5D,eAAO,MAAM,+BAA+B,QAAO,IAElD,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,IAAI,GAAG,IAAI,KAAG,IAE3C,CAAC;AAEF,eAAO,MAAM,OAAO,QAAO,IAAI,GAAG,IAAa,CAAC;AAEhD,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB;;;qDAGiD;IACjD,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAU,OAAO;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,KAAG,OAAO,CAAC,IAAI,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,IAAI,GAAG,MAAM,CAAA;CAAE,CAKxD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,OAAO,KAAK,EAAE,SAAS,aAAa,KAAG,IA2EnE,CAAC;AAyFF,eAAO,MAAM,gBAAgB,UApKO,KAAK,WAAW,aAAa,KAAG,IAoKxB,CAAC"}
package/lib/capture.js CHANGED
@@ -3,6 +3,7 @@ import { addBreadcrumb, getBreadcrumbs } from './breadcrumbs';
3
3
  import { getBundleInfo } from './bundle-info';
4
4
  import { getConfig, isInitialized } from './config';
5
5
  import { getFeatureFlagSnapshot } from './feature-flags';
6
+ import { drainReplay } from './replay';
6
7
  import { clearStateSnapshots, getStateSnapshots } from './state-snapshots';
7
8
  import { symbolicateErrorViaMetro } from './handlers/dev-symbolicate';
8
9
  import { captureScreenshot } from './handlers/screenshot';
@@ -12,6 +13,7 @@ import { getTrailBuffer } from './trail';
12
13
  import { enqueue, sendUserReport, uploadAttachment } from './transport';
13
14
  import { uuidV7 } from './uuid';
14
15
  import { getCachedNetworkType } from './netinfo';
16
+ import { getRecentNativeException } from './native';
15
17
  export { captureStep, __resetTrailForTests } from './trail';
16
18
  let _user = null;
17
19
  // Phase 42 sub-D.08 — per-session screenshot quota. Defaults: 10 in
@@ -121,10 +123,35 @@ export const captureError = (error, extras) => {
121
123
  await captureAndAttachStateSnapshots(event, stateSnapshots);
122
124
  clearStateSnapshots();
123
125
  }
126
+ // v0.9.6 #2 — wireframe replay attachment. drainReplay clears the
127
+ // ring as a side effect so next session's replay starts fresh.
128
+ const replayNdjson = drainReplay();
129
+ if (replayNdjson.length > 0) {
130
+ await captureAndAttachReplay(event, replayNdjson);
131
+ }
124
132
  enqueue(event);
125
133
  };
126
134
  void pipeline();
127
135
  };
136
+ /** v0.9.6 #2 — upload the wireframe replay ring as a `replay`
137
+ * attachment. Plain NDJSON (one snapshot per line) — server may
138
+ * gzip on storage; the network upload is base64. */
139
+ async function captureAndAttachReplay(event, ndjson) {
140
+ try {
141
+ const base64 = typeof globalThis.btoa === 'function'
142
+ ? globalThis.btoa(ndjson)
143
+ : Buffer.from(ndjson, 'utf8').toString('base64');
144
+ const meta = await uploadAttachment(event.id, 'replay', { base64, mediaType: 'application/x-ndjson' }, { source: 'js' });
145
+ if (meta) {
146
+ if (!event.attachments)
147
+ event.attachments = [];
148
+ event.attachments.push(meta);
149
+ }
150
+ }
151
+ catch {
152
+ // best-effort
153
+ }
154
+ }
128
155
  /** v0.9.2 +S2 — upload the rolling state-snapshot ring as a
129
156
  * `stateSnapshot` attachment so the dashboard time-travel viewer can
130
157
  * scrub through diffs alongside the breadcrumb timeline. */
@@ -219,6 +246,26 @@ const errorToObject = (error) => {
219
246
  if (causeRaw instanceof Error) {
220
247
  cause = errorToObject(causeRaw);
221
248
  }
249
+ // v0.9.5 #8 — TurboModule swallowed-exception bridge. If the host
250
+ // wrapped a native call with `@try @catch + recordException`, the
251
+ // native ring may hold a fresh entry (< 1 s old). Synthesize that
252
+ // as a `cause` so the JS event includes the original native stack.
253
+ if (cause === null) {
254
+ const recent = getRecentNativeException();
255
+ if (recent && recent.ageMs <= 1500) {
256
+ cause = {
257
+ type: recent.name || 'NativeException',
258
+ message: recent.reason,
259
+ stack: recent.stack.map((line, i) => ({
260
+ function: line.trim(),
261
+ file: '<native>',
262
+ inApp: false,
263
+ line: i + 1,
264
+ })),
265
+ cause: null,
266
+ };
267
+ }
268
+ }
222
269
  return {
223
270
  type: error.name || 'Error',
224
271
  message: error.message,