@goliapkg/sentori-react-native 1.0.0-rc.9 → 1.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/bin/sentori-rn-upload-source-bundle.cjs +193 -0
- package/lib/capture.d.ts +34 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +96 -2
- package/lib/capture.js.map +1 -1
- package/lib/config.d.ts +3 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js.map +1 -1
- package/lib/heartbeat.d.ts +9 -0
- package/lib/heartbeat.d.ts.map +1 -0
- package/lib/heartbeat.js +140 -0
- package/lib/heartbeat.js.map +1 -0
- package/lib/index.d.ts +30 -3
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +30 -4
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +9 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +19 -0
- package/lib/init.js.map +1 -1
- package/lib/install-id.d.ts +17 -0
- package/lib/install-id.d.ts.map +1 -0
- package/lib/install-id.js +125 -0
- package/lib/install-id.js.map +1 -0
- package/lib/lifecycle.d.ts +28 -0
- package/lib/lifecycle.d.ts.map +1 -0
- package/lib/lifecycle.js +73 -0
- package/lib/lifecycle.js.map +1 -0
- package/lib/metrics.d.ts +18 -1
- package/lib/metrics.d.ts.map +1 -1
- package/lib/metrics.js +19 -2
- package/lib/metrics.js.map +1 -1
- package/lib/navigation.d.ts +1 -0
- package/lib/navigation.d.ts.map +1 -1
- package/lib/navigation.js +20 -0
- package/lib/navigation.js.map +1 -1
- package/lib/replay.d.ts +3 -1
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +8 -3
- package/lib/replay.js.map +1 -1
- package/lib/report-security.d.ts +40 -0
- package/lib/report-security.d.ts.map +1 -0
- package/lib/report-security.js +159 -0
- package/lib/report-security.js.map +1 -0
- package/lib/track.d.ts +34 -0
- package/lib/track.d.ts.map +1 -0
- package/lib/track.js +98 -0
- package/lib/track.js.map +1 -0
- package/lib/transport.d.ts +15 -0
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +23 -0
- package/lib/transport.js.map +1 -1
- package/lib/trust-score.d.ts +20 -0
- package/lib/trust-score.d.ts.map +1 -0
- package/lib/trust-score.js +151 -0
- package/lib/trust-score.js.map +1 -0
- package/package.json +7 -3
- package/src/__tests__/install-id.test.ts +60 -0
- package/src/__tests__/report-security.test.ts +106 -0
- package/src/__tests__/track.test.ts +91 -0
- package/src/capture.ts +111 -3
- package/src/config.ts +3 -0
- package/src/heartbeat.ts +158 -0
- package/src/index.ts +59 -2
- package/src/init.ts +27 -0
- package/src/install-id.ts +146 -0
- package/src/lifecycle.ts +76 -0
- package/src/metrics.ts +19 -1
- package/src/navigation.ts +26 -0
- package/src/replay.ts +11 -4
- package/src/report-security.ts +165 -0
- package/src/track.ts +114 -0
- package/src/transport.ts +35 -0
- package/src/trust-score.ts +176 -0
package/src/capture.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type CaptureMessageOptions,
|
|
3
|
+
type MessageLevel,
|
|
4
|
+
safeFn,
|
|
5
|
+
sealTrail,
|
|
6
|
+
shouldSample,
|
|
7
|
+
} from '@goliapkg/sentori-core';
|
|
2
8
|
|
|
3
9
|
import { base64Utf8 } from './base64';
|
|
4
10
|
import {
|
|
@@ -18,6 +24,7 @@ import { parseStack } from './stack';
|
|
|
18
24
|
import { getTrailBuffer } from './trail';
|
|
19
25
|
import { enqueue, sendUserReport, uploadAttachment } from './transport';
|
|
20
26
|
import { uuidV7 } from './uuid';
|
|
27
|
+
import { peekInstallId } from './install-id';
|
|
21
28
|
import { getCachedNetworkType } from './netinfo';
|
|
22
29
|
import { getRecentNativeException } from './native';
|
|
23
30
|
import type { App, AttachmentMeta, Device, Event, SentoriError, Tags, User } from './types';
|
|
@@ -28,6 +35,41 @@ declare const __DEV__: boolean | undefined;
|
|
|
28
35
|
|
|
29
36
|
let _user: User | null = null;
|
|
30
37
|
|
|
38
|
+
/** v2.0 — global scope tags merged onto every captured event
|
|
39
|
+
* (captureException + captureMessage). Set via `setTag` /
|
|
40
|
+
* `setTags`; reset by passing `{}`. Per-call tags win on conflict. */
|
|
41
|
+
let _scopeTags: Record<string, string> = {};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* v2.0 — set a single scope tag merged onto every subsequent
|
|
45
|
+
* capture. Per-call `extras.tags` / `opts.tags` win over scope tags.
|
|
46
|
+
*
|
|
47
|
+
* sentori.setTag('rollout', 'dark-mode-v2');
|
|
48
|
+
* sentori.captureException(err); // event.tags carries rollout
|
|
49
|
+
*/
|
|
50
|
+
export const setTag = (key: string, value: string): void => {
|
|
51
|
+
_scopeTags[key] = String(value);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* v2.0 — bulk variant of setTag. Existing tags are merged with
|
|
56
|
+
* the input record (`Object.assign` style).
|
|
57
|
+
*/
|
|
58
|
+
export const setTags = (record: Record<string, string>): void => {
|
|
59
|
+
for (const [k, v] of Object.entries(record)) _scopeTags[k] = String(v);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Internal — merge global scope tags into a per-call tag record. */
|
|
63
|
+
function mergeScopeTags(perCall: Tags | undefined): Tags | undefined {
|
|
64
|
+
if (Object.keys(_scopeTags).length === 0) return perCall;
|
|
65
|
+
return { ..._scopeTags, ...(perCall ?? {}) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const __resetScopeForTests = (): void => {
|
|
69
|
+
_user = null;
|
|
70
|
+
_scopeTags = {};
|
|
71
|
+
};
|
|
72
|
+
|
|
31
73
|
// Phase 42 sub-D.08 — per-session screenshot quota. Defaults: 10 in
|
|
32
74
|
// prod, unlimited (-1 sentinel) in dev so test loops + react-error-
|
|
33
75
|
// overlay reruns don't run out partway through the session.
|
|
@@ -124,7 +166,7 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
|
124
166
|
device: collectDevice(),
|
|
125
167
|
app: collectApp(config.release),
|
|
126
168
|
user: extras?.user ?? _user,
|
|
127
|
-
tags: extras?.tags,
|
|
169
|
+
tags: mergeScopeTags(extras?.tags),
|
|
128
170
|
...(flags ? { flags } : {}),
|
|
129
171
|
...(bundle ? { bundle } : {}),
|
|
130
172
|
breadcrumbs: crumbs,
|
|
@@ -164,7 +206,7 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
|
164
206
|
// the server symbolicate at ingest against the uploaded map.
|
|
165
207
|
const pipeline = async (): Promise<void> => {
|
|
166
208
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
167
|
-
await symbolicateErrorViaMetro(event.error).catch(() => {});
|
|
209
|
+
await symbolicateErrorViaMetro(event.error!).catch(() => {});
|
|
168
210
|
}
|
|
169
211
|
if (wantScreenshot) {
|
|
170
212
|
await captureAndAttachScreenshot(event);
|
|
@@ -317,6 +359,65 @@ async function captureAndAttachSessionTrail(event: Event): Promise<void> {
|
|
|
317
359
|
|
|
318
360
|
export const captureException = captureError;
|
|
319
361
|
|
|
362
|
+
const DEFAULT_MESSAGE_LEVEL: MessageLevel = 'info';
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Manually report an issue without an Error instance.
|
|
366
|
+
*
|
|
367
|
+
* Routes to the dashboard Issues module — distinct from `track`
|
|
368
|
+
* (analytics) and `recordMetric` (numeric). Use for "operator should
|
|
369
|
+
* look at this" signals: a fallback that fired, an unexpected state,
|
|
370
|
+
* a feature flag rollout that crossed a threshold.
|
|
371
|
+
*
|
|
372
|
+
* sentori.captureMessage('Payment provider returned 500, used fallback')
|
|
373
|
+
* sentori.captureMessage('Detected impossible state in session reducer', {
|
|
374
|
+
* level: 'error',
|
|
375
|
+
* tags: { reducer: 'session' },
|
|
376
|
+
* })
|
|
377
|
+
*
|
|
378
|
+
* Wrapped in `safeFn` per the NEVER rule — any internal failure is
|
|
379
|
+
* swallowed and (optionally) self-reported; the host app never sees
|
|
380
|
+
* a thrown error.
|
|
381
|
+
*/
|
|
382
|
+
export const captureMessage = safeFn(
|
|
383
|
+
'captureMessage',
|
|
384
|
+
(message: string, opts: CaptureMessageOptions = {}): void => {
|
|
385
|
+
if (!isInitialized()) return;
|
|
386
|
+
if (typeof message !== 'string' || message.length === 0) return;
|
|
387
|
+
const config = getConfig();
|
|
388
|
+
if (!config) return;
|
|
389
|
+
|
|
390
|
+
if (!shouldSample(config.messageSampleRate)) {
|
|
391
|
+
addBreadcrumb({ type: 'custom', data: { reason: 'sampled-out', kind: 'message' } });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const flags = getFeatureFlagSnapshot();
|
|
396
|
+
const bundle = getBundleInfo();
|
|
397
|
+
const crumbs = opts.breadcrumbs ?? getBreadcrumbs();
|
|
398
|
+
|
|
399
|
+
const event: Event = {
|
|
400
|
+
id: uuidV7(),
|
|
401
|
+
timestamp: new Date().toISOString(),
|
|
402
|
+
kind: 'message',
|
|
403
|
+
level: opts.level ?? DEFAULT_MESSAGE_LEVEL,
|
|
404
|
+
message,
|
|
405
|
+
platform: 'javascript',
|
|
406
|
+
release: config.release,
|
|
407
|
+
environment: config.environment,
|
|
408
|
+
device: collectDevice(),
|
|
409
|
+
app: collectApp(config.release),
|
|
410
|
+
user: opts.user ?? _user,
|
|
411
|
+
tags: mergeScopeTags(opts.tags),
|
|
412
|
+
...(flags ? { flags } : {}),
|
|
413
|
+
...(bundle ? { bundle } : {}),
|
|
414
|
+
breadcrumbs: crumbs,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
enqueue(event);
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
|
|
320
421
|
/** rc.4 — test hook. The real replay attach path is internal so we
|
|
321
422
|
* don't bloat the public surface, but the encoding bug Insight hit
|
|
322
423
|
* on 2026-05-18 needs a behaviour-level test that exercises the
|
|
@@ -459,6 +560,13 @@ const collectDevice = (): Device => {
|
|
|
459
560
|
const device: Device = { os, osVersion };
|
|
460
561
|
if (locale) device.locale = locale;
|
|
461
562
|
if (networkType) device.networkType = networkType;
|
|
563
|
+
// v1.1 chunk S1 — stable per-install id. Read sync from the
|
|
564
|
+
// in-memory cache populated by the install-id module's first
|
|
565
|
+
// resolve (kicked off from init()). `null` before init resolves —
|
|
566
|
+
// the field is omitted in that case rather than blocking event
|
|
567
|
+
// assembly, which sits on the JS thread.
|
|
568
|
+
const installId = peekInstallId();
|
|
569
|
+
if (installId) device.installId = installId;
|
|
462
570
|
return device;
|
|
463
571
|
};
|
|
464
572
|
|
package/src/config.ts
CHANGED
|
@@ -10,6 +10,9 @@ export type Config = {
|
|
|
10
10
|
* `null` = keep everything (default). */
|
|
11
11
|
errorSampleRate: null | number;
|
|
12
12
|
traceSampleRate: null | number;
|
|
13
|
+
/** v2.0 — sampling rate for `kind: 'message'` events emitted via
|
|
14
|
+
* `captureMessage`. `null` = keep all (default). */
|
|
15
|
+
messageSampleRate: null | number;
|
|
13
16
|
/** Phase 46: when true, every `captureException` seals the
|
|
14
17
|
* session-trail buffer and uploads it as a `sessionTrail`
|
|
15
18
|
* attachment. Defaults to false. */
|
package/src/heartbeat.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Analytics v1 — concurrent-user heartbeat.
|
|
2
|
+
//
|
|
3
|
+
// Once per minute, while the app is in the foreground, POST a tiny
|
|
4
|
+
// `{ sessionId, userId?, release, route?, os?, ts }` body to
|
|
5
|
+
// `/v1/heartbeat`. The server keeps a per-project Valkey ZSET keyed
|
|
6
|
+
// by member (user.id when set, sessionId otherwise) and the
|
|
7
|
+
// dashboard's `Live` page reads the set to render concurrent-user
|
|
8
|
+
// count + per-dim breakdowns.
|
|
9
|
+
//
|
|
10
|
+
// Iron-rule budget (CLAUDE.md):
|
|
11
|
+
// - 1 POST / min foreground only — never fires when backgrounded
|
|
12
|
+
// - ~200 B body
|
|
13
|
+
// - fire-and-forget; never blocks the JS thread, no retries
|
|
14
|
+
// - bounce suppression: if AppState flaps active → inactive → active
|
|
15
|
+
// inside 30 s, we still fire at most one heartbeat per 30 s
|
|
16
|
+
//
|
|
17
|
+
// The heartbeat is independent of the session ping in
|
|
18
|
+
// `session-tracker.ts`. Session pings fire only at session close
|
|
19
|
+
// (transport-batched); the heartbeat exists *during* the session to
|
|
20
|
+
// signal presence.
|
|
21
|
+
|
|
22
|
+
import { getConfig } from './config';
|
|
23
|
+
import { getUser } from './capture';
|
|
24
|
+
import { getLastRoute } from './navigation';
|
|
25
|
+
import { uuidV7 } from './uuid';
|
|
26
|
+
|
|
27
|
+
declare const __DEV__: boolean | undefined;
|
|
28
|
+
|
|
29
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
30
|
+
const MIN_GAP_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
type AppStateLike = {
|
|
33
|
+
addEventListener: (
|
|
34
|
+
event: 'change',
|
|
35
|
+
handler: (state: string) => void
|
|
36
|
+
) => { remove: () => void };
|
|
37
|
+
currentState?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let _running = false;
|
|
41
|
+
let _timer: ReturnType<typeof setInterval> | null = null;
|
|
42
|
+
let _appStateSub: null | { remove: () => void } = null;
|
|
43
|
+
let _sessionId: null | string = null;
|
|
44
|
+
let _lastBeatTs = 0;
|
|
45
|
+
let _intervalMs = DEFAULT_INTERVAL_MS;
|
|
46
|
+
|
|
47
|
+
export type HeartbeatOptions = {
|
|
48
|
+
/** Override the default 60 s interval. Floor 10 s — anything below
|
|
49
|
+
* trips the perf rule and the server's rate-limit anyway. */
|
|
50
|
+
intervalMs?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function startHeartbeat(opts: HeartbeatOptions = {}): void {
|
|
54
|
+
if (_running) return;
|
|
55
|
+
_running = true;
|
|
56
|
+
_intervalMs = Math.max(10_000, opts.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
57
|
+
_sessionId = uuidV7();
|
|
58
|
+
|
|
59
|
+
// AppState gate — only beat while app is in the foreground.
|
|
60
|
+
let AppState: AppStateLike | undefined;
|
|
61
|
+
try {
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
63
|
+
AppState = (require('react-native') as { AppState?: AppStateLike }).AppState;
|
|
64
|
+
} catch {
|
|
65
|
+
// Not in RN runtime (tests). The interval still runs; the gate
|
|
66
|
+
// is just permissive. Suppression below still applies.
|
|
67
|
+
AppState = undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isForeground = (): boolean => {
|
|
71
|
+
if (!AppState) return true;
|
|
72
|
+
return (AppState.currentState ?? 'active') === 'active';
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const beat = () => {
|
|
76
|
+
if (!_running) return;
|
|
77
|
+
if (!isForeground()) return;
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
if (now - _lastBeatTs < MIN_GAP_MS) return;
|
|
80
|
+
_lastBeatTs = now;
|
|
81
|
+
void send();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// First beat as soon as we start (so the dashboard sees the user
|
|
85
|
+
// immediately, not 60 s after launch). Subsequent fires on the
|
|
86
|
+
// interval. AppState transitions can poke an immediate beat too —
|
|
87
|
+
// an active resume is a meaningful presence event.
|
|
88
|
+
beat();
|
|
89
|
+
_timer = setInterval(beat, _intervalMs);
|
|
90
|
+
|
|
91
|
+
if (AppState && typeof AppState.addEventListener === 'function') {
|
|
92
|
+
_appStateSub = AppState.addEventListener('change', (state) => {
|
|
93
|
+
if (state === 'active') beat();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function stopHeartbeat(): void {
|
|
99
|
+
_running = false;
|
|
100
|
+
if (_timer !== null) {
|
|
101
|
+
clearInterval(_timer);
|
|
102
|
+
_timer = null;
|
|
103
|
+
}
|
|
104
|
+
if (_appStateSub) {
|
|
105
|
+
_appStateSub.remove();
|
|
106
|
+
_appStateSub = null;
|
|
107
|
+
}
|
|
108
|
+
_sessionId = null;
|
|
109
|
+
_lastBeatTs = 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function send(): Promise<void> {
|
|
113
|
+
const config = getConfig();
|
|
114
|
+
if (!config) return;
|
|
115
|
+
const user = getUser();
|
|
116
|
+
const body: Record<string, unknown> = {
|
|
117
|
+
sessionId: _sessionId ?? '',
|
|
118
|
+
release: config.release,
|
|
119
|
+
ts: Date.now(),
|
|
120
|
+
};
|
|
121
|
+
if (user?.id) body.userId = user.id;
|
|
122
|
+
const route = getLastRoute();
|
|
123
|
+
if (route) body.route = route;
|
|
124
|
+
const os = readOsString();
|
|
125
|
+
if (os) body.os = os;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await fetch(`${config.ingestUrl}/v1/heartbeat`, {
|
|
129
|
+
body: JSON.stringify(body),
|
|
130
|
+
headers: {
|
|
131
|
+
Authorization: `Bearer ${config.token}`,
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
},
|
|
134
|
+
method: 'POST',
|
|
135
|
+
});
|
|
136
|
+
} catch (e) {
|
|
137
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
138
|
+
// eslint-disable-next-line no-console
|
|
139
|
+
console.warn('[sentori] heartbeat failed (best-effort)', e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readOsString(): null | string {
|
|
145
|
+
try {
|
|
146
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
147
|
+
const RN = require('react-native') as {
|
|
148
|
+
Platform: { OS: string; Version: string | number };
|
|
149
|
+
};
|
|
150
|
+
return `${RN.Platform.OS} ${RN.Platform.Version}`;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function __resetHeartbeatForTests(): void {
|
|
157
|
+
stopHeartbeat();
|
|
158
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,9 +3,12 @@ import { addBreadcrumb } from './breadcrumbs';
|
|
|
3
3
|
import {
|
|
4
4
|
captureError,
|
|
5
5
|
captureException,
|
|
6
|
+
captureMessage,
|
|
6
7
|
captureStep,
|
|
7
8
|
getUser,
|
|
8
9
|
sendUserFeedback,
|
|
10
|
+
setTag,
|
|
11
|
+
setTags,
|
|
9
12
|
setUser,
|
|
10
13
|
} from './capture';
|
|
11
14
|
import { ErrorBoundary } from './error-boundary';
|
|
@@ -24,8 +27,18 @@ import {
|
|
|
24
27
|
type TimeToFullDisplayHandle,
|
|
25
28
|
} from './mobile-vitals';
|
|
26
29
|
import { bindState, recordState, unbindState } from './state-snapshots';
|
|
27
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
startMoment,
|
|
32
|
+
startSpan,
|
|
33
|
+
startTrace,
|
|
34
|
+
withScopedSpan,
|
|
35
|
+
} from '@goliapkg/sentori-core';
|
|
36
|
+
import { getInstallId } from './install-id';
|
|
28
37
|
import { flushMetrics, recordMetric } from './metrics';
|
|
38
|
+
import { close, flush } from './lifecycle';
|
|
39
|
+
import { linkFederatedIdentity, reportPinMismatch, reportSecurity } from './report-security';
|
|
40
|
+
import { flushTrack, track } from './track';
|
|
41
|
+
import { queryTrustScore } from './trust-score';
|
|
29
42
|
import { RageTapCapture } from './rage-tap';
|
|
30
43
|
import {
|
|
31
44
|
endSession,
|
|
@@ -38,14 +51,27 @@ export const sentori = {
|
|
|
38
51
|
addBreadcrumb,
|
|
39
52
|
setUser,
|
|
40
53
|
getUser,
|
|
54
|
+
setTag,
|
|
55
|
+
setTags,
|
|
41
56
|
captureError,
|
|
42
57
|
captureException,
|
|
58
|
+
captureMessage,
|
|
43
59
|
captureStep,
|
|
44
60
|
sendUserFeedback,
|
|
45
61
|
recordMetric,
|
|
46
62
|
flushMetrics,
|
|
63
|
+
track,
|
|
64
|
+
flushTrack,
|
|
65
|
+
getInstallId,
|
|
66
|
+
reportSecurity,
|
|
67
|
+
reportPinMismatch,
|
|
68
|
+
queryTrustScore,
|
|
69
|
+
linkFederatedIdentity,
|
|
47
70
|
measureFn,
|
|
48
71
|
startMoment,
|
|
72
|
+
startSpan,
|
|
73
|
+
startTrace,
|
|
74
|
+
withScopedSpan,
|
|
49
75
|
bindState,
|
|
50
76
|
recordState,
|
|
51
77
|
unbindState,
|
|
@@ -63,6 +89,8 @@ export const sentori = {
|
|
|
63
89
|
startSession,
|
|
64
90
|
endSession,
|
|
65
91
|
markSessionCrashed,
|
|
92
|
+
flush,
|
|
93
|
+
close,
|
|
66
94
|
};
|
|
67
95
|
|
|
68
96
|
export default sentori;
|
|
@@ -72,11 +100,27 @@ export { addBreadcrumb } from './breadcrumbs';
|
|
|
72
100
|
export {
|
|
73
101
|
captureError,
|
|
74
102
|
captureException,
|
|
103
|
+
captureMessage,
|
|
75
104
|
captureStep,
|
|
76
105
|
getUser,
|
|
77
106
|
sendUserFeedback,
|
|
107
|
+
setTag,
|
|
108
|
+
setTags,
|
|
78
109
|
setUser,
|
|
79
110
|
} from './capture';
|
|
111
|
+
export {
|
|
112
|
+
startMoment,
|
|
113
|
+
startSpan,
|
|
114
|
+
startTrace,
|
|
115
|
+
withScopedSpan,
|
|
116
|
+
type SpanContextLike,
|
|
117
|
+
type StartSpanOptions,
|
|
118
|
+
} from '@goliapkg/sentori-core';
|
|
119
|
+
export { close, flush } from './lifecycle';
|
|
120
|
+
export type {
|
|
121
|
+
CaptureMessageOptions,
|
|
122
|
+
MessageLevel,
|
|
123
|
+
} from '@goliapkg/sentori-core';
|
|
80
124
|
export { ErrorBoundary } from './error-boundary';
|
|
81
125
|
export { FeedbackButton, type FeedbackButtonHandle, type FeedbackButtonProps } from './feedback-widget';
|
|
82
126
|
export {
|
|
@@ -87,13 +131,26 @@ export {
|
|
|
87
131
|
} from './feature-flags';
|
|
88
132
|
export { clearMaskQuery, registerMaskQuery } from './mask';
|
|
89
133
|
export { flushMetrics, recordMetric } from './metrics';
|
|
134
|
+
export { flushTrack, track, type TrackEvent, type TrackProps } from './track';
|
|
135
|
+
export { getInstallId, peekInstallId } from './install-id';
|
|
136
|
+
export {
|
|
137
|
+
linkFederatedIdentity,
|
|
138
|
+
reportPinMismatch,
|
|
139
|
+
reportSecurity,
|
|
140
|
+
type SecurityReportData,
|
|
141
|
+
} from './report-security';
|
|
142
|
+
export {
|
|
143
|
+
queryTrustScore,
|
|
144
|
+
type TrustScore,
|
|
145
|
+
type TrustSignal,
|
|
146
|
+
} from './trust-score';
|
|
90
147
|
export { measureFn } from './measure';
|
|
91
148
|
export {
|
|
92
149
|
getColdStartMs,
|
|
93
150
|
markTimeToFullDisplay,
|
|
94
151
|
type TimeToFullDisplayHandle,
|
|
95
152
|
} from './mobile-vitals';
|
|
96
|
-
export { MomentHandle, type MomentProperties
|
|
153
|
+
export { MomentHandle, type MomentProperties } from '@goliapkg/sentori-core';
|
|
97
154
|
export {
|
|
98
155
|
bindState,
|
|
99
156
|
recordState,
|
package/src/init.ts
CHANGED
|
@@ -8,9 +8,12 @@ import {
|
|
|
8
8
|
markLaunchCompleted,
|
|
9
9
|
runLaunchCrashGuard,
|
|
10
10
|
} from './launch-crash-guard';
|
|
11
|
+
import { getInstallId } from './install-id';
|
|
11
12
|
import { startMetricsTimer } from './metrics';
|
|
13
|
+
import { startTrackTimer } from './track';
|
|
12
14
|
import { drainNativePending, markNativeJsBridgeReady, setNativeConfig } from './native';
|
|
13
15
|
import { getColdStartMs } from './mobile-vitals';
|
|
16
|
+
import { startHeartbeat, type HeartbeatOptions } from './heartbeat';
|
|
14
17
|
import { startSpan } from '@goliapkg/sentori-core';
|
|
15
18
|
import { startControlChannel } from './control-channel';
|
|
16
19
|
import { startLongTaskMonitor } from './long-task-monitor';
|
|
@@ -92,6 +95,11 @@ export type InitOptions = {
|
|
|
92
95
|
* longTaskMonitor (≥200ms outliers) — sample profiler 看 idle
|
|
93
96
|
* 分布、long-task 看 outliers。 */
|
|
94
97
|
sampleProfiler?: boolean | { flushMs?: number; sampleMs?: number };
|
|
98
|
+
/** Analytics v1 — live-presence heartbeat. Foreground 1/min
|
|
99
|
+
* default; opt out with `false`, or pass an options object
|
|
100
|
+
* to tune. Powers the Audience > Live dashboard.
|
|
101
|
+
* Iron-rule budget: < 1 KB / min, < 1 ms / call. */
|
|
102
|
+
heartbeat?: boolean | HeartbeatOptions;
|
|
95
103
|
/** v0.9.0 #3 — launch-crash loop guard. When two consecutive
|
|
96
104
|
* launches don't reach `markLaunchCompleted()` (typical of an
|
|
97
105
|
* OTA update with a fatal bug), invoke the host callback with
|
|
@@ -116,6 +124,9 @@ export type InitOptions = {
|
|
|
116
124
|
sampling?: {
|
|
117
125
|
errors?: null | number;
|
|
118
126
|
traces?: null | number;
|
|
127
|
+
/** v2.0 — sampling rate for `kind: 'message'` events emitted
|
|
128
|
+
* via `sentori.captureMessage()`. `null` / absent → keep all. */
|
|
129
|
+
messages?: null | number;
|
|
119
130
|
};
|
|
120
131
|
};
|
|
121
132
|
|
|
@@ -155,6 +166,7 @@ export const init = (options: InitOptions): void => {
|
|
|
155
166
|
screenshotsEnabled: options.capture?.screenshot === true,
|
|
156
167
|
errorSampleRate: options.sampling?.errors ?? null,
|
|
157
168
|
traceSampleRate: options.sampling?.traces ?? null,
|
|
169
|
+
messageSampleRate: options.sampling?.messages ?? null,
|
|
158
170
|
sessionTrailEnabled: options.capture?.sessionTrail === true,
|
|
159
171
|
});
|
|
160
172
|
|
|
@@ -193,6 +205,13 @@ export const init = (options: InitOptions): void => {
|
|
|
193
205
|
startNetworkTypeWatch();
|
|
194
206
|
// v0.8.3 — drain custom-metric ring every 30 s.
|
|
195
207
|
startMetricsTimer();
|
|
208
|
+
// v1.1 chunk B — drain `sentori.track()` ring every 30 s.
|
|
209
|
+
startTrackTimer();
|
|
210
|
+
// v1.1 chunk S1 — warm the install-id cache. Fire-and-forget;
|
|
211
|
+
// any event captured before the first resolve simply omits
|
|
212
|
+
// `device.installId`. Subsequent captures pick it up via the
|
|
213
|
+
// sync `peekInstallId()` read in collectDevice().
|
|
214
|
+
void getInstallId();
|
|
196
215
|
// v0.9.1 +S4 — pre-crash sentinel. Off by default; opt-in via
|
|
197
216
|
// `capture.preCrashSentinel: true`.
|
|
198
217
|
if (options.capture?.preCrashSentinel === true) {
|
|
@@ -240,6 +259,14 @@ export const init = (options: InitOptions): void => {
|
|
|
240
259
|
startSession();
|
|
241
260
|
installLifecycleHandler();
|
|
242
261
|
}
|
|
262
|
+
// Analytics v1 — live-presence heartbeat. Foreground 1/min by
|
|
263
|
+
// default; pass `capture.heartbeat = false` to opt out, or
|
|
264
|
+
// `{ intervalMs: 30_000 }` to override. The default budget is well
|
|
265
|
+
// under the "几乎不能造成性能抖动" rule (CLAUDE.md).
|
|
266
|
+
const heartbeatOpt = capture.heartbeat;
|
|
267
|
+
if (heartbeatOpt !== false) {
|
|
268
|
+
startHeartbeat(typeof heartbeatOpt === 'object' ? heartbeatOpt : {});
|
|
269
|
+
}
|
|
243
270
|
|
|
244
271
|
// Drain events persisted from previous session (best-effort):
|
|
245
272
|
// - native crashes from <Documents>/sentori/pending/*.json
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// v1.1 chunk S1 — stable per-install identifier.
|
|
2
|
+
//
|
|
3
|
+
// `getInstallId()` returns a UUIDv7 generated on first call and
|
|
4
|
+
// persisted to device storage. The id survives app restarts and, on
|
|
5
|
+
// iOS specifically, app reinstalls (because the Keychain backend is
|
|
6
|
+
// preserved across uninstall — that's the whole point).
|
|
7
|
+
//
|
|
8
|
+
// Storage tier order:
|
|
9
|
+
// 1. `react-native-keychain` (optional peer; iOS + Android Keystore)
|
|
10
|
+
// 2. AsyncStorage (host already needs this for launch-crash-guard /
|
|
11
|
+
// offline queue, so we don't add a hard new peer dep)
|
|
12
|
+
// 3. Process-memory only (no persistence — covers SSR / tests)
|
|
13
|
+
//
|
|
14
|
+
// Opaque to the host. The id is NOT tied to a `setUser` call; the
|
|
15
|
+
// server doesn't auto-correlate to user identity. It exists purely
|
|
16
|
+
// as a stable device key so the security posture engine (S3) can
|
|
17
|
+
// score per-install signals independently of authentication state.
|
|
18
|
+
|
|
19
|
+
import { uuidV7 } from '@goliapkg/sentori-core';
|
|
20
|
+
|
|
21
|
+
import { isAnyNativeModuleLinked } from './native-loader';
|
|
22
|
+
|
|
23
|
+
const KEYCHAIN_SERVICE = 'sentori.install-id';
|
|
24
|
+
const ASYNC_STORAGE_KEY = '@sentori/install-id';
|
|
25
|
+
|
|
26
|
+
type AsyncStorageLike = {
|
|
27
|
+
getItem: (key: string) => Promise<null | string>;
|
|
28
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type KeychainModule = {
|
|
32
|
+
getGenericPassword: (options: {
|
|
33
|
+
service: string;
|
|
34
|
+
}) => Promise<false | { password: string; username: string }>;
|
|
35
|
+
setGenericPassword: (
|
|
36
|
+
username: string,
|
|
37
|
+
password: string,
|
|
38
|
+
options: { service: string },
|
|
39
|
+
) => Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let _cached: null | string = null;
|
|
43
|
+
let _inflight: null | Promise<string> = null;
|
|
44
|
+
|
|
45
|
+
function loadKeychain(): KeychainModule | null {
|
|
46
|
+
// react-native-keychain is the recommended secure-storage peer.
|
|
47
|
+
// Optional: if the host hasn't installed it we fall through to
|
|
48
|
+
// AsyncStorage. The Keychain backend is what gives the iOS
|
|
49
|
+
// "survives reinstall" guarantee.
|
|
50
|
+
try {
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
52
|
+
const mod = require('react-native-keychain') as KeychainModule;
|
|
53
|
+
if (typeof mod.getGenericPassword !== 'function') return null;
|
|
54
|
+
return mod;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadAsyncStorage(): AsyncStorageLike | null {
|
|
61
|
+
if (!isAnyNativeModuleLinked(['RNCAsyncStorage', 'AsyncStorageModule'])) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
66
|
+
const mod = require('@react-native-async-storage/async-storage') as {
|
|
67
|
+
default?: AsyncStorageLike;
|
|
68
|
+
};
|
|
69
|
+
return mod.default ?? (mod as unknown as AsyncStorageLike);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the device's stable install id, generating one if absent.
|
|
77
|
+
* Cached in memory after first resolve so subsequent calls are sync-
|
|
78
|
+
* adjacent (no storage I/O). Idempotent under concurrent calls — a
|
|
79
|
+
* second caller during the first resolve awaits the same promise.
|
|
80
|
+
*/
|
|
81
|
+
export async function getInstallId(): Promise<string> {
|
|
82
|
+
if (_cached !== null) return _cached;
|
|
83
|
+
if (_inflight !== null) return _inflight;
|
|
84
|
+
_inflight = (async () => {
|
|
85
|
+
try {
|
|
86
|
+
const kc = loadKeychain();
|
|
87
|
+
if (kc) {
|
|
88
|
+
const existing = await kc
|
|
89
|
+
.getGenericPassword({ service: KEYCHAIN_SERVICE })
|
|
90
|
+
.catch(() => false as const);
|
|
91
|
+
if (existing && existing.password) {
|
|
92
|
+
_cached = existing.password;
|
|
93
|
+
return existing.password;
|
|
94
|
+
}
|
|
95
|
+
const fresh = uuidV7();
|
|
96
|
+
await kc
|
|
97
|
+
.setGenericPassword('sentori', fresh, { service: KEYCHAIN_SERVICE })
|
|
98
|
+
.catch(() => undefined);
|
|
99
|
+
_cached = fresh;
|
|
100
|
+
return fresh;
|
|
101
|
+
}
|
|
102
|
+
const storage = loadAsyncStorage();
|
|
103
|
+
if (storage) {
|
|
104
|
+
const existing = await storage
|
|
105
|
+
.getItem(ASYNC_STORAGE_KEY)
|
|
106
|
+
.catch(() => null);
|
|
107
|
+
if (existing) {
|
|
108
|
+
_cached = existing;
|
|
109
|
+
return existing;
|
|
110
|
+
}
|
|
111
|
+
const fresh = uuidV7();
|
|
112
|
+
await storage.setItem(ASYNC_STORAGE_KEY, fresh).catch(() => undefined);
|
|
113
|
+
_cached = fresh;
|
|
114
|
+
return fresh;
|
|
115
|
+
}
|
|
116
|
+
// No storage available — generate but don't persist. The id is
|
|
117
|
+
// still stable for the lifetime of the process which is good
|
|
118
|
+
// enough for tests / SSR / no-native-modules contexts.
|
|
119
|
+
const fresh = uuidV7();
|
|
120
|
+
_cached = fresh;
|
|
121
|
+
return fresh;
|
|
122
|
+
} finally {
|
|
123
|
+
_inflight = null;
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
return _inflight;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Sync read of the currently-cached install id. `null` before the
|
|
130
|
+
* first `getInstallId()` resolves. Use this in hot paths (event
|
|
131
|
+
* payload assembly) that can't await storage; callers should kick
|
|
132
|
+
* off `getInstallId()` once at startup to warm the cache. */
|
|
133
|
+
export function peekInstallId(): null | string {
|
|
134
|
+
return _cached;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function __resetInstallIdForTests(): void {
|
|
138
|
+
_cached = null;
|
|
139
|
+
_inflight = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** For tests + advanced operator flows that need to seed an id from
|
|
143
|
+
* an external secure-storage migration. */
|
|
144
|
+
export function __setInstallIdForTests(id: string): void {
|
|
145
|
+
_cached = id;
|
|
146
|
+
}
|