@goliapkg/sentori-react-native 0.9.11 → 1.0.0-rc.10

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.
Files changed (41) hide show
  1. package/android/src/main/java/com/sentori/SentoriForegroundActivity.kt +145 -0
  2. package/android/src/main/java/com/sentori/SentoriModule.kt +13 -0
  3. package/android/src/main/java/com/sentori/SentoriReplayCapture.kt +261 -68
  4. package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +72 -36
  5. package/ios/SentoriModule.swift +15 -0
  6. package/ios/SentoriReplayCapture.swift +135 -10
  7. package/ios/SentoriScreenshotCapture.swift +69 -3
  8. package/lib/base64.d.ts +25 -0
  9. package/lib/base64.d.ts.map +1 -0
  10. package/lib/base64.js +30 -0
  11. package/lib/base64.js.map +1 -0
  12. package/lib/capture.d.ts +20 -1
  13. package/lib/capture.d.ts.map +1 -1
  14. package/lib/capture.js +45 -21
  15. package/lib/capture.js.map +1 -1
  16. package/lib/index.d.ts +2 -1
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +2 -1
  19. package/lib/index.js.bak +64 -0
  20. package/lib/index.js.map +1 -1
  21. package/lib/native.d.ts +68 -0
  22. package/lib/native.d.ts.map +1 -1
  23. package/lib/native.js +115 -0
  24. package/lib/native.js.map +1 -1
  25. package/lib/replay.d.ts +28 -4
  26. package/lib/replay.d.ts.map +1 -1
  27. package/lib/replay.js +242 -65
  28. package/lib/replay.js.map +1 -1
  29. package/lib/transport.d.ts.map +1 -1
  30. package/lib/transport.js +16 -0
  31. package/lib/transport.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/__tests__/base64.test.ts +55 -0
  34. package/src/__tests__/capture-replay.test.ts +150 -0
  35. package/src/__tests__/replay-encoding.test.ts +237 -0
  36. package/src/base64.ts +29 -0
  37. package/src/capture.ts +56 -22
  38. package/src/index.ts +3 -0
  39. package/src/native.ts +177 -0
  40. package/src/replay.ts +294 -70
  41. package/src/transport.ts +31 -0
