@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/lifecycle.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// v2.0 W3 — top-level lifecycle: flush / close.
|
|
2
|
+
//
|
|
3
|
+
// Sentry / OTel parity: a single `flush(timeoutMs?)` that drains
|
|
4
|
+
// every in-flight buffer (events / metrics / track), gated by a
|
|
5
|
+
// timeout so a slow network doesn't hang the host's shutdown path.
|
|
6
|
+
//
|
|
7
|
+
// Use before short-lived process exit (CLI / serverless function /
|
|
8
|
+
// fixture cleanup) to ensure pending captures land before the
|
|
9
|
+
// process dies.
|
|
10
|
+
|
|
11
|
+
import { flush as flushTransport } from './transport';
|
|
12
|
+
import { flushMetrics } from './metrics';
|
|
13
|
+
import { flushTrack } from './track';
|
|
14
|
+
import { reportInternal } from '@goliapkg/sentori-core';
|
|
15
|
+
|
|
16
|
+
let _closed = false;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Force-flush every pending Sentori buffer (events, metrics,
|
|
20
|
+
* track). Returns when the flush settles or the timeout fires —
|
|
21
|
+
* whichever happens first.
|
|
22
|
+
*
|
|
23
|
+
* Never rejects: per the NEVER rule, individual flush failures
|
|
24
|
+
* are silently absorbed (and self-reported via the internal
|
|
25
|
+
* circuit-breaker), and the resolved promise's value is undefined.
|
|
26
|
+
*
|
|
27
|
+
* await sentori.flush(5_000) // wait up to 5 s, then move on
|
|
28
|
+
* process.exit(0)
|
|
29
|
+
*/
|
|
30
|
+
export async function flush(timeoutMs: number = 5_000): Promise<void> {
|
|
31
|
+
if (_closed) return;
|
|
32
|
+
try {
|
|
33
|
+
const drainAll = Promise.all([
|
|
34
|
+
flushTransport().catch((err) => {
|
|
35
|
+
reportInternal('flush.transport', err);
|
|
36
|
+
}),
|
|
37
|
+
flushMetrics().catch((err) => {
|
|
38
|
+
reportInternal('flush.metrics', err);
|
|
39
|
+
}),
|
|
40
|
+
flushTrack().catch((err) => {
|
|
41
|
+
reportInternal('flush.track', err);
|
|
42
|
+
}),
|
|
43
|
+
]).then(() => undefined);
|
|
44
|
+
const timer = new Promise<void>((resolve) => setTimeout(resolve, timeoutMs));
|
|
45
|
+
await Promise.race([drainAll, timer]);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// Belt-and-braces: even though each branch already has its own
|
|
48
|
+
// catch, the wrapping race shouldn't be able to surface a
|
|
49
|
+
// failure to the host either. NEVER rule.
|
|
50
|
+
reportInternal('flush', err);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Flush + shut down. After `close()`, further capture* calls remain
|
|
56
|
+
* silent no-ops via `_closed` gate. Idempotent — re-calling is safe.
|
|
57
|
+
*
|
|
58
|
+
* await sentori.close()
|
|
59
|
+
* // SDK is asleep; no more events go out.
|
|
60
|
+
*/
|
|
61
|
+
export async function close(timeoutMs?: number): Promise<void> {
|
|
62
|
+
await flush(timeoutMs);
|
|
63
|
+
_closed = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Test hook — reset the shutdown latch so unit tests don't have to
|
|
67
|
+
* re-init the whole SDK between cases. */
|
|
68
|
+
export function __resetLifecycleForTests(): void {
|
|
69
|
+
_closed = false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Internal — capture* implementations check this to short-circuit
|
|
73
|
+
* after `close()`. */
|
|
74
|
+
export function isClosed(): boolean {
|
|
75
|
+
return _closed;
|
|
76
|
+
}
|
package/src/metrics.ts
CHANGED
|
@@ -27,16 +27,34 @@ const FLUSH_INTERVAL_MS = 30_000;
|
|
|
27
27
|
let _buf: Point[] = [];
|
|
28
28
|
let _timer: ReturnType<typeof setInterval> | null = null;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Record a numeric metric point. Cheap to call from a render hook —
|
|
32
|
+
* pushes into a 500-slot ring drained every 30 s (or on overflow).
|
|
33
|
+
*
|
|
34
|
+
* v2.0 — accepts an optional `parent: SpanContextLike` so a metric
|
|
35
|
+
* can be correlated to a span. The dashboard span-detail view joins
|
|
36
|
+
* metric points by `tags.span_id` / `tags.trace_id`.
|
|
37
|
+
*
|
|
38
|
+
* const span = sentori.startSpan({ name: 'db.query users' })
|
|
39
|
+
* sentori.recordMetric('db.query.duration_ms', 42, undefined, { parent: span })
|
|
40
|
+
* span.end({ status: 'ok' })
|
|
41
|
+
*/
|
|
30
42
|
export function recordMetric(
|
|
31
43
|
name: string,
|
|
32
44
|
value: number,
|
|
33
45
|
tags?: Record<string, string>,
|
|
46
|
+
opts?: { parent?: { spanId: string; traceId: string } },
|
|
34
47
|
): void {
|
|
35
48
|
if (!isInitialized()) return;
|
|
36
49
|
if (typeof name !== 'string' || name.length === 0 || name.length > 200) return;
|
|
37
50
|
if (typeof value !== 'number' || !Number.isFinite(value)) return;
|
|
38
51
|
if (tags && Object.keys(tags).length > 20) return;
|
|
39
|
-
|
|
52
|
+
// Merge span-context tags so the dashboard can join metric points
|
|
53
|
+
// to the span that produced them without a separate schema column.
|
|
54
|
+
const finalTags: Record<string, string> | undefined = opts?.parent
|
|
55
|
+
? { ...(tags ?? {}), span_id: opts.parent.spanId, trace_id: opts.parent.traceId }
|
|
56
|
+
: tags;
|
|
57
|
+
_buf.push({ name, tags: finalTags, ts: new Date().toISOString(), value });
|
|
40
58
|
if (_buf.length >= MAX_BUFFER) {
|
|
41
59
|
void flushMetrics();
|
|
42
60
|
}
|
package/src/navigation.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getNativeFrameCounters,
|
|
29
29
|
resetNativeFrameCounters,
|
|
30
30
|
} from './native';
|
|
31
|
+
import { track } from './track';
|
|
31
32
|
import { captureStep } from './trail';
|
|
32
33
|
|
|
33
34
|
/** Minimal contract: anything with `addListener('state', cb)` and
|
|
@@ -38,6 +39,17 @@ export type NavigationRefLike = {
|
|
|
38
39
|
getCurrentRoute: () => { name: string } | undefined;
|
|
39
40
|
};
|
|
40
41
|
|
|
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). */
|
|
47
|
+
let _lastRoute: null | string = null;
|
|
48
|
+
|
|
49
|
+
export function getLastRoute(): null | string {
|
|
50
|
+
return _lastRoute;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
/**
|
|
42
54
|
* Subscribe to react-navigation state changes and emit a
|
|
43
55
|
* `react.navigation` span per screen (including the initial one),
|
|
@@ -85,6 +97,7 @@ export function useTraceNavigation(navigationRef: NavigationRefLike): void {
|
|
|
85
97
|
openSpanRef.current = span;
|
|
86
98
|
setActiveSpan(span);
|
|
87
99
|
lastRouteRef.current = to;
|
|
100
|
+
_lastRoute = to;
|
|
88
101
|
lastRouteEnteredAtRef.current = Date.now();
|
|
89
102
|
// v0.8.0-b — dwell on the previous screen surfaces in the
|
|
90
103
|
// session trail. The leaving span's `durationMs` already
|
|
@@ -98,6 +111,19 @@ export function useTraceNavigation(navigationRef: NavigationRefLike): void {
|
|
|
98
111
|
type: 'navigation',
|
|
99
112
|
},
|
|
100
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 },
|
|
125
|
+
to,
|
|
126
|
+
);
|
|
101
127
|
};
|
|
102
128
|
|
|
103
129
|
const finishOpenSpanWithDwell = (): null | number => {
|
package/src/replay.ts
CHANGED
|
@@ -22,8 +22,13 @@ import { describeWireframeNative } from './native';
|
|
|
22
22
|
|
|
23
23
|
declare const __DEV__: boolean | undefined;
|
|
24
24
|
|
|
25
|
-
/** Default capture interval (
|
|
26
|
-
|
|
25
|
+
/** Default capture interval (2 Hz). Override via `replay.hz`. rc.10
|
|
26
|
+
* rolled this back from rc.9's 4 Hz default: iOS sim measured 1 ms
|
|
27
|
+
* per tick on a thin dev panel but extrapolation to a 200-node
|
|
28
|
+
* Insight-class UI on Android pushes JS-thread occupancy past 1 %,
|
|
29
|
+
* which violates the "几乎不能造成性能抖动" rule. Apps that want
|
|
30
|
+
* smoother playback motion can opt into `replay.hz: 4` explicitly. */
|
|
31
|
+
const TICK_INTERVAL_MS = 500;
|
|
27
32
|
|
|
28
33
|
/** How often to emit a fresh keyframe — caps reconstruction chain
|
|
29
34
|
* length and lets the player re-sync after a dropped line. */
|
|
@@ -79,7 +84,9 @@ let _nativeMod: ReplayNativeModule | null = null;
|
|
|
79
84
|
|
|
80
85
|
export type ReplayOptions = {
|
|
81
86
|
mode?: 'off' | 'wireframe';
|
|
82
|
-
/** Ticks per second. Default
|
|
87
|
+
/** Ticks per second. Default 2. Opt into 4 (or 8) for
|
|
88
|
+
* motion-heavy apps where playback smoothness matters more than
|
|
89
|
+
* the marginal CPU saving. */
|
|
83
90
|
hz?: number;
|
|
84
91
|
/** Keyframe cadence in ms. Default 4000. */
|
|
85
92
|
keyframeMs?: number;
|
|
@@ -111,7 +118,7 @@ export function startReplay(opts: ReplayOptions): void {
|
|
|
111
118
|
_running = true;
|
|
112
119
|
_nativeMod = loadNativeReplay();
|
|
113
120
|
_keyframeIntervalMs = opts.keyframeMs ?? KEYFRAME_INTERVAL_MS;
|
|
114
|
-
const hz = opts.hz ??
|
|
121
|
+
const hz = opts.hz ?? 2;
|
|
115
122
|
const period = Math.max(MIN_TICK_PERIOD_MS, Math.round(1000 / hz));
|
|
116
123
|
_timer = setInterval(() => {
|
|
117
124
|
captureTick();
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// v1.1 chunk S2 — security event reporting.
|
|
2
|
+
//
|
|
3
|
+
// `sentori.reportSecurity(kind, data)` POSTs a single security event
|
|
4
|
+
// to `/v1/security:report`. Helpers wrap common kinds with the right
|
|
5
|
+
// payload shape so dashboards can rely on it without coordinating
|
|
6
|
+
// schemas with every host app.
|
|
7
|
+
//
|
|
8
|
+
// Why a separate API from `captureException` / `track`: security
|
|
9
|
+
// reports have different retention + access patterns (the trust
|
|
10
|
+
// scoring engine in S3 reads them on a hot path) and conflating
|
|
11
|
+
// them with errors would pollute issue grouping. Single endpoint,
|
|
12
|
+
// no batching: pin mismatches and root-detection signals are
|
|
13
|
+
// low-volume by nature (one per app-lifetime in most cases).
|
|
14
|
+
|
|
15
|
+
import { getConfig, isInitialized } from './config';
|
|
16
|
+
import { peekInstallId } from './install-id';
|
|
17
|
+
import { getCurrentUserId } from './capture';
|
|
18
|
+
|
|
19
|
+
const SDK_VERSION = '0.0.0';
|
|
20
|
+
|
|
21
|
+
export type SecurityReportData = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Report an arbitrary security signal. Fire-and-forget; resolves
|
|
25
|
+
* with the server-assigned event id on success or `null` on any
|
|
26
|
+
* failure (network down, server unhappy, SDK not initialised).
|
|
27
|
+
*
|
|
28
|
+
* Use the dedicated helpers (`reportPinMismatch`, future
|
|
29
|
+
* `reportRootDetected`, …) when their shape applies — the dashboard
|
|
30
|
+
* renders kind-specific panels off the well-known kinds.
|
|
31
|
+
*/
|
|
32
|
+
export async function reportSecurity(
|
|
33
|
+
kind: string,
|
|
34
|
+
data: SecurityReportData = {},
|
|
35
|
+
): Promise<null | string> {
|
|
36
|
+
if (!isInitialized()) return null;
|
|
37
|
+
if (typeof kind !== 'string' || kind.length === 0 || kind.length > 100) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (Object.keys(data).length > 40) return null;
|
|
41
|
+
|
|
42
|
+
const config = getConfig();
|
|
43
|
+
if (!config) return null;
|
|
44
|
+
const body = {
|
|
45
|
+
kind,
|
|
46
|
+
data,
|
|
47
|
+
ts: new Date().toISOString(),
|
|
48
|
+
userId: getCurrentUserId(),
|
|
49
|
+
installId: peekInstallId() ?? undefined,
|
|
50
|
+
release: config.release,
|
|
51
|
+
environment: config.environment,
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
const resp = await fetch(`${config.ingestUrl}/v1/security:report`, {
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Bearer ${config.token}`,
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
'Sentori-Sdk': `react-native/${SDK_VERSION}`,
|
|
60
|
+
},
|
|
61
|
+
method: 'POST',
|
|
62
|
+
});
|
|
63
|
+
if (!resp.ok) return null;
|
|
64
|
+
const parsed = (await resp.json().catch(() => null)) as null | { id?: string };
|
|
65
|
+
return parsed?.id ?? null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* v1.1 chunk S4 — link the current device's user / install to a
|
|
73
|
+
* federated identity (e.g. a Google sub or Apple `sub`). Idempotent;
|
|
74
|
+
* call on every sign-in. Posts to `/v1/security/link`. The dashboard
|
|
75
|
+
* uses this to stitch the same user across projects in the Posture
|
|
76
|
+
* cross-project view.
|
|
77
|
+
*
|
|
78
|
+
* Privacy: only the opaque OAuth `subject` value travels. Never pass
|
|
79
|
+
* the email, display name, avatar, or any other identity attribute.
|
|
80
|
+
*/
|
|
81
|
+
export async function linkFederatedIdentity(args: {
|
|
82
|
+
provider: string;
|
|
83
|
+
subject: string;
|
|
84
|
+
userId?: string;
|
|
85
|
+
}): Promise<boolean> {
|
|
86
|
+
if (!args || typeof args.provider !== 'string' || args.provider.length === 0) return false;
|
|
87
|
+
if (typeof args.subject !== 'string' || args.subject.length === 0) return false;
|
|
88
|
+
if (!isInitialized()) return false;
|
|
89
|
+
const config = getConfig();
|
|
90
|
+
if (!config) return false;
|
|
91
|
+
const installId = peekInstallId() ?? undefined;
|
|
92
|
+
const body = {
|
|
93
|
+
provider: args.provider,
|
|
94
|
+
subject: args.subject,
|
|
95
|
+
userId: args.userId ?? getCurrentUserId(),
|
|
96
|
+
installId,
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
const resp = await fetch(`${config.ingestUrl}/v1/security/link`, {
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
headers: {
|
|
102
|
+
Authorization: `Bearer ${config.token}`,
|
|
103
|
+
'Content-Type': 'application/json',
|
|
104
|
+
'Sentori-Sdk': `react-native/${SDK_VERSION}`,
|
|
105
|
+
},
|
|
106
|
+
method: 'POST',
|
|
107
|
+
});
|
|
108
|
+
return resp.ok;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* TLS certificate pin mismatch — caller observed a server cert that
|
|
116
|
+
* didn't match the configured pin set. Posts `kind = 'pin.mismatch'`
|
|
117
|
+
* with the expected + observed pin (or hash) so the dashboard's
|
|
118
|
+
* Pin anomaly panel can cluster reports by server.
|
|
119
|
+
*/
|
|
120
|
+
export async function reportPinMismatch(args: {
|
|
121
|
+
expected: string;
|
|
122
|
+
observed: string;
|
|
123
|
+
/** Hostname the SDK was connecting to. Used by the dashboard to
|
|
124
|
+
* cluster reports per server. */
|
|
125
|
+
serverName: string;
|
|
126
|
+
}): Promise<null | string> {
|
|
127
|
+
if (!args || typeof args.serverName !== 'string' || args.serverName.length === 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// The serverName rides on the top-level envelope (column-typed on
|
|
131
|
+
// the server) so dashboard queries don't need to crack the data
|
|
132
|
+
// JSONB. We still echo it inside `data` for self-contained payloads.
|
|
133
|
+
if (!isInitialized()) return null;
|
|
134
|
+
const config = getConfig();
|
|
135
|
+
if (!config) return null;
|
|
136
|
+
const body = {
|
|
137
|
+
kind: 'pin.mismatch',
|
|
138
|
+
serverName: args.serverName,
|
|
139
|
+
ts: new Date().toISOString(),
|
|
140
|
+
userId: getCurrentUserId(),
|
|
141
|
+
installId: peekInstallId() ?? undefined,
|
|
142
|
+
release: config.release,
|
|
143
|
+
environment: config.environment,
|
|
144
|
+
data: {
|
|
145
|
+
expected: args.expected,
|
|
146
|
+
observed: args.observed,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
const resp = await fetch(`${config.ingestUrl}/v1/security:report`, {
|
|
151
|
+
body: JSON.stringify(body),
|
|
152
|
+
headers: {
|
|
153
|
+
Authorization: `Bearer ${config.token}`,
|
|
154
|
+
'Content-Type': 'application/json',
|
|
155
|
+
'Sentori-Sdk': `react-native/${SDK_VERSION}`,
|
|
156
|
+
},
|
|
157
|
+
method: 'POST',
|
|
158
|
+
});
|
|
159
|
+
if (!resp.ok) return null;
|
|
160
|
+
const parsed = (await resp.json().catch(() => null)) as null | { id?: string };
|
|
161
|
+
return parsed?.id ?? null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/track.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// v1.1 chunk B — analytics `track` events on the SDK side.
|
|
2
|
+
//
|
|
3
|
+
// `sentori.track(name, props?)` pushes a single typed analytics event
|
|
4
|
+
// into a fixed-size ring. A timer flushes the ring every 30 s (or
|
|
5
|
+
// when the buffer hits 500) to `POST /v1/track:batch`. Best-effort;
|
|
6
|
+
// a flush failure drops the batch — analytics aren't critical
|
|
7
|
+
// telemetry.
|
|
8
|
+
//
|
|
9
|
+
// Why not one fetch per event: a busy app emitting `$pageview` +
|
|
10
|
+
// custom funnel events every nav would saturate the JS thread and
|
|
11
|
+
// the host's outbound connection pool. The same batching shape the
|
|
12
|
+
// metrics module uses keeps `track()` cheap to call from render
|
|
13
|
+
// hooks.
|
|
14
|
+
//
|
|
15
|
+
// Auto-pageview (`$pageview`) is emitted from `useTraceNavigation`
|
|
16
|
+
// in navigation.ts whenever react-navigation swaps the active route.
|
|
17
|
+
// Hosts that don't use react-navigation can call
|
|
18
|
+
// `sentori.track('$pageview', { route: 'Cart' })` themselves.
|
|
19
|
+
|
|
20
|
+
import { getCurrentUserId } from './capture';
|
|
21
|
+
import { getConfig, isInitialized } from './config';
|
|
22
|
+
import { sendTrackBatch } from './transport';
|
|
23
|
+
|
|
24
|
+
export type TrackProps = Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
export type TrackEvent = {
|
|
27
|
+
environment?: string;
|
|
28
|
+
name: string;
|
|
29
|
+
props?: TrackProps;
|
|
30
|
+
release?: string;
|
|
31
|
+
route?: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
ts: string;
|
|
34
|
+
userId?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const MAX_BUFFER = 500;
|
|
38
|
+
const NAME_MAX = 200;
|
|
39
|
+
const PROPS_KEYS_MAX = 40;
|
|
40
|
+
const FLUSH_INTERVAL_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
let _buf: TrackEvent[] = [];
|
|
43
|
+
let _timer: null | ReturnType<typeof setInterval> = null;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Record a typed analytics event. Cheap to call from render hooks —
|
|
47
|
+
* pushes into a 500-slot ring drained every 30 s (or on overflow) by
|
|
48
|
+
* the transport flusher.
|
|
49
|
+
*
|
|
50
|
+
* Reserved names start with `$` (e.g. `$pageview`) and are emitted by
|
|
51
|
+
* the SDK itself; you can still call `track('$pageview', …)` from app
|
|
52
|
+
* code to backfill routes the auto-instrumentation missed.
|
|
53
|
+
*
|
|
54
|
+
* Server caps: name ≤ 200 chars, ≤ 40 prop keys. Calls exceeding the
|
|
55
|
+
* cap are dropped client-side (no throw) so app code can fire-and-
|
|
56
|
+
* forget without try/catch.
|
|
57
|
+
*/
|
|
58
|
+
export function track(name: string, props?: TrackProps, route?: string): void {
|
|
59
|
+
if (!isInitialized()) return;
|
|
60
|
+
if (typeof name !== 'string' || name.length === 0 || name.length > NAME_MAX) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (props && Object.keys(props).length > PROPS_KEYS_MAX) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const config = getConfig();
|
|
67
|
+
const ev: TrackEvent = {
|
|
68
|
+
name,
|
|
69
|
+
props,
|
|
70
|
+
release: config?.release,
|
|
71
|
+
environment: config?.environment,
|
|
72
|
+
route,
|
|
73
|
+
ts: new Date().toISOString(),
|
|
74
|
+
userId: getCurrentUserId(),
|
|
75
|
+
};
|
|
76
|
+
_buf.push(ev);
|
|
77
|
+
if (_buf.length >= MAX_BUFFER) {
|
|
78
|
+
void flushTrack();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function flushTrack(): Promise<void> {
|
|
83
|
+
if (_buf.length === 0) return;
|
|
84
|
+
const config = getConfig();
|
|
85
|
+
if (!config) return;
|
|
86
|
+
const batch = _buf;
|
|
87
|
+
_buf = [];
|
|
88
|
+
await sendTrackBatch(config.ingestUrl, config.token, batch);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Start the 30 s flush timer. Called once from `init()`. Idempotent.
|
|
93
|
+
* `__resetTrackForTests` is exposed for vitest / bun:test teardown.
|
|
94
|
+
*/
|
|
95
|
+
export function startTrackTimer(): void {
|
|
96
|
+
if (_timer !== null) return;
|
|
97
|
+
_timer = setInterval(() => {
|
|
98
|
+
void flushTrack();
|
|
99
|
+
}, FLUSH_INTERVAL_MS);
|
|
100
|
+
// Don't keep the process alive solely for this timer.
|
|
101
|
+
(_timer as unknown as { unref?: () => void }).unref?.();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function __peekTrackBuffer(): readonly TrackEvent[] {
|
|
105
|
+
return _buf;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function __resetTrackForTests(): void {
|
|
109
|
+
if (_timer !== null) {
|
|
110
|
+
clearInterval(_timer);
|
|
111
|
+
_timer = null;
|
|
112
|
+
}
|
|
113
|
+
_buf = [];
|
|
114
|
+
}
|
package/src/transport.ts
CHANGED
|
@@ -263,6 +263,41 @@ export const sendSessionPing = async (
|
|
|
263
263
|
}
|
|
264
264
|
};
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* v1.1 chunk B — flush a batched set of `sentori.track()` analytics
|
|
268
|
+
* events. The host SDK batches calls into a fixed-size ring drained
|
|
269
|
+
* every 30 s. Best-effort; a failure drops the batch.
|
|
270
|
+
*/
|
|
271
|
+
export const sendTrackBatch = async (
|
|
272
|
+
ingestUrl: string,
|
|
273
|
+
token: string,
|
|
274
|
+
events: Array<{
|
|
275
|
+
environment?: string;
|
|
276
|
+
name: string;
|
|
277
|
+
props?: Record<string, unknown>;
|
|
278
|
+
release?: string;
|
|
279
|
+
route?: string;
|
|
280
|
+
sessionId?: string;
|
|
281
|
+
ts: string;
|
|
282
|
+
userId?: string;
|
|
283
|
+
}>,
|
|
284
|
+
): Promise<void> => {
|
|
285
|
+
if (events.length === 0) return;
|
|
286
|
+
try {
|
|
287
|
+
await fetch(`${ingestUrl}/v1/track:batch`, {
|
|
288
|
+
body: JSON.stringify({ events }),
|
|
289
|
+
headers: {
|
|
290
|
+
Authorization: `Bearer ${token}`,
|
|
291
|
+
'Content-Type': 'application/json',
|
|
292
|
+
'Sentori-Sdk': `react-native/${SDK_VERSION}`,
|
|
293
|
+
},
|
|
294
|
+
method: 'POST',
|
|
295
|
+
});
|
|
296
|
+
} catch {
|
|
297
|
+
// best-effort
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
266
301
|
/**
|
|
267
302
|
* v0.8.3 — flush a batched set of custom metrics. The host SDK
|
|
268
303
|
* batches recordMetric() calls into a fixed-size ring (drained on
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// v1.1 chunk S3 — `sentori.queryTrustScore()` for the host app.
|
|
2
|
+
//
|
|
3
|
+
// Returns the device's current trust score (0–100, higher = healthier)
|
|
4
|
+
// plus the per-kind signal mix that produced it. The host app uses
|
|
5
|
+
// this to decide whether to step up authentication, decline a
|
|
6
|
+
// purchase, or simply log a soft warning.
|
|
7
|
+
//
|
|
8
|
+
// Caching:
|
|
9
|
+
// L1 — process-memory, ~30 s. Same process should not re-fetch on
|
|
10
|
+
// every screen render; the score moves on a "minutes" cadence.
|
|
11
|
+
// L2 — install-id-derived AsyncStorage entry, ~5 minutes. Lets the
|
|
12
|
+
// host paint a cached score on cold start without waiting for
|
|
13
|
+
// the network. Stale entries are still served; the SDK
|
|
14
|
+
// re-fetches in the background.
|
|
15
|
+
//
|
|
16
|
+
// Fail-soft posture: if the network's down or the SDK isn't
|
|
17
|
+
// initialised, returns the cached value (if any), else the safe
|
|
18
|
+
// default (score = 100). The host should never see a thrown error
|
|
19
|
+
// from this call — the score system is supposed to be invisible.
|
|
20
|
+
|
|
21
|
+
import { isAnyNativeModuleLinked } from './native-loader';
|
|
22
|
+
import { getConfig, isInitialized } from './config';
|
|
23
|
+
import { getInstallId } from './install-id';
|
|
24
|
+
|
|
25
|
+
const SDK_VERSION = '0.0.0';
|
|
26
|
+
const STORAGE_KEY = '@sentori/trust-score';
|
|
27
|
+
const L1_TTL_MS = 30_000;
|
|
28
|
+
const L2_TTL_MS = 5 * 60_000;
|
|
29
|
+
|
|
30
|
+
export type TrustSignal = {
|
|
31
|
+
count: number;
|
|
32
|
+
kind: string;
|
|
33
|
+
weight: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TrustScore = {
|
|
37
|
+
computedAt: string;
|
|
38
|
+
installId: string;
|
|
39
|
+
score: number;
|
|
40
|
+
signals: TrustSignal[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type CacheEntry = { fetchedAt: number; score: TrustScore };
|
|
44
|
+
|
|
45
|
+
type AsyncStorageLike = {
|
|
46
|
+
getItem: (key: string) => Promise<null | string>;
|
|
47
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let _l1: null | CacheEntry = null;
|
|
51
|
+
let _inflight: null | Promise<TrustScore> = null;
|
|
52
|
+
|
|
53
|
+
function loadAsyncStorage(): AsyncStorageLike | null {
|
|
54
|
+
if (!isAnyNativeModuleLinked(['RNCAsyncStorage', 'AsyncStorageModule'])) return null;
|
|
55
|
+
try {
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
57
|
+
const mod = require('@react-native-async-storage/async-storage') as {
|
|
58
|
+
default?: AsyncStorageLike;
|
|
59
|
+
};
|
|
60
|
+
return mod.default ?? (mod as unknown as AsyncStorageLike);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function safeDefault(installId: string): TrustScore {
|
|
67
|
+
return {
|
|
68
|
+
computedAt: new Date().toISOString(),
|
|
69
|
+
installId,
|
|
70
|
+
score: 100,
|
|
71
|
+
signals: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readL2(): Promise<null | CacheEntry> {
|
|
76
|
+
const storage = loadAsyncStorage();
|
|
77
|
+
if (!storage) return null;
|
|
78
|
+
try {
|
|
79
|
+
const raw = await storage.getItem(STORAGE_KEY);
|
|
80
|
+
if (!raw) return null;
|
|
81
|
+
const parsed = JSON.parse(raw) as CacheEntry;
|
|
82
|
+
if (
|
|
83
|
+
typeof parsed?.fetchedAt !== 'number' ||
|
|
84
|
+
typeof parsed?.score?.score !== 'number'
|
|
85
|
+
) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return parsed;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function writeL2(entry: CacheEntry): Promise<void> {
|
|
95
|
+
const storage = loadAsyncStorage();
|
|
96
|
+
if (!storage) return;
|
|
97
|
+
try {
|
|
98
|
+
await storage.setItem(STORAGE_KEY, JSON.stringify(entry));
|
|
99
|
+
} catch {
|
|
100
|
+
// best-effort
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function fetchFresh(installId: string): Promise<TrustScore> {
|
|
105
|
+
const config = getConfig();
|
|
106
|
+
if (!config) return safeDefault(installId);
|
|
107
|
+
try {
|
|
108
|
+
const resp = await fetch(
|
|
109
|
+
`${config.ingestUrl}/v1/security/score?installId=${encodeURIComponent(installId)}`,
|
|
110
|
+
{
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${config.token}`,
|
|
113
|
+
'Sentori-Sdk': `react-native/${SDK_VERSION}`,
|
|
114
|
+
},
|
|
115
|
+
method: 'GET',
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
if (!resp.ok) return safeDefault(installId);
|
|
119
|
+
const parsed = (await resp.json()) as TrustScore;
|
|
120
|
+
return parsed;
|
|
121
|
+
} catch {
|
|
122
|
+
return safeDefault(installId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the device's trust score. Always resolves — never throws,
|
|
128
|
+
* never rejects. The two-layer cache means the first call after cold
|
|
129
|
+
* start usually serves a < 1 ms result while a background refresh
|
|
130
|
+
* keeps the value current.
|
|
131
|
+
*/
|
|
132
|
+
export async function queryTrustScore(): Promise<TrustScore> {
|
|
133
|
+
if (!isInitialized()) {
|
|
134
|
+
const id = await getInstallId();
|
|
135
|
+
return safeDefault(id);
|
|
136
|
+
}
|
|
137
|
+
// L1 hit
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
if (_l1 && now - _l1.fetchedAt < L1_TTL_MS) {
|
|
140
|
+
return _l1.score;
|
|
141
|
+
}
|
|
142
|
+
// Coalesce concurrent calls onto the same inflight fetch.
|
|
143
|
+
if (_inflight) return _inflight;
|
|
144
|
+
|
|
145
|
+
_inflight = (async () => {
|
|
146
|
+
try {
|
|
147
|
+
const installId = await getInstallId();
|
|
148
|
+
// L2 hit — serve immediately, kick off background refresh
|
|
149
|
+
const l2 = await readL2();
|
|
150
|
+
if (l2 && now - l2.fetchedAt < L2_TTL_MS && l2.score.installId === installId) {
|
|
151
|
+
_l1 = l2;
|
|
152
|
+
// background refresh (don't block caller)
|
|
153
|
+
void (async () => {
|
|
154
|
+
const fresh = await fetchFresh(installId);
|
|
155
|
+
const entry: CacheEntry = { fetchedAt: Date.now(), score: fresh };
|
|
156
|
+
_l1 = entry;
|
|
157
|
+
await writeL2(entry);
|
|
158
|
+
})();
|
|
159
|
+
return l2.score;
|
|
160
|
+
}
|
|
161
|
+
const fresh = await fetchFresh(installId);
|
|
162
|
+
const entry: CacheEntry = { fetchedAt: Date.now(), score: fresh };
|
|
163
|
+
_l1 = entry;
|
|
164
|
+
await writeL2(entry);
|
|
165
|
+
return fresh;
|
|
166
|
+
} finally {
|
|
167
|
+
_inflight = null;
|
|
168
|
+
}
|
|
169
|
+
})();
|
|
170
|
+
return _inflight;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function __resetTrustScoreForTests(): void {
|
|
174
|
+
_l1 = null;
|
|
175
|
+
_inflight = null;
|
|
176
|
+
}
|