@goliapkg/sentori-react-native 1.0.0-rc.8 → 1.0.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.map +1 -1
- package/lib/capture.js +9 -0
- package/lib/capture.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 +15 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +15 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +6 -0
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +18 -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/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 +27 -9
- package/lib/replay.d.ts.map +1 -1
- package/lib/replay.js +209 -167
- 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 +6 -2
- package/src/__tests__/install-id.test.ts +60 -0
- package/src/__tests__/replay-encoding.test.ts +237 -0
- package/src/__tests__/report-security.test.ts +106 -0
- package/src/__tests__/track.test.ts +91 -0
- package/src/capture.ts +8 -0
- package/src/heartbeat.ts +158 -0
- package/src/index.ts +24 -0
- package/src/init.ts +23 -0
- package/src/install-id.ts +146 -0
- package/src/navigation.ts +26 -0
- package/src/replay.ts +258 -176
- 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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// v1.1 chunk S1 — install-id module persistence + race-safety.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
afterEach,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
} from 'bun:test';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
__resetInstallIdForTests,
|
|
13
|
+
getInstallId,
|
|
14
|
+
peekInstallId,
|
|
15
|
+
} from '../install-id';
|
|
16
|
+
|
|
17
|
+
describe('install-id', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
__resetInstallIdForTests();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
__resetInstallIdForTests();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('peekInstallId is null before first resolve', () => {
|
|
27
|
+
expect(peekInstallId()).toBe(null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('getInstallId generates a stable id and caches it', async () => {
|
|
31
|
+
const first = await getInstallId();
|
|
32
|
+
expect(typeof first).toBe('string');
|
|
33
|
+
expect(first.length).toBeGreaterThan(10);
|
|
34
|
+
// sync peek after resolve returns the same value
|
|
35
|
+
expect(peekInstallId()).toBe(first);
|
|
36
|
+
// second call returns the cached value
|
|
37
|
+
expect(await getInstallId()).toBe(first);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('concurrent callers share the same resolve promise', async () => {
|
|
41
|
+
const [a, b, c] = await Promise.all([
|
|
42
|
+
getInstallId(),
|
|
43
|
+
getInstallId(),
|
|
44
|
+
getInstallId(),
|
|
45
|
+
]);
|
|
46
|
+
expect(a).toBe(b);
|
|
47
|
+
expect(b).toBe(c);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('produces UUIDv7 shape (variant + version nibble)', async () => {
|
|
51
|
+
const id = await getInstallId();
|
|
52
|
+
// RFC 4122 dash positions
|
|
53
|
+
expect(id[8]).toBe('-');
|
|
54
|
+
expect(id[13]).toBe('-');
|
|
55
|
+
expect(id[14]).toBe('7'); // version
|
|
56
|
+
expect(id[18]).toBe('-');
|
|
57
|
+
// variant nibble is 8|9|a|b (high 2 bits = 10)
|
|
58
|
+
expect(['8', '9', 'a', 'b']).toContain(id[19]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
__feedTickForTests,
|
|
5
|
+
__resetReplayForTests,
|
|
6
|
+
computeDelta,
|
|
7
|
+
drainReplay,
|
|
8
|
+
} from '../replay';
|
|
9
|
+
|
|
10
|
+
// Unit coverage for the rc.9 v2 replay encoder.
|
|
11
|
+
//
|
|
12
|
+
// The encoder lives in replay.ts; native module is mocked away via
|
|
13
|
+
// __feedTickForTests so we drive the captureTick body with hand-built
|
|
14
|
+
// snapshots and inspect the resulting NDJSON shape.
|
|
15
|
+
//
|
|
16
|
+
// See docs/replay-encoding-v2.md for the wire schema.
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
__resetReplayForTests();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
type Node = {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
w: number;
|
|
26
|
+
h: number;
|
|
27
|
+
kind?: string;
|
|
28
|
+
text?: string;
|
|
29
|
+
color?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function frame(ts: number, nodes: Node[]) {
|
|
33
|
+
return JSON.stringify({ ts, width: 1080, height: 2340, nodes });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nodeRect(x: number, y: number, w: number, h: number, color = '#FF0000FF'): Node {
|
|
37
|
+
return { x, y, w, h, kind: 'rect', color };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nodeText(x: number, y: number, w: number, h: number, text: string, color = '#FFFFFFFF'): Node {
|
|
41
|
+
return { x, y, w, h, kind: 'text', text, color };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readDrainedLines(): unknown[] {
|
|
45
|
+
const drained = drainReplay();
|
|
46
|
+
if (drained.length === 0) return [];
|
|
47
|
+
return drained.split('\n').map((l) => JSON.parse(l) as unknown);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('rc.9 encoder — keyframe vs delta', () => {
|
|
51
|
+
test('first tick after start always emits a keyframe', () => {
|
|
52
|
+
__feedTickForTests(frame(1_000, [nodeRect(0, 0, 1080, 2340)]));
|
|
53
|
+
const lines = readDrainedLines();
|
|
54
|
+
expect(lines.length).toBe(1);
|
|
55
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
56
|
+
expect((lines[0] as { width: number }).width).toBe(1080);
|
|
57
|
+
expect((lines[0] as { nodes: Node[] }).nodes.length).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('static UI ticks emit no line (no-op heartbeat dropped)', () => {
|
|
61
|
+
const nodes = [nodeRect(0, 0, 1080, 2340, '#000000FF')];
|
|
62
|
+
__feedTickForTests(frame(1_000, nodes));
|
|
63
|
+
__feedTickForTests(frame(1_250, nodes));
|
|
64
|
+
__feedTickForTests(frame(1_500, nodes));
|
|
65
|
+
__feedTickForTests(frame(1_750, nodes));
|
|
66
|
+
const lines = readDrainedLines();
|
|
67
|
+
// 1 keyframe + 0 deltas (all subsequent ticks are no-op)
|
|
68
|
+
expect(lines.length).toBe(1);
|
|
69
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a single node change emits exactly one delta with the changed node', () => {
|
|
73
|
+
const baseline = [
|
|
74
|
+
nodeRect(0, 0, 1080, 2340, '#0E0E10FF'),
|
|
75
|
+
nodeText(60, 192, 960, 112, 'before'),
|
|
76
|
+
];
|
|
77
|
+
const after = [
|
|
78
|
+
nodeRect(0, 0, 1080, 2340, '#0E0E10FF'),
|
|
79
|
+
nodeText(60, 192, 960, 112, 'after'),
|
|
80
|
+
];
|
|
81
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
82
|
+
__feedTickForTests(frame(1_250, after));
|
|
83
|
+
const lines = readDrainedLines();
|
|
84
|
+
expect(lines.length).toBe(2);
|
|
85
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
86
|
+
expect((lines[1] as { kind: string }).kind).toBe('delta');
|
|
87
|
+
const d = lines[1] as { added: Node[]; changed: Node[]; removed: Node[] };
|
|
88
|
+
expect(d.added.length).toBe(0);
|
|
89
|
+
expect(d.removed.length).toBe(0);
|
|
90
|
+
expect(d.changed.length).toBe(1);
|
|
91
|
+
expect((d.changed[0] as Node).text).toBe('after');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('added + removed nodes both appear in the delta', () => {
|
|
95
|
+
const baseline = [nodeRect(0, 0, 1080, 100), nodeRect(0, 200, 1080, 100)];
|
|
96
|
+
const after = [nodeRect(0, 0, 1080, 100), nodeRect(0, 400, 1080, 100)];
|
|
97
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
98
|
+
__feedTickForTests(frame(1_250, after));
|
|
99
|
+
const lines = readDrainedLines();
|
|
100
|
+
expect(lines.length).toBe(2);
|
|
101
|
+
const d = lines[1] as { added: Node[]; changed: Node[]; removed: Node[] };
|
|
102
|
+
expect(d.added.length).toBe(1);
|
|
103
|
+
expect((d.added[0] as Node).y).toBe(400);
|
|
104
|
+
expect(d.removed.length).toBe(1);
|
|
105
|
+
expect((d.removed[0] as Node).y).toBe(200);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('keyframe overdue ⇒ next emit is a keyframe even when delta is small', () => {
|
|
109
|
+
const baseline = [nodeRect(0, 0, 1080, 2340), nodeText(60, 192, 960, 112, 'a')];
|
|
110
|
+
// After 4 s (default keyframeMs), the next change triggers a fresh keyframe.
|
|
111
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
112
|
+
const slightlyChanged = [
|
|
113
|
+
nodeRect(0, 0, 1080, 2340),
|
|
114
|
+
nodeText(60, 192, 960, 112, 'a-changed'),
|
|
115
|
+
];
|
|
116
|
+
__feedTickForTests(frame(1_000 + 4_001, slightlyChanged));
|
|
117
|
+
const lines = readDrainedLines();
|
|
118
|
+
expect(lines.length).toBe(2);
|
|
119
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
120
|
+
expect((lines[1] as { kind: string }).kind).toBe('key'); // overdue → key
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('big screen transition (>40% nodes change) prefers a keyframe over a huge delta', () => {
|
|
124
|
+
const baseline = Array.from({ length: 10 }, (_, i) => nodeRect(0, i * 100, 1080, 90));
|
|
125
|
+
const after = Array.from({ length: 10 }, (_, i) => nodeRect(0, i * 100 + 500, 1080, 90));
|
|
126
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
127
|
+
__feedTickForTests(frame(1_250, after));
|
|
128
|
+
const lines = readDrainedLines();
|
|
129
|
+
expect(lines.length).toBe(2);
|
|
130
|
+
expect((lines[0] as { kind: string }).kind).toBe('key');
|
|
131
|
+
// Delta would be 10 added + 10 removed = 20 changes, > 10 * 0.4 threshold,
|
|
132
|
+
// so encoder should fall back to keyframe instead.
|
|
133
|
+
expect((lines[1] as { kind: string }).kind).toBe('key');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('reconstructing keyframe + applied deltas recovers identical final state', () => {
|
|
137
|
+
// Drive 6 ticks: keyframe + 5 small deltas, then assert reconstruction equals the last input state.
|
|
138
|
+
const finalNodes: Node[] = [];
|
|
139
|
+
const baseline = [nodeRect(0, 0, 1080, 2340, '#0E0E10FF')];
|
|
140
|
+
__feedTickForTests(frame(1_000, baseline));
|
|
141
|
+
let state: Node[] = [...baseline];
|
|
142
|
+
for (let i = 0; i < 5; i++) {
|
|
143
|
+
// Add one row per tick.
|
|
144
|
+
const row = nodeText(60, 200 + i * 100, 960, 80, `row ${i}`);
|
|
145
|
+
state = [...state, row];
|
|
146
|
+
__feedTickForTests(frame(1_000 + (i + 1) * 250, state));
|
|
147
|
+
}
|
|
148
|
+
finalNodes.push(...state);
|
|
149
|
+
|
|
150
|
+
const lines = readDrainedLines();
|
|
151
|
+
expect(lines.length).toBe(6);
|
|
152
|
+
// Reconstruct: start from keyframe, apply each delta in order.
|
|
153
|
+
let reconstructed = new Map<string, Node>();
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
const l = line as { kind: string; nodes?: Node[]; added?: Node[]; changed?: Node[]; removed?: Pick<Node, 'x' | 'y' | 'w' | 'h'>[] };
|
|
156
|
+
if (l.kind === 'key') {
|
|
157
|
+
reconstructed = new Map((l.nodes ?? []).map((n) => [`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`, n]));
|
|
158
|
+
} else {
|
|
159
|
+
for (const r of l.removed ?? []) {
|
|
160
|
+
reconstructed.delete(`${r.x | 0},${r.y | 0},${r.w | 0},${r.h | 0}`);
|
|
161
|
+
}
|
|
162
|
+
for (const a of l.added ?? []) {
|
|
163
|
+
reconstructed.set(`${a.x | 0},${a.y | 0},${a.w | 0},${a.h | 0}`, a);
|
|
164
|
+
}
|
|
165
|
+
for (const c of l.changed ?? []) {
|
|
166
|
+
reconstructed.set(`${c.x | 0},${c.y | 0},${c.w | 0},${c.h | 0}`, c);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
expect(reconstructed.size).toBe(finalNodes.length);
|
|
171
|
+
for (const n of finalNodes) {
|
|
172
|
+
const got = reconstructed.get(`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`);
|
|
173
|
+
expect(got).toBeDefined();
|
|
174
|
+
expect(got!.kind).toBe(n.kind);
|
|
175
|
+
expect(got!.text).toBe(n.text);
|
|
176
|
+
expect(got!.color).toBe(n.color);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('byte budget — 60 s × 4 Hz dense UI stays below the rc.8 baseline', () => {
|
|
181
|
+
// Simulate 60 s at 4 Hz = 240 ticks. Dense UI: 100 nodes, 2 nodes
|
|
182
|
+
// change per tick on average. This mirrors the typical
|
|
183
|
+
// mostly-static-with-small-animations app shape.
|
|
184
|
+
const baseNodes = Array.from({ length: 100 }, (_, i) => nodeRect(0, i * 24, 1080, 20));
|
|
185
|
+
let totalBytes = 0;
|
|
186
|
+
const tickCount = 240;
|
|
187
|
+
for (let t = 0; t < tickCount; t++) {
|
|
188
|
+
// Every 4th tick mutates two nodes.
|
|
189
|
+
const nodes = baseNodes.map((n, i) => {
|
|
190
|
+
if (t % 4 === 0 && (i === t % 100 || i === (t + 50) % 100)) {
|
|
191
|
+
return { ...n, color: t & 1 ? '#FF0000FF' : '#00FF00FF' };
|
|
192
|
+
}
|
|
193
|
+
return n;
|
|
194
|
+
});
|
|
195
|
+
__feedTickForTests(frame(1_000 + t * 250, nodes));
|
|
196
|
+
}
|
|
197
|
+
const drained = drainReplay();
|
|
198
|
+
totalBytes = drained.length;
|
|
199
|
+
|
|
200
|
+
// rc.8 baseline for the same window: 60 × 100 nodes × ~50 bytes/node ≈ 300 KB.
|
|
201
|
+
// rc.9 target: well under 200 KB.
|
|
202
|
+
expect(totalBytes).toBeLessThan(200_000);
|
|
203
|
+
// Sanity: not zero, and dominated by keyframes.
|
|
204
|
+
expect(totalBytes).toBeGreaterThan(10_000);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('computeDelta', () => {
|
|
209
|
+
function asMap(nodes: Node[]): Map<string, Node> {
|
|
210
|
+
return new Map(nodes.map((n) => [`${n.x | 0},${n.y | 0},${n.w | 0},${n.h | 0}`, n]));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
test('empty-vs-empty produces all-empty delta', () => {
|
|
214
|
+
const d = computeDelta(new Map(), new Map());
|
|
215
|
+
expect(d.added.length).toBe(0);
|
|
216
|
+
expect(d.changed.length).toBe(0);
|
|
217
|
+
expect(d.removed.length).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('identical state produces no delta', () => {
|
|
221
|
+
const m = asMap([nodeRect(0, 0, 10, 10), nodeRect(0, 10, 10, 10)]);
|
|
222
|
+
const d = computeDelta(m, m);
|
|
223
|
+
expect(d.added.length).toBe(0);
|
|
224
|
+
expect(d.changed.length).toBe(0);
|
|
225
|
+
expect(d.removed.length).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('integer-rounds fingerprints so sub-pixel jitter does not register', () => {
|
|
229
|
+
const prev = asMap([{ x: 0.1, y: 0.2, w: 10.3, h: 10.4, kind: 'rect' }]);
|
|
230
|
+
const curr = asMap([{ x: 0.4, y: 0.3, w: 10.2, h: 10.1, kind: 'rect' }]);
|
|
231
|
+
// Fingerprint of both is '0,0,10,10' → matched, no diff fields differ.
|
|
232
|
+
const d = computeDelta(prev, curr);
|
|
233
|
+
expect(d.added.length).toBe(0);
|
|
234
|
+
expect(d.changed.length).toBe(0);
|
|
235
|
+
expect(d.removed.length).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// v1.1 chunk S2 — reportSecurity + reportPinMismatch round-trip.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
afterEach,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
mock,
|
|
10
|
+
} from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import { __resetForTests as resetConfig, setConfig } from '../config';
|
|
13
|
+
import { __resetInstallIdForTests, __setInstallIdForTests } from '../install-id';
|
|
14
|
+
import { setUser } from '../capture';
|
|
15
|
+
import { reportPinMismatch, reportSecurity } from '../report-security';
|
|
16
|
+
|
|
17
|
+
const originalFetch = globalThis.fetch;
|
|
18
|
+
|
|
19
|
+
describe('reportSecurity', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
resetConfig();
|
|
22
|
+
__resetInstallIdForTests();
|
|
23
|
+
setConfig({
|
|
24
|
+
token: 'st_pk_test',
|
|
25
|
+
release: 'app@1.0.0+1',
|
|
26
|
+
environment: 'test',
|
|
27
|
+
ingestUrl: 'http://localhost:8080',
|
|
28
|
+
enabled: true,
|
|
29
|
+
});
|
|
30
|
+
setUser({ id: 'u_demo' });
|
|
31
|
+
__setInstallIdForTests('install-abc');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
globalThis.fetch = originalFetch;
|
|
36
|
+
setUser(null);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('posts the report envelope and returns the server id', async () => {
|
|
40
|
+
const calls: { body: string; url: string }[] = [];
|
|
41
|
+
globalThis.fetch = mock(async (url: unknown, init: unknown) => {
|
|
42
|
+
calls.push({
|
|
43
|
+
body: String((init as { body?: unknown })?.body ?? ''),
|
|
44
|
+
url: String(url),
|
|
45
|
+
});
|
|
46
|
+
return new Response(JSON.stringify({ id: 'sec-1' }), { status: 202 });
|
|
47
|
+
}) as unknown as typeof fetch;
|
|
48
|
+
|
|
49
|
+
const id = await reportSecurity('root.detected', { detector: 'rootbeer' });
|
|
50
|
+
expect(id).toBe('sec-1');
|
|
51
|
+
expect(calls.length).toBe(1);
|
|
52
|
+
expect(calls[0].url).toBe('http://localhost:8080/v1/security:report');
|
|
53
|
+
const parsed = JSON.parse(calls[0].body) as {
|
|
54
|
+
data: Record<string, unknown>;
|
|
55
|
+
installId: string;
|
|
56
|
+
kind: string;
|
|
57
|
+
release: string;
|
|
58
|
+
userId: string;
|
|
59
|
+
};
|
|
60
|
+
expect(parsed.kind).toBe('root.detected');
|
|
61
|
+
expect(parsed.data.detector).toBe('rootbeer');
|
|
62
|
+
expect(parsed.installId).toBe('install-abc');
|
|
63
|
+
expect(parsed.userId).toBe('u_demo');
|
|
64
|
+
expect(parsed.release).toBe('app@1.0.0+1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reportPinMismatch flattens to pin.mismatch with serverName', async () => {
|
|
68
|
+
const calls: { body: string }[] = [];
|
|
69
|
+
globalThis.fetch = mock(async (_url: unknown, init: unknown) => {
|
|
70
|
+
calls.push({ body: String((init as { body?: unknown })?.body ?? '') });
|
|
71
|
+
return new Response(JSON.stringify({ id: 'sec-2' }), { status: 202 });
|
|
72
|
+
}) as unknown as typeof fetch;
|
|
73
|
+
|
|
74
|
+
const id = await reportPinMismatch({
|
|
75
|
+
expected: 'sha256/AAAA',
|
|
76
|
+
observed: 'sha256/BBBB',
|
|
77
|
+
serverName: 'api.example.com',
|
|
78
|
+
});
|
|
79
|
+
expect(id).toBe('sec-2');
|
|
80
|
+
const parsed = JSON.parse(calls[0].body) as {
|
|
81
|
+
data: { expected: string; observed: string };
|
|
82
|
+
kind: string;
|
|
83
|
+
serverName: string;
|
|
84
|
+
};
|
|
85
|
+
expect(parsed.kind).toBe('pin.mismatch');
|
|
86
|
+
expect(parsed.serverName).toBe('api.example.com');
|
|
87
|
+
expect(parsed.data.expected).toBe('sha256/AAAA');
|
|
88
|
+
expect(parsed.data.observed).toBe('sha256/BBBB');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns null on transport failure rather than throwing', async () => {
|
|
92
|
+
globalThis.fetch = mock(async () => {
|
|
93
|
+
throw new Error('offline');
|
|
94
|
+
}) as unknown as typeof fetch;
|
|
95
|
+
|
|
96
|
+
const id = await reportSecurity('arbitrary.kind');
|
|
97
|
+
expect(id).toBe(null);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('drops bad inputs silently', async () => {
|
|
101
|
+
const id1 = await reportSecurity('');
|
|
102
|
+
const id2 = await reportPinMismatch({ expected: 'a', observed: 'b', serverName: '' });
|
|
103
|
+
expect(id1).toBe(null);
|
|
104
|
+
expect(id2).toBe(null);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// v1.1 chunk B — analytics `track` buffer + flush.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
afterEach,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
mock,
|
|
10
|
+
} from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import { __resetForTests as resetConfig, setConfig } from '../config';
|
|
13
|
+
import { setUser } from '../capture';
|
|
14
|
+
import {
|
|
15
|
+
__peekTrackBuffer,
|
|
16
|
+
__resetTrackForTests,
|
|
17
|
+
flushTrack,
|
|
18
|
+
track,
|
|
19
|
+
} from '../track';
|
|
20
|
+
|
|
21
|
+
const originalFetch = globalThis.fetch;
|
|
22
|
+
|
|
23
|
+
describe('track buffer', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
resetConfig();
|
|
26
|
+
__resetTrackForTests();
|
|
27
|
+
setConfig({
|
|
28
|
+
token: 'st_pk_test',
|
|
29
|
+
release: 'app@1.0.0+1',
|
|
30
|
+
environment: 'test',
|
|
31
|
+
ingestUrl: 'http://localhost:8080',
|
|
32
|
+
enabled: true,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
globalThis.fetch = originalFetch;
|
|
38
|
+
setUser(null);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('buffers calls and tags them with release + environment', () => {
|
|
42
|
+
track('checkout.started', { cart: 42 });
|
|
43
|
+
track('$pageview', undefined, 'Cart');
|
|
44
|
+
const buf = __peekTrackBuffer();
|
|
45
|
+
expect(buf.length).toBe(2);
|
|
46
|
+
expect(buf[0].name).toBe('checkout.started');
|
|
47
|
+
expect(buf[0].release).toBe('app@1.0.0+1');
|
|
48
|
+
expect(buf[0].environment).toBe('test');
|
|
49
|
+
expect(buf[0].props).toEqual({ cart: 42 });
|
|
50
|
+
expect(buf[1].name).toBe('$pageview');
|
|
51
|
+
expect(buf[1].route).toBe('Cart');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('attaches the current user id when set', () => {
|
|
55
|
+
setUser({ id: 'u_abc' });
|
|
56
|
+
track('signed_in');
|
|
57
|
+
const buf = __peekTrackBuffer();
|
|
58
|
+
expect(buf[0].userId).toBe('u_abc');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('drops oversized names + over-cap prop bags silently', () => {
|
|
62
|
+
track('x'.repeat(201));
|
|
63
|
+
const tooManyProps: Record<string, number> = {};
|
|
64
|
+
for (let i = 0; i < 41; i += 1) tooManyProps[`k${i}`] = i;
|
|
65
|
+
track('big-bag', tooManyProps);
|
|
66
|
+
expect(__peekTrackBuffer().length).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('flushTrack drains the buffer and POSTs the batch envelope', async () => {
|
|
70
|
+
const calls: { body: string; url: string }[] = [];
|
|
71
|
+
globalThis.fetch = mock(async (url: unknown, init: unknown) => {
|
|
72
|
+
calls.push({
|
|
73
|
+
body: String((init as { body?: unknown })?.body ?? ''),
|
|
74
|
+
url: String(url),
|
|
75
|
+
});
|
|
76
|
+
return new Response('{}', { status: 202 });
|
|
77
|
+
}) as unknown as typeof fetch;
|
|
78
|
+
|
|
79
|
+
track('a');
|
|
80
|
+
track('b');
|
|
81
|
+
await flushTrack();
|
|
82
|
+
|
|
83
|
+
expect(calls.length).toBe(1);
|
|
84
|
+
expect(calls[0].url).toBe('http://localhost:8080/v1/track:batch');
|
|
85
|
+
const parsed = JSON.parse(calls[0].body) as {
|
|
86
|
+
events: Array<{ name: string }>;
|
|
87
|
+
};
|
|
88
|
+
expect(parsed.events.map((e) => e.name)).toEqual(['a', 'b']);
|
|
89
|
+
expect(__peekTrackBuffer().length).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/capture.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { parseStack } from './stack';
|
|
|
18
18
|
import { getTrailBuffer } from './trail';
|
|
19
19
|
import { enqueue, sendUserReport, uploadAttachment } from './transport';
|
|
20
20
|
import { uuidV7 } from './uuid';
|
|
21
|
+
import { peekInstallId } from './install-id';
|
|
21
22
|
import { getCachedNetworkType } from './netinfo';
|
|
22
23
|
import { getRecentNativeException } from './native';
|
|
23
24
|
import type { App, AttachmentMeta, Device, Event, SentoriError, Tags, User } from './types';
|
|
@@ -459,6 +460,13 @@ const collectDevice = (): Device => {
|
|
|
459
460
|
const device: Device = { os, osVersion };
|
|
460
461
|
if (locale) device.locale = locale;
|
|
461
462
|
if (networkType) device.networkType = networkType;
|
|
463
|
+
// v1.1 chunk S1 — stable per-install id. Read sync from the
|
|
464
|
+
// in-memory cache populated by the install-id module's first
|
|
465
|
+
// resolve (kicked off from init()). `null` before init resolves —
|
|
466
|
+
// the field is omitted in that case rather than blocking event
|
|
467
|
+
// assembly, which sits on the JS thread.
|
|
468
|
+
const installId = peekInstallId();
|
|
469
|
+
if (installId) device.installId = installId;
|
|
462
470
|
return device;
|
|
463
471
|
};
|
|
464
472
|
|
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
|
+
}
|