@goliapkg/sentori-react-native 0.8.4 → 0.9.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 (49) hide show
  1. package/android/src/main/java/com/sentori/SentoriMobileVitals.kt +100 -0
  2. package/android/src/main/java/com/sentori/SentoriModule.kt +18 -0
  3. package/ios/SentoriMobileVitals.swift +104 -0
  4. package/ios/SentoriModule.swift +17 -0
  5. package/lib/feedback-widget.d.ts.map +1 -1
  6. package/lib/feedback-widget.js +8 -1
  7. package/lib/feedback-widget.js.map +1 -1
  8. package/lib/index.d.ts +4 -0
  9. package/lib/index.d.ts.map +1 -1
  10. package/lib/index.js +4 -0
  11. package/lib/index.js.map +1 -1
  12. package/lib/init.d.ts.map +1 -1
  13. package/lib/init.js +21 -1
  14. package/lib/init.js.map +1 -1
  15. package/lib/launch-crash-guard.d.ts.map +1 -1
  16. package/lib/launch-crash-guard.js +8 -0
  17. package/lib/launch-crash-guard.js.map +1 -1
  18. package/lib/mobile-vitals.d.ts +35 -0
  19. package/lib/mobile-vitals.d.ts.map +1 -0
  20. package/lib/mobile-vitals.js +89 -0
  21. package/lib/mobile-vitals.js.map +1 -0
  22. package/lib/native-loader.d.ts +6 -0
  23. package/lib/native-loader.d.ts.map +1 -0
  24. package/lib/native-loader.js +31 -0
  25. package/lib/native-loader.js.map +1 -0
  26. package/lib/native.d.ts +11 -0
  27. package/lib/native.d.ts.map +1 -1
  28. package/lib/native.js +37 -0
  29. package/lib/native.js.map +1 -1
  30. package/lib/navigation.d.ts.map +1 -1
  31. package/lib/navigation.js +14 -2
  32. package/lib/navigation.js.map +1 -1
  33. package/lib/netinfo.d.ts.map +1 -1
  34. package/lib/netinfo.js +6 -12
  35. package/lib/netinfo.js.map +1 -1
  36. package/lib/transport.d.ts.map +1 -1
  37. package/lib/transport.js +6 -0
  38. package/lib/transport.js.map +1 -1
  39. package/package.json +1 -9
  40. package/src/feedback-widget.tsx +8 -1
  41. package/src/index.ts +12 -0
  42. package/src/init.ts +21 -1
  43. package/src/launch-crash-guard.ts +9 -0
  44. package/src/mobile-vitals.ts +114 -0
  45. package/src/native-loader.ts +33 -0
  46. package/src/native.ts +60 -0
  47. package/src/navigation.ts +16 -2
  48. package/src/netinfo.ts +7 -14
  49. package/src/transport.ts +6 -0