@@ -0,0 +1,150 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+
3
+ import { __captureAndAttachReplayForTests } from '../capture';
4
+ import { setConfig, __resetForTests as resetConfig } from '../config';
5
+ import type { Event } from '../types';
6
+
7
+ // Insight 2026-05-18 rc.3 verify: walker is healthy, ticks land
8
+ // deep wireframe JSON with TextView text, but the `replay` kind
9
+ // never reaches `event.attachments`. Root cause was the inline
10
+ // `btoa(ndjson)` upload path — spec-compliant Hermes / Bun btoa
11
+ // throws `InvalidCharacterError` on any code point > 0xFF, and the
12
+ // surrounding catch swallowed the throw silently. rc.4 routes
13
+ // through `base64Utf8`, fixing both Latin-1 and UTF-8 inputs.
14
+ //
15
+ // These tests run the actual captureAndAttachReplay function the
16
+ // captureException pipeline runs. fetch is mocked at the
17
+ // transport.ts boundary so the upload landing isn't tested here
18
+ // (covered separately in screenshot.test.ts); what we care about
19
+ // is "does event.attachments grow when the input contains
20
+ // non-Latin-1 text?".
21
+
22
+ const origFetch = globalThis.fetch;
23
+
24
+ beforeEach(() => {
25
+ setConfig({
26
+ enabled: true,
27
+ environment: 'test',
28
+ errorSampleRate: null,
29
+ ingestUrl: 'http://localhost:18080',
30
+ release: 'app@1.0.0+1',
31
+ screenshotsEnabled: true,
32
+ sessionTrailEnabled: true,
33
+ token: 'st_pk_test',
34
+ traceSampleRate: null,
35
+ });
36
+ });
37
+
38
+ afterEach(() => {
39
+ globalThis.fetch = origFetch;
40
+ resetConfig();
41
+ });
42
+
43
+ function makeEvent(): Event {
44
+ return {
45
+ id: '019eaa00-0000-7000-8000-000000000001',
46
+ timestamp: '2026-05-18T05:15:24.000Z',
47
+ kind: 'error',
48
+ platform: 'javascript',
49
+ release: 'app@1.0.0+1',
50
+ environment: 'test',
51
+ device: { os: 'android', osVersion: '36' },
52
+ app: {
53
+ version: '1.0.0',
54
+ build: '1',
55
+ framework: { name: 'react-native', version: '0.80.0' },
56
+ },
57
+ user: null,
58
+ tags: undefined,
59
+ breadcrumbs: [],
60
+ error: { type: 'Error', message: 'boom', stack: [], cause: null },
61
+ fingerprint: undefined,
62
+ };
63
+ }
64
+
65
+ function mockUploadSuccess(): { calls: { url: string; body: unknown }[] } {
66
+ const calls: { url: string; body: unknown }[] = [];
67
+ globalThis.fetch = mock(async (url: Request | string | URL, init?: RequestInit) => {
68
+ let body: unknown = undefined;
69
+ try {
70
+ body = init?.body ? JSON.parse(String(init.body)) : undefined;
71
+ } catch {
72
+ body = init?.body;
73
+ }
74
+ calls.push({ url: String(url), body });
75
+ return new Response(
76
+ JSON.stringify({
77
+ kind: 'replay',
78
+ mediaType: 'application/x-ndjson',
79
+ refId: '019e3000-7000-7000-8000-00000000aaaa',
80
+ sizeBytes: 200,
81
+ }),
82
+ { headers: { 'content-type': 'application/json' }, status: 201 },
83
+ );
84
+ }) as typeof fetch;
85
+ return { calls };
86
+ }
87
+
88
+ describe('captureAndAttachReplay — Insight rc.3 regression', () => {
89
+ test('Latin-1 ndjson attaches replay kind (control)', async () => {
90
+ const { calls } = mockUploadSuccess();
91
+ const event = makeEvent();
92
+ const ndjson =
93
+ '{"ts":1700000000,"width":390,"height":844,"nodes":[' +
94
+ '{"kind":"text","x":10,"y":20,"w":100,"h":20,"text":"Settings"}]}';
95
+ await __captureAndAttachReplayForTests(event, ndjson);
96
+ expect(calls.length).toBe(1);
97
+ expect(event.attachments?.length).toBe(1);
98
+ expect(event.attachments?.[0]?.kind).toBe('replay');
99
+ });
100
+
101
+ test('UTF-8 ndjson attaches replay kind (the rc.3 Android crash repro)', async () => {
102
+ // This is the bug: pre-rc.4 `btoa(ndjson)` threw on the
103
+ // Japanese characters below, the catch swallowed it, the
104
+ // attachment silently never landed. Post-rc.4 the helper
105
+ // handles UTF-8 and the attachment lands normally.
106
+ const { calls } = mockUploadSuccess();
107
+ const event = makeEvent();
108
+ const ndjson =
109
+ '{"ts":1700000000,"width":390,"height":844,"nodes":[' +
110
+ '{"kind":"text","x":10,"y":20,"w":100,"h":20,"text":"設定"},' +
111
+ '{"kind":"text","x":10,"y":50,"w":100,"h":20,"text":"ログアウト"}]}';
112
+ await __captureAndAttachReplayForTests(event, ndjson);
113
+ expect(calls.length).toBe(1);
114
+ expect(event.attachments?.length).toBe(1);
115
+ expect(event.attachments?.[0]?.kind).toBe('replay');
116
+ });
117
+
118
+ test('mixed em-dash / smart-quote / emoji attaches replay kind', async () => {
119
+ const { calls } = mockUploadSuccess();
120
+ const event = makeEvent();
121
+ const ndjson =
122
+ '{"ts":1,"width":390,"height":844,"nodes":[' +
123
+ '{"kind":"text","x":0,"y":0,"w":50,"h":20,"text":"hello — world"},' +
124
+ '{"kind":"text","x":0,"y":30,"w":50,"h":20,"text":"\\"quoted\\""},' +
125
+ '{"kind":"text","x":0,"y":60,"w":50,"h":20,"text":"🎉"}]}';
126
+ await __captureAndAttachReplayForTests(event, ndjson);
127
+ expect(calls.length).toBe(1);
128
+ expect(event.attachments?.length).toBe(1);
129
+ expect(event.attachments?.[0]?.kind).toBe('replay');
130
+ });
131
+
132
+ test('upload returns null → no attachment, no throw', async () => {
133
+ globalThis.fetch = (async () =>
134
+ new Response('{"error":"tooLarge"}', { status: 413 })) as typeof fetch;
135
+ const event = makeEvent();
136
+ const ndjson = '{"ts":1,"nodes":[]}';
137
+ await __captureAndAttachReplayForTests(event, ndjson);
138
+ expect(event.attachments).toBeUndefined();
139
+ });
140
+
141
+ test('fetch throws (offline) → no attachment, no throw out of the function', async () => {
142
+ globalThis.fetch = (async () => {
143
+ throw new TypeError('Network request failed');
144
+ }) as typeof fetch;
145
+ const event = makeEvent();
146
+ const ndjson = '{"ts":1,"nodes":[]}';
147
+ await __captureAndAttachReplayForTests(event, ndjson);
148
+ expect(event.attachments).toBeUndefined();
149
+ });
150
+ });
@@ -0,0 +1,237 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+
3
+ import {
4
+ __feedTickForTests,
5
+ __resetReplayForTests,
6
+ computeDelta,
7
+ drainReplay,
8
+ } from '../replay';
9
+
10
+ // Unit coverage for the rc.9 v2 replay encoder.
11
+ //
12
+ // The encoder lives in replay.ts; native module is mocked away via
13
+ // __feedTickForTests so we drive the captureTick body with hand-built
14
+ // snapshots and inspect the resulting NDJSON shape.
15
+ //
16
+ // See docs/replay-encoding-v2.md for the wire schema.
17
+
18
+ afterEach(() => {
19
+ __resetReplayForTests();
20
+ });
21
+
22
+ type Node = {
23
+ x: number;
24
+ y: number;
25
+ w: number;
26
+ h: number;
27
+ kind?: string;
28
+ text?: string;
29
+ color?: string;
30
+ };
31
+
32
+ function frame(ts: number, nodes: Node[]) {
33
+ return JSON.stringify({ ts, width: 1080, height: 2340, nodes });
34
+ }
35
+
36
+ function nodeRect(x: number, y: number, w: number, h: number, color = '#FF0000FF'): Node {
37
+ return { x, y, w, h, kind: 'rect', color };
38
+ }
39
+
40
+ function nodeText(x: number, y: number, w: number, h: number, text: string, color = '#FFFFFFFF'): Node {
41
+ return { x, y, w, h, kind: 'text', text, color };
42
+ }
43
+
44
+ function readDrainedLines(): unknown[] {
45
+ const drained = drainReplay();
46
+ if (drained.length === 0) return [];
47
+ return drained.split('\n').map((l) => JSON.parse(l) as unknown);
48
+ }
49
+
50
+ describe('rc.9 encoder — keyframe vs delta', () => {
51
+ test('first tick after start always emits a keyframe', () => {
52
+ __feedTickForTests(frame(1_000, [nodeRect(0, 0, 1080, 2340)]));
53
+ const lines = readDrainedLines();
54
+ expect(lines.length).toBe(1);
55
+ expect((lines[0] as { kind: string }).kind).toBe('key');
56
+ expect((lines[0] as { width: number }).width).toBe(1080);
57
+ expect((lines[0] as { nodes: Node[] }).nodes.length).toBe(1);
58
+ });
59
+
60
+ test('static UI ticks emit no line (no-op heartbeat dropped)', () => {
61
+ const nodes = [nodeRect(0, 0, 1080, 2340, '#000000FF')];
62
+ __feedTickForTests(frame(1_000, nodes));
63
+ __feedTickForTests(frame(1_250, nodes));
64
+ __feedTickForTests(frame(1_500, nodes));
65
+ __feedTickForTests(frame(1_750, nodes));
66
+ const lines = readDrainedLines();
67
+ // 1 keyframe + 0 deltas (all subsequent ticks are no-op)
68
+ expect(lines.length).toBe(1);
69
+ expect((lines[0] as { kind: string }).kind).toBe('key');
70
+ });
71
+
72
+ test('a single node change emits exactly one delta with the changed node', () => {
73
+ const baseline = [
74
+ nodeRect(0, 0, 1080, 2340, '#0E0E10FF'),
75
+ nodeText(60, 192, 960, 112, 'before'),
76
+ ];
77
+ const after = [
78
+ nodeRect(0, 0, 1080, 2340, '#0E0E10FF'),
79
+ nodeText(60, 192, 960, 112, 'after'),
80
+ ];
81
+ __feedTickForTests(frame(1_000, baseline));
82
+ __feedTickForTests(frame(1_250, after));
83
+ const lines = readDrainedLines();
84
+ expect(lines.length).toBe(2);
85
+ expect((lines[0] as { kind: string }).kind).toBe('key');
86
+ expect((lines[1] as { kind: string }).kind).toBe('delta');
87
+ const d = lines[1] as { added: Node[]; changed: Node[]; removed: Node[] };
88
+ expect(d.added.length).toBe(0);
89
+ expect(d.removed.length).toBe(0);
90
+ expect(d.changed.length).toBe(1);
91
+ expect((d.changed[0] as Node).text).toBe('after');
92
+ });
93
+
94
+ test('added + removed nodes both appear in the delta', () => {
95
+ const baseline = [nodeRect(0, 0, 1080, 100), nodeRect(0, 200, 1080, 100)];
96
+ const after = [nodeRect(0, 0, 1080, 100), nodeRect(0, 400, 1080, 100)];
97
+ __feedTickForTests(frame(1_000, baseline));
98
+ __feedTickForTests(frame(1_250, after));
99
+ const lines = readDrainedLines();
100
+ expect(lines.length).toBe(2);
101
+ const d = lines[1] as { added: Node[]; changed: Node[]; removed: Node[] };
102
+ expect(d.added.length).toBe(1);
103
+ expect((d.added[0] as Node).y).toBe(400);
104
+ expect(d.removed.length).toBe(1);
105
+ expect((d.removed[0] as Node).y).toBe(200);
106
+ });
107
+
108
+ test('keyframe overdue ⇒ next emit is a keyframe even when delta is small', () => {
109
+ const baseline = [nodeRect(0, 0, 1080, 2340), nodeText(60, 192, 960, 112, 'a')];
110
+ // After 4 s (default keyframeMs), the next change triggers a fresh keyframe.
111
+ __feedTickForTests(frame(1_000, baseline));
112
+ const slightlyChanged = [
113
+ nodeRect(0, 0, 1080, 2340),
114
+ nodeText(60, 192, 960, 112, 'a-changed'),
115
+ ];
116
+ __feedTickForTests(frame(1_000 + 4_001, slightlyChanged));
117
+ const lines = readDrainedLines();
118
+ expect(lines.length).toBe(2);
119
+ expect((lines[0] as { kind: string }).kind).toBe('key');
120
+ expect((lines[1] as { kind: string }).kind).toBe('key'); // overdue → key
121
+ });
122
+
123
+ test('big screen transition (>40% nodes change) prefers a keyframe over a huge delta', () => {
124
+ const baseline = Array.from({ length: 10 }, (_, i) => nodeRect(0, i * 100, 1080, 90));
125
+ const after = Array.from({ length: 10 }, (_, i) => nodeRect(0, i * 100 + 500, 1080, 90));
126
+ __feedTickForTests(frame(1_000, baseline));
127
+ __feedTickForTests(frame(1_250, after));
128
+ const lines = readDrainedLines();
129
+ expect(lines.length).toBe(2);
130
+ expect((lines[0] as { kind: string }).kind).toBe('key');
131
+ // Delta would be 10 added + 10 removed = 20 changes, > 10 * 0.4 threshold,
132
+ // so encoder should fall back to keyframe instead.
133
+ expect((lines[1] as { kind: string }).kind).toBe('key');
134
+ });
135
+
136
+ test('reconstructing keyframe + applied deltas recovers identical final state', () => {
137
+ // Drive 6 ticks: keyframe + 5 small deltas, then assert reconstruction equals the last input state.
138
+ const finalNodes: Node[] = [];
139
+ const baseline = [nodeRect(0, 0, 1080, 2340, '#0E0E10FF')];
140
+ __feedTickForTests(frame(1_000, baseline));
141
+ let state: Node[] = [...baseline];
142
+ for (let i = 0; i < 5; i++) {
143
+ // Add one row per tick.
144
+ const row = nodeText(60, 200 + i * 100, 960, 80, `row ${i}`);
145
+ state = [...state, row];
146
+ __feedTickForTests(frame(1_000 + (i + 1) * 250, state));
147
+ }
148
+ finalNodes.push(...state);
149
+
150
+ const lines = readDrainedLines();
151
+ expect(lines.length).toBe(6);
152
+ // Reconstruct: start from keyframe, apply each delta in order.
153
+ let reconstructed = new Map<string, Node>();
154
+ for (const line of lines) {
155
+ const l = line as { kind: string; nodes?: Node[]; added?: Node[]; changed?: Node[]; removed?: Pick<Node, 'x' | 'y' | 'w' | 'h'>[] };
156
+ if (l.kind === 'key') {
157
+ reconstructed = new Map((l.nodes ?? []).map((n) => [`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`, n]));
158
+ } else {
159
+ for (const r of l.removed ?? []) {
160
+ reconstructed.delete(`${r.x | 0},${r.y | 0},${r.w | 0},${r.h | 0}`);
161
+ }
162
+ for (const a of l.added ?? []) {
163
+ reconstructed.set(`${a.x | 0},${a.y | 0},${a.w | 0},${a.h | 0}`, a);
164
+ }
165
+ for (const c of l.changed ?? []) {
166
+ reconstructed.set(`${c.x | 0},${c.y | 0},${c.w | 0},${c.h | 0}`, c);
167
+ }
168
+ }
169
+ }
170
+ expect(reconstructed.size).toBe(finalNodes.length);
171
+ for (const n of finalNodes) {
172
+ const got = reconstructed.get(`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`);
173
+ expect(got).toBeDefined();
174
+ expect(got!.kind).toBe(n.kind);
175
+ expect(got!.text).toBe(n.text);
176
+ expect(got!.color).toBe(n.color);
177
+ }
178
+ });
179
+
180
+ test('byte budget — 60 s × 4 Hz dense UI stays below the rc.8 baseline', () => {
181
+ // Simulate 60 s at 4 Hz = 240 ticks. Dense UI: 100 nodes, 2 nodes
182
+ // change per tick on average. This mirrors the typical
183
+ // mostly-static-with-small-animations app shape.
184
+ const baseNodes = Array.from({ length: 100 }, (_, i) => nodeRect(0, i * 24, 1080, 20));
185
+ let totalBytes = 0;
186
+ const tickCount = 240;
187
+ for (let t = 0; t < tickCount; t++) {
188
+ // Every 4th tick mutates two nodes.
189
+ const nodes = baseNodes.map((n, i) => {
190
+ if (t % 4 === 0 && (i === t % 100 || i === (t + 50) % 100)) {
191
+ return { ...n, color: t & 1 ? '#FF0000FF' : '#00FF00FF' };
192
+ }
193
+ return n;
194
+ });
195
+ __feedTickForTests(frame(1_000 + t * 250, nodes));
196
+ }
197
+ const drained = drainReplay();
198
+ totalBytes = drained.length;
199
+
200
+ // rc.8 baseline for the same window: 60 × 100 nodes × ~50 bytes/node ≈ 300 KB.
201
+ // rc.9 target: well under 200 KB.
202
+ expect(totalBytes).toBeLessThan(200_000);
203
+ // Sanity: not zero, and dominated by keyframes.
204
+ expect(totalBytes).toBeGreaterThan(10_000);
205
+ });
206
+ });
207
+
208
+ describe('computeDelta', () => {
209
+ function asMap(nodes: Node[]): Map<string, Node> {
210
+ return new Map(nodes.map((n) => [`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`, n]));
211
+ }
212
+
213
+ test('empty-vs-empty produces all-empty delta', () => {
214
+ const d = computeDelta(new Map(), new Map());
215
+ expect(d.added.length).toBe(0);
216
+ expect(d.changed.length).toBe(0);
217
+ expect(d.removed.length).toBe(0);
218
+ });
219
+
220
+ test('identical state produces no delta', () => {
221
+ const m = asMap([nodeRect(0, 0, 10, 10), nodeRect(0, 10, 10, 10)]);
222
+ const d = computeDelta(m, m);
223
+ expect(d.added.length).toBe(0);
224
+ expect(d.changed.length).toBe(0);
225
+ expect(d.removed.length).toBe(0);
226
+ });
227
+
228
+ test('integer-rounds fingerprints so sub-pixel jitter does not register', () => {
229
+ const prev = asMap([{ x: 0.1, y: 0.2, w: 10.3, h: 10.4, kind: 'rect' }]);
230
+ const curr = asMap([{ x: 0.4, y: 0.3, w: 10.2, h: 10.1, kind: 'rect' }]);
231
+ // Fingerprint of both is '0,0,10,10' → matched, no diff fields differ.
232
+ const d = computeDelta(prev, curr);
233
+ expect(d.added.length).toBe(0);
234
+ expect(d.changed.length).toBe(0);
235
+ expect(d.removed.length).toBe(0);
236
+ });
237
+ });
package/src/base64.ts ADDED
@@ -0,0 +1,29 @@
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: string): string {
25
+ if (typeof globalThis.btoa === 'function') {
26
+ return globalThis.btoa(unescape(encodeURIComponent(s)));
27
+ }
28
+ return Buffer.from(s, 'utf8').toString('base64');
29
+ }
package/src/capture.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { sealTrail, shouldSample } from '@goliapkg/sentori-core';
2
2
 
