@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.
Files changed (60) hide show
  1. package/bin/sentori-rn-upload-source-bundle.cjs +193 -0
  2. package/lib/capture.d.ts.map +1 -1
  3. package/lib/capture.js +9 -0
  4. package/lib/capture.js.map +1 -1
  5. package/lib/heartbeat.d.ts +9 -0
  6. package/lib/heartbeat.d.ts.map +1 -0
  7. package/lib/heartbeat.js +140 -0
  8. package/lib/heartbeat.js.map +1 -0
  9. package/lib/index.d.ts +15 -0
  10. package/lib/index.d.ts.map +1 -1
  11. package/lib/index.js +15 -0
  12. package/lib/index.js.map +1 -1
  13. package/lib/init.d.ts +6 -0
  14. package/lib/init.d.ts.map +1 -1
  15. package/lib/init.js +18 -0
  16. package/lib/init.js.map +1 -1
  17. package/lib/install-id.d.ts +17 -0
  18. package/lib/install-id.d.ts.map +1 -0
  19. package/lib/install-id.js +125 -0
  20. package/lib/install-id.js.map +1 -0
  21. package/lib/navigation.d.ts +1 -0
  22. package/lib/navigation.d.ts.map +1 -1
  23. package/lib/navigation.js +20 -0
  24. package/lib/navigation.js.map +1 -1
  25. package/lib/replay.d.ts +27 -9
  26. package/lib/replay.d.ts.map +1 -1
  27. package/lib/replay.js +209 -167
  28. package/lib/replay.js.map +1 -1
  29. package/lib/report-security.d.ts +40 -0
  30. package/lib/report-security.d.ts.map +1 -0
  31. package/lib/report-security.js +159 -0
  32. package/lib/report-security.js.map +1 -0
  33. package/lib/track.d.ts +34 -0
  34. package/lib/track.d.ts.map +1 -0
  35. package/lib/track.js +98 -0
  36. package/lib/track.js.map +1 -0
  37. package/lib/transport.d.ts +15 -0
  38. package/lib/transport.d.ts.map +1 -1
  39. package/lib/transport.js +23 -0
  40. package/lib/transport.js.map +1 -1
  41. package/lib/trust-score.d.ts +20 -0
  42. package/lib/trust-score.d.ts.map +1 -0
  43. package/lib/trust-score.js +151 -0
  44. package/lib/trust-score.js.map +1 -0
  45. package/package.json +6 -2
  46. package/src/__tests__/install-id.test.ts +60 -0
  47. package/src/__tests__/replay-encoding.test.ts +237 -0
  48. package/src/__tests__/report-security.test.ts +106 -0
  49. package/src/__tests__/track.test.ts +91 -0
  50. package/src/capture.ts +8 -0
  51. package/src/heartbeat.ts +158 -0
  52. package/src/index.ts +24 -0
  53. package/src/init.ts +23 -0
  54. package/src/install-id.ts +146 -0
  55. package/src/navigation.ts +26 -0
  56. package/src/replay.ts +258 -176
  57. package/src/report-security.ts +165 -0
  58. package/src/track.ts +114 -0
  59. package/src/transport.ts +35 -0
  60. 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
 
@@ -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
+ }