@goliapkg/sentori-react-native 4.0.0 → 5.1.0

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 (308) hide show
  1. package/MIGRATION.md +131 -0
  2. package/README.md +40 -30
  3. package/android/build.gradle +21 -0
  4. package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +29 -0
  5. package/android/src/main/java/com/sentori/SentoriModule.kt +8 -0
  6. package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +36 -4
  7. package/android/src/test/java/com/sentori/SentoriCrashHandlerTest.kt +7 -2
  8. package/android/src/test/resources/robolectric.properties +10 -0
  9. package/ios/SentoriCrashHandler.swift +48 -4
  10. package/ios/SentoriModule.swift +12 -0
  11. package/ios/SentoriScreenshotCapture.swift +34 -3
  12. package/lib/config.d.ts +17 -47
  13. package/lib/config.d.ts.map +1 -1
  14. package/lib/config.js +3 -0
  15. package/lib/config.js.map +1 -1
  16. package/lib/device.d.ts +5 -0
  17. package/lib/device.d.ts.map +1 -0
  18. package/lib/device.js +29 -0
  19. package/lib/device.js.map +1 -0
  20. package/lib/emit-hooks.d.ts +7 -0
  21. package/lib/emit-hooks.d.ts.map +1 -0
  22. package/lib/emit-hooks.js +24 -0
  23. package/lib/emit-hooks.js.map +1 -0
  24. package/lib/error-boundary.d.ts.map +1 -1
  25. package/lib/error-boundary.js +1 -1
  26. package/lib/error-boundary.js.map +1 -1
  27. package/lib/feature-flags.d.ts.map +1 -1
  28. package/lib/handlers/dev-symbolicate.d.ts +1 -1
  29. package/lib/handlers/dev-symbolicate.d.ts.map +1 -1
  30. package/lib/handlers/dev-symbolicate.js +2 -0
  31. package/lib/handlers/dev-symbolicate.js.map +1 -1
  32. package/lib/handlers/global.js +1 -1
  33. package/lib/handlers/global.js.map +1 -1
  34. package/lib/handlers/lifecycle.js +3 -3
  35. package/lib/handlers/lifecycle.js.map +1 -1
  36. package/lib/handlers/network.d.ts +2 -2
  37. package/lib/handlers/network.d.ts.map +1 -1
  38. package/lib/handlers/network.js +69 -187
  39. package/lib/handlers/network.js.map +1 -1
  40. package/lib/handlers/promise.js +1 -1
  41. package/lib/handlers/promise.js.map +1 -1
  42. package/lib/handlers/screenshot.d.ts.map +1 -1
  43. package/lib/handlers/screenshot.js +3 -12
  44. package/lib/handlers/screenshot.js.map +1 -1
  45. package/lib/index.d.ts +32 -109
  46. package/lib/index.d.ts.map +1 -1
  47. package/lib/index.js +54 -103
  48. package/lib/index.js.map +1 -1
  49. package/lib/init.d.ts +3 -150
  50. package/lib/init.d.ts.map +1 -1
  51. package/lib/init.js +100 -261
  52. package/lib/init.js.map +1 -1
  53. package/lib/long-task-monitor.d.ts +1 -7
  54. package/lib/long-task-monitor.d.ts.map +1 -1
  55. package/lib/long-task-monitor.js +37 -58
  56. package/lib/long-task-monitor.js.map +1 -1
  57. package/lib/mask.d.ts +5 -13
  58. package/lib/mask.d.ts.map +1 -1
  59. package/lib/mask.js +29 -30
  60. package/lib/mask.js.map +1 -1
  61. package/lib/mobile-vitals.d.ts +3 -31
  62. package/lib/mobile-vitals.d.ts.map +1 -1
  63. package/lib/mobile-vitals.js +22 -61
  64. package/lib/mobile-vitals.js.map +1 -1
  65. package/lib/native-pending.d.ts +3 -0
  66. package/lib/native-pending.d.ts.map +1 -0
  67. package/lib/native-pending.js +64 -0
  68. package/lib/native-pending.js.map +1 -0
  69. package/lib/native.d.ts +4 -11
  70. package/lib/native.d.ts.map +1 -1
  71. package/lib/native.js +23 -0
  72. package/lib/native.js.map +1 -1
  73. package/lib/navigation.d.ts +3 -17
  74. package/lib/navigation.d.ts.map +1 -1
  75. package/lib/navigation.js +32 -133
  76. package/lib/navigation.js.map +1 -1
  77. package/lib/netinfo.d.ts +1 -1
  78. package/lib/netinfo.d.ts.map +1 -1
  79. package/lib/netinfo.js +2 -2
  80. package/lib/netinfo.js.map +1 -1
  81. package/lib/push.d.ts.map +1 -1
  82. package/lib/push.js +2 -4
  83. package/lib/push.js.map +1 -1
  84. package/lib/rage-tap.d.ts.map +1 -1
  85. package/lib/rage-tap.js +56 -23
  86. package/lib/rage-tap.js.map +1 -1
  87. package/lib/replay-screens.d.ts +15 -0
  88. package/lib/replay-screens.d.ts.map +1 -0
  89. package/lib/replay-screens.js +87 -0
  90. package/lib/replay-screens.js.map +1 -0
  91. package/lib/replay.d.ts.map +1 -1
  92. package/lib/replay.js +3 -24
  93. package/lib/replay.js.map +1 -1
  94. package/lib/responsiveness-detector.d.ts +18 -0
  95. package/lib/responsiveness-detector.d.ts.map +1 -0
  96. package/lib/responsiveness-detector.js +55 -0
  97. package/lib/responsiveness-detector.js.map +1 -0
  98. package/lib/scope.d.ts +7 -0
  99. package/lib/scope.d.ts.map +1 -0
  100. package/lib/scope.js +41 -0
  101. package/lib/scope.js.map +1 -0
  102. package/lib/transport.d.ts +19 -92
  103. package/lib/transport.d.ts.map +1 -1
  104. package/lib/transport.js +75 -288
  105. package/lib/transport.js.map +1 -1
  106. package/lib/verbs.d.ts +16 -0
  107. package/lib/verbs.d.ts.map +1 -0
  108. package/lib/verbs.js +153 -0
  109. package/lib/verbs.js.map +1 -0
  110. package/package.json +4 -3
  111. package/src/__tests__/iron-rule.test.ts +128 -0
  112. package/src/__tests__/replay-screens.test.ts +63 -0
  113. package/src/__tests__/responsiveness-detector.test.ts +74 -0
  114. package/src/__tests__/screenshot.test.ts +0 -2
  115. package/src/__tests__/transport.test.ts +66 -213
  116. package/src/__tests__/verbs.test.ts +91 -0
  117. package/src/config.ts +20 -47
  118. package/src/device.ts +36 -0
  119. package/src/emit-hooks.ts +30 -0
  120. package/src/error-boundary.tsx +1 -1
  121. package/src/handlers/dev-symbolicate.ts +2 -1
  122. package/src/handlers/global.ts +1 -1
  123. package/src/handlers/lifecycle.ts +3 -3
  124. package/src/handlers/network.ts +95 -211
  125. package/src/handlers/promise.ts +1 -1
  126. package/src/handlers/screenshot.ts +3 -11
  127. package/src/index.ts +84 -215
  128. package/src/init.ts +115 -449
  129. package/src/long-task-monitor.ts +42 -64
  130. package/src/mask.ts +29 -31
  131. package/src/mobile-vitals.ts +22 -82
  132. package/src/native-pending.ts +92 -0
  133. package/src/native.ts +33 -0
  134. package/src/navigation.ts +34 -142
  135. package/src/netinfo.ts +3 -3
  136. package/src/push.ts +2 -5
  137. package/src/rage-tap.tsx +72 -25
  138. package/src/replay-screens.ts +95 -0
  139. package/src/replay.ts +3 -20
  140. package/src/responsiveness-detector.ts +66 -0
  141. package/src/scope.ts +50 -0
  142. package/src/transport.ts +84 -371
  143. package/src/verbs.ts +198 -0
  144. package/lib/breadcrumbs.d.ts +0 -18
  145. package/lib/breadcrumbs.d.ts.map +0 -1
  146. package/lib/breadcrumbs.js +0 -42
  147. package/lib/breadcrumbs.js.map +0 -1
  148. package/lib/bundle-info.d.ts +0 -12
  149. package/lib/bundle-info.d.ts.map +0 -1
  150. package/lib/bundle-info.js +0 -73
  151. package/lib/bundle-info.js.map +0 -1
  152. package/lib/capture.d.ts +0 -140
  153. package/lib/capture.d.ts.map +0 -1
  154. package/lib/capture.js +0 -511
  155. package/lib/capture.js.map +0 -1
  156. package/lib/compat/sentry.d.ts +0 -126
  157. package/lib/compat/sentry.d.ts.map +0 -1
  158. package/lib/compat/sentry.js +0 -332
  159. package/lib/compat/sentry.js.map +0 -1
  160. package/lib/control-channel.d.ts +0 -6
  161. package/lib/control-channel.d.ts.map +0 -1
  162. package/lib/control-channel.js +0 -96
  163. package/lib/control-channel.js.map +0 -1
  164. package/lib/expo-compat.d.ts +0 -270
  165. package/lib/expo-compat.d.ts.map +0 -1
  166. package/lib/expo-compat.js +0 -500
  167. package/lib/expo-compat.js.map +0 -1
  168. package/lib/feedback-widget.d.ts +0 -35
  169. package/lib/feedback-widget.d.ts.map +0 -1
  170. package/lib/feedback-widget.js +0 -193
  171. package/lib/feedback-widget.js.map +0 -1
  172. package/lib/feedback.d.ts +0 -2
  173. package/lib/feedback.d.ts.map +0 -1
  174. package/lib/feedback.js +0 -17
  175. package/lib/feedback.js.map +0 -1
  176. package/lib/heartbeat.d.ts +0 -9
  177. package/lib/heartbeat.d.ts.map +0 -1
  178. package/lib/heartbeat.js +0 -138
  179. package/lib/heartbeat.js.map +0 -1
  180. package/lib/index.js.bak +0 -64
  181. package/lib/install-id.d.ts +0 -17
  182. package/lib/install-id.d.ts.map +0 -1
  183. package/lib/install-id.js +0 -125
  184. package/lib/install-id.js.map +0 -1
  185. package/lib/lifecycle.d.ts +0 -28
  186. package/lib/lifecycle.d.ts.map +0 -1
  187. package/lib/lifecycle.js +0 -73
  188. package/lib/lifecycle.js.map +0 -1
  189. package/lib/measure.d.ts +0 -4
  190. package/lib/measure.d.ts.map +0 -1
  191. package/lib/measure.js +0 -25
  192. package/lib/measure.js.map +0 -1
  193. package/lib/metrics.d.ts +0 -24
  194. package/lib/metrics.d.ts.map +0 -1
  195. package/lib/metrics.js +0 -81
  196. package/lib/metrics.js.map +0 -1
  197. package/lib/pre-crash-sentinel.d.ts +0 -12
  198. package/lib/pre-crash-sentinel.d.ts.map +0 -1
  199. package/lib/pre-crash-sentinel.js +0 -116
  200. package/lib/pre-crash-sentinel.js.map +0 -1
  201. package/lib/report-security.d.ts +0 -40
  202. package/lib/report-security.d.ts.map +0 -1
  203. package/lib/report-security.js +0 -159
  204. package/lib/report-security.js.map +0 -1
  205. package/lib/runtime-metrics-fps.d.ts +0 -19
  206. package/lib/runtime-metrics-fps.d.ts.map +0 -1
  207. package/lib/runtime-metrics-fps.js +0 -128
  208. package/lib/runtime-metrics-fps.js.map +0 -1
  209. package/lib/runtime-metrics-heap.d.ts +0 -13
  210. package/lib/runtime-metrics-heap.d.ts.map +0 -1
  211. package/lib/runtime-metrics-heap.js +0 -67
  212. package/lib/runtime-metrics-heap.js.map +0 -1
  213. package/lib/runtime-metrics-network.d.ts +0 -23
  214. package/lib/runtime-metrics-network.d.ts.map +0 -1
  215. package/lib/runtime-metrics-network.js +0 -99
  216. package/lib/runtime-metrics-network.js.map +0 -1
  217. package/lib/runtime-metrics.d.ts +0 -43
  218. package/lib/runtime-metrics.d.ts.map +0 -1
  219. package/lib/runtime-metrics.js +0 -115
  220. package/lib/runtime-metrics.js.map +0 -1
  221. package/lib/sample-profiler.d.ts +0 -21
  222. package/lib/sample-profiler.d.ts.map +0 -1
  223. package/lib/sample-profiler.js +0 -152
  224. package/lib/sample-profiler.js.map +0 -1
  225. package/lib/sentinel-context.d.ts +0 -4
  226. package/lib/sentinel-context.d.ts.map +0 -1
  227. package/lib/sentinel-context.js +0 -52
  228. package/lib/sentinel-context.js.map +0 -1
  229. package/lib/session-tracker.d.ts +0 -6
  230. package/lib/session-tracker.d.ts.map +0 -1
  231. package/lib/session-tracker.js +0 -50
  232. package/lib/session-tracker.js.map +0 -1
  233. package/lib/stack.d.ts +0 -3
  234. package/lib/stack.d.ts.map +0 -1
  235. package/lib/stack.js +0 -7
  236. package/lib/stack.js.map +0 -1
  237. package/lib/state-snapshots.d.ts +0 -33
  238. package/lib/state-snapshots.d.ts.map +0 -1
  239. package/lib/state-snapshots.js +0 -109
  240. package/lib/state-snapshots.js.map +0 -1
  241. package/lib/track.d.ts +0 -34
  242. package/lib/track.d.ts.map +0 -1
  243. package/lib/track.js +0 -107
  244. package/lib/track.js.map +0 -1
  245. package/lib/trail.d.ts +0 -12
  246. package/lib/trail.d.ts.map +0 -1
  247. package/lib/trail.js +0 -28
  248. package/lib/trail.js.map +0 -1
  249. package/lib/trust-score.d.ts +0 -20
  250. package/lib/trust-score.d.ts.map +0 -1
  251. package/lib/trust-score.js +0 -151
  252. package/lib/trust-score.js.map +0 -1
  253. package/lib/types.d.ts +0 -2
  254. package/lib/types.d.ts.map +0 -1
  255. package/lib/types.js +0 -4
  256. package/lib/types.js.map +0 -1
  257. package/lib/uuid.d.ts +0 -2
  258. package/lib/uuid.d.ts.map +0 -1
  259. package/lib/uuid.js +0 -3
  260. package/lib/uuid.js.map +0 -1
  261. package/src/__tests__/before-send.test.ts +0 -72
  262. package/src/__tests__/breadcrumbs.test.ts +0 -44
  263. package/src/__tests__/capture-replay.test.ts +0 -150
  264. package/src/__tests__/compat-sentry.test.ts +0 -121
  265. package/src/__tests__/control-channel.test.ts +0 -111
  266. package/src/__tests__/install-id.test.ts +0 -60
  267. package/src/__tests__/long-task-monitor.test.ts +0 -59
  268. package/src/__tests__/measure.test.ts +0 -45
  269. package/src/__tests__/navigation.test.ts +0 -175
  270. package/src/__tests__/push.test.ts +0 -238
  271. package/src/__tests__/report-security.test.ts +0 -106
  272. package/src/__tests__/runtime-metrics-instruments.test.ts +0 -109
  273. package/src/__tests__/runtime-metrics-network.test.ts +0 -78
  274. package/src/__tests__/sample-profiler.test.ts +0 -59
  275. package/src/__tests__/stack.test.ts +0 -45
  276. package/src/__tests__/state-snapshots.test.ts +0 -119
  277. package/src/__tests__/tracing.test.ts +0 -201
  278. package/src/__tests__/track.test.ts +0 -91
  279. package/src/__tests__/uuid.test.ts +0 -41
  280. package/src/breadcrumbs.ts +0 -57
  281. package/src/bundle-info.ts +0 -95
  282. package/src/capture.ts +0 -660
  283. package/src/compat/sentry.ts +0 -490
  284. package/src/control-channel.ts +0 -98
  285. package/src/expo-compat.ts +0 -698
  286. package/src/feedback-widget.tsx +0 -316
  287. package/src/feedback.ts +0 -21
  288. package/src/heartbeat.ts +0 -157
  289. package/src/install-id.ts +0 -146
  290. package/src/lifecycle.ts +0 -76
  291. package/src/measure.ts +0 -28
  292. package/src/metrics.ts +0 -94
  293. package/src/pre-crash-sentinel.ts +0 -140
  294. package/src/report-security.ts +0 -165
  295. package/src/runtime-metrics-fps.ts +0 -137
  296. package/src/runtime-metrics-heap.ts +0 -78
  297. package/src/runtime-metrics-network.ts +0 -103
  298. package/src/runtime-metrics.ts +0 -122
  299. package/src/sample-profiler.ts +0 -169
  300. package/src/sentinel-context.ts +0 -60
  301. package/src/session-tracker.ts +0 -53
  302. package/src/stack.ts +0 -10
  303. package/src/state-snapshots.ts +0 -133
  304. package/src/track.ts +0 -123
  305. package/src/trail.ts +0 -32
  306. package/src/trust-score.ts +0 -176
  307. package/src/types.ts +0 -21
  308. package/src/uuid.ts +0 -2