3
+ import { base64Utf8 } from './base64';
3
4
  import {
4
5
  __peekBreadcrumbCount,
5
6
  addBreadcrumb,
@@ -8,7 +9,7 @@ import {
8
9
  import { getBundleInfo } from './bundle-info';
9
10
  import { getConfig, isInitialized } from './config';
10
11
  import { getFeatureFlagSnapshot } from './feature-flags';
11
- import { drainReplay } from './replay';
12
+ import { drainReplay, isReplayRunning } from './replay';
12
13
  import { clearStateSnapshots, getStateSnapshots } from './state-snapshots';
13
14
  import { symbolicateErrorViaMetro } from './handlers/dev-symbolicate';
14
15
  import { captureScreenshot } from './handlers/screenshot';
@@ -143,6 +144,7 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
143
144
  'breadcrumbs=', crumbs.length,
144
145
  'wantScreenshot=', config.screenshotsEnabled && extras?.screenshot !== false,
145
146
  'wantSessionTrail=', config.sessionTrailEnabled,
147
+ 'wantReplay=', isReplayRunning(),
146
148
  );
147
149
  }
148
150
 
@@ -184,6 +186,17 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
184
186
  const replayNdjson = drainReplay();
185
187
  if (replayNdjson.length > 0) {
186
188
  await captureAndAttachReplay(event, replayNdjson);
189
+ } else if (typeof __DEV__ !== 'undefined' && __DEV__ && isReplayRunning()) {
190
+ // rc.4 — explicit "replay was on but ring drained empty" signal.
191
+ // Without this, "kinds=screenshot,sessionTrail" looks
192
+ // indistinguishable from `replay: off` even though the ticks
193
+ // were healthy upstream. Insight 2026-05-18 verify shape made
194
+ // this gap painful to triage.
195
+ // eslint-disable-next-line no-console
196
+ console.warn(
197
+ '[sentori] replay drain empty (no frames buffered at captureException)',
198
+ 'eventId=', event.id,
199
+ );
187
200
  }
