@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.
- package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +14 -1
- package/android/src/main/java/com/sentori/SentoriModule.kt +10 -0
- package/android/src/main/java/com/sentori/SentoriNativeExceptionBridge.kt +75 -0
- package/android/src/main/java/com/sentori/SentoriNativeSignals.kt +32 -0
- package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +153 -0
- package/ios/SentoriModule.swift +11 -0
- package/ios/SentoriNativeExceptionBridge.swift +90 -0
- package/ios/SentoriReplayCapture.swift +147 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +47 -0
- package/lib/capture.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/init.d.ts +16 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +18 -0
- package/lib/init.js.map +1 -1
- package/lib/long-task-monitor.d.ts +11 -0
- package/lib/long-task-monitor.d.ts.map +1 -0
- package/lib/long-task-monitor.js +88 -0
- package/lib/long-task-monitor.js.map +1 -0
- package/lib/native.d.ts +9 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +11 -0
- package/lib/native.js.map +1 -1
- package/lib/replay.d.ts +13 -0
- package/lib/replay.d.ts.map +1 -0
- package/lib/replay.js +111 -0
- package/lib/replay.js.map +1 -0
- package/package.json +2 -2
- package/src/capture.ts +53 -0
- package/src/init.ts +28 -0
- package/src/long-task-monitor.ts +99 -0
- package/src/native.ts +28 -0
- package/src/replay.ts +123 -0
|
@@ -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",
|
|
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
|
+
}
|
package/ios/SentoriModule.swift
CHANGED
|
@@ -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
|
+
}
|
package/lib/capture.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"
|
|
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,
|