package/src/navigation.ts CHANGED
@@ -1,35 +1,17 @@
1
- // Phase 35 sub-C: react-navigation auto-instrumentation.
2
- // Phase 39 sub-B: the open `react.navigation` span is also made the
3
- // active span for that screen's lifetime, so the screen's
4
- // `http.client` spans (and any other startSpan() calls) attach to it
5
- // as children — one trace per screen instead of one per request.
1
+ // react-navigation auto-instrumentation — v1 role: feed the signal
2
+ // ring (`nav` signals) and remember the current screen so warn
3
+ // surfaces can name where they happened. The span/trace/metric
4
+ // machinery this file used to drive is gone with the APM vocabulary.
6
5
  //
7
6
  // Mount `useTraceNavigation(navigationRef)` next to your
8
- // `<NavigationContainer ref={navigationRef}>`. Span names are
9
- // `<from> → <to>` (or just the route name for the first screen) so
10
- // the trace list reads as a navigation log.
11
- //
12
- // react-navigation is an OPTIONAL peer dependency — apps that don't
13
- // use it never have to install it. The hook doesn't import from
14
- // @react-navigation/native; consumers pass in the ref they already
15
- // have, and we read its state via the public `getCurrentRoute()`.
16
- //
17
- // Caveat (active-span on RN is a module variable): requests fired
18
- // from a `setTimeout` / background poll / detached promise after the
19
- // screen settled may not see the nav span as active. If you want such
20
- // a request parented to the current screen, pass it explicitly:
21
- // `startSpan(op, { parent: activeSpan() })`.
7
+ // `<NavigationContainer ref={navigationRef}>`. react-navigation
8
+ // stays an OPTIONAL peer dependency: the hook never imports from
9
+ // @react-navigation/native — consumers pass the ref they already
10
+ // have and we read `getCurrentRoute()`.
22
11
 
