@logbrew/react-native 0.1.20 → 0.1.21

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/README.md CHANGED
@@ -347,6 +347,16 @@ clear it with `setLogBrewAppleNativeCrashContext(null)` on logout or session
347
347
  end. The crash report keeps one atomic snapshot from the crashed process, so a
348
348
  later app launch cannot replace it. Session and subject values must be opaque app-owned identifiers made from ASCII letters, numbers, `_`, or `-`. Do not use names, email addresses, IP addresses, or device identifiers. Resource context, tags, arbitrary fields, and values over the fixed 1 KiB snapshot limit fail before storage.
349
349
 
350
+ The iOS `createLogBrewReactNativeClient()` entry also mirrors its validated
351
+ breadcrumb history into native crash capture. Install Apple diagnostics first,
352
+ then use the normal screen, app-state, action, network, or `addBreadcrumb()`
353
+ helpers. A fatal crash retains the newest complete entries in oldest-to-newest
354
+ order. The snapshot keeps at most 64 entries and 64 KiB, drops oldest entries
355
+ when either limit is reached, and reports truncation. `clearBreadcrumbs()`
356
+ clears both the JavaScript history and its native crash snapshot. Missing,
357
+ corrupt, and captured breadcrumb state remains explicit on replay; corrupt
358
+ optional context never discards the crash.
359
+
350
360
  Installation creates app-private, data-protected storage that iOS excludes from
351
361
  device data archives. Pending reports are partitioned by project, so changing
352
362
  the configured project does not replay an earlier project's records with the
