@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.
- package/MIGRATION.md +131 -0
- package/README.md +40 -30
- package/android/build.gradle +21 -0
- package/android/src/main/java/com/sentori/SentoriCrashHandler.kt +29 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +8 -0
- package/android/src/main/java/com/sentori/SentoriScreenshotCapture.kt +36 -4
- package/android/src/test/java/com/sentori/SentoriCrashHandlerTest.kt +7 -2
- package/android/src/test/resources/robolectric.properties +10 -0
- package/ios/SentoriCrashHandler.swift +48 -4
- package/ios/SentoriModule.swift +12 -0
- package/ios/SentoriScreenshotCapture.swift +34 -3
- package/lib/config.d.ts +17 -47
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +3 -0
- package/lib/config.js.map +1 -1
- package/lib/device.d.ts +5 -0
- package/lib/device.d.ts.map +1 -0
- package/lib/device.js +29 -0
- package/lib/device.js.map +1 -0
- package/lib/emit-hooks.d.ts +7 -0
- package/lib/emit-hooks.d.ts.map +1 -0
- package/lib/emit-hooks.js +24 -0
- package/lib/emit-hooks.js.map +1 -0
- package/lib/error-boundary.d.ts.map +1 -1
- package/lib/error-boundary.js +1 -1
- package/lib/error-boundary.js.map +1 -1
- package/lib/feature-flags.d.ts.map +1 -1
- package/lib/handlers/dev-symbolicate.d.ts +1 -1
- package/lib/handlers/dev-symbolicate.d.ts.map +1 -1
- package/lib/handlers/dev-symbolicate.js +2 -0
- package/lib/handlers/dev-symbolicate.js.map +1 -1
- package/lib/handlers/global.js +1 -1
- package/lib/handlers/global.js.map +1 -1
- package/lib/handlers/lifecycle.js +3 -3
- package/lib/handlers/lifecycle.js.map +1 -1
- package/lib/handlers/network.d.ts +2 -2
- package/lib/handlers/network.d.ts.map +1 -1
- package/lib/handlers/network.js +69 -187
- package/lib/handlers/network.js.map +1 -1
- package/lib/handlers/promise.js +1 -1
- package/lib/handlers/promise.js.map +1 -1
- package/lib/handlers/screenshot.d.ts.map +1 -1
- package/lib/handlers/screenshot.js +3 -12
- package/lib/handlers/screenshot.js.map +1 -1
- package/lib/index.d.ts +32 -109
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +54 -103
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +3 -150
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +100 -261
- package/lib/init.js.map +1 -1
- package/lib/long-task-monitor.d.ts +1 -7
- package/lib/long-task-monitor.d.ts.map +1 -1
- package/lib/long-task-monitor.js +37 -58
- package/lib/long-task-monitor.js.map +1 -1
- package/lib/mask.d.ts +5 -13
- package/lib/mask.d.ts.map +1 -1
- package/lib/mask.js +29 -30
- package/lib/mask.js.map +1 -1
- package/lib/mobile-vitals.d.ts +3 -31
- package/lib/mobile-vitals.d.ts.map +1 -1
- package/lib/mobile-vitals.js +22 -61
- package/lib/mobile-vitals.js.map +1 -1
- package/lib/native-pending.d.ts +3 -0
- package/lib/native-pending.d.ts.map +1 -0
- package/lib/native-pending.js +64 -0
- package/lib/native-pending.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/navigation.d.ts +3 -17
- package/lib/navigation.d.ts.map +1 -1
- package/lib/navigation.js +32 -133
- package/lib/navigation.js.map +1 -1
- package/lib/netinfo.d.ts +1 -1
- package/lib/netinfo.d.ts.map +1 -1
- package/lib/netinfo.js +2 -2
- package/lib/netinfo.js.map +1 -1
- package/lib/push.d.ts.map +1 -1
- package/lib/push.js +2 -4
- package/lib/push.js.map +1 -1
- package/lib/rage-tap.d.ts.map +1 -1
- package/lib/rage-tap.js +56 -23
- 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/replay.d.ts.map +1 -1
- package/lib/replay.js +3 -24
- package/lib/replay.js.map +1 -1
- 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/scope.d.ts +7 -0
- package/lib/scope.d.ts.map +1 -0
- package/lib/scope.js +41 -0
- package/lib/scope.js.map +1 -0
- package/lib/transport.d.ts +19 -92
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +75 -288
- package/lib/transport.js.map +1 -1
- package/lib/verbs.d.ts +16 -0
- package/lib/verbs.d.ts.map +1 -0
- package/lib/verbs.js +153 -0
- package/lib/verbs.js.map +1 -0
- package/package.json +4 -3
- package/src/__tests__/iron-rule.test.ts +128 -0
- package/src/__tests__/replay-screens.test.ts +63 -0
- package/src/__tests__/responsiveness-detector.test.ts +74 -0
- package/src/__tests__/screenshot.test.ts +0 -2
- package/src/__tests__/transport.test.ts +66 -213
- package/src/__tests__/verbs.test.ts +91 -0
- package/src/config.ts +20 -47
- package/src/device.ts +36 -0
- package/src/emit-hooks.ts +30 -0
- package/src/error-boundary.tsx +1 -1
- package/src/handlers/dev-symbolicate.ts +2 -1
- package/src/handlers/global.ts +1 -1
- package/src/handlers/lifecycle.ts +3 -3
- package/src/handlers/network.ts +95 -211
- package/src/handlers/promise.ts +1 -1
- package/src/handlers/screenshot.ts +3 -11
- package/src/index.ts +84 -215
- package/src/init.ts +115 -449
- package/src/long-task-monitor.ts +42 -64
- package/src/mask.ts +29 -31
- package/src/mobile-vitals.ts +22 -82
- package/src/native-pending.ts +92 -0
- package/src/native.ts +33 -0
- package/src/navigation.ts +34 -142
- package/src/netinfo.ts +3 -3
- package/src/push.ts +2 -5
- package/src/rage-tap.tsx +72 -25
- package/src/replay-screens.ts +95 -0
- package/src/replay.ts +3 -20
- package/src/responsiveness-detector.ts +66 -0
- package/src/scope.ts +50 -0
- package/src/transport.ts +84 -371
- package/src/verbs.ts +198 -0
- package/lib/breadcrumbs.d.ts +0 -18
- package/lib/breadcrumbs.d.ts.map +0 -1
- package/lib/breadcrumbs.js +0 -42
- package/lib/breadcrumbs.js.map +0 -1
- package/lib/bundle-info.d.ts +0 -12
- package/lib/bundle-info.d.ts.map +0 -1
- package/lib/bundle-info.js +0 -73
- package/lib/bundle-info.js.map +0 -1
- package/lib/capture.d.ts +0 -140
- package/lib/capture.d.ts.map +0 -1
- package/lib/capture.js +0 -511
- package/lib/capture.js.map +0 -1
- package/lib/compat/sentry.d.ts +0 -126
- package/lib/compat/sentry.d.ts.map +0 -1
- package/lib/compat/sentry.js +0 -332
- package/lib/compat/sentry.js.map +0 -1
- package/lib/control-channel.d.ts +0 -6
- package/lib/control-channel.d.ts.map +0 -1
- package/lib/control-channel.js +0 -96
- package/lib/control-channel.js.map +0 -1
- package/lib/expo-compat.d.ts +0 -270
- package/lib/expo-compat.d.ts.map +0 -1
- package/lib/expo-compat.js +0 -500
- package/lib/expo-compat.js.map +0 -1
- package/lib/feedback-widget.d.ts +0 -35
- package/lib/feedback-widget.d.ts.map +0 -1
- package/lib/feedback-widget.js +0 -193
- package/lib/feedback-widget.js.map +0 -1
- package/lib/feedback.d.ts +0 -2
- package/lib/feedback.d.ts.map +0 -1
- package/lib/feedback.js +0 -17
- package/lib/feedback.js.map +0 -1
- package/lib/heartbeat.d.ts +0 -9
- package/lib/heartbeat.d.ts.map +0 -1
- package/lib/heartbeat.js +0 -138
- package/lib/heartbeat.js.map +0 -1
- package/lib/index.js.bak +0 -64
- package/lib/install-id.d.ts +0 -17
- package/lib/install-id.d.ts.map +0 -1
- package/lib/install-id.js +0 -125
- package/lib/install-id.js.map +0 -1
- package/lib/lifecycle.d.ts +0 -28
- package/lib/lifecycle.d.ts.map +0 -1
- package/lib/lifecycle.js +0 -73
- package/lib/lifecycle.js.map +0 -1
- package/lib/measure.d.ts +0 -4
- package/lib/measure.d.ts.map +0 -1
- package/lib/measure.js +0 -25
- package/lib/measure.js.map +0 -1
- package/lib/metrics.d.ts +0 -24
- package/lib/metrics.d.ts.map +0 -1
- package/lib/metrics.js +0 -81
- package/lib/metrics.js.map +0 -1
- package/lib/pre-crash-sentinel.d.ts +0 -12
- package/lib/pre-crash-sentinel.d.ts.map +0 -1
- package/lib/pre-crash-sentinel.js +0 -116
- package/lib/pre-crash-sentinel.js.map +0 -1
- package/lib/report-security.d.ts +0 -40
- package/lib/report-security.d.ts.map +0 -1
- package/lib/report-security.js +0 -159
- package/lib/report-security.js.map +0 -1
- package/lib/runtime-metrics-fps.d.ts +0 -19
- package/lib/runtime-metrics-fps.d.ts.map +0 -1
- package/lib/runtime-metrics-fps.js +0 -128
- package/lib/runtime-metrics-fps.js.map +0 -1
- package/lib/runtime-metrics-heap.d.ts +0 -13
- package/lib/runtime-metrics-heap.d.ts.map +0 -1
- package/lib/runtime-metrics-heap.js +0 -67
- package/lib/runtime-metrics-heap.js.map +0 -1
- package/lib/runtime-metrics-network.d.ts +0 -23
- package/lib/runtime-metrics-network.d.ts.map +0 -1
- package/lib/runtime-metrics-network.js +0 -99
- package/lib/runtime-metrics-network.js.map +0 -1
- package/lib/runtime-metrics.d.ts +0 -43
- package/lib/runtime-metrics.d.ts.map +0 -1
- package/lib/runtime-metrics.js +0 -115
- package/lib/runtime-metrics.js.map +0 -1
- package/lib/sample-profiler.d.ts +0 -21
- package/lib/sample-profiler.d.ts.map +0 -1
- package/lib/sample-profiler.js +0 -152
- package/lib/sample-profiler.js.map +0 -1
- package/lib/sentinel-context.d.ts +0 -4
- package/lib/sentinel-context.d.ts.map +0 -1
- package/lib/sentinel-context.js +0 -52
- package/lib/sentinel-context.js.map +0 -1
- package/lib/session-tracker.d.ts +0 -6
- package/lib/session-tracker.d.ts.map +0 -1
- package/lib/session-tracker.js +0 -50
- package/lib/session-tracker.js.map +0 -1
- package/lib/stack.d.ts +0 -3
- package/lib/stack.d.ts.map +0 -1
- package/lib/stack.js +0 -7
- package/lib/stack.js.map +0 -1
- package/lib/state-snapshots.d.ts +0 -33
- package/lib/state-snapshots.d.ts.map +0 -1
- package/lib/state-snapshots.js +0 -109
- package/lib/state-snapshots.js.map +0 -1
- package/lib/track.d.ts +0 -34
- package/lib/track.d.ts.map +0 -1
- package/lib/track.js +0 -107
- package/lib/track.js.map +0 -1
- package/lib/trail.d.ts +0 -12
- package/lib/trail.d.ts.map +0 -1
- package/lib/trail.js +0 -28
- package/lib/trail.js.map +0 -1
- package/lib/trust-score.d.ts +0 -20
- package/lib/trust-score.d.ts.map +0 -1
- package/lib/trust-score.js +0 -151
- package/lib/trust-score.js.map +0 -1
- package/lib/types.d.ts +0 -2
- package/lib/types.d.ts.map +0 -1
- package/lib/types.js +0 -4
- package/lib/types.js.map +0 -1
- package/lib/uuid.d.ts +0 -2
- package/lib/uuid.d.ts.map +0 -1
- package/lib/uuid.js +0 -3
- package/lib/uuid.js.map +0 -1
- package/src/__tests__/before-send.test.ts +0 -72
- package/src/__tests__/breadcrumbs.test.ts +0 -44
- package/src/__tests__/capture-replay.test.ts +0 -150
- package/src/__tests__/compat-sentry.test.ts +0 -121
- package/src/__tests__/control-channel.test.ts +0 -111
- package/src/__tests__/install-id.test.ts +0 -60
- package/src/__tests__/long-task-monitor.test.ts +0 -59
- package/src/__tests__/measure.test.ts +0 -45
- package/src/__tests__/navigation.test.ts +0 -175
- package/src/__tests__/push.test.ts +0 -238
- package/src/__tests__/report-security.test.ts +0 -106
- package/src/__tests__/runtime-metrics-instruments.test.ts +0 -109
- package/src/__tests__/runtime-metrics-network.test.ts +0 -78
- package/src/__tests__/sample-profiler.test.ts +0 -59
- package/src/__tests__/stack.test.ts +0 -45
- package/src/__tests__/state-snapshots.test.ts +0 -119
- package/src/__tests__/tracing.test.ts +0 -201
- package/src/__tests__/track.test.ts +0 -91
- package/src/__tests__/uuid.test.ts +0 -41
- package/src/breadcrumbs.ts +0 -57
- package/src/bundle-info.ts +0 -95
- package/src/capture.ts +0 -660
- package/src/compat/sentry.ts +0 -490
- package/src/control-channel.ts +0 -98
- package/src/expo-compat.ts +0 -698
- package/src/feedback-widget.tsx +0 -316
- package/src/feedback.ts +0 -21
- package/src/heartbeat.ts +0 -157
- package/src/install-id.ts +0 -146
- package/src/lifecycle.ts +0 -76
- package/src/measure.ts +0 -28
- package/src/metrics.ts +0 -94
- package/src/pre-crash-sentinel.ts +0 -140
- package/src/report-security.ts +0 -165
- package/src/runtime-metrics-fps.ts +0 -137
- package/src/runtime-metrics-heap.ts +0 -78
- package/src/runtime-metrics-network.ts +0 -103
- package/src/runtime-metrics.ts +0 -122
- package/src/sample-profiler.ts +0 -169
- package/src/sentinel-context.ts +0 -60
- package/src/session-tracker.ts +0 -53
- package/src/stack.ts +0 -10
- package/src/state-snapshots.ts +0 -133
- package/src/track.ts +0 -123
- package/src/trail.ts +0 -32
- package/src/trust-score.ts +0 -176
- package/src/types.ts +0 -21
- package/src/uuid.ts +0 -2
package/src/navigation.ts
CHANGED
|
@@ -1,35 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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}>`.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
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 —
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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['
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
1
|
+
// rage_tap — the first detected warn scenario (design.md §3,
|
|
2
|
+
// category A: 「我按了没反应/反复按」).
|
|
2
3
|
//
|
|
3
|
-
// Wrap
|
|
4
|
-
// `<
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 {
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
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
|
+
};
|