@@ -0,0 +1,114 @@
1
+ // v0.9.4 #1 — Mobile Vitals.
2
+ //
3
+ // Three measurements, three call paths, one server schema:
4
+ //
5
+ // • Cold start: native side measures at app launch (iOS
6
+ // mach_absolute_time / Android Process.getStartElapsedRealtime)
7
+ // and exposes via the bundled native module — read once on JS
8
+ // side at init() and ride along on the first event.
9
+ // • TTID (Time-To-Initial-Display): automatic via
10
+ // useTraceNavigation extension — span from navigation.dispatch
11
+ // to first frame after route mount.
12
+ // • TTFD (Time-To-Full-Display): manual. Host calls
13
+ // sentori.markTimeToFullDisplay('Home').end() when the screen's
14
+ // data has loaded.
15
+ // • Slow / frozen frame counts: native side hooks CADisplayLink /
16
+ // Choreographer.FrameCallback; counters flush per navigation
17
+ // span.
18
+ //
19
+ // JS-side first: TTFD API + bundle-level vital captures. Native
20
+ // pieces ship as a separate native module method `getColdStartMs`
21
+ // + `getFrameCounts()` — graceful no-op if not linked.
22
+
23
+ import { startSpan } from '@goliapkg/sentori-core';
24
+
25
+ import { getNativeColdStartMs } from './native';
26
+
27
+ let _coldStartMs: null | number = null;
28
+ let _coldStartCaptured = false;
29
+
30
+ /** Read the native-side cold start measurement once. Cached. Returns
31
+ * null when the native module isn't linked (Expo Go / tests). */
32
+ export function getColdStartMs(): null | number {
33
+ if (_coldStartCaptured) return _coldStartMs;
34
+ _coldStartCaptured = true;
35
+ try {
36
+ _coldStartMs = getNativeColdStartMs();
37
+ } catch {
38
+ _coldStartMs = null;
39
+ }
40
+ return _coldStartMs;
41
+ }
42
+
43
+ /**
44
+ * v0.9.4 #1 — Time-To-Full-Display marker. Host calls this at the
45
+ * point the screen is functionally "ready" (data fetched, images
46
+ * loaded, etc.) so the dashboard can show real perceived load time
47
+ * vs auto-detected TTID.
48
+ *
49
+ * const h = sentori.markTimeToFullDisplay('Home');
50
+ * // ...data fetched, images rendered...
51
+ * h.end();
52
+ *
53
+ * If `.end()` is never called, the handle's span is finished as
54
+ * `cancelled` at the next `markTimeToFullDisplay` call (one TTFD in
55
+ * flight at a time is the typical case).
56
+ */
57
+ export type TimeToFullDisplayHandle = {
58
+ end: (opts?: { status?: 'cancelled' | 'error' | 'ok' }) => void;
59
+ cancel: () => void;
60
+ };
61
+
62
+ let _activeTtfd: null | {
63
+ finish: (status: 'cancelled' | 'error' | 'ok') => void;
64
+ route: string;
65
+ } = null;
66
+
67
+ export function markTimeToFullDisplay(route: string): TimeToFullDisplayHandle {
68
+ if (_activeTtfd && _activeTtfd.route !== route) {
69
+ _activeTtfd.finish('cancelled');
70
+ }
71
+ const span = startSpan('react.navigation.ttfd', {
72
+ name: route,
73
+ tags: { 'nav.route': route, 'vital.kind': 'ttfd' },
74
+ });
75
+ let finished = false;
76
+ const finish = (status: 'cancelled' | 'error' | 'ok'): void => {
77
+ if (finished) return;
78
+ finished = true;
79
+ span.finish({ status });
80
+ if (_activeTtfd && _activeTtfd.route === route) _activeTtfd = null;
81
+ };
82
+ _activeTtfd = { finish, route };
83
+ return {
84
+ cancel: () => finish('cancelled'),
85
+ end: (opts?: { status?: 'cancelled' | 'error' | 'ok' }) =>
86
+ finish(opts?.status ?? 'ok'),
87
+ };
88
+ }
89
+
90
+ /** v0.9.4 #1 — read the per-screen slow/frozen frame counts since
91
+ * the most recent navigation transition. Native module reads
92
+ * CADisplayLink / Choreographer counters; returns null when not
93
+ * linked. */
94
+ export function getFrameCounters(): null | { slow: number; frozen: number } {
95
+ try {
96
+ // native binding lazily required in native.ts
97
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
98
+ const nativeMod = require('./native') as {
99
+ getNativeFrameCounters?: () => null | { frozen: number; slow: number };
100
+ };
101
+ if (typeof nativeMod.getNativeFrameCounters !== 'function') return null;
102
+ return nativeMod.getNativeFrameCounters();
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** Test-only. */
109
+ export function __resetMobileVitalsForTests(): void {
110
+ if (_activeTtfd) _activeTtfd.finish('cancelled');
111
+ _activeTtfd = null;
112
+ _coldStartCaptured = false;
113
+ _coldStartMs = null;
114
+ }
@@ -0,0 +1,33 @@
1
+ // v0.8.5 — shared guard for "JS package is in node_modules but native
2
+ // module isn't linked".
3
+ //
4
+ // The pattern that bit us in 0.8.0–0.8.3 (NetInfo, AsyncStorage,
5
+ // expo-sensors, ...): host had the JS package via npm hoisting (or
6
+ // via our old `optionalDependencies` mistake) but never ran
7
+ // `pod install` / `prebuild` / `react-native link`. We `require(...)`
8
+ // successfully, call a method, then the lib's internal native bridge
9
+ // access throws — and most of those errors arrive on an emitter or
10
+ // microtask where our try/catch can't reach.
11
+ //
12
+ // Fix: before requireing such a package, check that the registered
13
+ // NativeModule actually exists. `null` → skip the whole feature; cost
14
+ // is one nullable map read.
15
+
16
+ export function isNativeModuleLinked(name: string): boolean {
17
+ try {
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const RN = require('react-native') as {
20
+ NativeModules?: Record<string, unknown>;
21
+ };
22
+ return RN.NativeModules?.[name] != null;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ /** Some native modules are registered under multiple alternative names
29
+ * across RN versions / Expo packaging. Returns `true` iff *any* name
30
+ * is linked. */
31
+ export function isAnyNativeModuleLinked(names: string[]): boolean {
32
+ return names.some(isNativeModuleLinked);
33
+ }
package/src/native.ts CHANGED
@@ -11,6 +11,29 @@ type SentoriNativeModule = {
11
11
  release: string
12
12
  token: string
13
13
  }) => void
14
+ /**
15
+ * v0.9.4 #1 — cold start measurement. iOS:
16
+ * `mach_absolute_time` from `applicationDidFinishLaunching` to first
17
+ * JS bridge ready. Android: `Process.getStartElapsedRealtime()`.
18
+ * Returns null when native side hasn't captured yet.
19
+ */
20
+ getColdStartMs?: () => null | number
21
+ /**
22
+ * v0.9.4 #1 — call once at JS init() to finalize the cold-start
23
+ * measurement. iOS subtracts from the app-delegate anchor;
24
+ * Android uses Process.getStartElapsedRealtime() so the call is
25
+ * idempotent if missed.
26
+ */
27
+ markJsBridgeReady?: () => void
28
+ /**
29
+ * v0.9.4 #1 — slow/frozen frame counters since the most recent
30
+ * navigation transition. Native side hooks `CADisplayLink` (iOS)
31
+ * / `Choreographer.FrameCallback` (Android). Frame > 16.67ms =
32
+ * slow; > 700ms = frozen.
33
+ */
34
+ getFrameCounters?: () => null | { frozen: number; slow: number }
35
+ /** Reset counters on navigation transition (called by useTraceNavigation). */
36
+ resetFrameCounters?: () => void
14
37
  /**
15
38
  * v0.7.3 — JS-triggered screenshot with consumer-supplied mask IDs.
16
39
  * `maskedIds` are RN `nativeID` strings; native walks the view
@@ -127,6 +150,43 @@ export function stopAnrWatchdog(): void {
127
150
  }
128
151
  }
129
152
 
153
+ /** v0.9.4 #1 — finalize cold-start measurement. Idempotent. */
154
+ export function markNativeJsBridgeReady(): void {
155
+ try {
156
+ native()?.markJsBridgeReady?.()
157
+ } catch {
158
+ // ignore
159
+ }
160
+ }
161
+
162
+ /** v0.9.4 #1 — read cold start ms once. null when native unavailable. */
163
+ export function getNativeColdStartMs(): null | number {
164
+ try {
165
+ const v = native()?.getColdStartMs?.()
166
+ return typeof v === 'number' && Number.isFinite(v) ? v : null
167
+ } catch {
168
+ return null
169
+ }
170
+ }
171
+
172
+ /** v0.9.4 #1 — read slow/frozen frame counters since last reset. */
173
+ export function getNativeFrameCounters(): null | { frozen: number; slow: number } {
174
+ try {
175
+ return native()?.getFrameCounters?.() ?? null
176
+ } catch {
177
+ return null
178
+ }
179
+ }
180
+
181
+ /** v0.9.4 #1 — reset frame counters on navigation transition. */
182
+ export function resetNativeFrameCounters(): void {
183
+ try {
184
+ native()?.resetFrameCounters?.()
185
+ } catch {
186
+ // ignore
187
+ }
188
+ }
189
+
130
190
  /**
131
191
  * v0.7.3 — drives the native screenshot path. JS side passes the
132
192
  * current list of mask `nativeID`s (read from the consumer's
package/src/navigation.ts CHANGED
@@ -24,6 +24,10 @@ import { useEffect, useRef } from 'react';
24
24
 
25
25
  import { setActiveSpan, startSpan, type SpanHandle } from '@goliapkg/sentori-core';
26
26
 
27
+ import {
28
+ getNativeFrameCounters,
29
+ resetNativeFrameCounters,
30
+ } from './native';
27
31
  import { captureStep } from './trail';
28
32
 
29
33
  /** Minimal contract: anything with `addListener('state', cb)` and
@@ -101,11 +105,21 @@ export function useTraceNavigation(navigationRef: NavigationRefLike): void {
101
105
  const enteredAt = lastRouteEnteredAtRef.current;
102
106
  if (!span) return null;
103
107
  const dwellMs = enteredAt !== null ? Math.max(0, Date.now() - enteredAt) : null;
108
+ // v0.9.4 #1 — drain native frame counters at screen-leave.
109
+ // Empty/null when native module not linked; tags omitted.
110
+ const fc = getNativeFrameCounters();
111
+ const finishTags: Record<string, string> = {};
112
+ if (dwellMs !== null) finishTags['nav.dwell_ms'] = String(dwellMs);
113
+ if (fc) {
114
+ finishTags['vital.slow_frames'] = String(fc.slow);
115
+ finishTags['vital.frozen_frames'] = String(fc.frozen);
116
+ }
104
117
  span.finish({
105
118
  status: 'ok',
106
- // Tag values are strings on the wire — cast at finish-time.
107
- tags: dwellMs !== null ? { 'nav.dwell_ms': String(dwellMs) } : undefined,
119
+ tags: Object.keys(finishTags).length > 0 ? finishTags : undefined,
108
120
  });
121
+ // Reset counters for the next screen.
122
+ resetNativeFrameCounters();
109
123
  return dwellMs;
110
124
  };
111
125
 
package/src/netinfo.ts CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  import type { Device } from '@goliapkg/sentori-core';
18
18
 
19
+ import { isNativeModuleLinked } from './native-loader';
20
+
19
21
  type NetworkType = Device['networkType'];
20
22
 
21
23
  type NetInfoState = {
@@ -55,20 +57,11 @@ export function startNetworkTypeWatch(): void {
55
57
  if (_started) return;
56
58
  _started = true;
57
59
  try {
58
- // v0.8.4 hotfix when the host has `@react-native-community/netinfo`
59
- // in package.json but the *native* module isn't linked (Expo Go,
60
- // pod install never ran, RN autolink turned off, etc.) the JS
61
- // package still imports fine but calling addEventListener throws
62
- // "NativeModule.RNCNetInfo is null" from inside the lib's async
63
- // emitter — past where this try/catch can reach. Gate on the
64
- // NativeModules entry first so we no-op cleanly in that case.
65
- // eslint-disable-next-line @typescript-eslint/no-require-imports
66
- const RN = require('react-native') as {
67
- NativeModules?: Record<string, unknown>;
68
- };
69
- const native = RN.NativeModules?.RNCNetInfo;
70
- if (native == null) {
71
- // JS package present but native module isn't linked. Skip.
60
+ // v0.8.4 hotfix v0.8.5 generalised via isNativeModuleLinked.
61
+ // host may have the JS package via hoisted node_modules but
62
+ // never ran pod install / prebuild calling addEventListener
63
+ // crashes deep inside the lib's emitter past try/catch reach.
64
+ if (!isNativeModuleLinked('RNCNetInfo')) {
72
65
  return;
73
66
  }
74
67
  // eslint-disable-next-line @typescript-eslint/no-require-imports
package/src/transport.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { drainSpans } from '@goliapkg/sentori-core';
2
2
 
3
3
  import { getConfig } from './config';
4
+ import { isAnyNativeModuleLinked } from './native-loader';
4
5
  import type { Event } from './types';
5
6
 
6
7
  const FLUSH_INTERVAL_MS = 5_000;
@@ -166,6 +167,11 @@ type AsyncStorageLike = {
166
167
  };
167
168
 
168
169
  const getAsyncStorage = async (): Promise<AsyncStorageLike | null> => {
170
+ // v0.8.5 — host may have the JS package without pod install /
171
+ // prebuild → getItem crashes from a microtask outside our reach.
172
+ if (!isAnyNativeModuleLinked(['RNCAsyncStorage', 'AsyncStorageModule'])) {
173
+ return null;
174
+ }
169
175
  try {
170
176
  const mod = (await import(
171
177
  '@react-native-async-storage/async-storage'