@goliapkg/sentori-react-native 0.3.1 → 0.5.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/ios/PRIVACY_AND_REVIEW.md +122 -0
- package/ios/SentoriHangWatchdog.swift +54 -29
- package/ios/SentoriThreadSampler.swift +149 -0
- package/ios/Tests/SentoriThreadSamplerTests.swift +96 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +4 -0
- package/lib/capture.js.map +1 -1
- package/lib/handlers/lifecycle.d.ts +3 -0
- package/lib/handlers/lifecycle.d.ts.map +1 -0
- package/lib/handlers/lifecycle.js +48 -0
- package/lib/handlers/lifecycle.js.map +1 -0
- package/lib/handlers/network.d.ts.map +1 -1
- package/lib/handlers/network.js +44 -3
- package/lib/handlers/network.js.map +1 -1
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -1
- package/lib/native.d.ts +5 -4
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +5 -4
- package/lib/native.js.map +1 -1
- package/lib/navigation.d.ts +29 -0
- package/lib/navigation.d.ts.map +1 -0
- package/lib/navigation.js +72 -0
- package/lib/navigation.js.map +1 -0
- package/lib/session-tracker.d.ts +6 -0
- package/lib/session-tracker.d.ts.map +1 -0
- package/lib/session-tracker.js +50 -0
- package/lib/session-tracker.js.map +1 -0
- package/lib/transport.d.ts +8 -0
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +23 -0
- package/lib/transport.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/navigation.test.ts +148 -0
- package/src/__tests__/tracing.test.ts +108 -0
- package/src/__tests__/transport.test.ts +58 -0
- package/src/capture.ts +4 -0
- package/src/handlers/lifecycle.ts +54 -0
- package/src/handlers/network.ts +48 -3
- package/src/index.ts +14 -0
- package/src/native.ts +11 -8
- package/src/navigation.ts +85 -0
- package/src/session-tracker.ts +53 -0
- package/src/transport.ts +27 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { clearSpans, drainSpans } from '@goliapkg/sentori-core';
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
test,
|
|
9
|
+
} from 'bun:test';
|
|
10
|
+
|
|
11
|
+
import { installNetworkHandler } from '../handlers/network';
|
|
12
|
+
|
|
13
|
+
// network.ts installs ONCE per process. To test reliably we:
|
|
14
|
+
// 1. set up a single static recorder on globalThis.fetch
|
|
15
|
+
// 2. install the wrapper exactly once (in beforeAll)
|
|
16
|
+
// 3. between tests, only mutate the recorder's queue — NEVER
|
|
17
|
+
// re-assign globalThis.fetch, because the wrapper captured the
|
|
18
|
+
// first reference at install time.
|
|
19
|
+
|
|
20
|
+
const recorderCalls: Array<{ init?: RequestInit; url: string }> = [];
|
|
21
|
+
let recorderQueue: Array<{ status: number } | Error> = [];
|
|
22
|
+
|
|
23
|
+
const recorder = (async (input: Request | string | URL, init?: RequestInit) => {
|
|
24
|
+
const url =
|
|
25
|
+
typeof input === 'string'
|
|
26
|
+
? input
|
|
27
|
+
: input instanceof URL
|
|
28
|
+
? input.toString()
|
|
29
|
+
: input.url;
|
|
30
|
+
recorderCalls.push({ init, url });
|
|
31
|
+
const r = recorderQueue.shift() ?? { status: 200 };
|
|
32
|
+
if (r instanceof Error) throw r;
|
|
33
|
+
return new Response('', { status: r.status });
|
|
34
|
+
}) as unknown as typeof fetch;
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
(globalThis as { fetch: typeof fetch }).fetch = recorder;
|
|
38
|
+
installNetworkHandler();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
clearSpans();
|
|
43
|
+
recorderCalls.length = 0;
|
|
44
|
+
recorderQueue = [{ status: 200 }];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
clearSpans();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('RN network handler tracing', () => {
|
|
52
|
+
test('wrapped fetch emits an http.client span', async () => {
|
|
53
|
+
const resp = await fetch('https://api.example.com/v1/users/me', {
|
|
54
|
+
method: 'GET',
|
|
55
|
+
});
|
|
56
|
+
expect(resp.status).toBe(200);
|
|
57
|
+
|
|
58
|
+
const spans = drainSpans();
|
|
59
|
+
expect(spans).toHaveLength(1);
|
|
60
|
+
expect(spans[0]?.op).toBe('http.client');
|
|
61
|
+
expect(spans[0]?.name).toBe('GET https://api.example.com/v1/users/me');
|
|
62
|
+
expect(spans[0]?.tags).toMatchObject({
|
|
63
|
+
'http.method': 'GET',
|
|
64
|
+
'http.status': '200',
|
|
65
|
+
'http.url': 'https://api.example.com/v1/users/me',
|
|
66
|
+
});
|
|
67
|
+
expect(spans[0]?.status).toBe('ok');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('injects W3C traceparent header', async () => {
|
|
71
|
+
await fetch('https://api.example.com/x');
|
|
72
|
+
expect(recorderCalls).toHaveLength(1);
|
|
73
|
+
const headers = new Headers(recorderCalls[0]?.init?.headers);
|
|
74
|
+
const tp = headers.get('traceparent');
|
|
75
|
+
expect(tp).not.toBeNull();
|
|
76
|
+
expect(tp).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('5xx → span.status = "error"', async () => {
|
|
80
|
+
recorderQueue = [{ status: 503 }];
|
|
81
|
+
await fetch('https://api.example.com/x');
|
|
82
|
+
expect(drainSpans()[0]?.status).toBe('error');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('throws → status = "error" with error.message tag', async () => {
|
|
86
|
+
recorderQueue = [new TypeError('NetworkError: offline')];
|
|
87
|
+
await expect(fetch('https://api.example.com/x')).rejects.toThrow('NetworkError');
|
|
88
|
+
const sp = drainSpans()[0]!;
|
|
89
|
+
expect(sp.status).toBe('error');
|
|
90
|
+
expect(sp.tags['error.message']).toContain('offline');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('AbortError → status = "cancelled"', async () => {
|
|
94
|
+
recorderQueue = [Object.assign(new Error('aborted'), { name: 'AbortError' })];
|
|
95
|
+
await expect(fetch('https://api.example.com/x')).rejects.toThrow('aborted');
|
|
96
|
+
expect(drainSpans()[0]?.status).toBe('cancelled');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('preserves caller-supplied headers alongside traceparent', async () => {
|
|
100
|
+
await fetch('https://api.example.com/x', {
|
|
101
|
+
headers: { Authorization: 'Bearer xyz', 'X-Custom': '1' },
|
|
102
|
+
});
|
|
103
|
+
const h = new Headers(recorderCalls[0]?.init?.headers);
|
|
104
|
+
expect(h.get('authorization')).toBe('Bearer xyz');
|
|
105
|
+
expect(h.get('x-custom')).toBe('1');
|
|
106
|
+
expect(h.get('traceparent')).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -109,4 +109,62 @@ describe('transport', () => {
|
|
|
109
109
|
expect(capturedHeaders?.Authorization).toBe('Bearer st_pk_test');
|
|
110
110
|
expect(capturedHeaders?.['Sentori-Sdk']).toMatch(/^react-native\//);
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
// Phase 33 sub-D: offline / retry behavior.
|
|
114
|
+
|
|
115
|
+
it('retries up to MAX_RETRY (3) on a 5xx, then gives up', async () => {
|
|
116
|
+
let attempts = 0;
|
|
117
|
+
globalThis.fetch = mock(async () => {
|
|
118
|
+
attempts++;
|
|
119
|
+
return new Response('boom', { status: 503 });
|
|
120
|
+
}) as typeof fetch;
|
|
121
|
+
|
|
122
|
+
enqueue(makeEvent('a'));
|
|
123
|
+
// flush swallows the final throw (and falls through to persist).
|
|
124
|
+
// We're verifying the retry count, not the throw.
|
|
125
|
+
await flush();
|
|
126
|
+
expect(attempts).toBe(3);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('retries on network error (fetch throw), succeeds when recovered', async () => {
|
|
130
|
+
let attempts = 0;
|
|
131
|
+
globalThis.fetch = mock(async () => {
|
|
132
|
+
attempts++;
|
|
133
|
+
if (attempts < 3) throw new TypeError('NetworkError: offline');
|
|
134
|
+
return new Response(null, { status: 202 });
|
|
135
|
+
}) as typeof fetch;
|
|
136
|
+
|
|
137
|
+
enqueue(makeEvent('a'));
|
|
138
|
+
await flush();
|
|
139
|
+
expect(attempts).toBe(3);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('drops 4xx-other-than-429 without retry (client errors are unrecoverable)', async () => {
|
|
143
|
+
let attempts = 0;
|
|
144
|
+
globalThis.fetch = mock(async () => {
|
|
145
|
+
attempts++;
|
|
146
|
+
return new Response(null, { status: 400 });
|
|
147
|
+
}) as typeof fetch;
|
|
148
|
+
|
|
149
|
+
enqueue(makeEvent('a'));
|
|
150
|
+
await flush();
|
|
151
|
+
// sendOnce treats 4xx-other-than-429 as a no-throw exit, so the
|
|
152
|
+
// retry loop also exits — one attempt, no double-send.
|
|
153
|
+
expect(attempts).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('does not duplicate events when flush is called twice in a row', async () => {
|
|
157
|
+
let attempts = 0;
|
|
158
|
+
globalThis.fetch = mock(async () => {
|
|
159
|
+
attempts++;
|
|
160
|
+
return new Response(null, { status: 202 });
|
|
161
|
+
}) as typeof fetch;
|
|
162
|
+
|
|
163
|
+
enqueue(makeEvent('a'));
|
|
164
|
+
enqueue(makeEvent('b'));
|
|
165
|
+
await flush();
|
|
166
|
+
await flush(); // second flush sees an empty queue and no-ops
|
|
167
|
+
expect(attempts).toBe(1);
|
|
168
|
+
expect(__peekQueue()).toHaveLength(0);
|
|
169
|
+
});
|
|
112
170
|
});
|
package/src/capture.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getConfig, isInitialized } from './config';
|
|
2
2
|
import { getBreadcrumbs } from './breadcrumbs';
|
|
3
|
+
import { markSessionErrored } from './session-tracker';
|
|
3
4
|
import { parseStack } from './stack';
|
|
4
5
|
import { enqueue } from './transport';
|
|
5
6
|
import { uuidV7 } from './uuid';
|
|
@@ -52,6 +53,9 @@ export const captureError = (error: Error, extras?: CaptureExtras): void => {
|
|
|
52
53
|
fingerprint: extras?.fingerprint,
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
// Phase 26 sub-B: a captured error promotes the current session to
|
|
57
|
+
// `errored` so the next AppState=background ping reports unhealthy.
|
|
58
|
+
markSessionErrored();
|
|
55
59
|
enqueue(event);
|
|
56
60
|
};
|
|
57
61
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 26 sub-B: AppState binding.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to AppState transitions:
|
|
5
|
+
* - active → start a fresh session (after a previous background end)
|
|
6
|
+
* - background / inactive → end the current session
|
|
7
|
+
*
|
|
8
|
+
* RN's AppState fires `inactive` on iOS during multitasking peek; we
|
|
9
|
+
* end on it because that's effectively a background and the user may
|
|
10
|
+
* not return. If they do, `active` starts a new one — the on-the-wire
|
|
11
|
+
* session count goes up by one, which matches "the user opened the app
|
|
12
|
+
* twice". Sentry historically did the opposite (treat inactive as
|
|
13
|
+
* still alive), but that lets a swiped-away session never end.
|
|
14
|
+
*/
|
|
15
|
+
import { endSession, startSession } from '../session-tracker';
|
|
16
|
+
|
|
17
|
+
let _installed = false;
|
|
18
|
+
let _subscription: { remove: () => void } | null = null;
|
|
19
|
+
|
|
20
|
+
type AppStateLike = {
|
|
21
|
+
addEventListener: (
|
|
22
|
+
event: 'change',
|
|
23
|
+
handler: (state: string) => void
|
|
24
|
+
) => { remove: () => void };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const installLifecycleHandler = (): void => {
|
|
28
|
+
if (_installed) return;
|
|
29
|
+
_installed = true;
|
|
30
|
+
let AppState: AppStateLike | undefined;
|
|
31
|
+
try {
|
|
32
|
+
// RN ships AppState; in test / non-RN host the require throws and
|
|
33
|
+
// we silently no-op.
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
|
+
AppState = (require('react-native') as { AppState?: AppStateLike }).AppState;
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!AppState || typeof AppState.addEventListener !== 'function') return;
|
|
40
|
+
|
|
41
|
+
_subscription = AppState.addEventListener('change', (state) => {
|
|
42
|
+
if (state === 'active') {
|
|
43
|
+
startSession();
|
|
44
|
+
} else if (state === 'background' || state === 'inactive') {
|
|
45
|
+
endSession();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const __uninstallLifecycleForTests = (): void => {
|
|
51
|
+
_subscription?.remove();
|
|
52
|
+
_subscription = null;
|
|
53
|
+
_installed = false;
|
|
54
|
+
};
|
package/src/handlers/network.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { startSpan } from '@goliapkg/sentori-core';
|
|
2
|
+
|
|
1
3
|
import { addBreadcrumb } from '../breadcrumbs';
|
|
2
4
|
|
|
3
5
|
let _installed = false;
|
|
@@ -14,29 +16,50 @@ export const installNetworkHandler = (): void => {
|
|
|
14
16
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
15
17
|
const start = Date.now();
|
|
16
18
|
const url = extractUrl(input);
|
|
19
|
+
const scrubbed = scrubUrl(url);
|
|
17
20
|
const method = (init?.method ??
|
|
18
21
|
(typeof input !== 'string' && 'method' in (input as Request)
|
|
19
22
|
? (input as Request).method
|
|
20
23
|
: 'GET')) as string;
|
|
21
24
|
|
|
25
|
+
// Phase 35 sub-C: also open an http.client span so the request
|
|
26
|
+
// shows up in the trace waterfall. Breadcrumbs stay — they're
|
|
27
|
+
// attached to error events at capture time and serve a different
|
|
28
|
+
// surface (the "last 100 things" timeline on the issue page).
|
|
29
|
+
const span = startSpan('http.client', {
|
|
30
|
+
name: `${method.toUpperCase()} ${scrubbed}`,
|
|
31
|
+
tags: { 'http.method': method.toUpperCase(), 'http.url': scrubbed },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Inject traceparent header on outbound requests.
|
|
35
|
+
const reqInit: RequestInit = { ...(init ?? {}) };
|
|
36
|
+
const headers = mergeHeaders(input, init);
|
|
37
|
+
headers.set('traceparent', toTraceparent(span.traceId, span.spanId));
|
|
38
|
+
reqInit.headers = headers;
|
|
39
|
+
|
|
22
40
|
try {
|
|
23
|
-
const resp = await original(input,
|
|
41
|
+
const resp = await original(input, reqInit);
|
|
42
|
+
span.setTag('http.status', String(resp.status));
|
|
43
|
+
span.finish({ status: resp.status >= 400 ? 'error' : 'ok' });
|
|
24
44
|
addBreadcrumb({
|
|
25
45
|
type: 'net',
|
|
26
46
|
data: {
|
|
27
47
|
method,
|
|
28
|
-
url:
|
|
48
|
+
url: scrubbed,
|
|
29
49
|
status: resp.status,
|
|
30
50
|
durationMs: Date.now() - start,
|
|
31
51
|
},
|
|
32
52
|
});
|
|
33
53
|
return resp;
|
|
34
54
|
} catch (e) {
|
|
55
|
+
const isAbort = isAbortError(e);
|
|
56
|
+
if (e instanceof Error) span.setTag('error.message', e.message);
|
|
57
|
+
span.finish({ status: isAbort ? 'cancelled' : 'error' });
|
|
35
58
|
addBreadcrumb({
|
|
36
59
|
type: 'net',
|
|
37
60
|
data: {
|
|
38
61
|
method,
|
|
39
|
-
url:
|
|
62
|
+
url: scrubbed,
|
|
40
63
|
status: 0,
|
|
41
64
|
durationMs: Date.now() - start,
|
|
42
65
|
error: String(e),
|
|
@@ -47,6 +70,28 @@ export const installNetworkHandler = (): void => {
|
|
|
47
70
|
}) as typeof fetch;
|
|
48
71
|
};
|
|
49
72
|
|
|
73
|
+
function mergeHeaders(input: RequestInfo | URL, init?: RequestInit): Headers {
|
|
74
|
+
const out = new Headers();
|
|
75
|
+
if (typeof input !== 'string' && !(input instanceof URL)) {
|
|
76
|
+
(input as Request).headers.forEach((v, k) => out.set(k, v));
|
|
77
|
+
}
|
|
78
|
+
if (init?.headers) {
|
|
79
|
+
new Headers(init.headers).forEach((v, k) => out.set(k, v));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toTraceparent(traceId: string, spanId: string): string {
|
|
85
|
+
const trace = traceId.replace(/-/g, '').toLowerCase();
|
|
86
|
+
const parent = spanId.replace(/-/g, '').toLowerCase().slice(0, 16);
|
|
87
|
+
return `00-${trace}-${parent}-01`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isAbortError(err: unknown): boolean {
|
|
91
|
+
if (typeof err !== 'object' || err === null) return false;
|
|
92
|
+
return (err as { name?: unknown }).name === 'AbortError';
|
|
93
|
+
}
|
|
94
|
+
|
|
50
95
|
const extractUrl = (input: RequestInfo | URL): string => {
|
|
51
96
|
if (typeof input === 'string') return input;
|
|
52
97
|
if (input instanceof URL) return input.href;
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { init } from './init';
|
|
|
2
2
|
import { addBreadcrumb } from './breadcrumbs';
|
|
3
3
|
import { setUser, getUser, captureError, captureException } from './capture';
|
|
4
4
|
import { ErrorBoundary } from './error-boundary';
|
|
5
|
+
import {
|
|
6
|
+
endSession,
|
|
7
|
+
markSessionCrashed,
|
|
8
|
+
startSession,
|
|
9
|
+
} from './session-tracker';
|
|
5
10
|
|
|
6
11
|
export const sentori = {
|
|
7
12
|
init,
|
|
@@ -11,6 +16,9 @@ export const sentori = {
|
|
|
11
16
|
captureError,
|
|
12
17
|
captureException,
|
|
13
18
|
ErrorBoundary,
|
|
19
|
+
startSession,
|
|
20
|
+
endSession,
|
|
21
|
+
markSessionCrashed,
|
|
14
22
|
};
|
|
15
23
|
|
|
16
24
|
export default sentori;
|
|
@@ -24,6 +32,12 @@ export {
|
|
|
24
32
|
stopAnrWatchdog,
|
|
25
33
|
triggerNativeCrash,
|
|
26
34
|
} from './native';
|
|
35
|
+
export {
|
|
36
|
+
endSession,
|
|
37
|
+
markSessionCrashed,
|
|
38
|
+
startSession,
|
|
39
|
+
} from './session-tracker';
|
|
40
|
+
export { type NavigationRefLike, useTraceNavigation } from './navigation';
|
|
27
41
|
|
|
28
42
|
export type {
|
|
29
43
|
Event,
|
package/src/native.ts
CHANGED
|
@@ -12,10 +12,12 @@ type SentoriNativeModule = {
|
|
|
12
12
|
token: string
|
|
13
13
|
}) => void
|
|
14
14
|
/**
|
|
15
|
-
* Phase 22 sub-D:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Phase 22 sub-D / sub-E: cross-platform main-thread watchdog.
|
|
16
|
+
* Android: 5 s / 1 s defaults (matches the OS ANR threshold).
|
|
17
|
+
* iOS: 2 s / 1 s (more aggressive — iOS has no system-level
|
|
18
|
+
* watchdog signal we can lean on, so we surface stutter Apple's
|
|
19
|
+
* own runtime never flags).
|
|
20
|
+
* Reports a `kind = "anr"` event when the main thread is wedged.
|
|
19
21
|
*/
|
|
20
22
|
startAnrWatchdog?: (options?: {
|
|
21
23
|
force?: boolean
|
|
@@ -83,14 +85,15 @@ export function triggerNativeCrash(): void {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
/**
|
|
86
|
-
* Phase 22 sub-D
|
|
88
|
+
* Phase 22 sub-D / sub-E: cross-platform main-thread watchdog.
|
|
89
|
+
* Single JS call covers both Android ANR and iOS hang detection.
|
|
87
90
|
*
|
|
88
|
-
* startAnrWatchdog() //
|
|
91
|
+
* startAnrWatchdog() // platform defaults, prod-only
|
|
89
92
|
* startAnrWatchdog({ force: true }) // include debug builds
|
|
90
93
|
* startAnrWatchdog({ timeoutMs: 3000 }) // tighter threshold
|
|
91
94
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
95
|
+
* Defaults: Android 5 s / 1 s tick; iOS 2 s / 1 s tick. Returns
|
|
96
|
+
* silently on web / jest / unsupported runtimes.
|
|
94
97
|
*/
|
|
95
98
|
export function startAnrWatchdog(options?: {
|
|
96
99
|
force?: boolean
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Phase 35 sub-C: react-navigation auto-instrumentation.
|
|
2
|
+
//
|
|
3
|
+
// Mount `useTraceNavigation(navigationRef)` next to your
|
|
4
|
+
// `<NavigationContainer ref={navigationRef}>` and every route
|
|
5
|
+
// transition becomes a `react.navigation` span. Span names are
|
|
6
|
+
// `<from> → <to>` so the trace list reads as a navigation log.
|
|
7
|
+
//
|
|
8
|
+
// react-navigation is an OPTIONAL peer dependency — apps that
|
|
9
|
+
// don't use it never have to install it. The hook itself doesn't
|
|
10
|
+
// import from @react-navigation/native; consumers pass in the ref
|
|
11
|
+
// they already have, and we read its state via the public
|
|
12
|
+
// `getCurrentRoute()` API. That keeps the dep edge optional.
|
|
13
|
+
|
|
14
|
+
import { useEffect, useRef } from 'react';
|
|
15
|
+
|
|
16
|
+
import { startSpan, type SpanHandle } from '@goliapkg/sentori-core';
|
|
17
|
+
|
|
18
|
+
/** Minimal contract: anything with `addListener('state', cb)` and
|
|
19
|
+
* `getCurrentRoute()` works. The real @react-navigation/native
|
|
20
|
+
* NavigationContainer ref matches this shape. */
|
|
21
|
+
export type NavigationRefLike = {
|
|
22
|
+
addListener: (event: 'state', listener: () => void) => () => void;
|
|
23
|
+
getCurrentRoute: () => { name: string } | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Subscribe to react-navigation state changes and emit a
|
|
28
|
+
* `react.navigation` span per transition. First mount records the
|
|
29
|
+
* initial route as the start anchor but does NOT emit a span (the
|
|
30
|
+
* convention from `useSentoriRouter` in sentori-react).
|
|
31
|
+
*
|
|
32
|
+
* import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'
|
|
33
|
+
* import { useTraceNavigation } from '@goliapkg/sentori-react-native'
|
|
34
|
+
*
|
|
35
|
+
* function App() {
|
|
36
|
+
* const navigationRef = useNavigationContainerRef()
|
|
37
|
+
* useTraceNavigation(navigationRef)
|
|
38
|
+
* return <NavigationContainer ref={navigationRef}>{...}</NavigationContainer>
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* Each span carries `{ from, to }` as tags and uses the destination
|
|
42
|
+
* route name as the span name.
|
|
43
|
+
*/
|
|
44
|
+
export function useTraceNavigation(navigationRef: NavigationRefLike): void {
|
|
45
|
+
// Latest route name we've observed. `null` means "no transition
|
|
46
|
+
// recorded yet" (initial mount).
|
|
47
|
+
const lastRouteRef = useRef<null | string>(null);
|
|
48
|
+
// Span that started when this route was entered. Finished when the
|
|
49
|
+
// NEXT route transition arrives.
|
|
50
|
+
const openSpanRef = useRef<null | SpanHandle>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (typeof navigationRef.addListener !== 'function') return;
|
|
54
|
+
if (typeof navigationRef.getCurrentRoute !== 'function') return;
|
|
55
|
+
|
|
56
|
+
// Seed the "last route" reference from the current state so the
|
|
57
|
+
// first transition emits a span with the right `from`.
|
|
58
|
+
const initial = navigationRef.getCurrentRoute()?.name ?? null;
|
|
59
|
+
lastRouteRef.current = initial;
|
|
60
|
+
|
|
61
|
+
const unsubscribe = navigationRef.addListener('state', () => {
|
|
62
|
+
const next = navigationRef.getCurrentRoute()?.name ?? null;
|
|
63
|
+
const prev = lastRouteRef.current;
|
|
64
|
+
if (next === null || next === prev) return;
|
|
65
|
+
|
|
66
|
+
// Close the prior span (if any) before opening the new one so
|
|
67
|
+
// the trace looks like a sequence, not nested.
|
|
68
|
+
openSpanRef.current?.finish({ status: 'ok' });
|
|
69
|
+
|
|
70
|
+
const span = startSpan('react.navigation', {
|
|
71
|
+
name: prev ? `${prev} → ${next}` : next,
|
|
72
|
+
tags: { 'nav.from': prev ?? '', 'nav.to': next },
|
|
73
|
+
});
|
|
74
|
+
openSpanRef.current = span;
|
|
75
|
+
lastRouteRef.current = next;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
unsubscribe();
|
|
80
|
+
// Close any still-open span on unmount so we don't leak it.
|
|
81
|
+
openSpanRef.current?.finish({ status: 'ok' });
|
|
82
|
+
openSpanRef.current = null;
|
|
83
|
+
};
|
|
84
|
+
}, [navigationRef]);
|
|
85
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 26 sub-B: RN session tracker glue.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the JS SDK's session-tracker but sends through the RN
|
|
5
|
+
* transport. AppState binding lives in `handlers/lifecycle.ts`; this
|
|
6
|
+
* file is just the singleton + the start/end/markErrored/markCrashed
|
|
7
|
+
* surface.
|
|
8
|
+
*/
|
|
9
|
+
import { SessionTracker } from '@goliapkg/sentori-core';
|
|
10
|
+
|
|
11
|
+
import { getConfig } from './config';
|
|
12
|
+
import { getUser } from './capture';
|
|
13
|
+
import { sendSessionPing } from './transport';
|
|
14
|
+
|
|
15
|
+
let _tracker: null | SessionTracker = null;
|
|
16
|
+
|
|
17
|
+
const tracker = (): SessionTracker => {
|
|
18
|
+
if (_tracker) return _tracker;
|
|
19
|
+
_tracker = new SessionTracker((ping) => {
|
|
20
|
+
const cfg = getConfig();
|
|
21
|
+
if (!cfg) return;
|
|
22
|
+
void sendSessionPing(cfg.ingestUrl, cfg.token, ping);
|
|
23
|
+
});
|
|
24
|
+
return _tracker;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const startSession = (): void => {
|
|
28
|
+
const cfg = getConfig();
|
|
29
|
+
if (!cfg) return;
|
|
30
|
+
const user = getUser();
|
|
31
|
+
tracker().start({
|
|
32
|
+
environment: cfg.environment,
|
|
33
|
+
release: cfg.release,
|
|
34
|
+
userId: user?.id ?? null,
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const endSession = (status?: 'exited'): void => {
|
|
39
|
+
if (!_tracker) return;
|
|
40
|
+
_tracker.end(status);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const markSessionErrored = (): void => {
|
|
44
|
+
_tracker?.markErrored();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const markSessionCrashed = (): void => {
|
|
48
|
+
_tracker?.markCrashed();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const __resetSessionForTests = (): void => {
|
|
52
|
+
_tracker = null;
|
|
53
|
+
};
|
package/src/transport.ts
CHANGED
|
@@ -162,3 +162,30 @@ export const __resetForTests = (): void => {
|
|
|
162
162
|
};
|
|
163
163
|
|
|
164
164
|
export const __peekQueue = (): readonly Event[] => _queue;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Phase 26 sub-B: session ping transport. Best-effort; we don't queue
|
|
168
|
+
* pings the way we queue events because they fire on background and
|
|
169
|
+
* AsyncStorage writes during background can be killed by the OS. If
|
|
170
|
+
* the network's down, the ping is lost — the session counters tolerate
|
|
171
|
+
* this.
|
|
172
|
+
*/
|
|
173
|
+
export const sendSessionPing = async (
|
|
174
|
+
ingestUrl: string,
|
|
175
|
+
token: string,
|
|
176
|
+
ping: unknown
|
|
177
|
+
): Promise<void> => {
|
|
178
|
+
try {
|
|
179
|
+
await fetch(`${ingestUrl}/v1/sessions`, {
|
|
180
|
+
body: JSON.stringify(ping),
|
|
181
|
+
headers: {
|
|
182
|
+
Authorization: `Bearer ${token}`,
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
'Sentori-Sdk': `react-native/${SDK_VERSION}`,
|
|
185
|
+
},
|
|
186
|
+
method: 'POST',
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
// best-effort
|
|
190
|
+
}
|
|
191
|
+
};
|