@@ -48,17 +48,31 @@ export function setLogBrewAppleNativeCrashContext(context) {
48
48
  requireApplePlatform();
49
49
  const nativeModule = requireNativeModule();
50
50
  const payload = context === null ? null : normalizeCorrelationContext(context);
51
- const result = callNative(nativeModule, "setNativeDiagnosticsContext", payload);
51
+ return updateNativeSnapshot(nativeModule, "setNativeDiagnosticsContext", payload, "context");
52
+ }
53
+
54
+ export function syncLogBrewAppleNativeCrashBreadcrumbs(snapshot) {
55
+ requireApplePlatform();
56
+ return updateNativeSnapshot(
57
+ requireNativeModule(),
58
+ "setNativeDiagnosticsBreadcrumbs",
59
+ snapshot,
60
+ "breadcrumbs"
61
+ );
62
+ }
63
+
64
+ function updateNativeSnapshot(nativeModule, method, payload, label) {
65
+ const result = callNative(nativeModule, method, payload);
52
66
  const expectedStatus = payload === null ? "cleared" : "updated";
53
67
  if (isErrorResult(result)) {
54
- throw new SdkError(result.code, `LogBrew Apple native diagnostics context failed with ${result.code}`);
68
+ throw new SdkError(result.code, `LogBrew Apple native diagnostics ${label} failed with ${result.code}`);
55
69
  }
56
70
  if (!isPlainObject(result)
57
71
  || Object.keys(result).length !== 1
58
72
  || result.status !== expectedStatus) {
59
73
  throw new SdkError(
60
74
  "native_diagnostics_invalid_response",
61
- "LogBrew Apple native diagnostics context returned an invalid response"
75
+ `LogBrew Apple native diagnostics ${label} returned an invalid response`
62
76
  );
63
77
  }
64
78
  return Object.freeze({ status: expectedStatus });
package/index.cjs CHANGED
@@ -16,7 +16,7 @@ const {
16
16
  } = require("./metadata.cjs");
17
17
 
18
18
  const DEFAULT_SDK_NAME = "logbrew-react-native";
19
- const DEFAULT_SDK_VERSION = "0.1.20";
19
+ const DEFAULT_SDK_VERSION = "0.1.21";
20
20
  const DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events";
21
21
  const MAX_ACTION_NAME_LENGTH = 64;
22
22
  const MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH = 256;
package/index.native.js CHANGED
@@ -14,7 +14,8 @@ import {
14
14
  getLogBrewAppleNativeDiagnosticsStatus,
15
15
  installLogBrewAppleNativeDiagnostics,
16
16
  replayLogBrewAppleNativeDiagnostics,
17
- setLogBrewAppleNativeCrashContext
17
+ setLogBrewAppleNativeCrashContext,
18
+ syncLogBrewAppleNativeCrashBreadcrumbs
18
19
  } from "./apple-native-diagnostics.js";
19
20
  import {
20
21
  purgeReactNativePersistentQueue,
@@ -50,7 +51,7 @@ export function createLogBrewReactNativeClient(config = {}) {
50
51
  ) && input.persistentQueue !== undefined;
51
52
  const authKey = clientKey ?? apiKey;
52
53
  if (typeof authKey !== "string" || authKey.trim() === "") {
53
- return createPlatformNeutralClient(input);
54
+ return bindAppleNativeCrashBreadcrumbs(createPlatformNeutralClient(input));
54
55
  }
55
56
  const resolved = resolveReactNativePersistentEventStore({
56
57
  authKey,
@@ -61,20 +62,72 @@ export function createLogBrewReactNativeClient(config = {}) {
61
62
  hasExplicitPersistentQueue
62
63
  });
63
64
  try {
64
- return createPlatformNeutralClient({
65
+ return bindAppleNativeCrashBreadcrumbs(createPlatformNeutralClient({
65
66
  ...forwarded,
66
67
  apiKey,
67
68
  clientKey,
68
69
  eventStore: resolved.eventStore,
69
70
  maxQueueBytes,
70
71
  maxQueueSize
71
- });
72
+ }));
72
73
  } catch (error) {
73
74
  resolved.abort();
74
75
  throw error;
75
76
  }
76
77
  }
77
78
 
79
+ function bindAppleNativeCrashBreadcrumbs(client) {
80
+ if (Platform?.OS !== "ios") {
81
+ return client;
82
+ }
83
+ const addBreadcrumb = client.addBreadcrumb.bind(client);
84
+ const clearBreadcrumbs = client.clearBreadcrumbs.bind(client);
85
+ client.addBreadcrumb = (...args) => updateAppleNativeBreadcrumbs(
86
+ client, () => addBreadcrumb(...args)
87
+ );
88
+ client.clearBreadcrumbs = () => updateAppleNativeBreadcrumbs(client, clearBreadcrumbs);
89
+ syncAppleNativeCrashBreadcrumbs(client);
90
+ return client;
91
+ }
92
+
93
+ function updateAppleNativeBreadcrumbs(client, update) {
94
+ const previous = [client.issueBreadcrumbs.slice(), client.issueBreadcrumbsTruncated];
95
+ const result = update();
96
+ try {
97
+ syncAppleNativeCrashBreadcrumbs(client);
98
+ } catch (error) {
99
+ restoreBreadcrumbs(client, previous);
100
+ throw error;
101
+ }
102
+ return result;
103
+ }
104
+
105
+ function restoreBreadcrumbs(client, [breadcrumbs, truncated]) {
106
+ client.issueBreadcrumbs.splice(0, client.issueBreadcrumbs.length, ...breadcrumbs);
107
+ client.issueBreadcrumbsTruncated = truncated;
108
+ }
109
+
110
+ function syncAppleNativeCrashBreadcrumbs(client) {
111
+ const snapshot = client.issueBreadcrumbs.length === 0
112
+ ? null
113
+ : {
114
+ breadcrumbs: client.issueBreadcrumbs.map(({ data, ...breadcrumb }) => ({
115
+ ...breadcrumb,
116
+ ...(data === undefined ? {} : { data: { ...data } })
117
+ })),
118
+ schemaVersion: 1,
119
+ truncated: client.issueBreadcrumbsTruncated
120
+ };
121
+ try {
122
+ syncLogBrewAppleNativeCrashBreadcrumbs(snapshot);
123
+ } catch (error) {
124
+ if (error?.code !== "native_diagnostics_unavailable"
125
+ && error?.code !== "native_diagnostics_not_installed") {
126
+ throw error;
127
+ }
128
+ }
129
+ }
130
+
78
131
  export function purgeLogBrewReactNativePersistentQueue(config = {}) {
79
132
  purgeReactNativePersistentQueue(config);
80
133
  }
@@ -1,634 +1,5 @@
1
- import { SdkError } from "@logbrew/sdk";
2
- import {
3
- captureReactNativeResourceSpan,
4
- createReactNavigationSpanListener,
5
- createReactNativeTraceContext,
6
- createReactNativeTraceHeaders,
7
- getActiveLogBrewTrace,
8
- shouldPropagateTraceparent
9
- } from "./index.js";
10
- import { createAppStateLifecycleSpanListener } from "./lifecycle.js";
11
- import { createSafeReactNativeMetadata } from "./metadata.js";
12
- import {
13
- clearLogBrewNativeBridgeScope,
14
- syncLogBrewNativeBridgeScope,
15
- withLogBrewNativeBridgeScope
16
- } from "./native-bridge.js";
17
- import { createReactNativeResourceFetch } from "./resource-fetch.js";
1
+ import implementation from "./instrumentation.cjs";
18
2
 
19
- export function createLogBrewReactNativeInstrumentation(client, {
20
- appState,
21
- captureInitialLifecycleState = false,
22
- captureInitialNavigationRoute = false,
23
- fetchImpl,
24
- globalObject = globalThis,
25
- includeRouteKey = false,
26
- instrumentGlobalFetch = false,
27
- instrumentGlobalXMLHttpRequest = false,
28
- logger,
29
- measureFetchResponseBodySize = false,
30
- measureXhrResponseBodySize = false,
31
- metadata = {},
32
- metadataFactory,
33
- nativeBridge,
34
- navigation,
35
- navigationContainer,
36
- now = () => new Date().toISOString(),
37
- nowMs = () => Date.now(),
38
- onError,
39
- platform,
40
- randomValues,
41
- routeTemplate,
42
- routeTemplateFactory,
43
- screen,
44
- sessionId,
45
- trace,
46
- traceFlags = "01",
47
- tracePropagationTargets = []
48
- } = {}) {
49
- requireClient(client);
50
- const activeTrace = resolveInstrumentationTrace({ randomValues, trace, traceFlags });
51
- const removers = [];
52
- const resolvedNavigation = navigationContainer ?? navigation;
3
+ export const { createLogBrewReactNativeInstrumentation } = implementation;
53
4
 
54
- if (appState?.addEventListener) {
55
- removers.push(createAppStateLifecycleSpanListener(client, appState, {
56
- captureInitialState: captureInitialLifecycleState,
57
- metadata,
58
- now,
59
- nowMs,
60
- onError,
61
- platform,
62
- screen,
63
- sessionId,
64
- trace: activeTrace
65
- }));
66
- }
67
-
68
- if (resolvedNavigation) {
69
- removers.push(createReactNavigationSpanListener(client, resolvedNavigation, {
70
- appState,
71
- captureInitialRoute: captureInitialNavigationRoute,
72
- includeRouteKey,
73
- metadata,
74
- now,
75
- nowMs,
76
- onError,
77
- platform,
78
- trace: activeTrace
79
- }));
80
- }
81
-
82
- if (nativeBridge) {
83
- syncLogBrewNativeBridgeScope(nativeBridge, {
84
- logger,
85
- metadata,
86
- screen,
87
- sessionId,
88
- source: "react-native.instrumentation",
89
- trace: activeTrace
90
- });
91
- }
92
-
93
- const resourceFetch = createReactNativeResourceFetch(client, {
94
- appState,
95
- fetchImpl,
96
- measureResponseBodySize: measureFetchResponseBodySize,
97
- metadata,
98
- metadataFactory,
99
- now,
100
- nowMs,
101
- platform,
102
- randomValues,
103
- routeTemplate,
104
- routeTemplateFactory,
105
- screen,
106
- sessionId,
107
- trace: activeTrace,
108
- traceFlags,
109
- tracePropagationTargets
110
- });
111
- let globalFetch;
112
- let globalXMLHttpRequest;
113
- try {
114
- globalFetch = instrumentGlobalFetch ? installGlobalFetchInstrumentation(client, {
115
- appState,
116
- globalObject,
117
- measureFetchResponseBodySize,
118
- metadata,
119
- metadataFactory,
120
- now,
121
- nowMs,
122
- platform,
123
- randomValues,
124
- routeTemplate,
125
- routeTemplateFactory,
126
- screen,
127
- sessionId,
128
- trace: activeTrace,
129
- traceFlags,
130
- tracePropagationTargets
131
- }) : undefined;
132
- if (globalFetch) {
133
- removers.push(globalFetch.remove);
134
- }
135
- globalXMLHttpRequest = instrumentGlobalXMLHttpRequest ? installGlobalXMLHttpRequestInstrumentation(client, {
136
- appState,
137
- globalObject,
138
- measureXhrResponseBodySize,
139
- metadata,
140
- metadataFactory,
141
- now,
142
- nowMs,
143
- platform,
144
- routeTemplate,
145
- routeTemplateFactory,
146
- screen,
147
- sessionId,
148
- trace: activeTrace,
149
- tracePropagationTargets
150
- }) : undefined;
151
- if (globalXMLHttpRequest) {
152
- removers.push(globalXMLHttpRequest.remove);
153
- }
154
- } catch (error) {
155
- removeConfiguredInstrumentation({ nativeBridge, removers });
156
- throw error;
157
- }
158
-
159
- let removed = false;
160
- const remove = () => {
161
- if (removed) {
162
- return;
163
- }
164
- removed = true;
165
- removeConfiguredInstrumentation({ nativeBridge, removers });
166
- };
167
-
168
- const handle = {
169
- globalFetch,
170
- globalXMLHttpRequest,
171
- remove,
172
- resourceFetch,
173
- stop: remove,
174
- syncNativeBridgeScope(options = {}) {
175
- if (!nativeBridge) {
176
- return undefined;
177
- }
178
- return syncLogBrewNativeBridgeScope(nativeBridge, {
179
- logger,
180
- metadata,
181
- screen,
182
- sessionId,
183
- source: "react-native.instrumentation",
184
- trace: activeTrace,
185
- ...options
186
- });
187
- },
188
- trace: activeTrace,
189
- withNativeBridgeScope(callbackOrOptions, maybeCallback) {
190
- if (!nativeBridge) {
191
- throw new SdkError("configuration_error", "withNativeBridgeScope requires nativeBridge");
192
- }
193
- if (typeof callbackOrOptions === "function") {
194
- return withLogBrewNativeBridgeScope(nativeBridge, {
195
- logger,
196
- metadata,
197
- screen,
198
- sessionId,
199
- source: "react-native.instrumentation",
200
- trace: activeTrace
201
- }, callbackOrOptions);
202
- }
203
- return withLogBrewNativeBridgeScope(nativeBridge, {
204
- logger,
205
- metadata,
206
- screen,
207
- sessionId,
208
- source: "react-native.instrumentation",
209
- trace: activeTrace,
210
- ...(callbackOrOptions ?? {})
211
- }, maybeCallback);
212
- }
213
- };
214
- return Object.freeze(handle);
215
- }
216
-
217
- function resolveInstrumentationTrace({ randomValues, trace, traceFlags }) {
218
- if (typeof trace === "string") {
219
- return createReactNativeTraceContext({ randomValues, traceFlags, traceparent: trace });
220
- }
221
- return trace ?? getActiveLogBrewTrace() ?? createReactNativeTraceContext({ randomValues, traceFlags });
222
- }
223
-
224
- function requireClient(client) {
225
- if (!client) {
226
- throw new SdkError("configuration_error", "createLogBrewReactNativeInstrumentation requires a client");
227
- }
228
- }
229
-
230
- function removeConfiguredInstrumentation({ nativeBridge, removers }) {
231
- if (nativeBridge) {
232
- clearLogBrewNativeBridgeScope(nativeBridge);
233
- }
234
- for (const removeListener of removers.splice(0).reverse()) {
235
- removeListener();
236
- }
237
- }
238
-
239
- function installGlobalFetchInstrumentation(client, {
240
- appState,
241
- globalObject,
242
- measureFetchResponseBodySize,
243
- metadata,
244
- metadataFactory,
245
- now,
246
- nowMs,
247
- platform,
248
- randomValues,
249
- routeTemplate,
250
- routeTemplateFactory,
251
- screen,
252
- sessionId,
253
- trace,
254
- traceFlags,
255
- tracePropagationTargets
256
- }) {
257
- if ((typeof globalObject !== "object" && typeof globalObject !== "function") || globalObject === null) {
258
- throw new SdkError("configuration_error", "instrumentGlobalFetch requires a globalObject");
259
- }
260
- const originalFetch = globalObject.fetch;
261
- if (typeof originalFetch !== "function") {
262
- throw new SdkError("configuration_error", "instrumentGlobalFetch requires globalObject.fetch");
263
- }
264
- const globalResourceFetch = createReactNativeResourceFetch(client, {
265
- appState,
266
- fetchImpl: (input, init) => originalFetch.call(globalObject, input, init),
267
- measureResponseBodySize: measureFetchResponseBodySize,
268
- metadata,
269
- metadataFactory,
270
- now,
271
- nowMs,
272
- platform,
273
- randomValues,
274
- routeTemplate,
275
- routeTemplateFactory,
276
- screen,
277
- sessionId,
278
- trace,
279
- traceFlags,
280
- tracePropagationTargets
281
- });
282
- let removed = false;
283
- const wrappedFetch = (input, init) => globalResourceFetch(input, init);
284
- try {
285
- globalObject.fetch = wrappedFetch;
286
- } catch {
287
- throw new SdkError("configuration_error", "instrumentGlobalFetch could not patch globalObject.fetch");
288
- }
289
- if (globalObject.fetch !== wrappedFetch) {
290
- throw new SdkError("configuration_error", "instrumentGlobalFetch could not patch globalObject.fetch");
291
- }
292
- const remove = () => {
293
- if (removed) {
294
- return;
295
- }
296
- removed = true;
297
- if (globalObject.fetch === wrappedFetch) {
298
- globalObject.fetch = originalFetch;
299
- }
300
- };
301
- return Object.freeze({
302
- fetch: wrappedFetch,
303
- remove,
304
- stop: remove
305
- });
306
- }
307
-
308
- /* eslint-disable no-invalid-this */
309
- function installGlobalXMLHttpRequestInstrumentation(client, {
310
- appState,
311
- globalObject,
312
- measureXhrResponseBodySize,
313
- metadata,
314
- metadataFactory,
315
- now,
316
- nowMs,
317
- platform,
318
- routeTemplate,
319
- routeTemplateFactory,
320
- screen,
321
- sessionId,
322
- trace,
323
- tracePropagationTargets
324
- }) {
325
- if ((typeof globalObject !== "object" && typeof globalObject !== "function") || globalObject === null) {
326
- throw new SdkError("configuration_error", "instrumentGlobalXMLHttpRequest requires a globalObject");
327
- }
328
- const xhrType = globalObject.XMLHttpRequest;
329
- if (typeof xhrType !== "function" || !xhrType.prototype) {
330
- throw new SdkError("configuration_error", "instrumentGlobalXMLHttpRequest requires globalObject.XMLHttpRequest");
331
- }
332
- const prototype = xhrType.prototype;
333
- const originalOpen = prototype.open;
334
- const originalSend = prototype.send;
335
- const originalSetRequestHeader = prototype.setRequestHeader;
336
- if (typeof originalOpen !== "function" || typeof originalSend !== "function" || typeof originalSetRequestHeader !== "function") {
337
- throw new SdkError("configuration_error", "instrumentGlobalXMLHttpRequest requires open, send, and setRequestHeader");
338
- }
339
-
340
- const contextKey = Symbol("logbrew.xhr");
341
- const headersReceivedState = typeof xhrType.HEADERS_RECEIVED === "number" ? xhrType.HEADERS_RECEIVED : 2;
342
- const doneState = typeof xhrType.DONE === "number" ? xhrType.DONE : 4;
343
- const safeRouteTemplateFactory = routeTemplateFactory ?? defaultXhrRouteTemplateFactory;
344
-
345
- function wrappedOpen(method, url) {
346
- this[contextKey] = {
347
- method: normalizeXhrMethod(method),
348
- reported: false,
349
- url: xhrUrl(url)
350
- };
351
- return originalOpen.apply(this, arguments);
352
- }
353
-
354
- function wrappedSend(body) {
355
- const context = this[contextKey];
356
- if (!context) {
357
- return originalSend.apply(this, arguments);
358
- }
359
- context.body = xhrRequestBody(body);
360
- context.startedAtMs = nowMs();
361
- context.timestamp = now();
362
- installXhrReadyStateTracker(this, context, {
363
- appState,
364
- client,
365
- doneState,
366
- headersReceivedState,
367
- measureXhrResponseBodySize,
368
- metadata,
369
- metadataFactory,
370
- nowMs,
371
- platform,
372
- routeTemplate,
373
- routeTemplateFactory: safeRouteTemplateFactory,
374
- screen,
375
- sessionId,
376
- trace
377
- });
378
- if (shouldPropagateTraceparent(context.url, tracePropagationTargets)) {
379
- originalSetRequestHeader.call(this, "traceparent", createReactNativeTraceHeaders(trace).traceparent);
380
- }
381
- try {
382
- return originalSend.apply(this, arguments);
383
- } catch (error) {
384
- captureXhrResourceSpan(this, context, {
385
- appState,
386
- client,
387
- metadata: {
388
- ...metadata,
389
- xhrErrorName: errorName(error),
390
- xhrErrorValueType: typeof error
391
- },
392
- metadataFactory,
393
- nowMs,
394
- platform,
395
- routeTemplate,
396
- routeTemplateFactory: safeRouteTemplateFactory,
397
- screen,
398
- sessionId,
399
- status: "error",
400
- trace
401
- });
402
- throw error;
403
- }
404
- }
405
-
406
- prototype.open = wrappedOpen;
407
- prototype.send = wrappedSend;
408
- if (prototype.open !== wrappedOpen || prototype.send !== wrappedSend) {
409
- throw new SdkError("configuration_error", "instrumentGlobalXMLHttpRequest could not patch XMLHttpRequest");
410
- }
411
-
412
- let removed = false;
413
- const remove = () => {
414
- if (removed) {
415
- return;
416
- }
417
- removed = true;
418
- if (prototype.open === wrappedOpen) {
419
- prototype.open = originalOpen;
420
- }
421
- if (prototype.send === wrappedSend) {
422
- prototype.send = originalSend;
423
- }
424
- };
425
-
426
- return Object.freeze({
427
- remove,
428
- stop: remove
429
- });
430
- }
431
-
432
- function installXhrReadyStateTracker(xhr, context, options) {
433
- const onReadyStateChange = () => {
434
- if (xhr.readyState === options.headersReceivedState && context.responseStartAtMs === undefined) {
435
- context.responseStartAtMs = options.nowMs();
436
- return;
437
- }
438
- if (xhr.readyState === options.doneState && !context.reported) {
439
- captureXhrResourceSpan(xhr, context, options);
440
- }
441
- };
442
- if (typeof xhr.addEventListener === "function") {
443
- xhr.addEventListener("readystatechange", onReadyStateChange);
444
- return;
445
- }
446
- const originalOnReadyStateChange = xhr.onreadystatechange;
447
- xhr.onreadystatechange = function logBrewOnReadyStateChange() {
448
- onReadyStateChange();
449
- if (typeof originalOnReadyStateChange === "function") {
450
- return originalOnReadyStateChange.apply(this, arguments);
451
- }
452
- return undefined;
453
- };
454
- }
455
-
456
- function captureXhrResourceSpan(xhr, context, {
457
- appState,
458
- client,
459
- measureXhrResponseBodySize,
460
- metadata,
461
- metadataFactory,
462
- nowMs,
463
- platform,
464
- routeTemplate,
465
- routeTemplateFactory,
466
- screen,
467
- sessionId,
468
- status,
469
- trace
470
- }) {
471
- if (context.reported) {
472
- return;
473
- }
474
- context.reported = true;
475
- const durationMs = elapsedMs(context.startedAtMs, nowMs);
476
- const responseStartDurationMs = context.responseStartAtMs === undefined
477
- ? undefined
478
- : elapsedMs(context.startedAtMs, () => context.responseStartAtMs);
479
- const responseSizeBytes = xhrResponseSizeBytes(xhr, { measureXhrResponseBodySize });
480
- const safeRouteTemplate = routeTemplate ?? routeTemplateFactory({ url: context.url });
481
- const statusCode = xhrStatusCode(xhr);
482
- captureReactNativeResourceSpan(client, {
483
- appState,
484
- durationMs,
485
- metadata: {
486
- ...createSafeReactNativeMetadata(metadata, metadataFactory, {
487
- durationMs,
488
- init: context.body === undefined ? undefined : { body: context.body },
489
- input: context.url,
490
- method: context.method,
491
- routeTemplate: safeRouteTemplate,
492
- status: status ?? undefined,
493
- statusCode,
494
- url: context.url
495
- }),
496
- responseStartDurationMs,
497
- transport: "xhr"
498
- },
499
- method: context.method,
500
- platform,
501
- routeTemplate: safeRouteTemplate,
502
- screen,
503
- responseSizeBytes,
504
- sessionId,
505
- status: status ?? undefined,
506
- statusCode,
507
- timestamp: context.timestamp,
508
- trace
509
- });
510
- }
511
-
512
- function normalizeXhrMethod(method) {
513
- return String(method ?? "GET").toUpperCase();
514
- }
515
-
516
- function xhrUrl(url) {
517
- if (typeof url === "string") {
518
- return url;
519
- }
520
- try {
521
- return url.toString();
522
- } catch {
523
- return String(url);
524
- }
525
- }
526
-
527
- function xhrRequestBody(body) {
528
- if (typeof body === "string") {
529
- return body;
530
- }
531
- if (body instanceof String) {
532
- return body.toString();
533
- }
534
- return undefined;
535
- }
536
-
537
- function defaultXhrRouteTemplateFactory({ url }) {
538
- const URLConstructor = globalThis.URL;
539
- if (typeof URLConstructor === "function") {
540
- try {
541
- const parsedUrl = new URLConstructor(url, "https://logbrew.local");
542
- return parsedUrl.pathname;
543
- } catch {
544
- // Fall back to query/hash stripping below for non-standard request keys.
545
- }
546
- }
547
- return String(url).split(/[?#]/u, 1)[0];
548
- }
549
-
550
- function elapsedMs(startedAtMs, nowMs) {
551
- const durationMs = nowMs() - startedAtMs;
552
- return Number.isFinite(durationMs) ? Math.max(0, durationMs) : undefined;
553
- }
554
-
555
- function xhrStatusCode(xhr) {
556
- return typeof xhr?.status === "number" && Number.isFinite(xhr.status) ? xhr.status : undefined;
557
- }
558
-
559
- function xhrResponseSizeBytes(xhr, { measureXhrResponseBodySize = false } = {}) {
560
- if (typeof xhr?.getResponseHeader !== "function") {
561
- return measureXhrResponseBodySize ? xhrResponseBodySizeBytes(xhr) : undefined;
562
- }
563
- const contentLength = Number.parseInt(String(xhr.getResponseHeader("Content-Length") ?? ""), 10);
564
- if (Number.isFinite(contentLength) && contentLength >= 0) {
565
- return contentLength;
566
- }
567
- return measureXhrResponseBodySize ? xhrResponseBodySizeBytes(xhr) : undefined;
568
- }
569
-
570
- function xhrResponseBodySizeBytes(xhr) {
571
- const responseType = readXhrResponseValue(xhr, "responseType");
572
- if (responseType === undefined || responseType === "" || responseType === "text") {
573
- const responseText = readXhrResponseValue(xhr, "responseText");
574
- if (typeof responseText === "string" || responseText instanceof String) {
575
- return utf8ByteLength(responseText.toString());
576
- }
577
- }
578
- const response = readXhrResponseValue(xhr, "response");
579
- if (typeof response === "string" || response instanceof String) {
580
- return utf8ByteLength(response.toString());
581
- }
582
- if (typeof ArrayBuffer === "function") {
583
- if (response instanceof ArrayBuffer) {
584
- return response.byteLength;
585
- }
586
- if (typeof ArrayBuffer.isView === "function" && ArrayBuffer.isView(response)) {
587
- return response.byteLength;
588
- }
589
- }
590
- if (typeof response?.size === "number" && Number.isFinite(response.size) && response.size >= 0) {
591
- return response.size;
592
- }
593
- return undefined;
594
- }
595
-
596
- function readXhrResponseValue(xhr, property) {
597
- try {
598
- return xhr?.[property];
599
- } catch {
600
- return undefined;
601
- }
602
- }
603
-
604
- function utf8ByteLength(value) {
605
- let bytes = 0;
606
- for (let index = 0; index < value.length; index += 1) {
607
- const code = value.charCodeAt(index);
608
- if (code <= 0x7f) {
609
- bytes += 1;
610
- } else if (code <= 0x7ff) {
611
- bytes += 2;
612
- } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
613
- const next = value.charCodeAt(index + 1);
614
- if (next >= 0xdc00 && next <= 0xdfff) {
615
- bytes += 4;
616
- index += 1;
617
- } else {
618
- bytes += 3;
619
- }
620
- } else {
621
- bytes += 3;
622
- }
623
- }
624
- return bytes;
625
- }
626
-
627
- function errorName(error) {
628
- return typeof error?.name === "string" && error.name.trim() !== "" ? error.name : "Error";
629
- }
630
- /* eslint-enable no-invalid-this */
631
-
632
- export default {
633
- createLogBrewReactNativeInstrumentation
634
- };
5
+ export default implementation.default;
@@ -45,6 +45,11 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(setNativeDiagnosticsContext:(NSDictionary
45
45
  return [LBRNAppleNativeDiagnostics setCorrelationContext:context];
46
46
  }
47
47
 
48
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(setNativeDiagnosticsBreadcrumbs:(NSDictionary *)snapshot)
49
+ {
50
+ return [LBRNAppleNativeDiagnostics setBreadcrumbs:snapshot];
51
+ }
52
+
48
53
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(nativeDiagnosticsStatus)
49
54
  {
50
55
  return [LBRNAppleNativeDiagnostics status];
@@ -3,7 +3,7 @@ import Foundation
3
3
  @objc(LBRNAppleNativeDiagnostics)
4
4
  public final class LBRNAppleNativeDiagnostics: NSObject, @unchecked Sendable {
5
5
  private static let shared = LBRNAppleNativeDiagnostics()
6
- private static let sdkVersion = "0.1.20"
6
+ private static let sdkVersion = "0.1.21"
7
7
 
8
8
  private let lock = NSLock()
9
9
  private let replayQueue = DispatchQueue(label: "co.logbrew.react-native.apple-diagnostics-replay")
@@ -44,6 +44,11 @@ public final class LBRNAppleNativeDiagnostics: NSObject, @unchecked Sendable {
44
44
  shared.updateCorrelationContext(rawContext)
45
45
  }
46
46
 
47
+ @objc(setBreadcrumbs:)
48
+ public static func setBreadcrumbs(_ rawSnapshot: NSDictionary?) -> NSDictionary {
49
+ shared.updateBreadcrumbs(rawSnapshot)
50
+ }
51
+
47
52
  private func installOnMainThread(_ rawConfiguration: NSDictionary) -> NSDictionary {
48
53
  guard let configuration = InstalledConfiguration(rawConfiguration) else {
49
54
  return failure("native_diagnostics_invalid_configuration")
@@ -154,6 +159,26 @@ public final class LBRNAppleNativeDiagnostics: NSObject, @unchecked Sendable {
154
159
  }
155
160
  }
156
161
 
162
+ private func updateBreadcrumbs(_ rawSnapshot: NSDictionary?) -> NSDictionary {
163
+ lock.lock()
164
+ defer { lock.unlock() }
165
+ guard let capture else {
166
+ return failure("native_diagnostics_not_installed")
167
+ }
168
+ do {
169
+ let snapshot = try rawSnapshot.map { try NativeCrashBreadcrumbs.validated($0) }
170
+ try capture.setBreadcrumbs(
171
+ snapshot?.breadcrumbs,
172
+ truncated: snapshot?.truncated ?? false,
173
+ )
174
+ return ["status": rawSnapshot == nil ? "cleared" : "updated"]
175
+ } catch let error as NativeCrashError {
176
+ return failure(error.code.rawValue)
177
+ } catch {
178
+ return failure("native_diagnostics_failed")
179
+ }
180
+ }
181
+
157
182
  private func captureStatus(status: String) -> NSDictionary {
158
183
  guard let capture else {
159
184
  return failure("native_diagnostics_not_installed")
@@ -174,7 +174,7 @@ final class IssueBreadcrumbStore: @unchecked Sendable {
174
174
  }
175
175
  }
176
176
 
177
- let maximumIssueBreadcrumbs = 64
177
+ @_spi(CrashReplay) public let maximumIssueBreadcrumbs = 64
178
178
 
179
179
  func normalizeIssueAttributes(_ value: IssueAttributes) throws -> IssueAttributes {
180
180
  let exception = try value.exception.map(validateIssueException)
@@ -251,7 +251,8 @@ func validateIssueException(_ value: IssueException) throws -> IssueException {
251
251
  return IssueException(type: exceptionType, mechanism: mechanism)
252
252
  }
253
253
 
254
- func validateIssueBreadcrumb(_ value: IssueBreadcrumb) throws -> IssueBreadcrumb {
254
+ @_spi(CrashReplay)
255
+ public func validateIssueBreadcrumb(_ value: IssueBreadcrumb) throws -> IssueBreadcrumb {
255
256
  try requireTimestamp(value.timestamp)
256
257
  guard validMachineKey(value.category, maximum: 64, separators: ["_", ".", ":", "-"]) else {
257
258
  throw issueValidationError("issue breadcrumb category is invalid")
@@ -23,6 +23,7 @@ struct CrashReportSanitizer {
23
23
  let nativeStackFrames = NativeStackFrameSanitizer().frames(from: rawReport)
24
24
  let artifactIdentity = try NativeArtifactIdentityValue.persistedIdentity(in: rawReport)
25
25
  let correlation = NativeCrashCorrelation.captured(in: rawReport)
26
+ let breadcrumbs = NativeCrashBreadcrumbs.captured(in: rawReport)
26
27
  return NativeCrashRecord(
27
28
  eventID: uuid.uuidString.lowercased(),
28
29
  timestamp: timestamp.normalized,
@@ -35,6 +36,9 @@ struct CrashReportSanitizer {
35
36
  correlation: correlation.0,
36
37
  ),
37
38
  correlationState: correlation.1,
39
+ breadcrumbs: breadcrumbs.0?.breadcrumbs,
40
+ breadcrumbsTruncated: breadcrumbs.0?.truncated,
41
+ breadcrumbState: breadcrumbs.1,
38
42
  hangState: nil,
39
43
  hangDurationMs: nil,
40
44
  source: .engine(reportID: reportID),
@@ -122,6 +122,25 @@ public final class NativeCrashCapture: NSObject, @unchecked Sendable {
122
122
  )
123
123
  }
124
124
 
125
+ /// Replaces the crash-time breadcrumb snapshot in one bounded write.
126
+ @nonobjc
127
+ public func setBreadcrumbs(
128
+ _ breadcrumbs: [IssueBreadcrumb]?,
129
+ truncated: Bool = false,
130
+ ) throws {
131
+ lock.lock()
132
+ defer { lock.unlock() }
133
+ try verifyProcessLocked()
134
+ guard store != nil, lifecycle != .stopped else {
135
+ throw NativeCrashError(.notInstalled)
136
+ }
137
+ try verifyStorageLocked()
138
+ try driver.setUserInfo(
139
+ NativeCrashBreadcrumbs.encoded(breadcrumbs, truncated: truncated),
140
+ forKey: NativeCrashBreadcrumbs.reportKey,
141
+ )
142
+ }
143
+
125
144
  @objc(replayPendingReportsWithHandler:error:)
126
145
  public func replayPendingReports(
127
146
  _ handler: (NativeCrashRecord) -> Bool,
@@ -2,7 +2,7 @@
2
2
  // Edit the canonical Swift source, then regenerate this package boundary.
3
3
  import Foundation
4
4
 
5
- enum NativeCrashCorrelationState: String {
5
+ enum NativeCrashContextState: String {
6
6
  case captured
7
7
  case notCaptured = "not_captured"
8
8
  case unavailable
@@ -22,14 +22,12 @@ enum NativeCrashCorrelation {
22
22
  throw NativeCrashError(.invalidConfiguration)
23
23
  }
24
24
  return value
25
- } catch let error as NativeCrashError {
26
- throw error
27
25
  } catch {
28
26
  throw NativeCrashError(.invalidConfiguration)
29
27
  }
30
28
  }
31
29
 
32
- static func captured(in rawReport: [String: Any]) -> (TelemetryContext?, NativeCrashCorrelationState) {
30
+ static func captured(in rawReport: [String: Any]) -> (TelemetryContext?, NativeCrashContextState) {
33
31
  guard let user = rawReport["user"] as? [String: Any], let value = user[reportKey] else {
34
32
  return (nil, .notCaptured)
35
33
  }
@@ -75,3 +73,102 @@ enum NativeCrashCorrelation {
75
73
  return encoder
76
74
  }
77
75
  }
76
+
77
+ struct NativeCrashBreadcrumbSnapshot: Codable, Equatable {
78
+ let schemaVersion: Int
79
+ let breadcrumbs: [IssueBreadcrumb]
80
+ let truncated: Bool
81
+
82
+ init(breadcrumbs: [IssueBreadcrumb], truncated: Bool) {
83
+ schemaVersion = 1
84
+ self.breadcrumbs = breadcrumbs
85
+ self.truncated = truncated
86
+ }
87
+ }
88
+
89
+ enum NativeCrashBreadcrumbs {
90
+ static let reportKey = "logbrew_native_breadcrumbs"
91
+ static let maximumBytes = 64 * 1024
92
+
93
+ static func encoded(_ values: [IssueBreadcrumb]?, truncated: Bool) throws -> String? {
94
+ guard let values, !values.isEmpty else {
95
+ return nil
96
+ }
97
+ do {
98
+ var breadcrumbs = try values.suffix(maximumIssueBreadcrumbs).map(validateIssueBreadcrumb)
99
+ var wasTruncated = truncated || breadcrumbs.count < values.count
100
+ while !breadcrumbs.isEmpty {
101
+ let data = try encoder().encode(NativeCrashBreadcrumbSnapshot(
102
+ breadcrumbs: breadcrumbs,
103
+ truncated: wasTruncated,
104
+ ))
105
+ if data.count <= maximumBytes, let value = String(data: data, encoding: .utf8) {
106
+ return value
107
+ }
108
+ breadcrumbs.removeFirst()
109
+ wasTruncated = true
110
+ }
111
+ } catch let error as NativeCrashError {
112
+ throw error
113
+ } catch {
114
+ throw NativeCrashError(.invalidConfiguration)
115
+ }
116
+ throw NativeCrashError(.invalidConfiguration)
117
+ }
118
+
119
+ static func captured(
120
+ in rawReport: [String: Any],
121
+ ) -> (NativeCrashBreadcrumbSnapshot?, NativeCrashContextState) {
122
+ guard let user = rawReport["user"] as? [String: Any], let value = user[reportKey] else {
123
+ return (nil, .notCaptured)
124
+ }
125
+ guard let value = value as? String,
126
+ let data = value.data(using: .utf8),
127
+ data.count <= maximumBytes,
128
+ let object = try? JSONSerialization.jsonObject(with: data),
129
+ let snapshot = try? validated(object)
130
+ else {
131
+ return (nil, .unavailable)
132
+ }
133
+ return (snapshot, .captured)
134
+ }
135
+
136
+ static func validated(_ object: Any) throws -> NativeCrashBreadcrumbSnapshot {
137
+ do {
138
+ guard JSONSerialization.isValidJSONObject(object) else {
139
+ throw NativeCrashError(.invalidConfiguration)
140
+ }
141
+ let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
142
+ guard data.count <= maximumBytes else {
143
+ throw NativeCrashError(.invalidConfiguration)
144
+ }
145
+ let decoded = try JSONDecoder().decode(NativeCrashBreadcrumbSnapshot.self, from: data)
146
+ guard decoded.schemaVersion == 1,
147
+ !decoded.breadcrumbs.isEmpty,
148
+ decoded.breadcrumbs.count <= maximumIssueBreadcrumbs
149
+ else {
150
+ throw NativeCrashError(.invalidConfiguration)
151
+ }
152
+ let snapshot = try NativeCrashBreadcrumbSnapshot(
153
+ breadcrumbs: decoded.breadcrumbs.map(validateIssueBreadcrumb),
154
+ truncated: decoded.truncated,
155
+ )
156
+ let normalized = try encoder().encode(snapshot)
157
+ let normalizedObject = try JSONSerialization.jsonObject(with: normalized)
158
+ guard object as? NSDictionary == normalizedObject as? NSDictionary else {
159
+ throw NativeCrashError(.invalidConfiguration)
160
+ }
161
+ return snapshot
162
+ } catch let error as NativeCrashError {
163
+ throw error
164
+ } catch {
165
+ throw NativeCrashError(.invalidConfiguration)
166
+ }
167
+ }
168
+
169
+ private static func encoder() -> JSONEncoder {
170
+ let encoder = JSONEncoder()
171
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
172
+ return encoder
173
+ }
174
+ }
@@ -164,7 +164,10 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
164
164
  private let nativeStackFrames: [NativeStackFrame]?
165
165
  private let artifactIdentity: NativeArtifactIdentity?
166
166
  private let context: TelemetryContext?
167
- private let correlationState: NativeCrashCorrelationState?
167
+ private let correlationState: NativeCrashContextState?
168
+ private let breadcrumbs: [IssueBreadcrumb]?
169
+ private let breadcrumbsTruncated: Bool?
170
+ private let breadcrumbState: NativeCrashContextState?
168
171
  private let hangState: NativeHangIncidentState?
169
172
  private let hangDurationMs: Double?
170
173
  let source: NativeCrashRecordSource
@@ -178,7 +181,10 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
178
181
  nativeStackFrames: [NativeStackFrame]?,
179
182
  artifactIdentity: NativeArtifactIdentity?,
180
183
  context: TelemetryContext?,
181
- correlationState: NativeCrashCorrelationState? = nil,
184
+ correlationState: NativeCrashContextState? = nil,
185
+ breadcrumbs: [IssueBreadcrumb]? = nil,
186
+ breadcrumbsTruncated: Bool? = nil,
187
+ breadcrumbState: NativeCrashContextState? = nil,
182
188
  hangState: NativeHangIncidentState?,
183
189
  hangDurationMs: Double? = nil,
184
190
  source: NativeCrashRecordSource,
@@ -192,6 +198,9 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
192
198
  self.artifactIdentity = artifactIdentity
193
199
  self.context = context
194
200
  self.correlationState = correlationState
201
+ self.breadcrumbs = breadcrumbs
202
+ self.breadcrumbsTruncated = breadcrumbsTruncated
203
+ self.breadcrumbState = breadcrumbState
195
204
  self.hangState = hangState
196
205
  self.hangDurationMs = hangDurationMs
197
206
  self.source = source
@@ -234,6 +243,9 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
234
243
  if let correlationState {
235
244
  metadata["crash.correlation"] = .string(correlationState.rawValue)
236
245
  }
246
+ if let breadcrumbState {
247
+ metadata["crash.breadcrumbs"] = .string(breadcrumbState.rawValue)
248
+ }
237
249
  if let hangState {
238
250
  metadata["crash.handled"] = .bool(hangState == .recovered)
239
251
  }
@@ -245,6 +257,8 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
245
257
  level: hangState == .recovered ? .error : .fatal,
246
258
  exception: issueException,
247
259
  exceptionChain: nativeCrashExceptionChain(for: issueException),
260
+ breadcrumbs: breadcrumbs,
261
+ breadcrumbsTruncated: breadcrumbsTruncated,
248
262
  metadata: metadata,
249
263
  context: context,
250
264
  nativeStackFrames: nativeStackFrames,
@@ -287,12 +301,16 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
287
301
  else {
288
302
  return false
289
303
  }
290
- for legacyField in ["exception", "exceptionChain", "context"] where actual[legacyField] == nil {
304
+ let legacyFields = ["exception", "exceptionChain", "breadcrumbs", "breadcrumbsTruncated", "context"]
305
+ for legacyField in legacyFields where actual[legacyField] == nil {
291
306
  expected.removeValue(forKey: legacyField)
292
307
  }
293
- if (actual["metadata"] as? [String: Any])?["crash.correlation"] == nil {
308
+ let actualMetadata = actual["metadata"] as? [String: Any]
309
+ if actualMetadata?["crash.correlation"] == nil || actualMetadata?["crash.breadcrumbs"] == nil {
294
310
  var metadata = expected["metadata"] as? [String: Any]
295
- metadata?.removeValue(forKey: "crash.correlation")
311
+ for key in ["crash.correlation", "crash.breadcrumbs"] where actualMetadata?[key] == nil {
312
+ metadata?.removeValue(forKey: key)
313
+ }
296
314
  expected["metadata"] = metadata
297
315
  }
298
316
  return actual as NSDictionary == expected as NSDictionary
@@ -11,7 +11,7 @@
11
11
  "swift/logbrew-swift/Sources/LogBrew/DurableDeliveryStoreRecovery.swift": "79fee27975b6571954bf0b3692a50e60bbbcf53b5ab47f653a474db5c56d68ba",
12
12
  "swift/logbrew-swift/Sources/LogBrew/EventEncoding.swift": "b88b6b2d17f7d0cee9ac1a1f34ef35b192cd33d213ef3c9e1c2d21cbcc304a97",
13
13
  "swift/logbrew-swift/Sources/LogBrew/IssueDiagnosticEvidence.swift": "e471f27fadaefe06971c1a98892aeb0774e840a2ce0cbb243ae9aabfa41b1c47",
14
- "swift/logbrew-swift/Sources/LogBrew/IssueDiagnostics.swift": "297b8bbefd42c314a984fecb38237fb1395700c9eafcfdd1b545267bc34294cc",
14
+ "swift/logbrew-swift/Sources/LogBrew/IssueDiagnostics.swift": "bbdfe6169961ff27b8f2cee63c7dcdeacf2e5cee6a3ca17f6a26b933f0adfc39",
15
15
  "swift/logbrew-swift/Sources/LogBrew/IssueExceptionChain.swift": "10f81cbce95b47da0a44ef9e515b150e60d71dfcc8b04be24b037b1222a373a5",
16
16
  "swift/logbrew-swift/Sources/LogBrew/LifecycleTrace.swift": "738cd3f129fac821a892404d1d42ee92bd806b6583d3dbb5379e88b14ab23c16",
17
17
  "swift/logbrew-swift/Sources/LogBrew/LogBrewClient.swift": "186cdbda3754a811f17b82818ca8e7a1711b3b75123686cd17dc3b7441fa21cb",
@@ -29,13 +29,13 @@
29
29
  "swift/logbrew-swift/Sources/LogBrew/URLSessionTracer.swift": "0cdf08f92d6111c0dc2883b87837b81f69b5c2f9a8e5501780a114c23a741af7",
30
30
  "swift/logbrew-swift/Sources/LogBrew/Validation.swift": "07ff5c2daeb829b1df4f50edb0b79727f9a81b1c9b5cde5f995f5ad6ce660aa7",
31
31
  "swift/logbrew-swift/Sources/LogBrewCrash/CrashEngine.swift": "a94dfce653e3e7f381be42b6d2c4540f0be77e83777ecc53e06682cd2e569c5a",
32
- "swift/logbrew-swift/Sources/LogBrewCrash/CrashReportSanitizer.swift": "5b503240977bd0c266035b2024e16c50846e6d2932dea083182bc549192a804f",
32
+ "swift/logbrew-swift/Sources/LogBrewCrash/CrashReportSanitizer.swift": "ccd0ef83f14096816765278e007f5e4ba0a40286ce00d708b38ca0b94959171e",
33
33
  "swift/logbrew-swift/Sources/LogBrewCrash/CrashStorageDirectory.swift": "7cd566703cbf704dc99155451ee93dcace12c35c805abc09a6dcf23975cf43f9",
34
34
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeArtifactIdentity.swift": "3d785ad717dcf7302451531c18b5e1183983270a6cd78dd543a407e3facc6ae0",
35
- "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashCapture.swift": "3ebecef96e67b8e5a56a10d0d847470cb276dea44164e2e637584e110ea3ea39",
36
- "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashCorrelation.swift": "a513d933eea0b4e83418eb5dd60086cc6871c9e0147a6e7224c45cc6b502de2e",
35
+ "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashCapture.swift": "c2f5d0e9a8953cb988633ebf60a06f3b7ddaa0c9828f99372969fdfb60b99bc4",
36
+ "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashCorrelation.swift": "eef19c6e561bc03bb4c09736722afe57f63d557f9279b26353679bd53362c25d",
37
37
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashExceptionEvidence.swift": "ce191e46298d91d87b379528d610caa88b924b15a8dd912b4a164b51eb172a7a",
38
- "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashPublic.swift": "1672116a06fbdd10971838817591ee23af68bd5a9b07bbffb073e0fafc6cb1c0",
38
+ "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashPublic.swift": "2d0f2e7cdce09dc09906cede5d83928e8bd0e70a8522ff55c999d19366d84730",
39
39
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashReplay.swift": "3eb3d93b32cf14de416f56fa30d6d65e6f268b5ec9d1a67081d7525c4efd4f80",
40
40
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeHangIncidentStore.swift": "5a8150083f16d6b495c9c63434809dc5f11f13ebb23211f57151bad62040781b",
41
41
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeHangWatchdog.swift": "37b25904cd6576e20c40500be2c153156403b57ad7b4186a1b6b35b414145ecb",
package/metadata.js CHANGED
@@ -1,181 +1,10 @@
1
- const SENSITIVE_METADATA_FACTORY_KEY_RE = new RegExp([
2
- "body",
3
- "payload",
4
- "variable",
5
- "header",
6
- "authorization",
7
- "cookie",
8
- "to\u006ben",
9
- "sec\u0072et",
10
- "pass\u0077ord"
11
- ].join("|"), "u");
12
- const REACT_NATIVE_DEBUG_ID_REGISTRY = Symbol.for("@logbrew/react-native/debug-ids");
13
- const SAFE_RELEASE_ARTIFACT_DEBUG_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
14
- const MAX_REACT_NATIVE_DEBUG_ID_REGISTRY_ENTRIES = 64;
15
- const MAX_REACT_NATIVE_DEBUG_ID_REGISTRY_FRAMES = 128;
16
-
17
- export function createSafeReactNativeMetadata(metadata, metadataFactory, context) {
18
- if (typeof metadataFactory !== "function") {
19
- return metadata;
20
- }
21
- return {
22
- ...metadata,
23
- ...safeReactNativeMetadataFactoryResult(metadataFactory(context))
24
- };
25
- }
26
-
27
- export function safeReactNativeMetadataFactoryResult(candidate) {
28
- if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
29
- return {};
30
- }
31
- const metadata = {};
32
- for (const [key, value] of Object.entries(candidate)) {
33
- if (isSensitiveMetadataKey(key)) {
34
- continue;
35
- }
36
- if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
37
- metadata[key] = value;
38
- }
39
- }
40
- return metadata;
41
- }
42
-
43
- export function sanitizeReactNativeIssueMetadata(metadata, compactMetadata) {
44
- const next = { ...metadata };
45
- for (const key of ["errorFrameFile", "releaseArtifactCodeFile"]) {
46
- const path = reactNativeCodePath(next[key]);
47
- if (path) {
48
- next[key] = path;
49
- }
50
- }
51
- const match = typeof next.issueGroupingKey === "string" ? next.issueGroupingKey.match(/^([^:]+):([^:]+):(.+)$/u) : null;
52
- const path = match ? reactNativeCodePath(match[3]) : undefined;
53
- if (path) {
54
- next.issueGroupingKey = `${match[1]}:${match[2]}:${path}`;
55
- }
56
- return compactMetadata(next);
57
- }
58
-
59
- export function sanitizeReactNativeIssueStackFrames(stackFrames) {
60
- if (!Array.isArray(stackFrames)) {
61
- return undefined;
62
- }
63
- return stackFrames.map((frame) => ({
64
- ...frame,
65
- filename: reactNativeCodePath(frame.filename) ?? frame.filename
66
- }));
67
- }
68
-
69
- export function sanitizeReactNativeIssueExceptionChain(exceptionChain) {
70
- if (!exceptionChain || !Array.isArray(exceptionChain.entries)) {
71
- return undefined;
72
- }
73
- return {
74
- ...exceptionChain,
75
- entries: exceptionChain.entries.map((entry) => {
76
- const stackFrames = sanitizeReactNativeIssueStackFrames(entry.stackFrames);
77
- return {
78
- ...entry,
79
- ...(stackFrames ? { stackFrames } : {})
80
- };
81
- })
82
- };
83
- }
84
-
85
- export function runtimeReactNativeDebugIdMap() {
86
- try {
87
- const registry = globalThis?.[REACT_NATIVE_DEBUG_ID_REGISTRY];
88
- if (!registry || Array.isArray(registry) || typeof registry !== "object") {
89
- return undefined;
90
- }
91
- const entries = Object.entries(registry);
92
- if (entries.length === 0 || entries.length > MAX_REACT_NATIVE_DEBUG_ID_REGISTRY_ENTRIES) {
93
- return undefined;
94
- }
95
- const debugIdMap = Object.create(null);
96
- let frameCount = 0;
97
- for (const [stack, debugId] of entries) {
98
- if (typeof debugId !== "string" || !SAFE_RELEASE_ARTIFACT_DEBUG_ID.test(debugId)) {
99
- return undefined;
100
- }
101
- const normalizedDebugId = debugId.toLowerCase();
102
- let stackFrameCount = 0;
103
- for (const line of stack.split(/\r?\n/u)) {
104
- const filename = runtimeStackFrameFilename(line);
105
- if (!filename) {
106
- continue;
107
- }
108
- frameCount += 1;
109
- stackFrameCount += 1;
110
- if (frameCount > MAX_REACT_NATIVE_DEBUG_ID_REGISTRY_FRAMES) {
111
- return undefined;
112
- }
113
- const existingDebugId = debugIdMap[filename];
114
- if (existingDebugId && existingDebugId !== normalizedDebugId) {
115
- return undefined;
116
- }
117
- debugIdMap[filename] = normalizedDebugId;
118
- }
119
- if (stackFrameCount === 0) {
120
- return undefined;
121
- }
122
- }
123
- return frameCount > 0 ? debugIdMap : undefined;
124
- } catch {
125
- return undefined;
126
- }
127
- }
128
-
129
- function isSensitiveMetadataKey(key) {
130
- return SENSITIVE_METADATA_FACTORY_KEY_RE.test(String(key).toLowerCase());
131
- }
132
-
133
- function runtimeStackFrameFilename(rawLine) {
134
- let location = typeof rawLine === "string" ? rawLine.trim() : "";
135
- if (!location) {
136
- return undefined;
137
- }
138
- if (location.startsWith("at ")) {
139
- location = location.slice(3).trim();
140
- if (location.endsWith(")") && location.includes("(")) {
141
- location = location.slice(location.lastIndexOf("(") + 1, -1);
142
- }
143
- } else if (location.includes("@")) {
144
- location = location.slice(location.lastIndexOf("@") + 1);
145
- }
146
- const parts = location.split(":");
147
- if (parts.length < 3) {
148
- return undefined;
149
- }
150
- const columnText = parts.pop();
151
- const lineText = parts.pop();
152
- const filename = parts.join(":").trim();
153
- if (!/^[1-9]\d*$/u.test(lineText) || !/^[1-9]\d*$/u.test(columnText)) {
154
- return undefined;
155
- }
156
- const line = Number(lineText);
157
- const column = Number(columnText);
158
- return Number.isSafeInteger(line) && Number.isSafeInteger(column) && filename ? filename : undefined;
159
- }
160
-
161
- function reactNativeCodePath(value) {
162
- if (typeof value !== "string" || value.trim() === "") {
163
- return undefined;
164
- }
165
- let path = value.trim();
166
- const URLConstructor = globalThis.URL;
167
- if (typeof URLConstructor === "function") {
168
- try {
169
- path = new URLConstructor(path).pathname || path;
170
- } catch {
171
- path = path.split(/[?#]/u, 1)[0].replace(/\\/g, "/");
172
- }
173
- } else {
174
- path = path.split(/[?#]/u, 1)[0].replace(/\\/g, "/");
175
- }
176
- if (/^[A-Za-z]:\//u.test(path) || /^\/(?:Users|home|private|tmp|var)\//u.test(path)) {
177
- path = path.replace(/\/+$/u, "");
178
- return path.slice(path.lastIndexOf("/") + 1) || undefined;
179
- }
180
- return path || undefined;
181
- }
1
+ import implementation from "./metadata.cjs";
2
+
3
+ export const {
4
+ createSafeReactNativeMetadata,
5
+ runtimeReactNativeDebugIdMap,
6
+ safeReactNativeMetadataFactoryResult,
7
+ sanitizeReactNativeIssueExceptionChain,
8
+ sanitizeReactNativeIssueMetadata,
9
+ sanitizeReactNativeIssueStackFrames
10
+ } = implementation;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logbrew/react-native",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "React Native offline delivery, Apple native diagnostics, screen, error, trace, action, and network timeline helpers for LogBrew.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -240,7 +240,7 @@
240
240
  "url": "git+https://github.com/LogBrewCo/sdk.git"
241
241
  },
242
242
  "peerDependencies": {
243
- "@logbrew/sdk": "^0.1.12",
243
+ "@logbrew/sdk": "^0.1.15",
244
244
  "expo": ">=49",
245
245
  "react": ">=18",
246
246
  "react-native": ">=0.72"
@@ -9,6 +9,9 @@ export interface Spec extends TurboModule {
9
9
  setNativeDiagnosticsContext(
10
10
  context: CodegenTypes.UnsafeObject | null
11
11
  ): CodegenTypes.UnsafeObject;
12
+ setNativeDiagnosticsBreadcrumbs(
13
+ snapshot: CodegenTypes.UnsafeObject | null
14
+ ): CodegenTypes.UnsafeObject;
12
15
  nativeDiagnosticsStatus(): CodegenTypes.UnsafeObject;
13
16
  }
14
17