@goliapkg/sentori-react-native 0.7.5 → 0.8.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/lib/bundle-info.d.ts +12 -0
- package/lib/bundle-info.d.ts.map +1 -0
- package/lib/bundle-info.js +73 -0
- package/lib/bundle-info.js.map +1 -0
- package/lib/capture.d.ts.map +1 -1
- package/lib/capture.js +6 -0
- package/lib/capture.js.map +1 -1
- package/lib/feature-flags.d.ts +9 -0
- package/lib/feature-flags.d.ts.map +1 -0
- package/lib/feature-flags.js +44 -0
- package/lib/feature-flags.js.map +1 -0
- package/lib/handlers/network.d.ts +9 -1
- package/lib/handlers/network.d.ts.map +1 -1
- package/lib/handlers/network.js +189 -18
- package/lib/handlers/network.js.map +1 -1
- package/lib/index.d.ts +18 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +19 -0
- package/lib/index.js.map +1 -1
- package/lib/init.d.ts +16 -1
- package/lib/init.d.ts.map +1 -1
- package/lib/init.js +26 -2
- package/lib/init.js.map +1 -1
- package/lib/launch-crash-guard.d.ts +37 -0
- package/lib/launch-crash-guard.d.ts.map +1 -0
- package/lib/launch-crash-guard.js +163 -0
- package/lib/launch-crash-guard.js.map +1 -0
- package/lib/measure.d.ts +4 -0
- package/lib/measure.d.ts.map +1 -0
- package/lib/measure.js +25 -0
- package/lib/measure.js.map +1 -0
- package/lib/metrics.d.ts +9 -0
- package/lib/metrics.d.ts.map +1 -0
- package/lib/metrics.js +64 -0
- package/lib/metrics.js.map +1 -0
- package/lib/rage-tap-detector.d.ts +8 -0
- package/lib/rage-tap-detector.d.ts.map +1 -0
- package/lib/rage-tap-detector.js +21 -0
- package/lib/rage-tap-detector.js.map +1 -0
- package/lib/rage-tap.d.ts +6 -0
- package/lib/rage-tap.d.ts.map +1 -0
- package/lib/rage-tap.js +35 -0
- package/lib/rage-tap.js.map +1 -0
- package/lib/transport.d.ts +12 -0
- package/lib/transport.d.ts.map +1 -1
- package/lib/transport.js +24 -0
- package/lib/transport.js.map +1 -1
- package/package.json +11 -3
- package/src/__tests__/feature-flags.test.ts +55 -0
- package/src/__tests__/measure.test.ts +45 -0
- package/src/__tests__/network-graphql.test.ts +75 -0
- package/src/__tests__/rage-tap.test.ts +38 -0
- package/src/bundle-info.ts +95 -0
- package/src/capture.ts +6 -0
- package/src/feature-flags.ts +47 -0
- package/src/handlers/network.ts +198 -18
- package/src/index.ts +29 -0
- package/src/init.ts +57 -2
- package/src/launch-crash-guard.ts +221 -0
- package/src/measure.ts +28 -0
- package/src/metrics.ts +74 -0
- package/src/rage-tap-detector.ts +26 -0
- package/src/rage-tap.tsx +48 -0
- package/src/transport.ts +32 -0
package/src/handlers/network.ts
CHANGED
|
@@ -4,9 +4,15 @@ import { addBreadcrumb } from '../breadcrumbs';
|
|
|
4
4
|
import { getConfig } from '../config';
|
|
5
5
|
|
|
6
6
|
let _installed = false;
|
|
7
|
+
let _graphqlEnabled = true;
|
|
7
8
|
|
|
8
9
|
const AUTH_PARAMS = ['token', 'key', 'password', 'secret', 'access_token'];
|
|
9
10
|
|
|
11
|
+
// v0.9.0 #11 — cap on body size we'll parse for `operationName`.
|
|
12
|
+
// 8 KB is generous for any sensible GraphQL request and keeps the
|
|
13
|
+
// hot-path JSON.parse bounded.
|
|
14
|
+
const GQL_BODY_MAX_BYTES = 8 * 1024;
|
|
15
|
+
|
|
10
16
|
// Requests to our own ingest endpoint shouldn't be traced — otherwise
|
|
11
17
|
// every span upload spawns another http.client span, and so on.
|
|
12
18
|
const isIngestUrl = (url: string): boolean => {
|
|
@@ -14,13 +20,20 @@ const isIngestUrl = (url: string): boolean => {
|
|
|
14
20
|
return !!base && url.startsWith(base);
|
|
15
21
|
};
|
|
16
22
|
|
|
17
|
-
export const installNetworkHandler = (): void => {
|
|
23
|
+
export const installNetworkHandler = (opts?: { graphql?: boolean }): void => {
|
|
18
24
|
if (_installed) return;
|
|
19
25
|
_installed = true;
|
|
26
|
+
_graphqlEnabled = opts?.graphql !== false;
|
|
20
27
|
patchFetch();
|
|
21
28
|
patchXhr();
|
|
22
29
|
};
|
|
23
30
|
|
|
31
|
+
/** Test-only — reset module state between runs. */
|
|
32
|
+
export const __resetNetworkHandlerForTests = (): void => {
|
|
33
|
+
_installed = false;
|
|
34
|
+
_graphqlEnabled = true;
|
|
35
|
+
};
|
|
36
|
+
|
|
24
37
|
// ── fetch ──────────────────────────────────────────────────────────
|
|
25
38
|
|
|
26
39
|
function patchFetch(): void {
|
|
@@ -37,13 +50,32 @@ function patchFetch(): void {
|
|
|
37
50
|
? (input as Request).method
|
|
38
51
|
: 'GET')) as string;
|
|
39
52
|
|
|
53
|
+
// v0.9.0 #11 — GraphQL operation auto-naming. Inspect the request
|
|
54
|
+
// body cheaply (string only, < 8 KB) when the request looks like
|
|
55
|
+
// GraphQL (URL contains /graphql or content-type hints it). On
|
|
56
|
+
// success we override the span name to `graphql/<operationName>`
|
|
57
|
+
// and ride along `gql.operation` on the breadcrumb so the dashboard
|
|
58
|
+
// can group + filter by operation instead of by the useless
|
|
59
|
+
// `POST /graphql` line.
|
|
60
|
+
const gqlOp = _graphqlEnabled
|
|
61
|
+
? extractGraphqlOpFromInit(init, input, url)
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
40
64
|
// Phase 35 sub-C: also open an http.client span so the request
|
|
41
65
|
// shows up in the trace waterfall. Breadcrumbs stay — they're
|
|
42
66
|
// attached to error events at capture time and serve a different
|
|
43
67
|
// surface (the "last 100 things" timeline on the issue page).
|
|
44
68
|
const span = startSpan('http.client', {
|
|
45
|
-
name:
|
|
46
|
-
|
|
69
|
+
name: gqlOp
|
|
70
|
+
? `graphql/${gqlOp}`
|
|
71
|
+
: `${method.toUpperCase()} ${normalizeUrl(scrubbed)}`,
|
|
72
|
+
tags: gqlOp
|
|
73
|
+
? {
|
|
74
|
+
'http.method': method.toUpperCase(),
|
|
75
|
+
'http.url': scrubbed,
|
|
76
|
+
'gql.operation': gqlOp,
|
|
77
|
+
}
|
|
78
|
+
: { 'http.method': method.toUpperCase(), 'http.url': scrubbed },
|
|
47
79
|
});
|
|
48
80
|
|
|
49
81
|
// Inject traceparent header on outbound requests.
|
|
@@ -58,12 +90,20 @@ function patchFetch(): void {
|
|
|
58
90
|
span.finish({ status: resp.status >= 400 ? 'error' : 'ok' });
|
|
59
91
|
addBreadcrumb({
|
|
60
92
|
type: 'net',
|
|
61
|
-
data:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
93
|
+
data: gqlOp
|
|
94
|
+
? {
|
|
95
|
+
method,
|
|
96
|
+
url: scrubbed,
|
|
97
|
+
status: resp.status,
|
|
98
|
+
durationMs: Date.now() - start,
|
|
99
|
+
'gql.operation': gqlOp,
|
|
100
|
+
}
|
|
101
|
+
: {
|
|
102
|
+
method,
|
|
103
|
+
url: scrubbed,
|
|
104
|
+
status: resp.status,
|
|
105
|
+
durationMs: Date.now() - start,
|
|
106
|
+
},
|
|
67
107
|
});
|
|
68
108
|
return resp;
|
|
69
109
|
} catch (e) {
|
|
@@ -78,6 +118,7 @@ function patchFetch(): void {
|
|
|
78
118
|
status: 0,
|
|
79
119
|
durationMs: Date.now() - start,
|
|
80
120
|
error: String(e),
|
|
121
|
+
...(gqlOp ? { 'gql.operation': gqlOp } : {}),
|
|
81
122
|
},
|
|
82
123
|
});
|
|
83
124
|
throw e;
|
|
@@ -98,6 +139,7 @@ type TracedXhr = XMLHttpRequest & {
|
|
|
98
139
|
__sentoriUrl?: string;
|
|
99
140
|
__sentoriSpan?: ReturnType<typeof startSpan>;
|
|
100
141
|
__sentoriStart?: number;
|
|
142
|
+
__sentoriGqlOp?: string;
|
|
101
143
|
};
|
|
102
144
|
|
|
103
145
|
function patchXhr(): void {
|
|
@@ -129,9 +171,20 @@ function patchXhr(): void {
|
|
|
129
171
|
if (isIngestUrl(this.__sentoriUrl ?? '')) return originalSend.call(this, body);
|
|
130
172
|
const method = this.__sentoriMethod ?? 'GET';
|
|
131
173
|
const url = scrubUrl(this.__sentoriUrl ?? '');
|
|
174
|
+
// v0.9.0 #11 — GraphQL operation auto-naming on XHR.
|
|
175
|
+
const gqlOp = _graphqlEnabled
|
|
176
|
+
? extractGraphqlOpFromXhr(body, this.__sentoriUrl ?? '')
|
|
177
|
+
: undefined;
|
|
178
|
+
this.__sentoriGqlOp = gqlOp;
|
|
132
179
|
const span = startSpan('http.client', {
|
|
133
|
-
name: `${method} ${normalizeUrl(url)}`,
|
|
134
|
-
tags:
|
|
180
|
+
name: gqlOp ? `graphql/${gqlOp}` : `${method} ${normalizeUrl(url)}`,
|
|
181
|
+
tags: gqlOp
|
|
182
|
+
? {
|
|
183
|
+
'http.method': method,
|
|
184
|
+
'http.url': url,
|
|
185
|
+
'gql.operation': gqlOp,
|
|
186
|
+
}
|
|
187
|
+
: { 'http.method': method, 'http.url': url },
|
|
135
188
|
});
|
|
136
189
|
this.__sentoriSpan = span;
|
|
137
190
|
this.__sentoriStart = Date.now();
|
|
@@ -154,14 +207,23 @@ function patchXhr(): void {
|
|
|
154
207
|
// status 0 means network error / aborted / CORS block — treat
|
|
155
208
|
// as error. The `abort` event handler below downgrades aborts.
|
|
156
209
|
s.finish({ status: status === 0 || status >= 400 ? 'error' : 'ok' });
|
|
210
|
+
const op = this.__sentoriGqlOp;
|
|
157
211
|
addBreadcrumb({
|
|
158
212
|
type: 'net',
|
|
159
|
-
data:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
213
|
+
data: op
|
|
214
|
+
? {
|
|
215
|
+
method,
|
|
216
|
+
url,
|
|
217
|
+
status,
|
|
218
|
+
durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
|
|
219
|
+
'gql.operation': op,
|
|
220
|
+
}
|
|
221
|
+
: {
|
|
222
|
+
method,
|
|
223
|
+
url,
|
|
224
|
+
status,
|
|
225
|
+
durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
|
|
226
|
+
},
|
|
165
227
|
});
|
|
166
228
|
};
|
|
167
229
|
|
|
@@ -171,9 +233,17 @@ function patchXhr(): void {
|
|
|
171
233
|
if (!s) return;
|
|
172
234
|
this.__sentoriSpan = undefined;
|
|
173
235
|
s.finish({ status: 'cancelled' });
|
|
236
|
+
const op = this.__sentoriGqlOp;
|
|
174
237
|
addBreadcrumb({
|
|
175
238
|
type: 'net',
|
|
176
|
-
data: {
|
|
239
|
+
data: {
|
|
240
|
+
method,
|
|
241
|
+
url,
|
|
242
|
+
status: 0,
|
|
243
|
+
durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
|
|
244
|
+
error: 'aborted',
|
|
245
|
+
...(op ? { 'gql.operation': op } : {}),
|
|
246
|
+
},
|
|
177
247
|
});
|
|
178
248
|
});
|
|
179
249
|
|
|
@@ -224,3 +294,113 @@ const scrubUrl = (url: string): string => {
|
|
|
224
294
|
return url;
|
|
225
295
|
}
|
|
226
296
|
};
|
|
297
|
+
|
|
298
|
+
// ── v0.9.0 #11 — GraphQL operation extraction ─────────────────────
|
|
299
|
+
//
|
|
300
|
+
// Cheap, sync, never throws. Two callers (fetch + xhr) feed in
|
|
301
|
+
// whatever they have on hand; both end up calling `parseGqlOpName`.
|
|
302
|
+
|
|
303
|
+
function lookGraphqlish(url: string, contentType?: string): boolean {
|
|
304
|
+
if (contentType) {
|
|
305
|
+
if (contentType.includes('graphql')) return true;
|
|
306
|
+
// application/json is too generic to gate on alone, but combined
|
|
307
|
+
// with a `/graphql` path it's a strong hint.
|
|
308
|
+
}
|
|
309
|
+
if (url.includes('/graphql')) return true;
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Pull `operationName` out of a JSON body or a raw query body. Returns
|
|
314
|
+
* `undefined` on any failure mode. Cap at GQL_BODY_MAX_BYTES so a
|
|
315
|
+
* hostile / oversize body never lands in `JSON.parse`. */
|
|
316
|
+
export function parseGqlOpName(body: string): string | undefined {
|
|
317
|
+
if (typeof body !== 'string' || body.length === 0) return undefined;
|
|
318
|
+
if (body.length > GQL_BODY_MAX_BYTES) return undefined;
|
|
319
|
+
// First char `{` or `[` → JSON path. Most clients (Apollo, urql,
|
|
320
|
+
// Relay) send `{"query":"…","operationName":"…","variables":{…}}`
|
|
321
|
+
// or an array of such objects (batched).
|
|
322
|
+
const first = body.charCodeAt(0);
|
|
323
|
+
if (first === 0x7b /* { */ || first === 0x5b /* [ */) {
|
|
324
|
+
try {
|
|
325
|
+
const parsed = JSON.parse(body) as unknown;
|
|
326
|
+
const candidate = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
327
|
+
if (candidate && typeof candidate === 'object') {
|
|
328
|
+
const name = (candidate as { operationName?: unknown }).operationName;
|
|
329
|
+
if (typeof name === 'string' && name.length > 0 && name.length <= 200) {
|
|
330
|
+
return name;
|
|
331
|
+
}
|
|
332
|
+
// No operationName — try to sniff the `query` string for
|
|
333
|
+
// `query Foo {…}` / `mutation Bar {…}` / `subscription Baz {…}`.
|
|
334
|
+
const q = (candidate as { query?: unknown }).query;
|
|
335
|
+
if (typeof q === 'string') return parseQueryStringOpName(q);
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
// `application/graphql` body is the bare query string — no JSON wrapper.
|
|
343
|
+
return parseQueryStringOpName(body);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseQueryStringOpName(query: string): string | undefined {
|
|
347
|
+
// Skip leading whitespace + comments. We only need the first non-trivial
|
|
348
|
+
// top-level operation keyword to extract a name; nested operations are
|
|
349
|
+
// a non-issue because GraphQL forbids them.
|
|
350
|
+
const m = /^\s*(?:#[^\n]*\n\s*)*(query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(
|
|
351
|
+
query,
|
|
352
|
+
);
|
|
353
|
+
return m?.[2];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function extractGraphqlOpFromInit(
|
|
357
|
+
init: RequestInit | undefined,
|
|
358
|
+
input: RequestInfo | URL,
|
|
359
|
+
url: string,
|
|
360
|
+
): string | undefined {
|
|
361
|
+
const method = (init?.method ??
|
|
362
|
+
(typeof input !== 'string' && 'method' in (input as Request)
|
|
363
|
+
? (input as Request).method
|
|
364
|
+
: 'GET')) as string;
|
|
365
|
+
if (method.toUpperCase() !== 'POST') return undefined;
|
|
366
|
+
const contentType = headerValue(init, input, 'content-type');
|
|
367
|
+
if (!lookGraphqlish(url, contentType)) return undefined;
|
|
368
|
+
const body = init?.body;
|
|
369
|
+
if (typeof body !== 'string') return undefined;
|
|
370
|
+
return parseGqlOpName(body);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function extractGraphqlOpFromXhr(
|
|
374
|
+
body: Document | XMLHttpRequestBodyInit | null | undefined,
|
|
375
|
+
url: string,
|
|
376
|
+
): string | undefined {
|
|
377
|
+
if (typeof body !== 'string') return undefined;
|
|
378
|
+
if (!lookGraphqlish(url)) return undefined;
|
|
379
|
+
return parseGqlOpName(body);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function headerValue(
|
|
383
|
+
init: RequestInit | undefined,
|
|
384
|
+
input: RequestInfo | URL,
|
|
385
|
+
name: string,
|
|
386
|
+
): string | undefined {
|
|
387
|
+
const target = name.toLowerCase();
|
|
388
|
+
if (init?.headers) {
|
|
389
|
+
try {
|
|
390
|
+
const h = new Headers(init.headers);
|
|
391
|
+
const v = h.get(target);
|
|
392
|
+
if (v) return v;
|
|
393
|
+
} catch {
|
|
394
|
+
// ignore — bad header shape, treat as absent
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (typeof input !== 'string' && !(input instanceof URL)) {
|
|
398
|
+
try {
|
|
399
|
+
const v = (input as Request).headers.get(target);
|
|
400
|
+
if (v) return v;
|
|
401
|
+
} catch {
|
|
402
|
+
// ignore
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,17 @@ import {
|
|
|
9
9
|
setUser,
|
|
10
10
|
} from './capture';
|
|
11
11
|
import { ErrorBoundary } from './error-boundary';
|
|
12
|
+
import {
|
|
13
|
+
clearAllFeatureFlags,
|
|
14
|
+
clearFeatureFlag,
|
|
15
|
+
getFeatureFlags,
|
|
16
|
+
setFeatureFlag,
|
|
17
|
+
} from './feature-flags';
|
|
12
18
|
import { clearMaskQuery, registerMaskQuery } from './mask';
|
|
19
|
+
import { measureFn } from './measure';
|
|
20
|
+
import { startMoment } from '@goliapkg/sentori-core';
|
|
21
|
+
import { flushMetrics, recordMetric } from './metrics';
|
|
22
|
+
import { RageTapCapture } from './rage-tap';
|
|
13
23
|
import {
|
|
14
24
|
endSession,
|
|
15
25
|
markSessionCrashed,
|
|
@@ -25,7 +35,16 @@ export const sentori = {
|
|
|
25
35
|
captureException,
|
|
26
36
|
captureStep,
|
|
27
37
|
sendUserFeedback,
|
|
38
|
+
recordMetric,
|
|
39
|
+
flushMetrics,
|
|
40
|
+
measureFn,
|
|
41
|
+
startMoment,
|
|
42
|
+
setFeatureFlag,
|
|
43
|
+
clearFeatureFlag,
|
|
44
|
+
clearAllFeatureFlags,
|
|
45
|
+
getFeatureFlags,
|
|
28
46
|
ErrorBoundary,
|
|
47
|
+
RageTapCapture,
|
|
29
48
|
registerMaskQuery,
|
|
30
49
|
clearMaskQuery,
|
|
31
50
|
startSession,
|
|
@@ -46,7 +65,17 @@ export {
|
|
|
46
65
|
setUser,
|
|
47
66
|
} from './capture';
|
|
48
67
|
export { ErrorBoundary } from './error-boundary';
|
|
68
|
+
export {
|
|
69
|
+
clearAllFeatureFlags,
|
|
70
|
+
clearFeatureFlag,
|
|
71
|
+
getFeatureFlags,
|
|
72
|
+
setFeatureFlag,
|
|
73
|
+
} from './feature-flags';
|
|
49
74
|
export { clearMaskQuery, registerMaskQuery } from './mask';
|
|
75
|
+
export { flushMetrics, recordMetric } from './metrics';
|
|
76
|
+
export { measureFn } from './measure';
|
|
77
|
+
export { MomentHandle, type MomentProperties, startMoment } from '@goliapkg/sentori-core';
|
|
78
|
+
export { RageTapCapture } from './rage-tap';
|
|
50
79
|
export {
|
|
51
80
|
startAnrWatchdog,
|
|
52
81
|
stopAnrWatchdog,
|
package/src/init.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { installGlobalHandler } from './handlers/global';
|
|
|
3
3
|
import { installLifecycleHandler } from './handlers/lifecycle';
|
|
4
4
|
import { installPromiseHandler } from './handlers/promise';
|
|
5
5
|
import { installNetworkHandler } from './handlers/network';
|
|
6
|
+
import { getBundleInfo } from './bundle-info';
|
|
7
|
+
import {
|
|
8
|
+
markLaunchCompleted,
|
|
9
|
+
runLaunchCrashGuard,
|
|
10
|
+
} from './launch-crash-guard';
|
|
11
|
+
import { startMetricsTimer } from './metrics';
|
|
6
12
|
import { drainNativePending, setNativeConfig } from './native';
|
|
7
13
|
import { startNetworkTypeWatch } from './netinfo';
|
|
8
14
|
import { startSession } from './session-tracker';
|
|
@@ -29,7 +35,14 @@ export type InitOptions = {
|
|
|
29
35
|
capture?: {
|
|
30
36
|
globalErrors?: boolean;
|
|
31
37
|
promiseRejections?: boolean;
|
|
32
|
-
network?:
|
|
38
|
+
network?:
|
|
39
|
+
| boolean
|
|
40
|
+
| {
|
|
41
|
+
/** v0.9.0 #11 — auto-extract GraphQL `operationName` from
|
|
42
|
+
* POST request bodies and use it as the breadcrumb / span
|
|
43
|
+
* name (instead of `POST /graphql`). Default `true`. */
|
|
44
|
+
graphql?: boolean;
|
|
45
|
+
};
|
|
33
46
|
/** Session tracking: opens a session on init and on each
|
|
34
47
|
* foreground (`AppState` → `active`), ends it on background.
|
|
35
48
|
* Drives crash-free rate. Set `false` to opt out. */
|
|
@@ -48,6 +61,20 @@ export type InitOptions = {
|
|
|
48
61
|
* the buffer is sealed and uploaded as a `sessionTrail`
|
|
49
62
|
* attachment. Defaults to false. */
|
|
50
63
|
sessionTrail?: boolean;
|
|
64
|
+
/** v0.9.0 #3 — launch-crash loop guard. When two consecutive
|
|
65
|
+
* launches don't reach `markLaunchCompleted()` (typical of an
|
|
66
|
+
* OTA update with a fatal bug), invoke the host callback with
|
|
67
|
+
* a 200 ms timeout to decide rollback / reset / continue. */
|
|
68
|
+
launchCrashGuard?: {
|
|
69
|
+
enabled: boolean;
|
|
70
|
+
onLaunchCrashDetected?: (
|
|
71
|
+
info: import('./launch-crash-guard').LaunchCrashInfo,
|
|
72
|
+
) =>
|
|
73
|
+
| import('./launch-crash-guard').LaunchCrashAction
|
|
74
|
+
| Promise<import('./launch-crash-guard').LaunchCrashAction>;
|
|
75
|
+
threshold?: number;
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
};
|
|
51
78
|
};
|
|
52
79
|
/** Phase 44 sub-B: client-side sampling. Each rate is `[0, 1]`;
|
|
53
80
|
* absent / null keeps everything. Defaults to 1.0 for both
|
|
@@ -75,6 +102,19 @@ export const init = (options: InitOptions): void => {
|
|
|
75
102
|
options.environment ??
|
|
76
103
|
(typeof __DEV__ !== 'undefined' && __DEV__ ? 'dev' : 'prod');
|
|
77
104
|
|
|
105
|
+
// v0.9.0 #3 — launch-crash guard. Fires *before* any other setup so
|
|
106
|
+
// a known-bad bundle can roll back instead of running JS that's
|
|
107
|
+
// about to die again. AsyncStorage-backed; if the host doesn't have
|
|
108
|
+
// it the guard is a no-op.
|
|
109
|
+
const lcg = options.capture?.launchCrashGuard;
|
|
110
|
+
if (lcg?.enabled) {
|
|
111
|
+
void runLaunchCrashGuard(
|
|
112
|
+
lcg,
|
|
113
|
+
options.release,
|
|
114
|
+
getBundleInfo()?.id ?? null,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
setConfig({
|
|
79
119
|
token: options.token,
|
|
80
120
|
release: options.release,
|
|
@@ -100,11 +140,16 @@ export const init = (options: InitOptions): void => {
|
|
|
100
140
|
// installed; events just won't carry `device.networkType` in that
|
|
101
141
|
// case.
|
|
102
142
|
startNetworkTypeWatch();
|
|
143
|
+
// v0.8.3 — drain custom-metric ring every 30 s.
|
|
144
|
+
startMetricsTimer();
|
|
103
145
|
|
|
104
146
|
const capture = options.capture ?? {};
|
|
105
147
|
if (capture.globalErrors !== false) installGlobalHandler();
|
|
106
148
|
if (capture.promiseRejections !== false) installPromiseHandler();
|
|
107
|
-
if (capture.network !== false)
|
|
149
|
+
if (capture.network !== false) {
|
|
150
|
+
const netOpts = typeof capture.network === 'object' ? capture.network : undefined;
|
|
151
|
+
installNetworkHandler({ graphql: netOpts?.graphql });
|
|
152
|
+
}
|
|
108
153
|
if (capture.sessions !== false) {
|
|
109
154
|
// Open the cold-start session now (RN doesn't fire an AppState
|
|
110
155
|
// `change` for the initial `active` state), then bind AppState so
|
|
@@ -151,6 +196,16 @@ export const init = (options: InitOptions): void => {
|
|
|
151
196
|
})
|
|
152
197
|
.catch(() => {});
|
|
153
198
|
drainOfflineQueue().catch(() => {});
|
|
199
|
+
|
|
200
|
+
// v0.9.0 #3 — init reached the end without throwing. Schedule the
|
|
201
|
+
// "launch completed" marker after one tick so any synchronous user
|
|
202
|
+
// code right after `init()` gets to run first; we want the marker to
|
|
203
|
+
// confirm the JS bridge stayed alive, not just that `init()` returned.
|
|
204
|
+
if (lcg?.enabled) {
|
|
205
|
+
setTimeout(() => {
|
|
206
|
+
void markLaunchCompleted(getBundleInfo()?.id ?? null);
|
|
207
|
+
}, 2_000);
|
|
208
|
+
}
|
|
154
209
|
};
|
|
155
210
|
|
|
156
211
|
/**
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// v0.9.0 #3 — launch-crash loop guard.
|
|
2
|
+
//
|
|
3
|
+
// On every init we write a "launch_marker" to AsyncStorage. On
|
|
4
|
+
// `markLaunchCompleted()` we write a sibling "launch_completed". On
|
|
5
|
+
// startup we look at the previous launch state: marker present but
|
|
6
|
+
// completed missing → previous launch did not finish → increment a
|
|
7
|
+
// consecutive-crash counter.
|
|
8
|
+
//
|
|
9
|
+
// When the counter crosses `threshold` (default 2), we invoke the
|
|
10
|
+
// host-supplied `onLaunchCrashDetected` callback with a 200 ms timeout
|
|
11
|
+
// (D3) and follow its action: rollback the OTA bundle, reset a list
|
|
12
|
+
// of AsyncStorage keys, or continue. Rollback / reset trigger an
|
|
13
|
+
// `expo-updates` reload when available.
|
|
14
|
+
//
|
|
15
|
+
// v0.9.0 scope: JS-only — catches everything that runs after the JS
|
|
16
|
+
// bridge is up (almost every OTA-induced launch crash). v0.9.1 will
|
|
17
|
+
// add a native marker for the small set of "crashed before bridge"
|
|
18
|
+
// cases.
|
|
19
|
+
|
|
20
|
+
const MARKER_KEY = '@sentori/launch_marker';
|
|
21
|
+
const COMPLETED_KEY = '@sentori/launch_completed';
|
|
22
|
+
const COUNT_KEY = '@sentori/launch_crash_count';
|
|
23
|
+
|
|
24
|
+
export type LaunchCrashInfo = {
|
|
25
|
+
/** Consecutive failed launches detected so far (this one inclusive). */
|
|
26
|
+
consecutiveCount: number;
|
|
27
|
+
/** OTA bundle id of the crashing launch, if known. */
|
|
28
|
+
crashedBundle: null | string;
|
|
29
|
+
/** Most recent bundle id that *did* reach `markLaunchCompleted`. */
|
|
30
|
+
lastSafeBundle: null | string;
|
|
31
|
+
/** Store-binary release of the crashing launch. */
|
|
32
|
+
release: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type LaunchCrashAction =
|
|
36
|
+
| { action: 'continue' }
|
|
37
|
+
| { action: 'reset'; clearKeys: string[] }
|
|
38
|
+
| { action: 'rollback'; toBundle?: null | string };
|
|
39
|
+
|
|
40
|
+
export type LaunchCrashGuardOptions = {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
onLaunchCrashDetected?: (info: LaunchCrashInfo) => LaunchCrashAction | Promise<LaunchCrashAction>;
|
|
43
|
+
/** Default 2 — fires after the second consecutive failed launch. */
|
|
44
|
+
threshold?: number;
|
|
45
|
+
/** Default 200 — D3 decision. */
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type AsyncStorageLike = {
|
|
50
|
+
getItem: (key: string) => Promise<null | string>;
|
|
51
|
+
multiRemove?: (keys: string[]) => Promise<void>;
|
|
52
|
+
removeItem: (key: string) => Promise<void>;
|
|
53
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function loadAsyncStorage(): AsyncStorageLike | null {
|
|
57
|
+
try {
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
|
+
const mod = require('@react-native-async-storage/async-storage') as {
|
|
60
|
+
default?: AsyncStorageLike;
|
|
61
|
+
};
|
|
62
|
+
return mod.default ?? (mod as unknown as AsyncStorageLike);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Returns `false` iff we triggered a bundle rollback / reset and
|
|
69
|
+
* expect the app to reload momentarily; the caller (init) should
|
|
70
|
+
* short-circuit further setup. */
|
|
71
|
+
export async function runLaunchCrashGuard(
|
|
72
|
+
opts: LaunchCrashGuardOptions,
|
|
73
|
+
release: string,
|
|
74
|
+
currentBundleId: null | string,
|
|
75
|
+
): Promise<{ shouldContinueInit: boolean; info?: LaunchCrashInfo }> {
|
|
76
|
+
if (!opts.enabled) return { shouldContinueInit: true };
|
|
77
|
+
const storage = loadAsyncStorage();
|
|
78
|
+
if (!storage) return { shouldContinueInit: true };
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const marker = await storage.getItem(MARKER_KEY);
|
|
82
|
+
const completed = await storage.getItem(COMPLETED_KEY);
|
|
83
|
+
|
|
84
|
+
if (marker && !completed) {
|
|
85
|
+
const m = safeJsonParse<{ bundleId?: string; lastSafeBundle?: string }>(marker) ?? {};
|
|
86
|
+
const prevCount = parseInt((await storage.getItem(COUNT_KEY)) ?? '0', 10) || 0;
|
|
87
|
+
const consecutiveCount = prevCount + 1;
|
|
88
|
+
await storage.setItem(COUNT_KEY, String(consecutiveCount));
|
|
89
|
+
|
|
90
|
+
if (consecutiveCount >= (opts.threshold ?? 2) && opts.onLaunchCrashDetected) {
|
|
91
|
+
const info: LaunchCrashInfo = {
|
|
92
|
+
consecutiveCount,
|
|
93
|
+
crashedBundle: m.bundleId ?? null,
|
|
94
|
+
lastSafeBundle: m.lastSafeBundle ?? null,
|
|
95
|
+
release,
|
|
96
|
+
};
|
|
97
|
+
const action = await raceWithTimeout<LaunchCrashAction>(
|
|
98
|
+
Promise.resolve(opts.onLaunchCrashDetected(info)),
|
|
99
|
+
opts.timeoutMs ?? 200,
|
|
100
|
+
{ action: 'continue' },
|
|
101
|
+
);
|
|
102
|
+
const handled = await applyAction(action, storage);
|
|
103
|
+
if (!handled.shouldContinueInit) {
|
|
104
|
+
return { ...handled, info };
|
|
105
|
+
}
|
|
106
|
+
return { ...handled, info };
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// Previous launch completed; clean the counter.
|
|
110
|
+
await storage.setItem(COUNT_KEY, '0');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Write the marker for THIS launch. lastSafeBundle = previous
|
|
114
|
+
// completed bundle id, so the user's callback can target it.
|
|
115
|
+
const lastSafeBundle =
|
|
116
|
+
(completed && safeJsonParse<{ bundleId?: string }>(completed)?.bundleId) ?? null;
|
|
117
|
+
await storage.setItem(
|
|
118
|
+
MARKER_KEY,
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
bundleId: currentBundleId,
|
|
121
|
+
lastSafeBundle,
|
|
122
|
+
release,
|
|
123
|
+
ts: Date.now(),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
await storage.removeItem(COMPLETED_KEY);
|
|
127
|
+
} catch {
|
|
128
|
+
// AsyncStorage glitches must never block init.
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { shouldContinueInit: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function markLaunchCompleted(currentBundleId: null | string): Promise<void> {
|
|
135
|
+
const storage = loadAsyncStorage();
|
|
136
|
+
if (!storage) return;
|
|
137
|
+
try {
|
|
138
|
+
await storage.setItem(
|
|
139
|
+
COMPLETED_KEY,
|
|
140
|
+
JSON.stringify({ bundleId: currentBundleId, ts: Date.now() }),
|
|
141
|
+
);
|
|
142
|
+
await storage.setItem(COUNT_KEY, '0');
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function applyAction(
|
|
149
|
+
action: LaunchCrashAction,
|
|
150
|
+
storage: AsyncStorageLike,
|
|
151
|
+
): Promise<{ shouldContinueInit: boolean }> {
|
|
152
|
+
if (action.action === 'continue') return { shouldContinueInit: true };
|
|
153
|
+
if (action.action === 'reset') {
|
|
154
|
+
if (storage.multiRemove && Array.isArray(action.clearKeys)) {
|
|
155
|
+
try {
|
|
156
|
+
await storage.multiRemove(action.clearKeys);
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await reloadOTAIfPossible();
|
|
162
|
+
return { shouldContinueInit: false };
|
|
163
|
+
}
|
|
164
|
+
if (action.action === 'rollback') {
|
|
165
|
+
await reloadOTAIfPossible();
|
|
166
|
+
return { shouldContinueInit: false };
|
|
167
|
+
}
|
|
168
|
+
return { shouldContinueInit: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function reloadOTAIfPossible(): Promise<void> {
|
|
172
|
+
try {
|
|
173
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
174
|
+
const Updates = require('expo-updates') as {
|
|
175
|
+
reloadAsync?: () => Promise<void>;
|
|
176
|
+
};
|
|
177
|
+
if (typeof Updates.reloadAsync === 'function') {
|
|
178
|
+
await Updates.reloadAsync();
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// expo-updates not installed — caller will fall through and
|
|
182
|
+
// continue init; their callback returned `rollback` but we can't
|
|
183
|
+
// perform it without the OTA library. Document accordingly.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function raceWithTimeout<T>(p: Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
188
|
+
return new Promise<T>((resolve) => {
|
|
189
|
+
let done = false;
|
|
190
|
+
const t = setTimeout(() => {
|
|
191
|
+
if (!done) {
|
|
192
|
+
done = true;
|
|
193
|
+
resolve(fallback);
|
|
194
|
+
}
|
|
195
|
+
}, ms);
|
|
196
|
+
p.then(
|
|
197
|
+
(v) => {
|
|
198
|
+
if (!done) {
|
|
199
|
+
done = true;
|
|
200
|
+
clearTimeout(t);
|
|
201
|
+
resolve(v);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
() => {
|
|
205
|
+
if (!done) {
|
|
206
|
+
done = true;
|
|
207
|
+
clearTimeout(t);
|
|
208
|
+
resolve(fallback);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function safeJsonParse<T>(s: string): null | T {
|
|
216
|
+
try {
|
|
217
|
+
return JSON.parse(s) as T;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|