23
12
  import { useEffect, useRef } from 'react';
24
13
 
25
- import { emitMetric, setActiveSpan, startSpan, type SpanHandle } from '@goliapkg/sentori-core';
26
-
27
- import {
28
- getNativeFrameCounters,
29
- resetNativeFrameCounters,
30
- } from './native';
31
- import { track } from './track';
32
- import { captureStep } from './trail';
14
+ import { pushSignal } from '@goliapkg/sentori-core';
33
15
 
34
16
  /** Minimal contract: anything with `addListener('state', cb)` and
35
17
  * `getCurrentRoute()` works. The real @react-navigation/native
@@ -39,150 +21,60 @@ export type NavigationRefLike = {
39
21
  getCurrentRoute: () => { name: string } | undefined;
40
22
  };
41
23
 
42
- /** Process-global last-known route — populated by the navigation
43
- * span path below, read by the analytics heartbeat so its per-tick
44
- * payload includes the screen the user is on right now. Initial null
45
- * when no nav has happened yet (app just launched, dev-launcher,
46
- * splash). */
24
+ /** Process-global last-known route — surfaces on detected warns
25
+ * (`surface.screen`) and rides nav signals. Null before the first
26
+ * navigation (splash, dev launcher). */
47
27
  let _lastRoute: null | string = null;
