@goliapkg/sentori-react-native 1.0.0-rc.2 → 1.0.0-rc.4
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/SentoriReplayCapture.kt +125 -57
- package/ios/SentoriReplayCapture.swift +38 -7
- package/lib/base64.d.ts +25 -0
- package/lib/base64.d.ts.map +1 -0
- package/lib/base64.js +30 -0
- package/lib/base64.js.map +1 -0
- package/lib/capture.d.ts +20 -1
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +45 -21
- package/lib/capture.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/native.d.ts +11 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +16 -0
- package/lib/native.js.map +1 -1
- package/lib/replay.d.ts +6 -0
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +68 -0
- package/lib/replay.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/base64.test.ts +55 -0
- package/src/__tests__/capture-replay.test.ts +150 -0
- package/src/base64.ts +29 -0
- package/src/capture.ts +56 -22
- package/src/native.ts +41 -0
- package/src/replay.ts +74 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
package com.sentori
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
-
import android.graphics.Rect
|
|
5
4
|
import android.view.View
|
|
6
5
|
import android.view.ViewGroup
|
|
7
6
|
import android.widget.EditText
|
|
@@ -25,11 +24,29 @@ import org.json.JSONObject
|
|
|
25
24
|
object SentoriReplayCapture {
|
|
26
25
|
|
|
27
26
|
private const val MAX_NODES = 800
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
27
|
+
private const val MAX_DEPTH = 60
|
|
28
|
+
|
|
29
|
+
// Diagnostic readouts. Mirrors the iOS side. Surfaced via
|
|
30
|
+
// `probe()` so JS can answer "why is the ring shallow?" without
|
|
31
|
+
// parsing logcat.
|
|
32
|
+
//
|
|
33
|
+
// v0.9.12: lastPath + lastNodes
|
|
34
|
+
// v1.0.0-rc.3:
|
|
35
|
+
// * lastDepthMax — deepest descendant the walker reached. If
|
|
36
|
+
// this stays at 2 or 3 we know the recursion bailed early
|
|
37
|
+
// (the rc.2 zero-size-bails-subtree bug).
|
|
38
|
+
// * lastSizeBytes — byte length of the serialised payload. ~50
|
|
39
|
+
// bytes per node is typical; a 1 KB result with 800 nodes
|
|
40
|
+
// would be a red flag.
|
|
41
|
+
// * totalTicks / lastEmptyResultTicks — lifetime counters for
|
|
42
|
+
// ring health, so a thin-but-non-null capture doesn't slip
|
|
43
|
+
// through unnoticed.
|
|
31
44
|
@Volatile private var lastDiagPath: String = "none(not-yet-called)"
|
|
32
45
|
@Volatile private var lastDiagNodes: Int = 0
|
|
46
|
+
@Volatile private var lastDiagDepthMax: Int = 0
|
|
47
|
+
@Volatile private var lastDiagSizeBytes: Int = 0
|
|
48
|
+
@Volatile private var totalTicks: Long = 0
|
|
49
|
+
@Volatile private var totalEmptyResultTicks: Long = 0
|
|
33
50
|
|
|
34
51
|
@JvmStatic
|
|
35
52
|
fun probe(): Map<String, Any> {
|
|
@@ -37,6 +54,10 @@ object SentoriReplayCapture {
|
|
|
37
54
|
return mapOf(
|
|
38
55
|
"lastPath" to lastDiagPath,
|
|
39
56
|
"lastNodes" to lastDiagNodes,
|
|
57
|
+
"lastDepthMax" to lastDiagDepthMax,
|
|
58
|
+
"lastSizeBytes" to lastDiagSizeBytes,
|
|
59
|
+
"totalTicks" to totalTicks,
|
|
60
|
+
"totalEmptyResultTicks" to totalEmptyResultTicks,
|
|
40
61
|
"trackedSource" to SentoriForegroundActivity.lastPath,
|
|
41
62
|
"trackedActivity" to (activity?.javaClass?.name ?: "null"),
|
|
42
63
|
"decorViewFound" to (activity?.window?.decorView != null),
|
|
@@ -61,29 +82,30 @@ object SentoriReplayCapture {
|
|
|
61
82
|
|
|
62
83
|
@JvmStatic
|
|
63
84
|
fun captureWireframe(maskedIds: List<String>): String? {
|
|
85
|
+
totalTicks++
|
|
64
86
|
val activity = SentoriForegroundActivity.current()
|
|
65
87
|
if (activity == null) {
|
|
66
88
|
lastDiagPath = "activity.null"
|
|
89
|
+
totalEmptyResultTicks++
|
|
67
90
|
return null
|
|
68
91
|
}
|
|
69
92
|
val root = activity.window?.decorView
|
|
70
93
|
if (root == null) {
|
|
71
94
|
lastDiagPath = "decorView.null"
|
|
95
|
+
totalEmptyResultTicks++
|
|
72
96
|
return null
|
|
73
97
|
}
|
|
74
98
|
if (root.width <= 0 || root.height <= 0) {
|
|
75
99
|
lastDiagPath = "root.zero-size"
|
|
100
|
+
totalEmptyResultTicks++
|
|
76
101
|
return null
|
|
77
102
|
}
|
|
78
103
|
|
|
79
104
|
val maskedSet = maskedIds.toHashSet()
|
|
80
105
|
val nodes = JSONArray()
|
|
81
|
-
val rect = Rect()
|
|
82
106
|
val rootLoc = IntArray(2).also { root.getLocationInWindow(it) }
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
lastDiagPath = "ok(${SentoriForegroundActivity.lastPath})"
|
|
86
|
-
lastDiagNodes = nodes.length()
|
|
107
|
+
val ctx = WalkContext(rootLoc = rootLoc, maskedSet = maskedSet)
|
|
108
|
+
walk(root, depth = 0, parentMasked = false, ctx = ctx, nodes = nodes)
|
|
87
109
|
|
|
88
110
|
val payload = JSONObject().apply {
|
|
89
111
|
put("ts", System.currentTimeMillis())
|
|
@@ -91,77 +113,123 @@ object SentoriReplayCapture {
|
|
|
91
113
|
put("height", root.height)
|
|
92
114
|
put("nodes", nodes)
|
|
93
115
|
}
|
|
94
|
-
|
|
116
|
+
val serialised = payload.toString()
|
|
117
|
+
|
|
118
|
+
lastDiagPath = "ok(${SentoriForegroundActivity.lastPath})"
|
|
119
|
+
lastDiagNodes = nodes.length()
|
|
120
|
+
lastDiagDepthMax = ctx.depthMax
|
|
121
|
+
lastDiagSizeBytes = serialised.length
|
|
122
|
+
|
|
123
|
+
if (nodes.length() == 0) totalEmptyResultTicks++
|
|
124
|
+
|
|
125
|
+
return serialised
|
|
95
126
|
}
|
|
96
127
|
|
|
128
|
+
/** Per-walk scratch: tracks the deepest descendant reached so
|
|
129
|
+
* the probe can surface whether the recursion ran or bailed.
|
|
130
|
+
* Bundled into one object to keep the recursive signature
|
|
131
|
+
* manageable. */
|
|
132
|
+
private class WalkContext(
|
|
133
|
+
val rootLoc: IntArray,
|
|
134
|
+
val maskedSet: Set<String>,
|
|
135
|
+
var depthMax: Int = 0,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Recursive walker.
|
|
140
|
+
*
|
|
141
|
+
* v1.0.0-rc.3 fix: previously this function returned ENTIRELY
|
|
142
|
+
* when the view itself had `width <= 0 || height <= 0`. That
|
|
143
|
+
* meant any ViewGroup wrapper that happened to measure to zero
|
|
144
|
+
* size during the tick (common on Fabric / RN's intermediate
|
|
145
|
+
* shadow-tree wrappers, and on lazy-layout phases) skipped the
|
|
146
|
+
* whole descendant subtree — Insight 2026-05-17 verify event
|
|
147
|
+
* saw 800-node frames whose subtree was actually thousands of
|
|
148
|
+
* Views deep but only the root + 2-3 wrappers made it into the
|
|
149
|
+
* JSON.
|
|
150
|
+
*
|
|
151
|
+
* Now we separate "emit a node for this view" from "recurse into
|
|
152
|
+
* its children". A zero-size view doesn't get an emitted node
|
|
153
|
+
* (no visual contribution) but its descendants still get walked
|
|
154
|
+
* — they may have real frames.
|
|
155
|
+
*/
|
|
97
156
|
private fun walk(
|
|
98
157
|
view: View,
|
|
158
|
+
depth: Int,
|
|
99
159
|
parentMasked: Boolean,
|
|
100
|
-
|
|
101
|
-
rootLoc: IntArray,
|
|
102
|
-
scratch: Rect,
|
|
160
|
+
ctx: WalkContext,
|
|
103
161
|
nodes: JSONArray,
|
|
104
162
|
) {
|
|
105
163
|
if (nodes.length() >= MAX_NODES) return
|
|
164
|
+
if (depth >= MAX_DEPTH) return
|
|
106
165
|
if (view.visibility != View.VISIBLE || view.alpha < 0.01) return
|
|
107
166
|
|
|
167
|
+
if (depth > ctx.depthMax) ctx.depthMax = depth
|
|
168
|
+
|
|
108
169
|
val viewTag = view.tag as? String
|
|
109
|
-
val isThisMasked = viewTag != null && maskedSet.contains(viewTag)
|
|
170
|
+
val isThisMasked = viewTag != null && ctx.maskedSet.contains(viewTag)
|
|
110
171
|
val masked = parentMasked || isThisMasked
|
|
111
172
|
|
|
112
|
-
val loc = IntArray(2)
|
|
113
|
-
view.getLocationInWindow(loc)
|
|
114
|
-
val x = loc[0] - rootLoc[0]
|
|
115
|
-
val y = loc[1] - rootLoc[1]
|
|
116
173
|
val w = view.width
|
|
117
174
|
val h = view.height
|
|
118
|
-
if (w <= 0 || h <= 0) return
|
|
119
|
-
|
|
120
|
-
val node = JSONObject().apply {
|
|
121
|
-
put("x", x)
|
|
122
|
-
put("y", y)
|
|
123
|
-
put("w", w)
|
|
124
|
-
put("h", h)
|
|
125
|
-
}
|
|
126
175
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
node.put("kind", "text")
|
|
142
|
-
val text = (view.text ?: "").toString().let { if (it.length > 200) it.substring(0, 200) else it }
|
|
143
|
-
node.put("text", text)
|
|
144
|
-
kindEmitted = true
|
|
176
|
+
// Emit a node ONLY when the view has visual extent. A zero-
|
|
177
|
+
// size view contributes nothing to render but its subtree
|
|
178
|
+
// might; recurse below regardless.
|
|
179
|
+
if (w > 0 && h > 0) {
|
|
180
|
+
val loc = IntArray(2)
|
|
181
|
+
view.getLocationInWindow(loc)
|
|
182
|
+
val x = loc[0] - ctx.rootLoc[0]
|
|
183
|
+
val y = loc[1] - ctx.rootLoc[1]
|
|
184
|
+
|
|
185
|
+
val node = JSONObject().apply {
|
|
186
|
+
put("x", x)
|
|
187
|
+
put("y", y)
|
|
188
|
+
put("w", w)
|
|
189
|
+
put("h", h)
|
|
145
190
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
191
|
+
|
|
192
|
+
var kindEmitted = false
|
|
193
|
+
when {
|
|
194
|
+
masked -> {
|
|
195
|
+
node.put("kind", "mask")
|
|
196
|
+
kindEmitted = true
|
|
197
|
+
}
|
|
198
|
+
view is TextView && !view.text.isNullOrEmpty() -> {
|
|
199
|
+
node.put("kind", "text")
|
|
200
|
+
val text = view.text.toString().let { if (it.length > 200) it.substring(0, 200) else it }
|
|
201
|
+
node.put("text", text)
|
|
202
|
+
node.put("color", colorToHex(view.currentTextColor))
|
|
203
|
+
kindEmitted = true
|
|
204
|
+
}
|
|
205
|
+
view is EditText -> {
|
|
206
|
+
node.put("kind", "text")
|
|
207
|
+
val text = (view.text ?: "").toString().let { if (it.length > 200) it.substring(0, 200) else it }
|
|
208
|
+
node.put("text", text)
|
|
209
|
+
kindEmitted = true
|
|
210
|
+
}
|
|
211
|
+
view is ImageView -> {
|
|
212
|
+
node.put("kind", "image")
|
|
213
|
+
kindEmitted = true
|
|
214
|
+
}
|
|
215
|
+
view.background != null -> {
|
|
216
|
+
node.put("kind", "rect")
|
|
217
|
+
// Background drawables don't always expose color directly.
|
|
218
|
+
// Skip color for non-ColorDrawable; renderer falls back to neutral.
|
|
219
|
+
kindEmitted = true
|
|
220
|
+
}
|
|
155
221
|
}
|
|
156
|
-
}
|
|
157
222
|
|
|
158
|
-
|
|
159
|
-
|
|
223
|
+
if (kindEmitted) {
|
|
224
|
+
nodes.put(node)
|
|
225
|
+
}
|
|
160
226
|
}
|
|
161
227
|
|
|
228
|
+
// Always recurse — even zero-size wrappers can host real
|
|
229
|
+
// descendants (the rc.3 fix).
|
|
162
230
|
if (!masked && view is ViewGroup) {
|
|
163
231
|
for (i in 0 until view.childCount) {
|
|
164
|
-
walk(view.getChildAt(i),
|
|
232
|
+
walk(view.getChildAt(i), depth + 1, masked, ctx, nodes)
|
|
165
233
|
}
|
|
166
234
|
}
|
|
167
235
|
}
|
|
@@ -29,22 +29,31 @@ import UIKit
|
|
|
29
29
|
return result
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
///
|
|
33
|
-
///
|
|
34
|
-
///
|
|
35
|
-
///
|
|
32
|
+
/// Diagnostic readouts exposed to JS via `probeWireframe()`.
|
|
33
|
+
///
|
|
34
|
+
/// v0.9.12: lastPath / lastNodes / scene-window counts
|
|
35
|
+
/// v1.0.0-rc.3: + lastDepthMax / lastSizeBytes / totalTicks /
|
|
36
|
+
/// totalEmptyResultTicks — answers "the ring isn't
|
|
37
|
+
/// empty but the dashboard renders nothing, which
|
|
38
|
+
/// layer dropped the data?" without a re-roll.
|
|
36
39
|
@objc public static var lastDiagPath: String = "none(not-yet-called)"
|
|
37
40
|
@objc public static var lastDiagNodes: Int = 0
|
|
38
41
|
@objc public static var lastDiagSceneCount: Int = 0
|
|
39
42
|
@objc public static var lastDiagWindowCount: Int = 0
|
|
43
|
+
@objc public static var lastDiagDepthMax: Int = 0
|
|
44
|
+
@objc public static var lastDiagSizeBytes: Int = 0
|
|
45
|
+
@objc public static var totalTicks: Int = 0
|
|
46
|
+
@objc public static var totalEmptyResultTicks: Int = 0
|
|
40
47
|
private static var loggedFirstResult = false
|
|
41
48
|
|
|
42
49
|
private static func captureSync(maskedIds: Set<String>) -> String? {
|
|
50
|
+
totalTicks += 1
|
|
43
51
|
let (winOpt, path) = resolveKeyWindow()
|
|
44
52
|
lastDiagPath = path
|
|
45
53
|
lastDiagSceneCount = currentSceneCount()
|
|
46
54
|
lastDiagWindowCount = currentWindowCount()
|
|
47
55
|
guard let window = winOpt else {
|
|
56
|
+
totalEmptyResultTicks += 1
|
|
48
57
|
if !loggedFirstResult {
|
|
49
58
|
NSLog(
|
|
50
59
|
"[sentori] wireframe: returning nil — keyWindow path=%@ scenes=%d windows=%d",
|
|
@@ -57,21 +66,29 @@ import UIKit
|
|
|
57
66
|
return nil
|
|
58
67
|
}
|
|
59
68
|
var nodes: [[String: Any]] = []
|
|
69
|
+
var depthMax = 0
|
|
60
70
|
walk(
|
|
61
71
|
view: window,
|
|
72
|
+
depth: 0,
|
|
73
|
+
depthMax: &depthMax,
|
|
62
74
|
parentMasked: false,
|
|
63
75
|
maskedIds: maskedIds,
|
|
64
76
|
window: window,
|
|
65
77
|
nodes: &nodes
|
|
66
78
|
)
|
|
67
79
|
lastDiagNodes = nodes.count
|
|
80
|
+
lastDiagDepthMax = depthMax
|
|
81
|
+
if nodes.isEmpty {
|
|
82
|
+
totalEmptyResultTicks += 1
|
|
83
|
+
}
|
|
68
84
|
if !loggedFirstResult {
|
|
69
85
|
NSLog(
|
|
70
|
-
"[sentori] wireframe: first capture ok — keyWindow path=%@ bounds=%.0fx%.0f nodes=%d",
|
|
86
|
+
"[sentori] wireframe: first capture ok — keyWindow path=%@ bounds=%.0fx%.0f nodes=%d depthMax=%d",
|
|
71
87
|
path,
|
|
72
88
|
window.bounds.width,
|
|
73
89
|
window.bounds.height,
|
|
74
|
-
nodes.count
|
|
90
|
+
nodes.count,
|
|
91
|
+
depthMax
|
|
75
92
|
)
|
|
76
93
|
loggedFirstResult = true
|
|
77
94
|
}
|
|
@@ -82,7 +99,9 @@ import UIKit
|
|
|
82
99
|
"nodes": nodes,
|
|
83
100
|
]
|
|
84
101
|
if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) {
|
|
85
|
-
|
|
102
|
+
let s = String(data: data, encoding: .utf8)
|
|
103
|
+
lastDiagSizeBytes = s?.utf8.count ?? 0
|
|
104
|
+
return s
|
|
86
105
|
}
|
|
87
106
|
return nil
|
|
88
107
|
}
|
|
@@ -157,23 +176,33 @@ import UIKit
|
|
|
157
176
|
"lastNodes": lastDiagNodes,
|
|
158
177
|
"sceneCount": lastDiagSceneCount,
|
|
159
178
|
"windowCount": lastDiagWindowCount,
|
|
179
|
+
"lastDepthMax": lastDiagDepthMax,
|
|
180
|
+
"lastSizeBytes": lastDiagSizeBytes,
|
|
181
|
+
"totalTicks": totalTicks,
|
|
182
|
+
"totalEmptyResultTicks": totalEmptyResultTicks,
|
|
160
183
|
]
|
|
161
184
|
}
|
|
162
185
|
|
|
163
186
|
/// Cap on nodes per snapshot — extremely deep / wide trees can
|
|
164
187
|
/// have thousands of subviews (UICollectionView recyclers).
|
|
165
188
|
private static let MAX_NODES = 800
|
|
189
|
+
private static let MAX_DEPTH = 60
|
|
166
190
|
|
|
167
191
|
private static func walk(
|
|
168
192
|
view: UIView,
|
|
193
|
+
depth: Int,
|
|
194
|
+
depthMax: inout Int,
|
|
169
195
|
parentMasked: Bool,
|
|
170
196
|
maskedIds: Set<String>,
|
|
171
197
|
window: UIWindow,
|
|
172
198
|
nodes: inout [[String: Any]]
|
|
173
199
|
) {
|
|
174
200
|
if nodes.count >= MAX_NODES { return }
|
|
201
|
+
if depth >= MAX_DEPTH { return }
|
|
175
202
|
if view.isHidden || view.alpha < 0.01 { return }
|
|
176
203
|
|
|
204
|
+
if depth > depthMax { depthMax = depth }
|
|
205
|
+
|
|
177
206
|
let isThisMasked = view.accessibilityIdentifier
|
|
178
207
|
.map { maskedIds.contains($0) } ?? false
|
|
179
208
|
let masked = parentMasked || isThisMasked
|
|
@@ -219,6 +248,8 @@ import UIKit
|
|
|
219
248
|
for sub in view.subviews {
|
|
220
249
|
walk(
|
|
221
250
|
view: sub,
|
|
251
|
+
depth: depth + 1,
|
|
252
|
+
depthMax: &depthMax,
|
|
222
253
|
parentMasked: masked,
|
|
223
254
|
maskedIds: maskedIds,
|
|
224
255
|
window: window,
|
package/lib/base64.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTF-8-safe base64 encoder used by every JSON attachment path
|
|
3
|
+
* (sessionTrail, stateSnapshot, replay).
|
|
4
|
+
*
|
|
5
|
+
* Why this needs its own helper:
|
|
6
|
+
* • Hermes' `globalThis.btoa` (and the WHATWG spec) is **Latin-1
|
|
7
|
+
* only** — it throws `InvalidCharacterError` on any code point
|
|
8
|
+
* > 0xFF. A wireframe NDJSON that includes a TextView with
|
|
9
|
+
* Japanese / Chinese / em-dash text triggers it; the JS-side
|
|
10
|
+
* `try / catch` then swallows the throw and the replay
|
|
11
|
+
* attachment silently never lands.
|
|
12
|
+
* • Insight 2026-05-18 rc.3 verify hit exactly this on Android —
|
|
13
|
+
* the walker fix in rc.3 surfaced deep TextView text, which
|
|
14
|
+
* then collided with the unsafe `btoa(ndjson)` path that had
|
|
15
|
+
* worked accidentally on rc.2's shallow (text-free) snapshots.
|
|
16
|
+
*
|
|
17
|
+
* The pattern `btoa(unescape(encodeURIComponent(s)))` rewrites the
|
|
18
|
+
* UTF-8 byte sequence into a Latin-1-equivalent string that btoa
|
|
19
|
+
* can chew. `unescape` is deprecated for HTML but its byte-level
|
|
20
|
+
* behaviour is stable across every JS engine we ship to.
|
|
21
|
+
*
|
|
22
|
+
* Node / bun test fallback uses `Buffer` directly.
|
|
23
|
+
*/
|
|
24
|
+
export declare function base64Utf8(s: string): string;
|
|
25
|
+
//# sourceMappingURL=base64.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base64.d.ts","sourceRoot":"","sources":["../src/base64.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK5C"}
|
package/lib/base64.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UTF-8-safe base64 encoder used by every JSON attachment path
|
|
3
|
+
* (sessionTrail, stateSnapshot, replay).
|
|
4
|
+
*
|
|
5
|
+
* Why this needs its own helper:
|
|
6
|
+
* • Hermes' `globalThis.btoa` (and the WHATWG spec) is **Latin-1
|
|
7
|
+
* only** — it throws `InvalidCharacterError` on any code point
|
|
8
|
+
* > 0xFF. A wireframe NDJSON that includes a TextView with
|
|
9
|
+
* Japanese / Chinese / em-dash text triggers it; the JS-side
|
|
10
|
+
* `try / catch` then swallows the throw and the replay
|
|
11
|
+
* attachment silently never lands.
|
|
12
|
+
* • Insight 2026-05-18 rc.3 verify hit exactly this on Android —
|
|
13
|
+
* the walker fix in rc.3 surfaced deep TextView text, which
|
|
14
|
+
* then collided with the unsafe `btoa(ndjson)` path that had
|
|
15
|
+
* worked accidentally on rc.2's shallow (text-free) snapshots.
|
|
16
|
+
*
|
|
17
|
+
* The pattern `btoa(unescape(encodeURIComponent(s)))` rewrites the
|
|
18
|
+
* UTF-8 byte sequence into a Latin-1-equivalent string that btoa
|
|
19
|
+
* can chew. `unescape` is deprecated for HTML but its byte-level
|
|
20
|
+
* behaviour is stable across every JS engine we ship to.
|
|
21
|
+
*
|
|
22
|
+
* Node / bun test fallback uses `Buffer` directly.
|
|
23
|
+
*/
|
|
24
|
+
export function base64Utf8(s) {
|
|
25
|
+
if (typeof globalThis.btoa === 'function') {
|
|
26
|
+
return globalThis.btoa(unescape(encodeURIComponent(s)));
|
|
27
|
+
}
|
|
28
|
+
return Buffer.from(s, 'utf8').toString('base64');
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=base64.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base64.js","sourceRoot":"","sources":["../src/base64.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,UAAU,CAAC,CAAS;IAClC,IAAI,OAAO,UAAU,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;QAC1C,OAAO,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1D,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AACnD,CAAC"}
|
package/lib/capture.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Tags, User } from './types';
|
|
1
|
+
import type { Event, Tags, User } from './types';
|
|
2
2
|
export { captureStep, __resetTrailForTests } from './trail';
|
|
3
3
|
export declare const __resetScreenshotBudgetForTests: () => void;
|
|
4
4
|
/**
|
|
@@ -49,5 +49,24 @@ export declare const sendUserFeedback: (input: {
|
|
|
49
49
|
issueId: null | string;
|
|
50
50
|
}>;
|
|
51
51
|
export declare const captureError: (error: Error, extras?: CaptureExtras) => void;
|
|
52
|
+
/** v0.9.6 #2 — upload the wireframe replay ring as a `replay`
|
|
53
|
+
* attachment. Plain NDJSON (one snapshot per line) — server may
|
|
54
|
+
* gzip on storage; the network upload is base64.
|
|
55
|
+
*
|
|
56
|
+
* rc.4: route through `base64Utf8` so non-Latin-1 text inside any
|
|
57
|
+
* walked TextView (Japanese / Chinese / em-dash etc.) doesn't blow
|
|
58
|
+
* up the Hermes-spec `btoa`. The pre-rc.4 inline `btoa(ndjson)` path
|
|
59
|
+
* threw `InvalidCharacterError` on those code points, the
|
|
60
|
+
* surrounding catch swallowed it silently, and the replay
|
|
61
|
+
* attachment never landed. Insight 2026-05-18 verify caught it
|
|
62
|
+
* after rc.3's walker fix surfaced deep TextView content. Dev
|
|
63
|
+
* logs replace the silent catch so the next failure shape is
|
|
64
|
+
* visible. */
|
|
65
|
+
declare function captureAndAttachReplay(event: Event, ndjson: string): Promise<void>;
|
|
52
66
|
export declare const captureException: (error: Error, extras?: CaptureExtras) => void;
|
|
67
|
+
/** rc.4 — test hook. The real replay attach path is internal so we
|
|
68
|
+
* don't bloat the public surface, but the encoding bug Insight hit
|
|
69
|
+
* on 2026-05-18 needs a behaviour-level test that exercises the
|
|
70
|
+
* same code path captureException runs in production. */
|
|
71
|
+
export declare const __captureAndAttachReplayForTests: typeof captureAndAttachReplay;
|
|
53
72
|
//# sourceMappingURL=capture.d.ts.map
|
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":"AAsBA,OAAO,KAAK,EAA+B,KAAK,EAAgB,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;sEACsE;AACtE,eAAO,MAAM,gBAAgB,QAAO,MAAM,GAAG,SAAmC,CAAC;AAEjF,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,IAiHnE,CAAC;AAEF;;;;;;;;;;;;eAYe;AACf,iBAAe,sBAAsB,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAiCjF;AAwDD,eAAO,MAAM,gBAAgB,UAzNO,KAAK,WAAW,aAAa,KAAG,IAyNxB,CAAC;AAE7C;;;0DAG0D;AAC1D,eAAO,MAAM,gCAAgC,+BAAyB,CAAC"}
|
package/lib/capture.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { sealTrail, shouldSample } from '@goliapkg/sentori-core';
|
|
2
|
+
import { base64Utf8 } from './base64';
|
|
2
3
|
import { __peekBreadcrumbCount, addBreadcrumb, getBreadcrumbs, } from './breadcrumbs';
|
|
3
4
|
import { getBundleInfo } from './bundle-info';
|
|
4
5
|
import { getConfig, isInitialized } from './config';
|
|
5
6
|
import { getFeatureFlagSnapshot } from './feature-flags';
|
|
6
|
-
import { drainReplay } from './replay';
|
|
7
|
+
import { drainReplay, isReplayRunning } from './replay';
|
|
7
8
|
import { clearStateSnapshots, getStateSnapshots } from './state-snapshots';
|
|
8
9
|
import { symbolicateErrorViaMetro } from './handlers/dev-symbolicate';
|
|
9
10
|
import { captureScreenshot } from './handlers/screenshot';
|
|
@@ -104,7 +105,7 @@ export const captureError = (error, extras) => {
|
|
|
104
105
|
// been silently dropped on the wire. Production builds gate out.
|
|
105
106
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
106
107
|
// eslint-disable-next-line no-console
|
|
107
|
-
console.warn('[sentori] captureException', 'eventId=', event.id, 'breadcrumbs=', crumbs.length, 'wantScreenshot=', config.screenshotsEnabled && extras?.screenshot !== false, 'wantSessionTrail=', config.sessionTrailEnabled);
|
|
108
|
+
console.warn('[sentori] captureException', 'eventId=', event.id, 'breadcrumbs=', crumbs.length, 'wantScreenshot=', config.screenshotsEnabled && extras?.screenshot !== false, 'wantSessionTrail=', config.sessionTrailEnabled, 'wantReplay=', isReplayRunning());
|
|
108
109
|
}
|
|
109
110
|
// Phase 26 sub-B: a captured error promotes the current session to
|
|
110
111
|
// `errored` so the next AppState=background ping reports unhealthy.
|
|
@@ -142,6 +143,15 @@ export const captureError = (error, extras) => {
|
|
|
142
143
|
if (replayNdjson.length > 0) {
|
|
143
144
|
await captureAndAttachReplay(event, replayNdjson);
|
|
144
145
|
}
|
|
146
|
+
else if (typeof __DEV__ !== 'undefined' && __DEV__ && isReplayRunning()) {
|
|
147
|
+
// rc.4 — explicit "replay was on but ring drained empty" signal.
|
|
148
|
+
// Without this, "kinds=screenshot,sessionTrail" looks
|
|
149
|
+
// indistinguishable from `replay: off` even though the ticks
|
|
150
|
+
// were healthy upstream. Insight 2026-05-18 verify shape made
|
|
151
|
+
// this gap painful to triage.
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.warn('[sentori] replay drain empty (no frames buffered at captureException)', 'eventId=', event.id);
|
|
154
|
+
}
|
|
145
155
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
146
156
|
// eslint-disable-next-line no-console
|
|
147
157
|
console.warn('[sentori] enqueue', 'eventId=', event.id, 'attachments=', event.attachments?.length ?? 0, 'kinds=', (event.attachments ?? []).map((a) => a.kind).join(',') || '(none)', 'breadcrumbsAtEnqueue=', __peekBreadcrumbCount());
|
|
@@ -152,21 +162,37 @@ export const captureError = (error, extras) => {
|
|
|
152
162
|
};
|
|
153
163
|
/** v0.9.6 #2 — upload the wireframe replay ring as a `replay`
|
|
154
164
|
* attachment. Plain NDJSON (one snapshot per line) — server may
|
|
155
|
-
* gzip on storage; the network upload is base64.
|
|
165
|
+
* gzip on storage; the network upload is base64.
|
|
166
|
+
*
|
|
167
|
+
* rc.4: route through `base64Utf8` so non-Latin-1 text inside any
|
|
168
|
+
* walked TextView (Japanese / Chinese / em-dash etc.) doesn't blow
|
|
169
|
+
* up the Hermes-spec `btoa`. The pre-rc.4 inline `btoa(ndjson)` path
|
|
170
|
+
* threw `InvalidCharacterError` on those code points, the
|
|
171
|
+
* surrounding catch swallowed it silently, and the replay
|
|
172
|
+
* attachment never landed. Insight 2026-05-18 verify caught it
|
|
173
|
+
* after rc.3's walker fix surfaced deep TextView content. Dev
|
|
174
|
+
* logs replace the silent catch so the next failure shape is
|
|
175
|
+
* visible. */
|
|
156
176
|
async function captureAndAttachReplay(event, ndjson) {
|
|
157
177
|
try {
|
|
158
|
-
const base64 =
|
|
159
|
-
? globalThis.btoa(ndjson)
|
|
160
|
-
: Buffer.from(ndjson, 'utf8').toString('base64');
|
|
178
|
+
const base64 = base64Utf8(ndjson);
|
|
161
179
|
const meta = await uploadAttachment(event.id, 'replay', { base64, mediaType: 'application/x-ndjson' }, { source: 'js' });
|
|
162
|
-
if (meta) {
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
180
|
+
if (!meta) {
|
|
181
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
182
|
+
// eslint-disable-next-line no-console
|
|
183
|
+
console.warn('[sentori] replay upload returned null', 'eventId=', event.id, 'ndjsonBytes=', ndjson.length);
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
166
186
|
}
|
|
187
|
+
if (!event.attachments)
|
|
188
|
+
event.attachments = [];
|
|
189
|
+
event.attachments.push(meta);
|
|
167
190
|
}
|
|
168
|
-
catch {
|
|
169
|
-
|
|
191
|
+
catch (e) {
|
|
192
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
console.warn('[sentori] replay attachment threw', 'eventId=', event.id, 'ndjsonBytes=', ndjson.length, e);
|
|
195
|
+
}
|
|
170
196
|
}
|
|
171
197
|
}
|
|
172
198
|
/** v0.9.2 +S2 — upload the rolling state-snapshot ring as a
|
|
@@ -175,10 +201,7 @@ async function captureAndAttachReplay(event, ndjson) {
|
|
|
175
201
|
async function captureAndAttachStateSnapshots(event, snapshots) {
|
|
176
202
|
try {
|
|
177
203
|
const payload = JSON.stringify({ snapshots });
|
|
178
|
-
const base64 =
|
|
179
|
-
? globalThis.btoa(payload)
|
|
180
|
-
: // Bun / node fallback
|
|
181
|
-
Buffer.from(payload, 'utf8').toString('base64');
|
|
204
|
+
const base64 = base64Utf8(payload);
|
|
182
205
|
const meta = await uploadAttachment(event.id, 'stateSnapshot', { base64, mediaType: 'application/json' }, { source: 'js' });
|
|
183
206
|
if (meta) {
|
|
184
207
|
if (!event.attachments)
|
|
@@ -204,11 +227,7 @@ async function captureAndAttachSessionTrail(event) {
|
|
|
204
227
|
const payload = sealTrail(trail);
|
|
205
228
|
trail.clear();
|
|
206
229
|
const json = JSON.stringify(payload);
|
|
207
|
-
|
|
208
|
-
// trick the screenshot path uses).
|
|
209
|
-
const base64 = typeof globalThis.btoa === 'function'
|
|
210
|
-
? globalThis.btoa(unescape(encodeURIComponent(json)))
|
|
211
|
-
: Buffer.from(json, 'utf-8').toString('base64');
|
|
230
|
+
const base64 = base64Utf8(json);
|
|
212
231
|
const attachment = await uploadAttachment(event.id, 'sessionTrail', { base64, mediaType: 'application/json' }, { source: 'js' });
|
|
213
232
|
if (!attachment) {
|
|
214
233
|
addBreadcrumb({ type: 'custom', data: { reason: 'session-trail-upload-failed' } });
|
|
@@ -219,6 +238,11 @@ async function captureAndAttachSessionTrail(event) {
|
|
|
219
238
|
event.attachments.push(attachment);
|
|
220
239
|
}
|
|
221
240
|
export const captureException = captureError;
|
|
241
|
+
/** rc.4 — test hook. The real replay attach path is internal so we
|
|
242
|
+
* don't bloat the public surface, but the encoding bug Insight hit
|
|
243
|
+
* on 2026-05-18 needs a behaviour-level test that exercises the
|
|
244
|
+
* same code path captureException runs in production. */
|
|
245
|
+
export const __captureAndAttachReplayForTests = captureAndAttachReplay;
|
|
222
246
|
/** Phase 42 sub-D.08: per-session screenshot quota gate. */
|
|
223
247
|
function allowScreenshot() {
|
|
224
248
|
const budget = screenshotBudget();
|