@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.
@@ -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
- // v0.9.12 — diagnostic readout so the JS side can ask "why is the
30
- // ring empty?" without parsing logcat. Mirrors the iOS side.
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
- walk(root, false, maskedSet, rootLoc, rect, nodes)
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
- return payload.toString()
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
- maskedSet: Set<String>,
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
- var kindEmitted = false
128
- when {
129
- masked -> {
130
- node.put("kind", "mask")
131
- kindEmitted = true
132
- }
133
- view is TextView && !view.text.isNullOrEmpty() -> {
134
- node.put("kind", "text")
135
- val text = view.text.toString().let { if (it.length > 200) it.substring(0, 200) else it }
136
- node.put("text", text)
137
- node.put("color", colorToHex(view.currentTextColor))
138
- kindEmitted = true
139
- }
140
- view is EditText -> {
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
- view is ImageView -> {
147
- node.put("kind", "image")
148
- kindEmitted = true
149
- }
150
- view.background != null -> {
151
- node.put("kind", "rect")
152
- // Background drawables don't always expose color directly.
153
- // Skip color for non-ColorDrawable; renderer falls back to neutral.
154
- kindEmitted = true
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
- if (kindEmitted) {
159
- nodes.put(node)
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), masked, maskedSet, rootLoc, scratch, nodes)
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
- /// Last path the keyWindow lookup took. Exposed to JS via
33
- /// `probeWireframe()` so the failure-mode diagnostic in Metro can
34
- /// tell scene-race from "no window at all" without re-rolling the
35
- /// pod. Updated on every captureSync call.
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
- return String(data: data, encoding: .utf8)
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,
@@ -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
@@ -1 +1 @@
1
- {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../src/capture.ts"],"names":[],"mappings":"AAqBA,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;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,IAqGnE,CAAC;AAyFF,eAAO,MAAM,gBAAgB,UA9LO,KAAK,WAAW,aAAa,KAAG,IA8LxB,CAAC"}
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 = typeof globalThis.btoa === 'function'
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 (!event.attachments)
164
- event.attachments = [];
165
- event.attachments.push(meta);
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
- // best-effort
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 = typeof globalThis.btoa === 'function'
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
- // base64 the JSON for the `data:` URI multipart bridge (same
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();