48
28
 
29
+ export function currentScreen(): string | undefined {
30
+ return _lastRoute ?? undefined;
31
+ }
32
+
33
+ /** Kept for compat with earlier integrations. */
49
34
  export function getLastRoute(): null | string {
50
35
  return _lastRoute;
51
36
  }
52
37
 
53
- /**
54
- * Subscribe to react-navigation state changes and emit a
55
- * `react.navigation` span per screen (including the initial one),
56
- * each a fresh trace root. The span is kept active while the screen
57
- * is current; child spans created in that window attribute up to it.
58
- *
59
- * import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'
60
- * import { useTraceNavigation } from '@goliapkg/sentori-react-native'
61
- *
62
- * function App() {
63
- * const navigationRef = useNavigationContainerRef()
64
- * useTraceNavigation(navigationRef)
65
- * return <NavigationContainer ref={navigationRef}>{...}</NavigationContainer>
66
- * }
67
- *
68
- * Each span carries `{ nav.from, nav.to }` tags.
69
- */
70
38
  export function useTraceNavigation(navigationRef: NavigationRefLike): void {
71
- // Latest route name we've observed.
72
39
  const lastRouteRef = useRef<null | string>(null);
73
- // Wall-clock ms when the last screen was entered. Drives dwell time
74
- // attached to the leaving span + the next captureStep breadcrumb.
75
- const lastRouteEnteredAtRef = useRef<null | number>(null);
76
- // Span for the screen the user is currently on. Finished when the
77
- // next screen is entered (or on unmount).
78
- const openSpanRef = useRef<null | SpanHandle>(null);
40
+ const enteredAtRef = useRef<null | number>(null);
79
41
 
80
42
  useEffect(() => {
81
43
  if (typeof navigationRef.addListener !== 'function') return;
82
44
  if (typeof navigationRef.getCurrentRoute !== 'function') return;
83
45
 
84
- // Each screen gets its own trace root — detach from whatever the
85
- // previous screen's span was (we keep it active, so without
86
- // `parent: null` the new one would nest under it).
87
- const openScreenSpan = (
88
- from: null | string,
89
- to: string,
90
- prevDwellMs: null | number,
91
- ) => {
92
- const span = startSpan('react.navigation', {
93
- name: from ? `${from} → ${to}` : to,
94
- parent: null,
95
- tags: { 'nav.from': from ?? '', 'nav.to': to },
96
- });
97
- openSpanRef.current = span;
98
- setActiveSpan(span);
46
+ const noteScreen = (from: null | string, to: string) => {
47
+ const dwellMs =
48
+ enteredAtRef.current !== null
49
+ ? Math.max(0, Date.now() - enteredAtRef.current)
50
+ : undefined;
99
51
  lastRouteRef.current = to;
100
52
  _lastRoute = to;
101
- lastRouteEnteredAtRef.current = Date.now();
102
- // v0.8.0-b — dwell on the previous screen surfaces in the
103
- // session trail. The leaving span's `durationMs` already
104
- // carries the same number, but the trail is the most-glanced
105
- // surface so we duplicate it as breadcrumb data. No bytes wasted:
106
- // a breadcrumb without sessionTrail enabled is a no-op.
107
- captureStep(`screen:${to}`, {
108
- breadcrumb: {
109
- data: prevDwellMs !== null ? { dwellMsPrev: prevDwellMs } : undefined,
110
- message: from ? `${from} → ${to}` : to,
111
- type: 'navigation',
112
- },
113
- });
114
- // v1.1 chunk B — auto-pageview. Pushed into the track ring
115
- // alongside the navigation span so the analytics path sees
116
- // every screen entry without app code needing to wire its own
117
- // pageview calls. `from`/`dwellMsPrev` ride in props so the
118
- // Behavior view (chunk D) can reconstruct the user journey
119
- // even when only the track stream is queried.
120
- track(
121
- '$pageview',
122
- prevDwellMs !== null
123
- ? { from: from ?? null, dwellMsPrev: prevDwellMs }
124
- : { from: from ?? null },
53
+ enteredAtRef.current = Date.now();
54
+ pushSignal('nav', {
55
+ from: from ?? undefined,
125
56
  to,
126
- );
127
- };
128
-
129
- const finishOpenSpanWithDwell = (): null | number => {
130
- const span = openSpanRef.current;
131
- const enteredAt = lastRouteEnteredAtRef.current;
132
- if (!span) return null;
133
- const dwellMs = enteredAt !== null ? Math.max(0, Date.now() - enteredAt) : null;
134
- // v0.9.4 #1 — drain native frame counters at screen-leave.
135
- // Empty/null when native module not linked; tags omitted.
136
- const fc = getNativeFrameCounters();
137
- const finishTags: Record<string, string> = {};
138
- if (dwellMs !== null) finishTags['nav.dwell_ms'] = String(dwellMs);
139
- if (fc) {
140
- finishTags['vital.slow_frames'] = String(fc.slow);
141
- finishTags['vital.frozen_frames'] = String(fc.frozen);
142
- }
143
- span.finish({
144
- status: 'ok',
145
- tags: Object.keys(finishTags).length > 0 ? finishTags : undefined,
57
+ dwellMsPrev: dwellMs,
146
58
  });
147
- // Reset counters for the next screen.
148
- resetNativeFrameCounters();
149
- return dwellMs;
150
59
  };
151
60
 
152
- // Open a span for the initial screen so its requests are grouped
153
- // too (auth / config / first data load are usually the busiest
154
- // screen of a session).
155
61
  const initial = navigationRef.getCurrentRoute()?.name ?? null;
156
- if (initial !== null) openScreenSpan(null, initial, null);
157
- else lastRouteRef.current = null;
62
+ if (initial !== null) noteScreen(null, initial);
158
63
 
159
64
  const unsubscribe = navigationRef.addListener('state', () => {
160
65
  const next = navigationRef.getCurrentRoute()?.name ?? null;
161
66
  const prev = lastRouteRef.current;
162
67
  if (next === null || next === prev) return;
163
- const dwellMs = finishOpenSpanWithDwell();
164
- // v2.1 W2 — emit dwell as a runtime metric so the BI panel
165
- // can rank screens by how long users sat on them. Tagged
166
- // with `from` + `to` so a release-over-release diff can spot
167
- // a 2x dwell regression on Onboarding etc. emit is cheap +
168
- // bounded by the core ring cap; if `capture.runtimeMetrics:
169
- // false` the flush is off but the ring still records up to
170
- // the cap before dropping oldest.
171
- if (typeof dwellMs === 'number' && dwellMs >= 0) {
172
- emitMetric('runtime.route_nav_ms', dwellMs, {
173
- from: prev ?? '',
174
- to: next ?? '',
175
- });
176
- }
177
- openScreenSpan(prev, next, dwellMs);
68
+ noteScreen(prev, next);
178
69
  });
179
70
 
180
71
  return () => {
181
72
  unsubscribe();
182
- finishOpenSpanWithDwell();
183
- openSpanRef.current = null;
184
- lastRouteEnteredAtRef.current = null;
185
- setActiveSpan(null);
73
+ enteredAtRef.current = null;
186
74
  };
187
75
  }, [navigationRef]);
188
76
  }
77
+
78
+ export const __resetForTests = (): void => {
79
+ _lastRoute = null;
80
+ };
package/src/netinfo.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  // standard library), we subscribe at SDK init time and cache the
6
6
  // latest network state. `collectDevice()` reads the cache
7
7
  // synchronously at capture time. If the peer isn't installed, the
8
- // cache stays `undefined` and `device.networkType` is omitted —
8
+ // cache stays `undefined` and `device.network` is omitted —
9
9
  // no warning, no crash.
10
10
  //
11
11
  // We collapse NetInfo's enum into the smaller set the protocol
12
- // allows (see `Device.networkType` in `sdk/core/src/types.ts`):
12
+ // allows (see `Device.network` in `sdk/core/src/types.ts`):
13
13
  // `wifi`, `2g/3g/4g/slow-2g`, `offline`, `unknown`. 5G collapses
14
14
  // into `4g` because the schema doesn't have a 5g slot yet; the
15
15
  // information loss is acceptable for an analytics dimension.
@@ -18,7 +18,7 @@ import type { Device } from '@goliapkg/sentori-core';
18
18
 
19
19
  import { isNativeModuleLinked } from './native-loader';
20
20
 
21
- type NetworkType = Device['networkType'];
21
+ type NetworkType = Device['network'];
22
22
 
23
23
  type NetInfoState = {
24
24
  details?: { cellularGeneration?: null | string };
package/src/push.ts CHANGED
@@ -21,9 +21,8 @@
21
21
  // buffered events while the app is foreground. Pauses on
22
22
  // background, resumes on active, per the perf iron rule.
23
23
 
24
- import { addBreadcrumb, logger } from '@goliapkg/sentori-core'
24
+ import { logger, pushSignal } from '@goliapkg/sentori-core'
25
25
 
26
- import { track } from './track.js'
27
26
  // AppState is RN-only; we treat it dynamically so the SDK keeps
28
27
  // importing cleanly under Bun / web.
29
28
  type AppStateModule = {
@@ -318,13 +317,11 @@ function autoCorrelate(
318
317
  // Breadcrumb buffer: O(1) in-memory push. Tag both event types
319
318
  // ('received' vs 'opened') so a later captureException shows
320
319
  // whether the user actually saw the push.
321
- addBreadcrumb('push', { body, msgId, opened: eventType === 'opened', provider, title })
320
+ pushSignal('push', { body: undefined, msgId, opened: eventType === 'opened', provider, title })
322
321
 
323
322
  // Track event: reuses the existing SDK event pipeline. Two
324
323
  // distinct names so dashboards can separate delivery from open.
325
324
  const trackName = eventType === 'opened' ? 'sentori.push.opened' : 'sentori.push.received'
326
- track(trackName, { msgId, provider })
327
-
328
325
  // Enqueue ack — batched, see drainAckQueue.
329
326
  enqueueAck(msgId)
330
327
  }
package/src/rage-tap.tsx CHANGED
@@ -1,42 +1,89 @@
1
- // v0.9.0 #12 — rage-tap / multi-click detection.
1
+ // rage_tap — the first detected warn scenario (design.md §3,
2
+ // category A: 「我按了没反应/反复按」).
2
3
  //
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.
4
+ // Wrap the app root (next to ErrorBoundary) with
5
+ // `<RageTapCapture>{children}</RageTapCapture>`. Bubble-phase
6
+ // `onTouchEnd` only — pure observation, no gesture interference.
7
+ //
8
+ // Mini-spec: ≥3 taps on the same native target within 800 ms ⇒ one
9
+ // `warn` event, scenario `rage_tap`, surface = current screen +
10
+ // target id, plus a signal-ring entry. Per-target cooldown resets
11
+ // after firing so a frustrated 10-tap burst is one event, not four.
10
12
 
11
13
  import React, { useCallback, useRef } from 'react';
12
14
  import { View, type GestureResponderEvent, type ViewProps } from 'react-native';
13
15
 
14
- import { addBreadcrumb } from './breadcrumbs';
16
+ import { pushSignal, snapshotSignals } from '@goliapkg/sentori-core';
17
+
18
+ import { getConfig } from './config';
19
+ import { currentScreen } from './navigation';
20
+ import { RAGE_THRESHOLD, RAGE_WINDOW_MS, recordTap } from './rage-tap-detector';
15
21
  import {
16
- RAGE_THRESHOLD,
17
- RAGE_WINDOW_MS,
18
- recordTap,
19
- } from './rage-tap-detector';
22
+ RESPONSE_WINDOW_MS,
23
+ SLUGGISH_MS,
24
+ classifyTap,
25
+ recordDeadTap,
26
+ recordSluggish,
27
+ } from './responsiveness-detector';
28
+ import { warnDetected } from './verbs';
20
29
 
21
30
  export function RageTapCapture({
22
31
  children,
23
32
  ...rest
24
33
  }: ViewProps & { children?: React.ReactNode }): React.JSX.Element {
25
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());
26
37
 
27
38
  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
- });
39
+ try {
40
+ const target = e.nativeEvent?.target;
41
+ if (typeof target !== 'number') return;
42
+ const tapAt = Date.now();
43
+ pushSignal('tap', { target });
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;
79
+ if (getConfig()?.detect.rageTap === false) return;
80
+ warnDetected(
81
+ 'rage_tap',
82
+ { screen: currentScreen(), element: String(target) },
83
+ { taps: RAGE_THRESHOLD, windowMs: RAGE_WINDOW_MS },
84
+ );
85
+ } catch {
86
+ // A detector bug must never reach the host's touch pipeline.
40
87
  }
41
88
  }, []);
