@goliapkg/sentori-react-native 5.0.0 → 5.1.1
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/MIGRATION.md +131 -0
- package/README.md +40 -30
- package/android/src/main/java/com/sentori/SentoriModule.kt +8 -0
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +36 -4
- package/ios/SentoriCrashHandler.swift +48 -4
- package/ios/SentoriModule.swift +12 -0
- package/ios/SentoriScreenshotCapture.swift +50 -4
- package/lib/config.d.ts +1 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +19 -18
- package/lib/init.js.map +1 -1
- package/lib/mask.d.ts +8 -0
- package/lib/mask.d.ts.map +1 -0
- package/lib/mask.js +38 -0
- package/lib/mask.js.map +1 -0
- package/lib/native.d.ts +4 -11
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +23 -0
- package/lib/native.js.map +1 -1
- package/lib/rage-tap.d.ts.map +1 -1
- package/lib/rage-tap.js +30 -2
- package/lib/rage-tap.js.map +1 -1
- package/lib/replay-screens.d.ts +15 -0
- package/lib/replay-screens.d.ts.map +1 -0
- package/lib/replay-screens.js +87 -0
- package/lib/replay-screens.js.map +1 -0
- package/lib/responsiveness-detector.d.ts +18 -0
- package/lib/responsiveness-detector.d.ts.map +1 -0
- package/lib/responsiveness-detector.js +55 -0
- package/lib/responsiveness-detector.js.map +1 -0
- package/lib/transport.d.ts +18 -1
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +48 -8
- package/lib/transport.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/replay-screens.test.ts +63 -0
- package/src/__tests__/responsiveness-detector.test.ts +74 -0
- package/src/config.ts +1 -0
- package/src/index.ts +3 -0
- package/src/init.ts +18 -22
- package/src/mask.ts +42 -0
- package/src/native.ts +33 -0
- package/src/rage-tap.tsx +46 -2
- package/src/replay-screens.ts +95 -0
- package/src/responsiveness-detector.ts +66 -0
- package/src/transport.ts +64 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/sentori-react-native",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.1",
|
|
4
4
|
"description": "Sentori SDK for React Native — JS-layer error capture, native crash handlers (iOS / Android), batched transport, fetch + react-navigation tracing.",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"author": "GOLIA K.K. <takagi@golia.jp> (https://golia.jp)",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"sentori-rn-upload-source-bundle": "bin/sentori-rn-upload-source-bundle.cjs"
|
|
47
47
|
},
|
|
48
48
|
"files": [
|
|
49
|
+
"MIGRATION.md",
|
|
49
50
|
"lib/",
|
|
50
51
|
"src/",
|
|
51
52
|
"bin/",
|
|
@@ -96,6 +97,6 @@
|
|
|
96
97
|
"access": "public"
|
|
97
98
|
},
|
|
98
99
|
"dependencies": {
|
|
99
|
-
"@goliapkg/sentori-core": "^2.
|
|
100
|
+
"@goliapkg/sentori-core": "^2.1.0"
|
|
100
101
|
}
|
|
101
102
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The screens ring: capacity, drain shape, mask-query isolation,
|
|
2
|
+
// and graceful absence on an old native build.
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { __resetForTests as resetMask, maskedNativeIds, registerMaskQuery } from '../mask';
|
|
7
|
+
import {
|
|
8
|
+
__resetForTests as resetRing,
|
|
9
|
+
__setCaptureForTests,
|
|
10
|
+
drainScreenReplay,
|
|
11
|
+
startScreenReplay,
|
|
12
|
+
} from '../replay-screens';
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
resetRing();
|
|
16
|
+
resetMask();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('mask query', () => {
|
|
20
|
+
test('unregistered → empty', () => {
|
|
21
|
+
expect(maskedNativeIds()).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('registered query flows through and filters junk', () => {
|
|
25
|
+
registerMaskQuery(() => ['camera-feed', 42 as unknown as string, 'user-email']);
|
|
26
|
+
expect(maskedNativeIds()).toEqual(['camera-feed', 'user-email']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('a throwing query masks nothing and never throws', () => {
|
|
30
|
+
registerMaskQuery(() => {
|
|
31
|
+
throw new Error('boom');
|
|
32
|
+
});
|
|
33
|
+
expect(maskedNativeIds()).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('screens ring', () => {
|
|
38
|
+
test('drain on an empty ring is null (old native builds)', () => {
|
|
39
|
+
startScreenReplay(60);
|
|
40
|
+
expect(drainScreenReplay()).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('drain emits NDJSON with negative relative timestamps', async () => {
|
|
44
|
+
let n = 0;
|
|
45
|
+
__setCaptureForTests(
|
|
46
|
+
mock(async () => ({ base64: `frame${(n += 1)}`, mediaType: 'image/jpeg' })),
|
|
47
|
+
);
|
|
48
|
+
startScreenReplay(60);
|
|
49
|
+
await new Promise((r) => setTimeout(r, 2_600));
|
|
50
|
+
const out = drainScreenReplay();
|
|
51
|
+
expect(out).not.toBeNull();
|
|
52
|
+
const lines = (out ?? '')
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((l) => JSON.parse(l) as { t: number; base64: string });
|
|
55
|
+
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
56
|
+
for (const l of lines) {
|
|
57
|
+
expect(l.t).toBeLessThanOrEqual(0);
|
|
58
|
+
expect(l.base64.startsWith('frame')).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
// drain does not clear: a second error still sees the window
|
|
61
|
+
expect(drainScreenReplay()).not.toBeNull();
|
|
62
|
+
}, 10_000);
|
|
63
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// dead_button / sluggish_button verdict logic.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEAD_THRESHOLD,
|
|
7
|
+
DEAD_WINDOW_MS,
|
|
8
|
+
RESPONSE_WINDOW_MS,
|
|
9
|
+
SLUGGISH_COOLDOWN_MS,
|
|
10
|
+
SLUGGISH_MS,
|
|
11
|
+
classifyTap,
|
|
12
|
+
recordDeadTap,
|
|
13
|
+
recordSluggish,
|
|
14
|
+
} from '../responsiveness-detector';
|
|
15
|
+
|
|
16
|
+
describe('classifyTap', () => {
|
|
17
|
+
test('a fast reaction is responsive', () => {
|
|
18
|
+
expect(classifyTap(1000, [1300])).toBe('responsive');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('a slow reaction is sluggish', () => {
|
|
22
|
+
expect(classifyTap(1000, [1000 + SLUGGISH_MS + 200])).toBe('sluggish');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('no reaction inside the window is dead', () => {
|
|
26
|
+
expect(classifyTap(1000, [])).toBe('dead');
|
|
27
|
+
expect(classifyTap(1000, [1000 + RESPONSE_WINDOW_MS + 500])).toBe('dead');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('signals from before the tap do not count', () => {
|
|
31
|
+
expect(classifyTap(1000, [900, 500])).toBe('dead');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('the earliest reaction decides', () => {
|
|
35
|
+
expect(classifyTap(1000, [1000 + SLUGGISH_MS + 400, 1200])).toBe('responsive');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('recordDeadTap', () => {
|
|
40
|
+
test('warns only at the threshold, then resets', () => {
|
|
41
|
+
const buckets = new Map<number, number[]>();
|
|
42
|
+
let warned = 0;
|
|
43
|
+
for (let i = 0; i < DEAD_THRESHOLD * 2; i++) {
|
|
44
|
+
if (recordDeadTap(buckets, 7, 1000 + i * 100)) warned += 1;
|
|
45
|
+
}
|
|
46
|
+
expect(warned).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('stale dead taps age out of the window', () => {
|
|
50
|
+
const buckets = new Map<number, number[]>();
|
|
51
|
+
recordDeadTap(buckets, 7, 0);
|
|
52
|
+
recordDeadTap(buckets, 7, 100);
|
|
53
|
+
// Third tap arrives after the window: the old two are gone.
|
|
54
|
+
expect(recordDeadTap(buckets, 7, DEAD_WINDOW_MS + 200)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('targets are independent', () => {
|
|
58
|
+
const buckets = new Map<number, number[]>();
|
|
59
|
+
recordDeadTap(buckets, 1, 0);
|
|
60
|
+
recordDeadTap(buckets, 1, 10);
|
|
61
|
+
expect(recordDeadTap(buckets, 2, 20)).toBe(false);
|
|
62
|
+
expect(recordDeadTap(buckets, 1, 30)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('recordSluggish', () => {
|
|
67
|
+
test('cooldown gates repeat warns per target', () => {
|
|
68
|
+
const warns = new Map<number, number>();
|
|
69
|
+
expect(recordSluggish(warns, 7, 1000)).toBe(true);
|
|
70
|
+
expect(recordSluggish(warns, 7, 2000)).toBe(false);
|
|
71
|
+
expect(recordSluggish(warns, 7, 1000 + SLUGGISH_COOLDOWN_MS + 1)).toBe(true);
|
|
72
|
+
expect(recordSluggish(warns, 8, 2000)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type Config = {
|
|
|
19
19
|
};
|
|
20
20
|
/** B-type replay rolling buffer, seconds. 0 disables. */
|
|
21
21
|
replaySeconds: number;
|
|
22
|
+
replayScreens: boolean;
|
|
22
23
|
/** Sentori console output gate. Default `warn`: silent on the
|
|
23
24
|
* host's console unless something is genuinely broken. */
|
|
24
25
|
logLevel?: LogLevel;
|
package/src/index.ts
CHANGED
|
@@ -62,6 +62,9 @@ export default sentori;
|
|
|
62
62
|
export { init } from './init';
|
|
63
63
|
export { ErrorBoundary } from './error-boundary';
|
|
64
64
|
export { RageTapCapture } from './rage-tap';
|
|
65
|
+
// Privacy: register a query returning nativeIDs that must be
|
|
66
|
+
// blacked out in every captured frame (visual replay, screenshots).
|
|
67
|
+
export { registerMaskQuery } from './mask';
|
|
65
68
|
// Dev-panel helper: crash the native layer on purpose to exercise
|
|
66
69
|
// the pending-crash upload pipeline. Not part of the 8-verb surface.
|
|
67
70
|
export { triggerNativeCrash } from './native';
|
package/src/init.ts
CHANGED
|
@@ -17,7 +17,8 @@ import { checkColdStart } from './mobile-vitals';
|
|
|
17
17
|
import { markNativeJsBridgeReady, setNativeConfig } from './native';
|
|
18
18
|
import { shipNativePending } from './native-pending';
|
|
19
19
|
import { drainReplay, startReplay } from './replay';
|
|
20
|
-
import {
|
|
20
|
+
import { drainScreenReplay, startScreenReplay } from './replay-screens';
|
|
21
|
+
import { drainOfflineQueue, queueAttachment, startTransport } from './transport';
|
|
21
22
|
|
|
22
23
|
let _initialized = false;
|
|
23
24
|
|
|
@@ -50,6 +51,7 @@ export const init = safeFn('init', (config: InitConfig): void => {
|
|
|
50
51
|
slowApi: config.detect?.slowApi ?? false,
|
|
51
52
|
},
|
|
52
53
|
replaySeconds: config.replaySeconds ?? 30,
|
|
54
|
+
replayScreens: config.replayScreens ?? false,
|
|
53
55
|
beforeSend: config.beforeSend,
|
|
54
56
|
});
|
|
55
57
|
setLogLevel(config.logLevel ?? 'warn');
|
|
@@ -69,19 +71,25 @@ export const init = safeFn('init', (config: InitConfig): void => {
|
|
|
69
71
|
const replaySeconds = config.replaySeconds ?? 30;
|
|
70
72
|
if (replaySeconds > 0) {
|
|
71
73
|
startReplay({ mode: 'wireframe' });
|
|
74
|
+
// Visual ring is opt-in: screenshots can carry user content.
|
|
75
|
+
if (config.replayScreens === true) startScreenReplay(replaySeconds);
|
|
72
76
|
registerEmitHook((event) => {
|
|
73
77
|
if (event.kind !== 'error' && event.kind !== 'warn') return;
|
|
74
78
|
if (!event.id) return;
|
|
75
79
|
const lines = drainReplay();
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
if (lines) {
|
|
81
|
+
queueAttachment(event.id, 'replay', {
|
|
82
|
+
text: lines,
|
|
83
|
+
mediaType: 'application/x-sentori-replay',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const frames = drainScreenReplay();
|
|
87
|
+
if (frames) {
|
|
88
|
+
queueAttachment(event.id, 'screens', {
|
|
89
|
+
text: frames,
|
|
90
|
+
mediaType: 'application/x-sentori-screens',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
85
93
|
});
|
|
86
94
|
}
|
|
87
95
|
|
|
@@ -102,18 +110,6 @@ export const init = safeFn('init', (config: InitConfig): void => {
|
|
|
102
110
|
void drainOfflineQueue();
|
|
103
111
|
});
|
|
104
112
|
|
|
105
|
-
/** RN's Hermes has no btoa in older releases; go through base64.ts. */
|
|
106
|
-
const base64Encode = (text: string): string | null => {
|
|
107
|
-
try {
|
|
108
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
109
|
-
const { encodeBase64 } = require('./base64') as {
|
|
110
|
-
encodeBase64: (s: string) => string;
|
|
111
|
-
};
|
|
112
|
-
return encodeBase64(text);
|
|
113
|
-
} catch {
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
113
|
|
|
118
114
|
export const __resetForTests = (): void => {
|
|
119
115
|
_initialized = false;
|
package/src/mask.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Screen masking — the privacy half of visual replay.
|
|
2
|
+
//
|
|
3
|
+
// The host registers a query returning the `nativeID`s of views
|
|
4
|
+
// that must never appear in a screenshot (camera feeds, user
|
|
5
|
+
// identity, payment fields). On iOS the native matcher accepts the
|
|
6
|
+
// same value via either `nativeID` or `testID` (RN maps testID to
|
|
7
|
+
// accessibilityIdentifier); on Android `nativeID` rides the view
|
|
8
|
+
// tag. One prop, both platforms: use `nativeID`. Native paints black rectangles over
|
|
9
|
+
// those subtrees in the same render pass, so the pixels never
|
|
10
|
+
// leave the device.
|
|
11
|
+
//
|
|
12
|
+
// The query runs on every frame tick; keep it cheap (return a
|
|
13
|
+
// cached array). A throwing query is swallowed and masks NOTHING
|
|
14
|
+
// that tick — so a broken query fails visible-in-review rather
|
|
15
|
+
// than silently, and can never take the capture path down.
|
|
16
|
+
|
|
17
|
+
import { reportInternal } from '@goliapkg/sentori-core';
|
|
18
|
+
|
|
19
|
+
type MaskQuery = () => string[];
|
|
20
|
+
|
|
21
|
+
let _query: MaskQuery | null = null;
|
|
22
|
+
|
|
23
|
+
/** Register (or with `null`, clear) the mask query. */
|
|
24
|
+
export const registerMaskQuery = (query: MaskQuery | null): void => {
|
|
25
|
+
_query = query;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The current mask list; empty when unregistered or throwing. */
|
|
29
|
+
export const maskedNativeIds = (): string[] => {
|
|
30
|
+
if (!_query) return [];
|
|
31
|
+
try {
|
|
32
|
+
const ids = _query();
|
|
33
|
+
return Array.isArray(ids) ? ids.filter((x) => typeof x === 'string') : [];
|
|
34
|
+
} catch (e) {
|
|
35
|
+
reportInternal('mask-query', e);
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const __resetForTests = (): void => {
|
|
41
|
+
_query = null;
|
|
42
|
+
};
|
package/src/native.ts
CHANGED
|
@@ -69,6 +69,13 @@ type SentoriNativeModule = {
|
|
|
69
69
|
* snapshot string or null on failure.
|
|
70
70
|
*/
|
|
71
71
|
captureWireframe?: (maskedIds: string[]) => null | string
|
|
72
|
+
|
|
73
|
+
/** v5.1 — low-bitrate replay frame; absent on older native builds. */
|
|
74
|
+
captureReplayFrame?: (
|
|
75
|
+
maskedIds: string[],
|
|
76
|
+
longEdgePx: number,
|
|
77
|
+
quality: number,
|
|
78
|
+
) => Promise<null | { base64: string; mediaType: string }>
|
|
72
79
|
/**
|
|
73
80
|
* v0.9.12 — diagnostic readout for the wireframe path. Cheap
|
|
74
81
|
* synchronous call that returns the path the last `captureWireframe`
|
|
@@ -387,6 +394,32 @@ export function getRecentNativeException(): null | {
|
|
|
387
394
|
* Callers must treat `null` as "no screenshot this round" — the
|
|
388
395
|
* error event still ships, just without a thumbnail.
|
|
389
396
|
*/
|
|
397
|
+
/** v5.1 — one low-bitrate frame for the screens replay ring.
|
|
398
|
+
* Null on every failure mode (module unbound, method missing on an
|
|
399
|
+
* older native build, capture failed); the ring just skips a beat. */
|
|
400
|
+
let warnedNoReplayFrame = false
|
|
401
|
+
export async function captureNativeReplayFrame(
|
|
402
|
+
maskedIds: string[],
|
|
403
|
+
longEdgePx: number,
|
|
404
|
+
quality: number,
|
|
405
|
+
): Promise<null | { base64: string; mediaType: string }> {
|
|
406
|
+
const n = native()
|
|
407
|
+
if (!n) return null
|
|
408
|
+
if (!n.captureReplayFrame) {
|
|
409
|
+
if (!warnedNoReplayFrame) {
|
|
410
|
+
warnedNoReplayFrame = true
|
|
411
|
+
logger.warn('native', 'captureReplayFrame missing — rebuild the native app for visual replay')
|
|
412
|
+
}
|
|
413
|
+
return null
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
return (await n.captureReplayFrame(maskedIds, longEdgePx, quality)) ?? null
|
|
417
|
+
} catch (e) {
|
|
418
|
+
logger.warn('native', 'captureReplayFrame threw', e)
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
390
423
|
export async function captureNativeScreenshotWithMask(
|
|
391
424
|
maskedIds: string[],
|
|
392
425
|
): Promise<null | { base64: string; mediaType: string }> {
|
package/src/rage-tap.tsx
CHANGED
|
@@ -13,11 +13,18 @@
|
|
|
13
13
|
import React, { useCallback, useRef } from 'react';
|
|
14
14
|
import { View, type GestureResponderEvent, type ViewProps } from 'react-native';
|
|
15
15
|
|
|
16
|
-
import { pushSignal } from '@goliapkg/sentori-core';
|
|
16
|
+
import { pushSignal, snapshotSignals } from '@goliapkg/sentori-core';
|
|
17
17
|
|
|
18
18
|
import { getConfig } from './config';
|
|
19
19
|
import { currentScreen } from './navigation';
|
|
20
20
|
import { RAGE_THRESHOLD, RAGE_WINDOW_MS, recordTap } from './rage-tap-detector';
|
|
21
|
+
import {
|
|
22
|
+
RESPONSE_WINDOW_MS,
|
|
23
|
+
SLUGGISH_MS,
|
|
24
|
+
classifyTap,
|
|
25
|
+
recordDeadTap,
|
|
26
|
+
recordSluggish,
|
|
27
|
+
} from './responsiveness-detector';
|
|
21
28
|
import { warnDetected } from './verbs';
|
|
22
29
|
|
|
23
30
|
export function RageTapCapture({
|
|
@@ -25,13 +32,50 @@ export function RageTapCapture({
|
|
|
25
32
|
...rest
|
|
26
33
|
}: ViewProps & { children?: React.ReactNode }): React.JSX.Element {
|
|
27
34
|
const recent = useRef<Map<number, number[]>>(new Map());
|
|
35
|
+
const deadBuckets = useRef<Map<number, number[]>>(new Map());
|
|
36
|
+
const sluggishWarns = useRef<Map<number, number>>(new Map());
|
|
28
37
|
|
|
29
38
|
const onTouchEnd = useCallback((e: GestureResponderEvent) => {
|
|
30
39
|
try {
|
|
31
40
|
const target = e.nativeEvent?.target;
|
|
32
41
|
if (typeof target !== 'number') return;
|
|
42
|
+
const tapAt = Date.now();
|
|
33
43
|
pushSignal('tap', { target });
|
|
34
|
-
|
|
44
|
+
|
|
45
|
+
// Responsiveness verdict lands after the window closes: the
|
|
46
|
+
// ring tells us whether the app reacted to this tap at all.
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
try {
|
|
49
|
+
const screen = currentScreen();
|
|
50
|
+
// Ring snapshots carry event-relative seconds (one decimal);
|
|
51
|
+
// rebase them onto the epoch for the classifier.
|
|
52
|
+
const nowMs = Date.now();
|
|
53
|
+
const times = snapshotSignals(nowMs)
|
|
54
|
+
.filter((s) => s.kind !== 'tap')
|
|
55
|
+
.map((s) => nowMs + s.t * 1000);
|
|
56
|
+
const outcome = classifyTap(tapAt, times);
|
|
57
|
+
if (outcome === 'dead' && recordDeadTap(deadBuckets.current, target, Date.now())) {
|
|
58
|
+
warnDetected(
|
|
59
|
+
'dead_button',
|
|
60
|
+
{ screen, element: String(target) },
|
|
61
|
+
{ windowMs: RESPONSE_WINDOW_MS },
|
|
62
|
+
);
|
|
63
|
+
} else if (
|
|
64
|
+
outcome === 'sluggish' &&
|
|
65
|
+
recordSluggish(sluggishWarns.current, target, Date.now())
|
|
66
|
+
) {
|
|
67
|
+
warnDetected(
|
|
68
|
+
'sluggish_button',
|
|
69
|
+
{ screen, element: String(target) },
|
|
70
|
+
{ thresholdMs: SLUGGISH_MS },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// detector bug must never surface
|
|
75
|
+
}
|
|
76
|
+
}, RESPONSE_WINDOW_MS + 50);
|
|
77
|
+
|
|
78
|
+
if (!recordTap(recent.current, target, tapAt)) return;
|
|
35
79
|
if (getConfig()?.detect.rageTap === false) return;
|
|
36
80
|
warnDetected(
|
|
37
81
|
'rage_tap',
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// B-type visual replay — a rolling ring of low-bitrate screenshots.
|
|
2
|
+
//
|
|
3
|
+
// OFF by default (privacy + the client-zero-cost rule): enable
|
|
4
|
+
// with `init({ replayScreens: true })`. One frame every 2.5 s at
|
|
5
|
+
// 360 px / q≈0.35 runs 10-20 KB, so the 60 s window a triage
|
|
6
|
+
// actually wants is ~24 frames and a few hundred KB — and those
|
|
7
|
+
// bytes only ever leave the device when an error/warn fires,
|
|
8
|
+
// stapled to that event as a `screens` attachment.
|
|
9
|
+
//
|
|
10
|
+
// Older native builds have no `captureReplayFrame`; the ring then
|
|
11
|
+
// simply stays empty (one warn) — wireframe replay still works.
|
|
12
|
+
|
|
13
|
+
import { logger } from '@goliapkg/sentori-core';
|
|
14
|
+
|
|
15
|
+
import { maskedNativeIds } from './mask';
|
|
16
|
+
import { captureNativeReplayFrame } from './native';
|
|
17
|
+
|
|
18
|
+
type CaptureFn = typeof captureNativeReplayFrame;
|
|
19
|
+
let _capture: CaptureFn = captureNativeReplayFrame;
|
|
20
|
+
|
|
21
|
+
/** Frame cadence. 2.5 s keeps the main-thread cost of a capture
|
|
22
|
+
* (~1-3 ms) far under the 1 % occupancy budget. */
|
|
23
|
+
const TICK_INTERVAL_MS = 2_500;
|
|
24
|
+
/** Long edge of a replay frame, px. Enough to read a screen's
|
|
25
|
+
* layout and large text; deliberately not enough to read a
|
|
26
|
+
* document over someone's shoulder. */
|
|
27
|
+
const FRAME_LONG_EDGE_PX = 360;
|
|
28
|
+
/** JPEG/WebP quality for replay frames. */
|
|
29
|
+
const FRAME_QUALITY = 0.35;
|
|
30
|
+
|
|
31
|
+
type ScreenFrame = { t: number; base64: string; mediaType: string };
|
|
32
|
+
|
|
33
|
+
let _ring: ScreenFrame[] = [];
|
|
34
|
+
let _capacity = 0;
|
|
35
|
+
let _timer: ReturnType<typeof setInterval> | null = null;
|
|
36
|
+
let _capturing = false;
|
|
37
|
+
|
|
38
|
+
/** Start the ring. `windowSeconds` is how far back the replay
|
|
39
|
+
* reaches when an event fires. */
|
|
40
|
+
export const startScreenReplay = (windowSeconds: number): void => {
|
|
41
|
+
if (_timer !== null || windowSeconds <= 0) return;
|
|
42
|
+
_capacity = Math.max(1, Math.ceil((windowSeconds * 1000) / TICK_INTERVAL_MS));
|
|
43
|
+
_timer = setInterval(() => {
|
|
44
|
+
void tick();
|
|
45
|
+
}, TICK_INTERVAL_MS);
|
|
46
|
+
logger.debug('replay-screens', `ring started: ${_capacity} slots / ${windowSeconds}s`);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const tick = async (): Promise<void> => {
|
|
50
|
+
// Never overlap captures: a slow frame skips a beat instead of
|
|
51
|
+
// queueing main-thread work.
|
|
52
|
+
if (_capturing) return;
|
|
53
|
+
_capturing = true;
|
|
54
|
+
try {
|
|
55
|
+
const frame = await _capture(maskedNativeIds(), FRAME_LONG_EDGE_PX, FRAME_QUALITY);
|
|
56
|
+
if (frame) {
|
|
57
|
+
_ring.push({ t: Date.now(), ...frame });
|
|
58
|
+
if (_ring.length > _capacity) _ring.splice(0, _ring.length - _capacity);
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
_capturing = false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Drain the ring into the wire form: NDJSON, one frame per line,
|
|
66
|
+
* `t` rewritten to seconds-before-now (negative, so the player
|
|
67
|
+
* reads "-42.5s → 0s"). Returns null when empty. The ring is NOT
|
|
68
|
+
* cleared — a second error two seconds later should still see
|
|
69
|
+
* the minute before it. */
|
|
70
|
+
export const drainScreenReplay = (): null | string => {
|
|
71
|
+
if (_ring.length === 0) return null;
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
return _ring
|
|
74
|
+
.map((f) =>
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
t: Number(((f.t - now) / 1000).toFixed(1)),
|
|
77
|
+
mediaType: f.mediaType,
|
|
78
|
+
base64: f.base64,
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
.join('\n');
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const __resetForTests = (): void => {
|
|
85
|
+
if (_timer !== null) clearInterval(_timer);
|
|
86
|
+
_timer = null;
|
|
87
|
+
_ring = [];
|
|
88
|
+
_capacity = 0;
|
|
89
|
+
_capturing = false;
|
|
90
|
+
_capture = captureNativeReplayFrame;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const __setCaptureForTests = (fn: CaptureFn): void => {
|
|
94
|
+
_capture = fn;
|
|
95
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// dead_button / sluggish_button — pure detection logic.
|
|
2
|
+
//
|
|
3
|
+
// A tap is judged by what the signal ring records after it: any
|
|
4
|
+
// non-tap signal (navigation, network, a trace point) inside the
|
|
5
|
+
// response window counts as the app reacting.
|
|
6
|
+
//
|
|
7
|
+
// - responsive: a reaction within SLUGGISH_MS.
|
|
8
|
+
// - sluggish: a reaction, but slower than SLUGGISH_MS.
|
|
9
|
+
// - dead: no reaction at all inside RESPONSE_WINDOW_MS.
|
|
10
|
+
//
|
|
11
|
+
// One dead tap is usually decoration (backgrounds, labels), so a
|
|
12
|
+
// dead_button warn needs DEAD_THRESHOLD dead taps on the SAME
|
|
13
|
+
// target inside DEAD_WINDOW_MS — a user repeatedly poking one
|
|
14
|
+
// unresponsive control, at a slower cadence than a rage tap.
|
|
15
|
+
// Sluggish warns are per-target cooldown-limited so one slow
|
|
16
|
+
// button files one issue, not one per tap.
|
|
17
|
+
|
|
18
|
+
export const RESPONSE_WINDOW_MS = 1_500;
|
|
19
|
+
export const SLUGGISH_MS = 1_000;
|
|
20
|
+
export const DEAD_THRESHOLD = 3;
|
|
21
|
+
export const DEAD_WINDOW_MS = 30_000;
|
|
22
|
+
export const SLUGGISH_COOLDOWN_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
export type TapOutcome = 'dead' | 'responsive' | 'sluggish';
|
|
25
|
+
|
|
26
|
+
/** Judge one tap by the ring signals that followed it.
|
|
27
|
+
* `signalTimes` are the timestamps (ms) of every non-tap signal
|
|
28
|
+
* recorded after `tapAt`. */
|
|
29
|
+
export function classifyTap(tapAt: number, signalTimes: number[]): TapOutcome {
|
|
30
|
+
const first = signalTimes
|
|
31
|
+
.filter((t) => t > tapAt && t - tapAt <= RESPONSE_WINDOW_MS)
|
|
32
|
+
.sort((a, b) => a - b)[0];
|
|
33
|
+
if (first === undefined) return 'dead';
|
|
34
|
+
return first - tapAt > SLUGGISH_MS ? 'sluggish' : 'responsive';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Per-target dead-tap bookkeeping. Returns true when this dead tap
|
|
38
|
+
* crosses the warn threshold (and clears the bucket so the next
|
|
39
|
+
* warn needs a fresh run of dead taps). */
|
|
40
|
+
export function recordDeadTap(
|
|
41
|
+
buckets: Map<number, number[]>,
|
|
42
|
+
target: number,
|
|
43
|
+
now: number,
|
|
44
|
+
): boolean {
|
|
45
|
+
const fresh = (buckets.get(target) ?? []).filter((t) => now - t <= DEAD_WINDOW_MS);
|
|
46
|
+
fresh.push(now);
|
|
47
|
+
if (fresh.length >= DEAD_THRESHOLD) {
|
|
48
|
+
buckets.delete(target);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
buckets.set(target, fresh);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Per-target sluggish cooldown. Returns true when a warn should
|
|
56
|
+
* fire (and stamps the cooldown). */
|
|
57
|
+
export function recordSluggish(
|
|
58
|
+
lastWarnAt: Map<number, number>,
|
|
59
|
+
target: number,
|
|
60
|
+
now: number,
|
|
61
|
+
): boolean {
|
|
62
|
+
const last = lastWarnAt.get(target);
|
|
63
|
+
if (last !== undefined && now - last < SLUGGISH_COOLDOWN_MS) return false;
|
|
64
|
+
lastWarnAt.set(target, now);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
package/src/transport.ts
CHANGED
|
@@ -82,12 +82,53 @@ export const flush = async (): Promise<void> => {
|
|
|
82
82
|
|
|
83
83
|
try {
|
|
84
84
|
await sendWithRetry(envelope, config.ingestUrl, config.token);
|
|
85
|
+
// Only now do the events exist server-side — release their
|
|
86
|
+
// queued attachments. Uploading before this point 404s: the
|
|
87
|
+
// attachment races the 5s event batch and always wins.
|
|
88
|
+
for (const ev of events) {
|
|
89
|
+
if (ev.id) void sendQueuedAttachments(ev.id);
|
|
90
|
+
}
|
|
85
91
|
} catch {
|
|
86
92
|
// Events survive offline; assert deltas are cheap enough to lose.
|
|
93
|
+
// Their attachments are memory-only and lost with the process —
|
|
94
|
+
// documented; a replay is context, not the crash report itself.
|
|
87
95
|
await persist(events);
|
|
88
96
|
}
|
|
89
97
|
};
|
|
90
98
|
|
|
99
|
+
// ── attachments (deferred until their event is delivered) ─────────
|
|
100
|
+
|
|
101
|
+
type QueuedAttachment = {
|
|
102
|
+
kind: import('@goliapkg/sentori-core').AttachmentKind;
|
|
103
|
+
blob: { base64?: string; text?: string; mediaType: string };
|
|
104
|
+
source: 'android' | 'ios' | 'js';
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const _pendingAttachments = new Map<string, QueuedAttachment[]>();
|
|
108
|
+
|
|
109
|
+
/** Attach a blob to an event that is still in the batch queue. It
|
|
110
|
+
* uploads right after the batch containing the event lands, so the
|
|
111
|
+
* server always already knows the event. */
|
|
112
|
+
export const queueAttachment = (
|
|
113
|
+
eventId: string,
|
|
114
|
+
kind: QueuedAttachment['kind'],
|
|
115
|
+
blob: QueuedAttachment['blob'],
|
|
116
|
+
opts: { source?: QueuedAttachment['source'] } = {},
|
|
117
|
+
): void => {
|
|
118
|
+
const list = _pendingAttachments.get(eventId) ?? [];
|
|
119
|
+
list.push({ kind, blob, source: opts.source ?? 'js' });
|
|
120
|
+
_pendingAttachments.set(eventId, list);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const sendQueuedAttachments = async (eventId: string): Promise<void> => {
|
|
124
|
+
const list = _pendingAttachments.get(eventId);
|
|
125
|
+
if (!list) return;
|
|
126
|
+
_pendingAttachments.delete(eventId);
|
|
127
|
+
for (const a of list) {
|
|
128
|
+
await uploadAttachment(eventId, a.kind, a.blob, { source: a.source });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
91
132
|
const sendWithRetry = async (
|
|
92
133
|
envelope: BatchEnvelope,
|
|
93
134
|
ingestUrl: string,
|
|
@@ -206,26 +247,40 @@ export const drainOfflineQueue = async (): Promise<void> => {
|
|
|
206
247
|
export const uploadAttachment = async (
|
|
207
248
|
eventId: string,
|
|
208
249
|
kind: import('@goliapkg/sentori-core').AttachmentKind,
|
|
209
|
-
blob: { base64
|
|
250
|
+
blob: { base64?: string; text?: string; mediaType: string },
|
|
210
251
|
opts: { source?: 'android' | 'ios' | 'js' } = {},
|
|
211
252
|
): Promise<{ ref: string } | null> => {
|
|
212
253
|
const config = getConfig();
|
|
213
254
|
if (!config) return null;
|
|
214
255
|
const url = `${config.ingestUrl}/v1/events/${encodeURIComponent(eventId)}/attachments/${encodeURIComponent(kind)}`;
|
|
215
256
|
|
|
216
|
-
|
|
217
|
-
form
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
257
|
+
// Hand-built multipart. React Native's FormData file part wants a
|
|
258
|
+
// `uri`, and its `data:` URI form throws a bare network error on
|
|
259
|
+
// iOS — this shipped untested and every JS attachment silently
|
|
260
|
+
// died. Text payloads (replay/screens NDJSON) embed directly;
|
|
261
|
+
// base64 payloads embed as base64 with the transfer-encoding
|
|
262
|
+
// header so the server knows to decode.
|
|
263
|
+
const boundary = `----sentori-${eventId}`;
|
|
264
|
+
const isText = typeof blob.text === 'string';
|
|
265
|
+
const content = isText ? (blob.text ?? '') : (blob.base64 ?? '');
|
|
266
|
+
const encodingHeader = isText ? '' : 'Content-Transfer-Encoding: base64\r\n';
|
|
267
|
+
const wireBody =
|
|
268
|
+
`--${boundary}\r\n` +
|
|
269
|
+
`Content-Disposition: form-data; name="file"; filename="${kind}.bin"\r\n` +
|
|
270
|
+
`Content-Type: ${blob.mediaType}\r\n` +
|
|
271
|
+
encodingHeader +
|
|
272
|
+
`\r\n${content}\r\n` +
|
|
273
|
+
`--${boundary}\r\n` +
|
|
274
|
+
`Content-Disposition: form-data; name="source"\r\n` +
|
|
275
|
+
`\r\n${opts.source ?? 'js'}\r\n` +
|
|
276
|
+
`--${boundary}--\r\n`;
|
|
223
277
|
|
|
224
278
|
try {
|
|
225
279
|
const resp = await fetch(url, {
|
|
226
|
-
body:
|
|
280
|
+
body: wireBody,
|
|
227
281
|
headers: {
|
|
228
282
|
Authorization: `Bearer ${config.token}`,
|
|
283
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
229
284
|
'Sentori-Sdk': `react-native/${SDK_VERSION}`,
|
|
230
285
|
},
|
|
231
286
|
method: 'POST',
|