@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.
Files changed (64) hide show
  1. package/lib/bundle-info.d.ts +12 -0
  2. package/lib/bundle-info.d.ts.map +1 -0
  3. package/lib/bundle-info.js +73 -0
  4. package/lib/bundle-info.js.map +1 -0
  5. package/lib/capture.d.ts.map +1 -1
  6. package/lib/capture.js +6 -0
  7. package/lib/capture.js.map +1 -1
  8. package/lib/feature-flags.d.ts +9 -0
  9. package/lib/feature-flags.d.ts.map +1 -0
  10. package/lib/feature-flags.js +44 -0
  11. package/lib/feature-flags.js.map +1 -0
  12. package/lib/handlers/network.d.ts +9 -1
  13. package/lib/handlers/network.d.ts.map +1 -1
  14. package/lib/handlers/network.js +189 -18
  15. package/lib/handlers/network.js.map +1 -1
  16. package/lib/index.d.ts +18 -0
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +19 -0
  19. package/lib/index.js.map +1 -1
  20. package/lib/init.d.ts +16 -1
  21. package/lib/init.d.ts.map +1 -1
  22. package/lib/init.js +26 -2
  23. package/lib/init.js.map +1 -1
  24. package/lib/launch-crash-guard.d.ts +37 -0
  25. package/lib/launch-crash-guard.d.ts.map +1 -0
  26. package/lib/launch-crash-guard.js +163 -0
  27. package/lib/launch-crash-guard.js.map +1 -0
  28. package/lib/measure.d.ts +4 -0
  29. package/lib/measure.d.ts.map +1 -0
  30. package/lib/measure.js +25 -0
  31. package/lib/measure.js.map +1 -0
  32. package/lib/metrics.d.ts +9 -0
  33. package/lib/metrics.d.ts.map +1 -0
  34. package/lib/metrics.js +64 -0
  35. package/lib/metrics.js.map +1 -0
  36. package/lib/rage-tap-detector.d.ts +8 -0
  37. package/lib/rage-tap-detector.d.ts.map +1 -0
  38. package/lib/rage-tap-detector.js +21 -0
  39. package/lib/rage-tap-detector.js.map +1 -0
  40. package/lib/rage-tap.d.ts +6 -0
  41. package/lib/rage-tap.d.ts.map +1 -0
  42. package/lib/rage-tap.js +35 -0
  43. package/lib/rage-tap.js.map +1 -0
  44. package/lib/transport.d.ts +12 -0
  45. package/lib/transport.d.ts.map +1 -1
  46. package/lib/transport.js +24 -0
  47. package/lib/transport.js.map +1 -1
  48. package/package.json +11 -3
  49. package/src/__tests__/feature-flags.test.ts +55 -0
  50. package/src/__tests__/measure.test.ts +45 -0
  51. package/src/__tests__/network-graphql.test.ts +75 -0
  52. package/src/__tests__/rage-tap.test.ts +38 -0
  53. package/src/bundle-info.ts +95 -0
  54. package/src/capture.ts +6 -0
  55. package/src/feature-flags.ts +47 -0
  56. package/src/handlers/network.ts +198 -18
  57. package/src/index.ts +29 -0
  58. package/src/init.ts +57 -2
  59. package/src/launch-crash-guard.ts +221 -0
  60. package/src/measure.ts +28 -0
  61. package/src/metrics.ts +74 -0
  62. package/src/rage-tap-detector.ts +26 -0
  63. package/src/rage-tap.tsx +48 -0
  64. package/src/transport.ts +32 -0
@@ -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: `${method.toUpperCase()} ${normalizeUrl(scrubbed)}`,
46
- tags: { 'http.method': method.toUpperCase(), 'http.url': scrubbed },
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
- method,
63
- url: scrubbed,
64
- status: resp.status,
65
- durationMs: Date.now() - start,
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: { 'http.method': method, 'http.url': url },
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
- method,
161
- url,
162
- status,
163
- durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
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: { method, url, status: 0, durationMs: Date.now() - (this.__sentoriStart ?? Date.now()), error: 'aborted' },
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?: boolean;
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) installNetworkHandler();
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
+ }