42
89
 
@@ -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
+ };
package/src/replay.ts CHANGED
@@ -15,9 +15,8 @@
15
15
  //
16
16
  // Wire schema: docs/replay-encoding-v2.md.
17
17
 
18
- import { logger, startSpan } from '@goliapkg/sentori-core';
18
+ import { logger } from '@goliapkg/sentori-core';
19
19
 
20
- import { getRegisteredMaskQuery } from './mask';
21
20
  import { describeWireframeNative } from './native';
22
21
 
23
22
  declare const __DEV__: boolean | undefined;
@@ -153,18 +152,11 @@ function captureTick(): void {
153
152
  logger.debug('replay', 'tick: first invocation');
154
153
  _firstTickLogged = true;
155
154
  }
156
- let tickSpan: ReturnType<typeof startSpan> | null = null;
157
- try {
158
- tickSpan = startSpan('sentori.replay.tick', { name: 'tick' });
159
- } catch {
160
- // never fatal
161
- }
162
155
  try {
163
156
  const maskIds = readMaskIds();
164
157
  const snapshotJson = _nativeMod?.captureWireframe?.(maskIds);
165
158
  if (typeof snapshotJson !== 'string' || snapshotJson.length === 0) {
166
159
  handleEmptyTick(snapshotJson);
167
- tickSpan?.finish({ status: 'ok' });
168
160
  return;
169
161
  }
170
162
 
@@ -173,7 +165,6 @@ function captureTick(): void {
173
165
  snapshot = JSON.parse(snapshotJson) as NativeFrame;
174
166
  } catch (e) {
175
167
  logger.warn('replay', 'tick: native JSON parse failed', e);
176
- tickSpan?.finish({ status: 'error' });
177
168
  return;
178
169
  }
179
170
 
@@ -185,10 +176,7 @@ function captureTick(): void {
185
176
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
186
177
  diagnosticForTick(snapshot, snapshotJson.length);
187
178
  }
188
- tickSpan?.finish({ status: 'ok' });
189
179
  } catch (e) {
190
- if (e instanceof Error) tickSpan?.setTag('error.message', e.message);
191
- tickSpan?.finish({ status: 'error' });
192
180
  logger.warn('replay', 'tick threw', e);
193
181
  }