188
201
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
189
202
  // eslint-disable-next-line no-console
@@ -202,25 +215,49 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
202
215
 
203
216
  /** v0.9.6 #2 — upload the wireframe replay ring as a `replay`
204
217
  * attachment. Plain NDJSON (one snapshot per line) — server may
205
- * gzip on storage; the network upload is base64. */
218
+ * gzip on storage; the network upload is base64.
219
+ *
220
+ * rc.4: route through `base64Utf8` so non-Latin-1 text inside any
221
+ * walked TextView (Japanese / Chinese / em-dash etc.) doesn't blow
222
+ * up the Hermes-spec `btoa`. The pre-rc.4 inline `btoa(ndjson)` path
223
+ * threw `InvalidCharacterError` on those code points, the
224
+ * surrounding catch swallowed it silently, and the replay
225
+ * attachment never landed. Insight 2026-05-18 verify caught it
226
+ * after rc.3's walker fix surfaced deep TextView content. Dev
227
+ * logs replace the silent catch so the next failure shape is
228
+ * visible. */
206
229
  async function captureAndAttachReplay(event: Event, ndjson: string): Promise<void> {
207
230
  try {
208
- const base64 =
209
- typeof globalThis.btoa === 'function'
210
- ? globalThis.btoa(ndjson)
211
- : Buffer.from(ndjson, 'utf8').toString('base64');
231
+ const base64 = base64Utf8(ndjson);
212
232
  const meta = await uploadAttachment(
213
233
  event.id,
214
234
  'replay',
215
235
  { base64, mediaType: 'application/x-ndjson' },
216
236
  { source: 'js' },
217
237
  );
218
- if (meta) {
219
- if (!event.attachments) event.attachments = [];
220
- event.attachments.push(meta);
238
+ if (!meta) {
239
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
240
+ // eslint-disable-next-line no-console
241
+ console.warn(
242
+ '[sentori] replay upload returned null',
243
+ 'eventId=', event.id,
244
+ 'ndjsonBytes=', ndjson.length,
245
+ );
246
+ }
247
+ return;
248
+ }
249
+ if (!event.attachments) event.attachments = [];
250
+ event.attachments.push(meta);
251
+ } catch (e) {
252
+ if (typeof __DEV__ !== 'undefined' && __DEV__) {
253
+ // eslint-disable-next-line no-console
254
+ console.warn(
255
+ '[sentori] replay attachment threw',
256
+ 'eventId=', event.id,
257
+ 'ndjsonBytes=', ndjson.length,
258
+ e,
259
+ );
221
260
  }
222
- } catch {
223
- // best-effort
224
261
  }
225
262
  }
