@goliapkg/sentori-react-native 0.7.6 → 0.8.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/lib/bundle-info.d.ts +12 -0
- package/lib/bundle-info.d.ts.map +1 -0
- package/lib/bundle-info.js +73 -0
- package/lib/bundle-info.js.map +1 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +6 -0
- package/lib/capture.js.map +1 -1
- package/lib/feature-flags.d.ts +9 -0
- package/lib/feature-flags.d.ts.map +1 -0
- package/lib/feature-flags.js +44 -0
- package/lib/feature-flags.js.map +1 -0
- package/lib/feedback-widget.d.ts +35 -0
- package/lib/feedback-widget.d.ts.map +1 -0
- package/lib/feedback-widget.js +186 -0
- package/lib/feedback-widget.js.map +1 -0
- package/lib/handlers/network.d.ts +9 -1
- package/lib/handlers/network.d.ts.map +1 -1
- package/lib/handlers/network.js +189 -18
- package/lib/handlers/network.js.map +1 -1
- package/lib/index.d.ts +17 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +18 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +16 -1
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +23 -2
- package/lib/init.js.map +1 -1
- package/lib/launch-crash-guard.d.ts +37 -0
- package/lib/launch-crash-guard.d.ts.map +1 -0
- package/lib/launch-crash-guard.js +163 -0
- package/lib/launch-crash-guard.js.map +1 -0
- package/lib/measure.d.ts +4 -0
- package/lib/measure.d.ts.map +1 -0
- package/lib/measure.js +25 -0
- package/lib/measure.js.map +1 -0
- package/lib/rage-tap-detector.d.ts +8 -0
- package/lib/rage-tap-detector.d.ts.map +1 -0
- package/lib/rage-tap-detector.js +21 -0
- package/lib/rage-tap-detector.js.map +1 -0
- package/lib/rage-tap.d.ts +6 -0
- package/lib/rage-tap.d.ts.map +1 -0
- package/lib/rage-tap.js +35 -0
- package/lib/rage-tap.js.map +1 -0
- package/package.json +15 -3
- package/src/__tests__/feature-flags.test.ts +55 -0
- package/src/__tests__/measure.test.ts +45 -0
- package/src/__tests__/network-graphql.test.ts +75 -0
- package/src/__tests__/rage-tap.test.ts +38 -0
- package/src/bundle-info.ts +95 -0
- package/src/capture.ts +6 -0
- package/src/feature-flags.ts +47 -0
- package/src/feedback-widget.tsx +309 -0
- package/src/handlers/network.ts +198 -18
- package/src/index.ts +28 -0
- package/src/init.ts +54 -2
- package/src/launch-crash-guard.ts +221 -0
- package/src/measure.ts +28 -0
- package/src/rage-tap-detector.ts +26 -0
- package/src/rage-tap.tsx +48 -0
package/src/index.ts
CHANGED
|
@@ -9,8 +9,18 @@ import {
|
|
|
9
9
|
setUser,
|
|
10
10
|
} from './capture';
|
|
11
11
|
import { ErrorBoundary } from './error-boundary';
|
|
12
|
+
import { FeedbackButton, type FeedbackButtonHandle, type FeedbackButtonProps } from './feedback-widget';
|
|
13
|
+
import {
|
|
14
|
+
clearAllFeatureFlags,
|
|
15
|
+
clearFeatureFlag,
|
|
16
|
+
getFeatureFlags,
|
|
17
|
+
setFeatureFlag,
|
|
18
|
+
} from './feature-flags';
|
|
12
19
|
import { clearMaskQuery, registerMaskQuery } from './mask';
|
|
20
|
+
import { measureFn } from './measure';
|
|
21
|
+
import { startMoment } from '@goliapkg/sentori-core';
|
|
13
22
|
import { flushMetrics, recordMetric } from './metrics';
|
|
23
|
+
import { RageTapCapture } from './rage-tap';
|
|
14
24
|
import {
|
|
15
25
|
endSession,
|
|
16
26
|
markSessionCrashed,
|
|
@@ -28,7 +38,15 @@ export const sentori = {
|
|
|
28
38
|
sendUserFeedback,
|
|
29
39
|
recordMetric,
|
|
30
40
|
flushMetrics,
|
|
41
|
+
measureFn,
|
|
42
|
+
startMoment,
|
|
43
|
+
setFeatureFlag,
|
|
44
|
+
clearFeatureFlag,
|
|
45
|
+
clearAllFeatureFlags,
|
|
46
|
+
getFeatureFlags,
|
|
31
47
|
ErrorBoundary,
|
|
48
|
+
FeedbackButton,
|
|
49
|
+
RageTapCapture,
|
|
32
50
|
registerMaskQuery,
|
|
33
51
|
clearMaskQuery,
|
|
34
52
|
startSession,
|
|
@@ -49,8 +67,18 @@ export {
|
|
|
49
67
|
setUser,
|
|
50
68
|
} from './capture';
|
|
51
69
|
export { ErrorBoundary } from './error-boundary';
|
|
70
|
+
export { FeedbackButton, type FeedbackButtonHandle, type FeedbackButtonProps } from './feedback-widget';
|
|
71
|
+
export {
|
|
72
|
+
clearAllFeatureFlags,
|
|
73
|
+
clearFeatureFlag,
|
|
74
|
+
getFeatureFlags,
|
|
75
|
+
setFeatureFlag,
|
|
76
|
+
} from './feature-flags';
|
|
52
77
|
export { clearMaskQuery, registerMaskQuery } from './mask';
|
|
53
78
|
export { flushMetrics, recordMetric } from './metrics';
|
|
79
|
+
export { measureFn } from './measure';
|
|
80
|
+
export { MomentHandle, type MomentProperties, startMoment } from '@goliapkg/sentori-core';
|
|
81
|
+
export { RageTapCapture } from './rage-tap';
|
|
54
82
|
export {
|
|
55
83
|
startAnrWatchdog,
|
|
56
84
|
stopAnrWatchdog,
|
package/src/init.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { installGlobalHandler } from './handlers/global';
|
|
|
3
3
|
import { installLifecycleHandler } from './handlers/lifecycle';
|
|
4
4
|
import { installPromiseHandler } from './handlers/promise';
|
|
5
5
|
import { installNetworkHandler } from './handlers/network';
|
|
6
|
+
import { getBundleInfo } from './bundle-info';
|
|
7
|
+
import {
|
|
8
|
+
markLaunchCompleted,
|
|
9
|
+
runLaunchCrashGuard,
|
|
10
|
+
} from './launch-crash-guard';
|
|
6
11
|
import { startMetricsTimer } from './metrics';
|
|
7
12
|
import { drainNativePending, setNativeConfig } from './native';
|
|
8
13
|
import { startNetworkTypeWatch } from './netinfo';
|
|
@@ -30,7 +35,14 @@ export type InitOptions = {
|
|
|
30
35
|
capture?: {
|
|
31
36
|
globalErrors?: boolean;
|
|
32
37
|
promiseRejections?: boolean;
|
|
33
|
-
network?:
|
|
38
|
+
network?:
|
|
39
|
+
| boolean
|
|
40
|
+
| {
|
|
41
|
+
/** v0.9.0 #11 — auto-extract GraphQL `operationName` from
|
|
42
|
+
* POST request bodies and use it as the breadcrumb / span
|
|
43
|
+
* name (instead of `POST /graphql`). Default `true`. */
|
|
44
|
+
graphql?: boolean;
|
|
45
|
+
};
|
|
34
46
|
/** Session tracking: opens a session on init and on each
|
|
35
47
|
* foreground (`AppState` → `active`), ends it on background.
|
|
36
48
|
* Drives crash-free rate. Set `false` to opt out. */
|
|
@@ -49,6 +61,20 @@ export type InitOptions = {
|
|
|
49
61
|
* the buffer is sealed and uploaded as a `sessionTrail`
|
|
50
62
|
* attachment. Defaults to false. */
|
|
51
63
|
sessionTrail?: boolean;
|
|
64
|
+
/** v0.9.0 #3 — launch-crash loop guard. When two consecutive
|
|
65
|
+
* launches don't reach `markLaunchCompleted()` (typical of an
|
|
66
|
+
* OTA update with a fatal bug), invoke the host callback with
|
|
67
|
+
* a 200 ms timeout to decide rollback / reset / continue. */
|
|
68
|
+
launchCrashGuard?: {
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
onLaunchCrashDetected?: (
|
|
71
|
+
info: import('./launch-crash-guard').LaunchCrashInfo,
|
|
72
|
+
) =>
|
|
73
|
+
| import('./launch-crash-guard').LaunchCrashAction
|
|
74
|
+
| Promise<import('./launch-crash-guard').LaunchCrashAction>;
|
|
75
|
+
threshold?: number;
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
};
|
|
52
78
|
};
|
|
53
79
|
/** Phase 44 sub-B: client-side sampling. Each rate is `[0, 1]`;
|
|
54
80
|
* absent / null keeps everything. Defaults to 1.0 for both
|
|
@@ -76,6 +102,19 @@ export const init = (options: InitOptions): void => {
|
|
|
76
102
|
options.environment ??
|
|
77
103
|
(typeof __DEV__ !== 'undefined' && __DEV__ ? 'dev' : 'prod');
|
|
78
104
|
|
|
105
|
+
// v0.9.0 #3 — launch-crash guard. Fires *before* any other setup so
|
|
106
|
+
// a known-bad bundle can roll back instead of running JS that's
|
|
107
|
+
// about to die again. AsyncStorage-backed; if the host doesn't have
|
|
108
|
+
// it the guard is a no-op.
|
|
109
|
+
const lcg = options.capture?.launchCrashGuard;
|
|
110
|
+
if (lcg?.enabled) {
|
|
111
|
+
void runLaunchCrashGuard(
|
|
112
|
+
lcg,
|
|
113
|
+
options.release,
|
|
114
|
+
getBundleInfo()?.id ?? null,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
79
118
|
setConfig({
|
|
80
119
|
token: options.token,
|
|
81
120
|
release: options.release,
|
|
@@ -107,7 +146,10 @@ export const init = (options: InitOptions): void => {
|
|
|
107
146
|
const capture = options.capture ?? {};
|
|
108
147
|
if (capture.globalErrors !== false) installGlobalHandler();
|
|
109
148
|
if (capture.promiseRejections !== false) installPromiseHandler();
|
|
110
|
-
if (capture.network !== false)
|
|
149
|
+
if (capture.network !== false) {
|
|
150
|
+
const netOpts = typeof capture.network === 'object' ? capture.network : undefined;
|
|
151
|
+
installNetworkHandler({ graphql: netOpts?.graphql });
|
|
152
|
+
}
|
|
111
153
|
if (capture.sessions !== false) {
|
|
112
154
|
// Open the cold-start session now (RN doesn't fire an AppState
|
|
113
155
|
// `change` for the initial `active` state), then bind AppState so
|
|
@@ -154,6 +196,16 @@ export const init = (options: InitOptions): void => {
|
|
|
154
196
|
})
|
|
155
197
|
.catch(() => {});
|
|
156
198
|
drainOfflineQueue().catch(() => {});
|
|
199
|
+
|
|
200
|
+
// v0.9.0 #3 — init reached the end without throwing. Schedule the
|
|
201
|
+
// "launch completed" marker after one tick so any synchronous user
|
|
202
|
+
// code right after `init()` gets to run first; we want the marker to
|
|
203
|
+
// confirm the JS bridge stayed alive, not just that `init()` returned.
|
|
204
|
+
if (lcg?.enabled) {
|
|
205
|
+
setTimeout(() => {
|
|
206
|
+
void markLaunchCompleted(getBundleInfo()?.id ?? null);
|
|
207
|
+
}, 2_000);
|
|
208
|
+
}
|
|
157
209
|
};
|
|
158
210
|
|
|
159
211
|
/**
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// v0.9.0 #3 — launch-crash loop guard.
|
|
2
|
+
//
|
|
3
|
+
// On every init we write a "launch_marker" to AsyncStorage. On
|
|
4
|
+
// `markLaunchCompleted()` we write a sibling "launch_completed". On
|
|
5
|
+
// startup we look at the previous launch state: marker present but
|
|
6
|
+
// completed missing → previous launch did not finish → increment a
|
|
7
|
+
// consecutive-crash counter.
|
|
8
|
+
//
|
|
9
|
+
// When the counter crosses `threshold` (default 2), we invoke the
|
|
10
|
+
// host-supplied `onLaunchCrashDetected` callback with a 200 ms timeout
|
|
11
|
+
// (D3) and follow its action: rollback the OTA bundle, reset a list
|
|
12
|
+
// of AsyncStorage keys, or continue. Rollback / reset trigger an
|
|
13
|
+
// `expo-updates` reload when available.
|
|
14
|
+
//
|
|
15
|
+
// v0.9.0 scope: JS-only — catches everything that runs after the JS
|
|
16
|
+
// bridge is up (almost every OTA-induced launch crash). v0.9.1 will
|
|
17
|
+
// add a native marker for the small set of "crashed before bridge"
|
|
18
|
+
// cases.
|
|
19
|
+
|
|
20
|
+
const MARKER_KEY = '@sentori/launch_marker';
|
|
21
|
+
const COMPLETED_KEY = '@sentori/launch_completed';
|
|
22
|
+
const COUNT_KEY = '@sentori/launch_crash_count';
|
|
23
|
+
|
|
24
|
+
export type LaunchCrashInfo = {
|
|
25
|
+
/** Consecutive failed launches detected so far (this one inclusive). */
|
|
26
|
+
consecutiveCount: number;
|
|
27
|
+
/** OTA bundle id of the crashing launch, if known. */
|
|
28
|
+
crashedBundle: null | string;
|
|
29
|
+
/** Most recent bundle id that *did* reach `markLaunchCompleted`. */
|
|
30
|
+
lastSafeBundle: null | string;
|
|
31
|
+
/** Store-binary release of the crashing launch. */
|
|
32
|
+
release: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type LaunchCrashAction =
|
|
36
|
+
| { action: 'continue' }
|
|
37
|
+
| { action: 'reset'; clearKeys: string[] }
|
|
38
|
+
| { action: 'rollback'; toBundle?: null | string };
|
|
39
|
+
|
|
40
|
+
export type LaunchCrashGuardOptions = {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
onLaunchCrashDetected?: (info: LaunchCrashInfo) => LaunchCrashAction | Promise<LaunchCrashAction>;
|
|
43
|
+
/** Default 2 — fires after the second consecutive failed launch. */
|
|
44
|
+
threshold?: number;
|
|
45
|
+
/** Default 200 — D3 decision. */
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type AsyncStorageLike = {
|
|
50
|
+
getItem: (key: string) => Promise<null | string>;
|
|
51
|
+
multiRemove?: (keys: string[]) => Promise<void>;
|
|
52
|
+
removeItem: (key: string) => Promise<void>;
|
|
53
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function loadAsyncStorage(): AsyncStorageLike | null {
|
|
57
|
+
try {
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
|
+
const mod = require('@react-native-async-storage/async-storage') as {
|
|
60
|
+
default?: AsyncStorageLike;
|
|
61
|
+
};
|
|
62
|
+
return mod.default ?? (mod as unknown as AsyncStorageLike);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Returns `false` iff we triggered a bundle rollback / reset and
|
|
69
|
+
* expect the app to reload momentarily; the caller (init) should
|
|
70
|
+
* short-circuit further setup. */
|
|
71
|
+
export async function runLaunchCrashGuard(
|
|
72
|
+
opts: LaunchCrashGuardOptions,
|
|
73
|
+
release: string,
|
|
74
|
+
currentBundleId: null | string,
|
|
75
|
+
): Promise<{ shouldContinueInit: boolean; info?: LaunchCrashInfo }> {
|
|
76
|
+
if (!opts.enabled) return { shouldContinueInit: true };
|
|
77
|
+
const storage = loadAsyncStorage();
|
|
78
|
+
if (!storage) return { shouldContinueInit: true };
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const marker = await storage.getItem(MARKER_KEY);
|
|
82
|
+
const completed = await storage.getItem(COMPLETED_KEY);
|
|
83
|
+
|
|
84
|
+
if (marker && !completed) {
|
|
85
|
+
const m = safeJsonParse<{ bundleId?: string; lastSafeBundle?: string }>(marker) ?? {};
|
|
86
|
+
const prevCount = parseInt((await storage.getItem(COUNT_KEY)) ?? '0', 10) || 0;
|
|
87
|
+
const consecutiveCount = prevCount + 1;
|
|
88
|
+
await storage.setItem(COUNT_KEY, String(consecutiveCount));
|
|
89
|
+
|
|
90
|
+
if (consecutiveCount >= (opts.threshold ?? 2) && opts.onLaunchCrashDetected) {
|
|
91
|
+
const info: LaunchCrashInfo = {
|
|
92
|
+
consecutiveCount,
|
|
93
|
+
crashedBundle: m.bundleId ?? null,
|
|
94
|
+
lastSafeBundle: m.lastSafeBundle ?? null,
|
|
95
|
+
release,
|
|
96
|
+
};
|
|
97
|
+
const action = await raceWithTimeout<LaunchCrashAction>(
|
|
98
|
+
Promise.resolve(opts.onLaunchCrashDetected(info)),
|
|
99
|
+
opts.timeoutMs ?? 200,
|
|
100
|
+
{ action: 'continue' },
|
|
101
|
+
);
|
|
102
|
+
const handled = await applyAction(action, storage);
|
|
103
|
+
if (!handled.shouldContinueInit) {
|
|
104
|
+
return { ...handled, info };
|
|
105
|
+
}
|
|
106
|
+
return { ...handled, info };
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// Previous launch completed; clean the counter.
|
|
110
|
+
await storage.setItem(COUNT_KEY, '0');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Write the marker for THIS launch. lastSafeBundle = previous
|
|
114
|
+
// completed bundle id, so the user's callback can target it.
|
|
115
|
+
const lastSafeBundle =
|
|
116
|
+
(completed && safeJsonParse<{ bundleId?: string }>(completed)?.bundleId) ?? null;
|
|
117
|
+
await storage.setItem(
|
|
118
|
+
MARKER_KEY,
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
bundleId: currentBundleId,
|
|
121
|
+
lastSafeBundle,
|
|
122
|
+
release,
|
|
123
|
+
ts: Date.now(),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
await storage.removeItem(COMPLETED_KEY);
|
|
127
|
+
} catch {
|
|
128
|
+
// AsyncStorage glitches must never block init.
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { shouldContinueInit: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function markLaunchCompleted(currentBundleId: null | string): Promise<void> {
|
|
135
|
+
const storage = loadAsyncStorage();
|
|
136
|
+
if (!storage) return;
|
|
137
|
+
try {
|
|
138
|
+
await storage.setItem(
|
|
139
|
+
COMPLETED_KEY,
|
|
140
|
+
JSON.stringify({ bundleId: currentBundleId, ts: Date.now() }),
|
|
141
|
+
);
|
|
142
|
+
await storage.setItem(COUNT_KEY, '0');
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function applyAction(
|
|
149
|
+
action: LaunchCrashAction,
|
|
150
|
+
storage: AsyncStorageLike,
|
|
151
|
+
): Promise<{ shouldContinueInit: boolean }> {
|
|
152
|
+
if (action.action === 'continue') return { shouldContinueInit: true };
|
|
153
|
+
if (action.action === 'reset') {
|
|
154
|
+
if (storage.multiRemove && Array.isArray(action.clearKeys)) {
|
|
155
|
+
try {
|
|
156
|
+
await storage.multiRemove(action.clearKeys);
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await reloadOTAIfPossible();
|
|
162
|
+
return { shouldContinueInit: false };
|
|
163
|
+
}
|
|
164
|
+
if (action.action === 'rollback') {
|
|
165
|
+
await reloadOTAIfPossible();
|
|
166
|
+
return { shouldContinueInit: false };
|
|
167
|
+
}
|
|
168
|
+
return { shouldContinueInit: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function reloadOTAIfPossible(): Promise<void> {
|
|
172
|
+
try {
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
174
|
+
const Updates = require('expo-updates') as {
|
|
175
|
+
reloadAsync?: () => Promise<void>;
|
|
176
|
+
};
|
|
177
|
+
if (typeof Updates.reloadAsync === 'function') {
|
|
178
|
+
await Updates.reloadAsync();
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// expo-updates not installed — caller will fall through and
|
|
182
|
+
// continue init; their callback returned `rollback` but we can't
|
|
183
|
+
// perform it without the OTA library. Document accordingly.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function raceWithTimeout<T>(p: Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
188
|
+
return new Promise<T>((resolve) => {
|
|
189
|
+
let done = false;
|
|
190
|
+
const t = setTimeout(() => {
|
|
191
|
+
if (!done) {
|
|
192
|
+
done = true;
|
|
193
|
+
resolve(fallback);
|
|
194
|
+
}
|
|
195
|
+
}, ms);
|
|
196
|
+
p.then(
|
|
197
|
+
(v) => {
|
|
198
|
+
if (!done) {
|
|
199
|
+
done = true;
|
|
200
|
+
clearTimeout(t);
|
|
201
|
+
resolve(v);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
() => {
|
|
205
|
+
if (!done) {
|
|
206
|
+
done = true;
|
|
207
|
+
clearTimeout(t);
|
|
208
|
+
resolve(fallback);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function safeJsonParse<T>(s: string): null | T {
|
|
216
|
+
try {
|
|
217
|
+
return JSON.parse(s) as T;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/measure.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// v0.9.0 #14 — `sentori.measureFn(name, fn)`. Profile-lite. Wrap an
|
|
2
|
+
// async (or sync) function call in a span so it shows on the issue
|
|
3
|
+
// detail trace waterfall without writing the boilerplate every time.
|
|
4
|
+
// The full Hermes-sampler profiler (#4) is the deep version of this
|
|
5
|
+
// idea; `measureFn` is the cheap version that doesn't need a native
|
|
6
|
+
// module.
|
|
7
|
+
|
|
8
|
+
import { startSpan } from '@goliapkg/sentori-core';
|
|
9
|
+
|
|
10
|
+
export async function measureFn<T>(
|
|
11
|
+
name: string,
|
|
12
|
+
fn: () => Promise<T> | T,
|
|
13
|
+
opts?: { tags?: Record<string, string> },
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
const span = startSpan('sentori.measureFn', {
|
|
16
|
+
name,
|
|
17
|
+
tags: opts?.tags ?? {},
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
const result = await fn();
|
|
21
|
+
span.finish({ status: 'ok' });
|
|
22
|
+
return result;
|
|
23
|
+
} catch (e) {
|
|
24
|
+
if (e instanceof Error) span.setTag('error.message', e.message);
|
|
25
|
+
span.finish({ status: 'error' });
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// v0.9.0 #12 — pure rage-tap detection logic. Lives outside the .tsx
|
|
2
|
+
// component so unit tests can import it without dragging in
|
|
3
|
+
// `react-native` (whose flow syntax breaks bun:test parser).
|
|
4
|
+
|
|
5
|
+
export const RAGE_WINDOW_MS = 800;
|
|
6
|
+
export const RAGE_THRESHOLD = 3;
|
|
7
|
+
|
|
8
|
+
/** Given the per-target recent-tap buckets, a target id, and `now`,
|
|
9
|
+
* return `true` iff this tap crosses the rage threshold. Side
|
|
10
|
+
* effect: writes/clears the bucket inside `map` so successive
|
|
11
|
+
* taps after a triggered rage event don't immediately re-trigger. */
|
|
12
|
+
export function recordTap(
|
|
13
|
+
map: Map<number, number[]>,
|
|
14
|
+
target: number,
|
|
15
|
+
now: number,
|
|
16
|
+
): boolean {
|
|
17
|
+
const previous = map.get(target) ?? [];
|
|
18
|
+
const fresh = previous.filter((t) => now - t <= RAGE_WINDOW_MS);
|
|
19
|
+
fresh.push(now);
|
|
20
|
+
if (fresh.length >= RAGE_THRESHOLD) {
|
|
21
|
+
map.delete(target);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
map.set(target, fresh);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
package/src/rage-tap.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// v0.9.0 #12 — rage-tap / multi-click detection.
|
|
2
|
+
//
|
|
3
|
+
// Wrap your app root (typically next to ErrorBoundary) with
|
|
4
|
+
// `<sentori.RageTapCapture>{children}</sentori.RageTapCapture>`.
|
|
5
|
+
// We listen to bubble-phase `onTouchEnd` and emit a `ui.multiClick`
|
|
6
|
+
// breadcrumb when the same native target receives ≥ 3 taps within
|
|
7
|
+
// 800 ms. Pure observation — no event capture, no gesture
|
|
8
|
+
// interference; existing Touchables / Pressables / GestureHandler
|
|
9
|
+
// continue to fire normally.
|
|
10
|
+
|
|
11
|
+
import React, { useCallback, useRef } from 'react';
|
|
12
|
+
import { View, type GestureResponderEvent, type ViewProps } from 'react-native';
|
|
13
|
+
|
|
14
|
+
import { addBreadcrumb } from './breadcrumbs';
|
|
15
|
+
import {
|
|
16
|
+
RAGE_THRESHOLD,
|
|
17
|
+
RAGE_WINDOW_MS,
|
|
18
|
+
recordTap,
|
|
19
|
+
} from './rage-tap-detector';
|
|
20
|
+
|
|
21
|
+
export function RageTapCapture({
|
|
22
|
+
children,
|
|
23
|
+
...rest
|
|
24
|
+
}: ViewProps & { children?: React.ReactNode }): React.JSX.Element {
|
|
25
|
+
const recent = useRef<Map<number, number[]>>(new Map());
|
|
26
|
+
|
|
27
|
+
const onTouchEnd = useCallback((e: GestureResponderEvent) => {
|
|
28
|
+
const target = e.nativeEvent?.target;
|
|
29
|
+
if (typeof target !== 'number') return;
|
|
30
|
+
if (recordTap(recent.current, target, Date.now())) {
|
|
31
|
+
addBreadcrumb({
|
|
32
|
+
type: 'user',
|
|
33
|
+
data: {
|
|
34
|
+
kind: 'ui.multiClick',
|
|
35
|
+
target: String(target),
|
|
36
|
+
taps: RAGE_THRESHOLD,
|
|
37
|
+
windowMs: RAGE_WINDOW_MS,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<View {...rest} onTouchEnd={onTouchEnd}>
|
|
45
|
+
{children}
|
|
46
|
+
</View>
|
|
47
|
+
);
|
|
48
|
+
}
|