194
182
  }
@@ -326,13 +314,8 @@ function diagnosticForTick(snapshot: NativeFrame, snapshotBytes: number): void {
326
314
  }
327
315
 
328
316
  function readMaskIds(): string[] {
329
- const q = getRegisteredMaskQuery();
330
- if (!q) return [];
331
- try {
332
- return q();
333
- } catch {
334
- return [];
335
- }
317
+ // Masking returns with a v1 privacy pass; nothing masks today.
318
+ return [];
336
319
  }
337
320
 
338
321
  type ReplayNativeModule = {
@@ -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/scope.ts ADDED
@@ -0,0 +1,50 @@
1
+ // Ambient scope: the current user and the context patch, attached
2
+ // to every outgoing event. Two verbs own this state (user() and
3
+ // context()); everything else reads it.
4
+
5
+ import { hashIdentities, safeFn } from '@goliapkg/sentori-core';
6
+ import type { User } from '@goliapkg/sentori-core';
7
+
8
+ let _user: User | null = null;
9
+ let _userKey: string | undefined;
10
+ let _context: Record<string, unknown> = {};
11
+ let _hashGeneration = 0;
12
+
13
+ export const setUser = safeFn('user', function setUser(u: User | null): void {
14
+ _user = u;
15
+ _userKey = undefined;
16
+ const generation = ++_hashGeneration;
17
+ if (u === null) return;
18
+ // The wire carries only a salted hash — raw identity never leaves
19
+ // the device (breadth × depth needs distinctness, not identity).
20
+ // Hashing is async (WebCrypto); the verb stays synchronous and the
21
+ // key lands a tick later. Events sent in that gap simply carry no
22
+ // userKey, which only under-counts breadth briefly.
23
+ void hashIdentities({ email: u.email, id: u.id })
24
+ .then((hashes) => {
25
+ if (generation === _hashGeneration) {
26
+ _userKey = hashes.id ?? hashes.email;
27
+ }
28
+ })
29
+ .catch(() => {
30
+ // NEVER rule: no key beats a raw identity on the wire.
31
+ });
32
+ });
33
+
34
+ export const patchContext = safeFn('context', function patchContext(
35
+ patch: Record<string, unknown>,
36
+ ): void {
37
+ _context = { ..._context, ...patch };
38
+ });
39
+
40
+ export const currentUserKey = (): string | undefined => _userKey;
41
+
42
+ export const currentContext = (): Record<string, unknown> | undefined =>
43
+ Object.keys(_context).length > 0 ? { ..._context } : undefined;
44
+
45
+ export const __resetForTests = (): void => {
46
+ _user = null;
47
+ _userKey = undefined;
48
+ _context = {};
49
+ _hashGeneration++;
50
+ };