226
263
 
@@ -233,11 +270,7 @@ async function captureAndAttachStateSnapshots(
233
270
  ): Promise<void> {
234
271
  try {
235
272
  const payload = JSON.stringify({ snapshots });
236
- const base64 =
237
- typeof globalThis.btoa === 'function'
238
- ? globalThis.btoa(payload)
239
- : // Bun / node fallback
240
- Buffer.from(payload, 'utf8').toString('base64');
273
+ const base64 = base64Utf8(payload);
241
274
  const meta = await uploadAttachment(
242
275
  event.id,
243
276
  'stateSnapshot',
@@ -267,12 +300,7 @@ async function captureAndAttachSessionTrail(event: Event): Promise<void> {
267
300
  const payload = sealTrail(trail);
268
301
  trail.clear();
269
302
  const json = JSON.stringify(payload);
270
- // base64 the JSON for the `data:` URI multipart bridge (same
271
- // trick the screenshot path uses).
272
- const base64 =
273
- typeof globalThis.btoa === 'function'
274
- ? globalThis.btoa(unescape(encodeURIComponent(json)))
275
- : Buffer.from(json, 'utf-8').toString('base64');
303
+ const base64 = base64Utf8(json);
276
304
  const attachment = await uploadAttachment(
277
305
  event.id,
278
306
  'sessionTrail',
@@ -289,6 +317,12 @@ async function captureAndAttachSessionTrail(event: Event): Promise<void> {
289
317
 
290
318
  export const captureException = captureError;
291
319
 
320
+ /** rc.4 — test hook. The real replay attach path is internal so we
321
+ * don't bloat the public surface, but the encoding bug Insight hit
322
+ * on 2026-05-18 needs a behaviour-level test that exercises the
323
+ * same code path captureException runs in production. */
324
+ export const __captureAndAttachReplayForTests = captureAndAttachReplay;
325
+
292
326
  /** Phase 42 sub-D.08: per-session screenshot quota gate. */
293
327
  function allowScreenshot(): boolean {
294
328
  const budget = screenshotBudget();
package/src/index.ts CHANGED
@@ -102,10 +102,13 @@ export {
102
102
  } from './state-snapshots';
103
103
  export { RageTapCapture } from './rage-tap';
104
104
  export {
105
+ probeNativeScreenshot,
106
+ probeNativeWireframe,
105
107
  startAnrWatchdog,
106
108
  stopAnrWatchdog,
107
109
  triggerNativeCrash,
108
110
  } from './native';
111
+ export { drainReplay, startReplay, stopReplay } from './replay';
109
112
  export {
110
113
  endSession,
111
114
  markSessionCrashed,