@logbrew/react-native 0.1.5 → 0.1.7

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  React Native helpers for the public LogBrew JavaScript SDK.
8
8
 
9
- This package is intentionally thin. It keeps all event validation, retry, flush, and shutdown behavior in `@logbrew/sdk`, while adding mobile-friendly helpers for screen views, app-state changes, product actions, API milestones, handled JavaScript errors, provider/hook usage, active W3C trace correlation, explicit W3C trace propagation, opt-in lifecycle spans, opt-in resource fetch spans, opt-in reversible global fetch spans, app-owned native bridge scope sync, and reversible instrumentation setup.
9
+ This package is intentionally thin. It keeps all event validation, retry, flush, and shutdown behavior in `@logbrew/sdk`, while adding mobile-friendly helpers for screen views, app-state changes, product actions, API milestones, handled JavaScript errors, app-owned Promise rejection reports, provider/hook usage, active W3C trace correlation, explicit W3C trace propagation, opt-in lifecycle spans, opt-in resource fetch spans, opt-in reversible global fetch spans, app-owned native bridge scope sync, and reversible instrumentation setup.
10
10
 
11
11
  ## Install
12
12
 
@@ -22,27 +22,96 @@ import { AppState, Platform } from "react-native";
22
22
  import {
23
23
  captureScreenView,
24
24
  createAppStateListener,
25
- createLogBrewReactNativeClient
25
+ createLogBrewReactNativeClient,
26
+ createReactNativeFetchTransport
26
27
  } from "@logbrew/react-native";
27
28
 
29
+ // Expo example. Bare React Native apps can use the same public key from
30
+ // their app-owned configuration layer.
31
+ const clientKey = process.env.EXPO_PUBLIC_LOGBREW_CLIENT_KEY;
32
+ if (!clientKey) {
33
+ throw new Error("Set EXPO_PUBLIC_LOGBREW_CLIENT_KEY to the public app-scoped key");
34
+ }
35
+
28
36
  const client = createLogBrewReactNativeClient({
29
- clientKey: "LOGBREW_CLIENT_KEY",
37
+ clientKey,
30
38
  sdkName: "my-mobile-app",
31
- sdkVersion: "0.1.0"
39
+ sdkVersion: "0.1.0",
40
+ transport: createReactNativeFetchTransport()
32
41
  });
33
42
 
34
43
  captureScreenView(client, "Checkout", {
35
44
  platform: Platform,
36
- appState: AppState,
37
- timestamp: "2026-06-02T10:00:03Z"
45
+ appState: AppState
38
46
  });
39
47
 
40
48
  const stopListening = createAppStateListener(client, AppState, {
49
+ flushOnBackground: true,
41
50
  platform: Platform
42
51
  });
52
+
53
+ export async function verifyLogBrewSetup() {
54
+ const timestamp = new Date().toISOString();
55
+ client.log(`evt_react_native_setup_${Date.now()}`, timestamp, {
56
+ level: "info",
57
+ message: "React Native setup check",
58
+ metadata: {
59
+ environment: __DEV__ ? "development" : "production",
60
+ service: "my-mobile-app"
61
+ }
62
+ });
63
+
64
+ const receipt = await client.flush();
65
+ return {
66
+ delivery: "hosted_accepted",
67
+ statusCode: receipt.statusCode,
68
+ attempts: receipt.attempts,
69
+ batches: receipt.batches
70
+ };
71
+ }
43
72
  ```
44
73
 
45
- For mobile apps, prefer an app-scoped public key through `clientKey`. `apiKey` is still accepted for compatibility with lower-level SDK examples.
74
+ For mobile apps, prefer an app-scoped public key through `clientKey`. Expo
75
+ inlines `EXPO_PUBLIC_*` values into the app, so never use a server key there.
76
+ `apiKey` is still accepted for compatibility with lower-level SDK examples.
77
+
78
+ Supplying `transport` enables the core SDK's bounded automatic delivery. It
79
+ coalesces concurrent work, retries transient failures, retains rejected
80
+ batches, and pauses repeated automatic sends after authentication, rate-limit,
81
+ or non-retryable failures. Do not add a second app-owned flush interval.
82
+ `flushOnBackground: true` requests one final flush when AppState becomes
83
+ `inactive` or `background`; a failure never escapes the AppState callback.
84
+
85
+ ## Confirm Hosted Delivery And Event Visibility
86
+
87
+ Call `verifyLogBrewSetup()` once from a development-only button or setup
88
+ screen. A returned `hosted_accepted` receipt means the configured HTTPS intake
89
+ accepted every batch in that flush. Then use an authenticated CLI session to
90
+ confirm that the backend stored the event for the intended project:
91
+
92
+ ```bash
93
+ logbrew read logs --project <project_id> \
94
+ --search "React Native setup check" --since 1h --json
95
+ ```
96
+
97
+ These checks have different meanings:
98
+
99
+ - `client.previewJson()` validates and displays the local queued payload.
100
+ - `RecordingTransport.alwaysAccept()` is a local recording transport. Its
101
+ synthetic HTTP `202` makes no network request and never indicates hosted
102
+ delivery.
103
+ - `createReactNativeFetchTransport()` returns the actual intake status to
104
+ `client.flush()`.
105
+ - An authenticated CLI read confirms that the accepted event is visible in the
106
+ selected project. An intake `2xx` alone does not confirm event visibility.
107
+
108
+ Use `client.deliveryHealth()` for content-free queue and delivery state. In
109
+ particular, inspect `deliveryState`, `lastOutcome`, `lastStatusClass`,
110
+ `pausedReason`, `queueEvents`, and `acceptedEvents`. A `401` pauses automatic
111
+ delivery with `pausedReason: "authentication"`; rotate or correct the public
112
+ client key before creating a new client. A `429` preserves the queue and
113
+ reports `pausedReason: "rate_limit"` plus the bounded retry signal exposed by
114
+ the failed flush.
46
115
 
47
116
  ## Product Actions And API Milestones
48
117
 
@@ -128,7 +197,37 @@ Installation is idempotent for the active React Native `ErrorUtils` object. The
128
197
 
129
198
  The React Native conditional export obtains LogBrew's synchronous native fatal store through the supported TurboModule or `NativeModules` seam. Before chaining a fatal report, it writes one bounded record to app-private storage that is excluded from operating-system archives. On a later installation it performs stable-ID at-least-once replay, and acknowledgement happens only after local queue admission is observable through the SDK queue counters. Filtered, dropped, unknown-admission, persistence-failed, and acknowledgement-failed records are retained. A failed acknowledgement is retried without admitting the same ID twice in one JavaScript runtime. Use `fatalHealth()` for frozen bounded counters and status, or `discardPendingFatalRecord()` for an explicit rollback discard. The Node ESM and CommonJS entries never import React Native; non-React-Native callers must inject `fatalStore` explicitly.
130
199
 
131
- Automatic events exclude the original error message, raw stack, arbitrary metadata, full URLs, hosts, query strings, local absolute paths, payloads, and native error text. `onDiagnostic` receives only a fixed code. This integration does not claim mathematically exactly-once delivery, backend-visible deduplication, native crash capture, Promise rejection ownership, ANR or hang detection, general offline queueing, or symbolication. Unhandled Promise rejections are not installed or patched because React Native does not expose one stable supported ownership seam across its runtimes.
200
+ Automatic events exclude the original error message, raw stack, arbitrary metadata, full URLs, hosts, query strings, local absolute paths, payloads, and native error text. `onDiagnostic` receives only a fixed code. This integration does not claim mathematically exactly-once delivery, backend-visible deduplication, native crash capture, automatic Promise rejection tracker ownership, ANR or hang detection, general offline queueing, or symbolication.
201
+
202
+ ### App-owned Promise rejection reports
203
+
204
+ Use the Promise rejection callbacks when your app or framework already owns rejection tracking:
205
+
206
+ ```js
207
+ import {
208
+ createLogBrewReactNativePromiseRejectionHandlers
209
+ } from "@logbrew/react-native/global-errors";
210
+
211
+ const logBrewPromiseRejections =
212
+ createLogBrewReactNativePromiseRejectionHandlers({
213
+ client,
214
+ onDiagnostic({ code }) {
215
+ console.warn(`LogBrew Promise rejection handler: ${code}`);
216
+ }
217
+ });
218
+
219
+ const promiseRejectionTrackerOptions = {
220
+ allRejections: true,
221
+ onUnhandled: logBrewPromiseRejections.onUnhandled,
222
+ onHandled: logBrewPromiseRejections.onHandled
223
+ };
224
+
225
+ // Pass promiseRejectionTrackerOptions to the tracker your app already owns.
226
+ ```
227
+
228
+ The callbacks match the common `(id, rejection)` and `(id)` tracker shapes, but LogBrew does not install, replace, or patch Promise, Hermes, JavaScriptCore, or a global rejection tracker. If another integration already has callbacks, keep that integration as the tracker owner and call both callback sets from the app-owned composition point.
229
+
230
+ `onUnhandled()` emits a fixed-content issue and deliberately does not inspect or send the rejection value, raw runtime rejection ID, error message, stack, Promise, or arbitrary metadata. Numeric IDs and bounded strings are retained only in local memory for duplicate suppression and `onHandled()` health. The set defaults to 128 entries and can be configured from 1 to 1024 with `maxTrackedRejections`; old entries are evicted. Missing or unsafe IDs still produce an untracked privacy-safe report. `onHandled()` updates local health only and cannot retract an issue that was already queued. Use `health()` for frozen counters and the last bounded outcome. Capture and diagnostic failures never escape these callbacks.
132
231
 
133
232
  When you prepare React Native release artifacts, wrap the app-owned Metro config once. Production bundles and source maps receive one matching Debug ID, while development and hot-reload serialization remain unchanged:
134
233
 
@@ -28,7 +28,14 @@ captureScreenView(client, "Checkout", {
28
28
 
29
29
  console.log(client.previewJson());
30
30
  const response = await client.shutdown(RecordingTransport.alwaysAccept());
31
- console.error(JSON.stringify({ ok: true, status: response.statusCode, attempts: response.attempts, events: 6 }));
31
+ console.error(JSON.stringify({
32
+ ok: true,
33
+ mode: "local_recording",
34
+ hostedAccepted: false,
35
+ status: response.statusCode,
36
+ attempts: response.attempts,
37
+ events: 6
38
+ }));
32
39
 
33
40
  function addFullBatch(client) {
34
41
  client.release("evt_release_001", "2026-06-02T10:00:00Z", {
package/global-errors.cjs CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  const { createReactNativeErrorEvent } = require("./index.cjs");
4
4
  const { createFatalController } = require("./fatal-replay.cjs");
5
+ const {
6
+ createLogBrewReactNativePromiseRejectionHandlers
7
+ } = require("./promise-rejections.cjs");
5
8
 
6
9
  const AUTOMATIC_ERROR_MESSAGE = "React Native global JavaScript report";
7
10
  const MAX_STACK_BYTES = 16 * 1024;
@@ -408,6 +411,7 @@ function isObjectLike(value) {
408
411
  }
409
412
 
410
413
  const defaultExport = {
414
+ createLogBrewReactNativePromiseRejectionHandlers,
411
415
  installLogBrewReactNativeGlobalErrorHandler
412
416
  };
413
417
 
@@ -7,6 +7,19 @@ export type ReactNativeErrorUtilsLike = {
7
7
  setGlobalHandler(handler: ReactNativeGlobalErrorHandler | undefined): void;
8
8
  };
9
9
 
10
+ export type ReactNativePromiseRejectionDiagnosticCode =
11
+ | "promise_rejection_capture_failed"
12
+ | "promise_rejection_configuration_invalid"
13
+ | "promise_rejection_duplicate_suppressed"
14
+ | "promise_rejection_handler_unavailable"
15
+ | "promise_rejection_id_unavailable"
16
+ | "promise_rejection_recursive_capture_suppressed"
17
+ | "promise_rejection_tracking_evicted";
18
+
19
+ export type ReactNativePromiseRejectionDiagnostic = Readonly<{
20
+ code: ReactNativePromiseRejectionDiagnosticCode;
21
+ }>;
22
+
10
23
  export type ReactNativeGlobalErrorDiagnosticCode =
11
24
  | "capture_failed"
12
25
  | "duplicate_capture_suppressed"
@@ -44,6 +57,45 @@ export type ReactNativeGlobalErrorHealth = Readonly<{
44
57
  suppressedEvents: number;
45
58
  }>;
46
59
 
60
+ export type ReactNativePromiseRejectionHealth = Readonly<{
61
+ available: boolean;
62
+ capturedEvents: number;
63
+ evictedRejections: number;
64
+ handledLaterEvents: number;
65
+ lastOutcome:
66
+ | "capture_failed"
67
+ | "captured"
68
+ | "captured_untracked"
69
+ | "captured_with_eviction"
70
+ | "duplicate_suppressed"
71
+ | "handled_later"
72
+ | "handled_unknown"
73
+ | "idle"
74
+ | "recursive_suppressed"
75
+ | "unavailable";
76
+ maxTrackedRejections: number;
77
+ suppressedEvents: number;
78
+ trackedRejections: number;
79
+ unknownHandledEvents: number;
80
+ untrackedEvents: number;
81
+ }>;
82
+
83
+ export type CreateLogBrewReactNativePromiseRejectionHandlersOptions = {
84
+ client: Pick<LogBrewClient, "issue">;
85
+ /**
86
+ * Maximum number of safe runtime rejection identifiers retained for duplicate
87
+ * suppression and later-handled health. Defaults to 128 and must be 1-1024.
88
+ */
89
+ maxTrackedRejections?: number;
90
+ onDiagnostic?: (diagnostic: ReactNativePromiseRejectionDiagnostic) => void;
91
+ };
92
+
93
+ export type LogBrewReactNativePromiseRejectionHandlers = Readonly<{
94
+ health(): ReactNativePromiseRejectionHealth;
95
+ onHandled(runtimeRejectionId: unknown): void;
96
+ onUnhandled(runtimeRejectionId: unknown, rejection?: unknown): void;
97
+ }>;
98
+
47
99
  export type ReactNativeFatalRecord = Readonly<{
48
100
  corruptRecords: number;
49
101
  droppedRecords: number;
@@ -112,6 +164,17 @@ export type LogBrewReactNativeGlobalErrorHandlerInstallation = Readonly<{
112
164
  remove(): boolean;
113
165
  }>;
114
166
 
167
+ /**
168
+ * Create privacy-safe callbacks for a Promise rejection tracker owned by the app.
169
+ *
170
+ * The callbacks do not install or patch Promise, Hermes, JavaScriptCore, or globals.
171
+ * Automatic reports never inspect or emit the rejection value or runtime identifier.
172
+ * Tracking is bounded and suppresses duplicate callbacks only while an identifier is retained.
173
+ */
174
+ export declare function createLogBrewReactNativePromiseRejectionHandlers(
175
+ options: CreateLogBrewReactNativePromiseRejectionHandlersOptions
176
+ ): LogBrewReactNativePromiseRejectionHandlers;
177
+
115
178
  /**
116
179
  * Install reversible automatic capture for React Native global JavaScript errors.
117
180
  *
@@ -125,6 +188,7 @@ export declare function installLogBrewReactNativeGlobalErrorHandler(
125
188
  ): LogBrewReactNativeGlobalErrorHandlerInstallation;
126
189
 
127
190
  declare const logBrewReactNativeGlobalErrors: {
191
+ createLogBrewReactNativePromiseRejectionHandlers: typeof createLogBrewReactNativePromiseRejectionHandlers;
128
192
  installLogBrewReactNativeGlobalErrorHandler: typeof installLogBrewReactNativeGlobalErrorHandler;
129
193
  };
130
194
 
@@ -7,6 +7,19 @@ export type ReactNativeErrorUtilsLike = {
7
7
  setGlobalHandler(handler: ReactNativeGlobalErrorHandler | undefined): void;
8
8
  };
9
9
 
10
+ export type ReactNativePromiseRejectionDiagnosticCode =
11
+ | "promise_rejection_capture_failed"
12
+ | "promise_rejection_configuration_invalid"
13
+ | "promise_rejection_duplicate_suppressed"
14
+ | "promise_rejection_handler_unavailable"
15
+ | "promise_rejection_id_unavailable"
16
+ | "promise_rejection_recursive_capture_suppressed"
17
+ | "promise_rejection_tracking_evicted";
18
+
19
+ export type ReactNativePromiseRejectionDiagnostic = Readonly<{
20
+ code: ReactNativePromiseRejectionDiagnosticCode;
21
+ }>;
22
+
10
23
  export type ReactNativeGlobalErrorDiagnosticCode =
11
24
  | "capture_failed"
12
25
  | "duplicate_capture_suppressed"
@@ -44,6 +57,45 @@ export type ReactNativeGlobalErrorHealth = Readonly<{
44
57
  suppressedEvents: number;
45
58
  }>;
46
59
 
60
+ export type ReactNativePromiseRejectionHealth = Readonly<{
61
+ available: boolean;
62
+ capturedEvents: number;
63
+ evictedRejections: number;
64
+ handledLaterEvents: number;
65
+ lastOutcome:
66
+ | "capture_failed"
67
+ | "captured"
68
+ | "captured_untracked"
69
+ | "captured_with_eviction"
70
+ | "duplicate_suppressed"
71
+ | "handled_later"
72
+ | "handled_unknown"
73
+ | "idle"
74
+ | "recursive_suppressed"
75
+ | "unavailable";
76
+ maxTrackedRejections: number;
77
+ suppressedEvents: number;
78
+ trackedRejections: number;
79
+ unknownHandledEvents: number;
80
+ untrackedEvents: number;
81
+ }>;
82
+
83
+ export type CreateLogBrewReactNativePromiseRejectionHandlersOptions = {
84
+ client: Pick<LogBrewClient, "issue">;
85
+ /**
86
+ * Maximum number of safe runtime rejection identifiers retained for duplicate
87
+ * suppression and later-handled health. Defaults to 128 and must be 1-1024.
88
+ */
89
+ maxTrackedRejections?: number;
90
+ onDiagnostic?: (diagnostic: ReactNativePromiseRejectionDiagnostic) => void;
91
+ };
92
+
93
+ export type LogBrewReactNativePromiseRejectionHandlers = Readonly<{
94
+ health(): ReactNativePromiseRejectionHealth;
95
+ onHandled(runtimeRejectionId: unknown): void;
96
+ onUnhandled(runtimeRejectionId: unknown, rejection?: unknown): void;
97
+ }>;
98
+
47
99
  export type ReactNativeFatalRecord = Readonly<{
48
100
  corruptRecords: number;
49
101
  droppedRecords: number;
@@ -112,6 +164,17 @@ export type LogBrewReactNativeGlobalErrorHandlerInstallation = Readonly<{
112
164
  remove(): boolean;
113
165
  }>;
114
166
 
167
+ /**
168
+ * Create privacy-safe callbacks for a Promise rejection tracker owned by the app.
169
+ *
170
+ * The callbacks do not install or patch Promise, Hermes, JavaScriptCore, or globals.
171
+ * Automatic reports never inspect or emit the rejection value or runtime identifier.
172
+ * Tracking is bounded and suppresses duplicate callbacks only while an identifier is retained.
173
+ */
174
+ export declare function createLogBrewReactNativePromiseRejectionHandlers(
175
+ options: CreateLogBrewReactNativePromiseRejectionHandlersOptions
176
+ ): LogBrewReactNativePromiseRejectionHandlers;
177
+
115
178
  /**
116
179
  * Install reversible automatic capture for React Native global JavaScript errors.
117
180
  *
@@ -125,6 +188,7 @@ export declare function installLogBrewReactNativeGlobalErrorHandler(
125
188
  ): LogBrewReactNativeGlobalErrorHandlerInstallation;
126
189
 
127
190
  declare const logBrewReactNativeGlobalErrors: {
191
+ createLogBrewReactNativePromiseRejectionHandlers: typeof createLogBrewReactNativePromiseRejectionHandlers;
128
192
  installLogBrewReactNativeGlobalErrorHandler: typeof installLogBrewReactNativeGlobalErrorHandler;
129
193
  };
130
194
 
package/global-errors.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import implementation from "./global-errors.cjs";
2
2
 
3
3
  export const {
4
+ createLogBrewReactNativePromiseRejectionHandlers,
4
5
  installLogBrewReactNativeGlobalErrorHandler
5
6
  } = implementation;
6
7
 
@@ -1,9 +1,14 @@
1
1
  import { NativeModules, TurboModuleRegistry } from "react-native";
2
2
 
3
3
  import {
4
+ createLogBrewReactNativePromiseRejectionHandlers,
4
5
  installLogBrewReactNativeGlobalErrorHandler as installPlatformNeutralHandler
5
6
  } from "./global-errors.js";
6
7
 
8
+ export {
9
+ createLogBrewReactNativePromiseRejectionHandlers
10
+ };
11
+
7
12
  function defaultFatalStore() {
8
13
  try {
9
14
  return TurboModuleRegistry?.get?.("LogBrewFatalStore")
@@ -33,6 +38,7 @@ export function installLogBrewReactNativeGlobalErrorHandler(options = {}) {
33
38
  }
34
39
 
35
40
  const logBrewReactNativeGlobalErrors = {
41
+ createLogBrewReactNativePromiseRejectionHandlers,
36
42
  installLogBrewReactNativeGlobalErrorHandler
37
43
  };
38
44
 
package/index.cjs CHANGED
@@ -4,7 +4,8 @@ const {
4
4
  createTraceparent,
5
5
  LogBrewClient,
6
6
  parseTraceparent,
7
- SdkError
7
+ SdkError,
8
+ TransportError
8
9
  } = require("@logbrew/sdk");
9
10
  const {
10
11
  runtimeReactNativeDebugIdMap,
@@ -14,22 +15,83 @@ const {
14
15
 
15
16
  const DEFAULT_SDK_NAME = "logbrew-react-native";
16
17
  const DEFAULT_SDK_VERSION = "0.1.0";
18
+ const DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events";
19
+ const BACKGROUND_APP_STATES = new Set(["background", "inactive"]);
17
20
  const LogBrewNativeContext = React.createContext(null);
18
21
  const activeTraceScopes = [];
19
22
  let nextTraceScopeId = 0;
20
23
 
21
24
  function createLogBrewReactNativeClient({
25
+ automaticDelivery,
22
26
  apiKey,
23
27
  clientKey,
28
+ deliveryIntervalMs,
29
+ deliveryQueueThreshold,
30
+ maxBatchBytes,
31
+ maxBatchEvents,
32
+ maxQueueBytes,
33
+ maxQueueSize,
34
+ onEventDropped,
24
35
  sdkName = DEFAULT_SDK_NAME,
25
36
  sdkVersion = DEFAULT_SDK_VERSION,
26
- maxRetries = 2
37
+ maxRetries = 2,
38
+ transport
27
39
  }) {
28
40
  const authKey = clientKey ?? apiKey;
29
41
  if (!authKey) {
30
42
  throw new SdkError("configuration_error", "createLogBrewReactNativeClient requires clientKey or apiKey");
31
43
  }
32
- return LogBrewClient.create({ apiKey: authKey, sdkName, sdkVersion, maxRetries });
44
+ return LogBrewClient.create({
45
+ apiKey: authKey,
46
+ automaticDelivery,
47
+ deliveryIntervalMs,
48
+ deliveryQueueThreshold,
49
+ maxBatchBytes,
50
+ maxBatchEvents,
51
+ maxQueueBytes,
52
+ maxQueueSize,
53
+ maxRetries,
54
+ onEventDropped,
55
+ sdkName,
56
+ sdkVersion,
57
+ transport
58
+ });
59
+ }
60
+
61
+ function createReactNativeFetchTransport({
62
+ endpoint = DEFAULT_ENDPOINT,
63
+ fetchImpl = defaultFetch(),
64
+ headers = {}
65
+ } = {}) {
66
+ validateDeliveryEndpoint(endpoint);
67
+ if (typeof fetchImpl !== "function") {
68
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport requires fetch");
69
+ }
70
+ if (!headers || Array.isArray(headers) || typeof headers !== "object") {
71
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport headers must be an object");
72
+ }
73
+
74
+ return Object.freeze({
75
+ async send(apiKey, body) {
76
+ try {
77
+ const response = await fetchImpl(endpoint, {
78
+ body,
79
+ headers: {
80
+ ...headers,
81
+ authorization: `Bearer ${apiKey}`,
82
+ "content-type": "application/json"
83
+ },
84
+ method: "POST"
85
+ });
86
+ const retryAfterMs = retryAfterMsFromHeaders(response?.headers);
87
+ return retryAfterMs === undefined
88
+ ? { statusCode: response?.status, attempts: 1 }
89
+ : { statusCode: response?.status, attempts: 1, retryAfterMs };
90
+ } catch {
91
+ throw TransportError.network("LogBrew React Native delivery failed");
92
+ }
93
+ }
94
+ });
33
95
  }
34
96
 
35
97
  function createReactNativeTraceparent({ randomValues = defaultRandomValues, spanId, traceFlags = "01", traceId } = {}) {
@@ -574,12 +636,34 @@ function createAppStateListener(client, appState, options = {}) {
574
636
  if (!appState || typeof appState.addEventListener !== "function") {
575
637
  throw new SdkError("configuration_error", "createAppStateListener requires AppState.addEventListener");
576
638
  }
639
+ const {
640
+ flushOnBackground = false,
641
+ onFlushError,
642
+ ...captureOptions
643
+ } = options;
644
+ if (typeof flushOnBackground !== "boolean") {
645
+ throw new SdkError("configuration_error", "createAppStateListener flushOnBackground must be a boolean");
646
+ }
647
+ if (onFlushError !== undefined && typeof onFlushError !== "function") {
648
+ throw new SdkError("configuration_error", "createAppStateListener onFlushError must be a function");
649
+ }
577
650
 
578
651
  const subscription = appState.addEventListener("change", (nextState) => {
579
652
  captureAppStateChange(client, nextState, {
580
- ...options,
653
+ ...captureOptions,
581
654
  appState
582
655
  });
656
+ if (flushOnBackground && BACKGROUND_APP_STATES.has(nextState)) {
657
+ void client.flush().catch((error) => {
658
+ if (typeof onFlushError === "function") {
659
+ try {
660
+ onFlushError(error);
661
+ } catch {
662
+ // Delivery diagnostics must not interrupt the app-state callback.
663
+ }
664
+ }
665
+ });
666
+ }
583
667
  });
584
668
 
585
669
  return subscriptionRemover(subscription);
@@ -616,6 +700,9 @@ function useLogBrewNativeActions() {
616
700
  action: (id, timestamp, attributes) => client.action(id, timestamp, attributesWithTrace(attributes, trace)),
617
701
  flush: client.flush.bind(client),
618
702
  shutdown: client.shutdown.bind(client),
703
+ deliveryHealth: client.deliveryHealth.bind(client),
704
+ droppedEvents: client.droppedEvents.bind(client),
705
+ pendingBytes: client.pendingBytes.bind(client),
619
706
  previewJson: client.previewJson.bind(client),
620
707
  pendingEvents: client.pendingEvents.bind(client),
621
708
  trace,
@@ -699,6 +786,44 @@ function errorMessage(error) {
699
786
  return String(error ?? "unknown error");
700
787
  }
701
788
 
789
+ function validateDeliveryEndpoint(endpoint) {
790
+ if (typeof endpoint !== "string" || endpoint.trim() === "") {
791
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport requires a non-empty endpoint");
792
+ }
793
+ let parsed;
794
+ try {
795
+ parsed = new URL(endpoint);
796
+ } catch {
797
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport endpoint must be an absolute URL");
798
+ }
799
+ if (parsed.protocol !== "https:") {
800
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport endpoint must use HTTPS");
801
+ }
802
+ if (endpoint !== `${parsed.origin}${parsed.pathname}`) {
803
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport endpoint must be a plain HTTPS path");
804
+ }
805
+ }
806
+
807
+ function retryAfterMsFromHeaders(headers) {
808
+ if (!headers || typeof headers.get !== "function") {
809
+ return undefined;
810
+ }
811
+ return retryAfterMsFromHeader(headers.get("retry-after"));
812
+ }
813
+
814
+ function retryAfterMsFromHeader(value, now = Date.now()) {
815
+ if (typeof value !== "string" || value.trim() === "") {
816
+ return undefined;
817
+ }
818
+ const trimmed = value.trim();
819
+ if (/^\d+$/u.test(trimmed)) {
820
+ const milliseconds = Number(trimmed) * 1000;
821
+ return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
822
+ }
823
+ const timestamp = Date.parse(trimmed);
824
+ return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : undefined;
825
+ }
826
+
702
827
  function defaultErrorEventId({ message, screen }) {
703
828
  return `evt_native_error_${slugify(`${screen ?? "app"}_${message}`)}`;
704
829
  }
@@ -977,7 +1102,8 @@ const defaultExport = {
977
1102
  LogBrewNativeProvider, captureAppStateChange, captureReactNativeAction, captureReactNativeError,
978
1103
  captureReactNativeNetwork, captureReactNativeNavigationSpan, captureReactNativeResourceSpan, captureScreenView,
979
1104
  bindLogBrewTrace, createAppStateListener, createLogBrewReactNativeClient, createReactNavigationSpanListener,
980
- createReactNativeSpanAttributes, createReactNativeTraceContext, createReactNativeTraceHeaders, createReactNativeActionEvent,
1105
+ createReactNativeFetchTransport, createReactNativeSpanAttributes, createReactNativeTraceContext,
1106
+ createReactNativeTraceHeaders, createReactNativeActionEvent,
981
1107
  createReactNativeErrorEvent, createReactNativeNetworkEvent, createReactNativeNavigationSpanEvent,
982
1108
  createReactNativeResourceSpanEvent, createReactNativeTraceparent, createTraceparentFetch, getActiveLogBrewTrace,
983
1109
  getReactNativeContext, getReactNativeTraceMetadata, shouldPropagateTraceparent, useLogBrewNative,
package/index.d.cts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type * as React from "react";
2
2
  import type {
3
3
  ActionAttributes,
4
+ DeliveryHealthSnapshot,
5
+ DroppedEvent,
4
6
  EnvironmentAttributes,
5
7
  IssueAttributes,
6
8
  LogAttributes,
@@ -30,11 +32,42 @@ export type ReactNativeAppStateLike = {
30
32
  };
31
33
 
32
34
  export type CreateLogBrewReactNativeClientConfig = {
35
+ automaticDelivery?: boolean;
33
36
  apiKey?: string;
34
37
  clientKey?: string;
38
+ deliveryIntervalMs?: number;
39
+ deliveryQueueThreshold?: number;
40
+ maxBatchBytes?: number;
41
+ maxBatchEvents?: number;
42
+ maxQueueBytes?: number;
43
+ maxQueueSize?: number;
44
+ maxRetries?: number;
45
+ onEventDropped?: (drop: DroppedEvent) => void;
35
46
  sdkName?: string;
36
47
  sdkVersion?: string;
37
- maxRetries?: number;
48
+ transport?: Transport;
49
+ };
50
+
51
+ export type ReactNativeFetchResponseLike = {
52
+ status: number;
53
+ headers?: {
54
+ get(name: string): string | null | undefined;
55
+ };
56
+ };
57
+
58
+ export type ReactNativeFetchLike = (
59
+ endpoint: string,
60
+ init: {
61
+ body: string;
62
+ headers: Record<string, string>;
63
+ method: "POST";
64
+ }
65
+ ) => Promise<ReactNativeFetchResponseLike> | ReactNativeFetchResponseLike;
66
+
67
+ export type ReactNativeFetchTransportConfig = {
68
+ endpoint?: string;
69
+ fetchImpl?: ReactNativeFetchLike;
70
+ headers?: Record<string, string>;
38
71
  };
39
72
 
40
73
  export type TracePropagationTarget = string | RegExp | ((url: string) => boolean);
@@ -122,6 +155,13 @@ export type CaptureAppStateChangeOptions = ReactNativeContextOptions & {
122
155
  timestamp?: string;
123
156
  };
124
157
 
158
+ export type CreateAppStateListenerOptions = CaptureAppStateChangeOptions & {
159
+ /** Flush the configured client transport when AppState becomes inactive or background. */
160
+ flushOnBackground?: boolean;
161
+ /** Observe a background flush failure without throwing from the AppState callback. */
162
+ onFlushError?: (error: unknown) => void;
163
+ };
164
+
125
165
  export type ReactNativeActionEvent = {
126
166
  id: string;
127
167
  timestamp: string;
@@ -278,8 +318,11 @@ export type LogBrewNativeActions = {
278
318
  log(id: string, timestamp: string, attributes: LogAttributes): void;
279
319
  span(id: string, timestamp: string, attributes: SpanAttributes): void;
280
320
  action(id: string, timestamp: string, attributes: ActionAttributes): void;
281
- flush(transport: Transport): Promise<TransportResponse>;
282
- shutdown(transport: Transport): Promise<TransportResponse>;
321
+ flush(transport?: Transport): Promise<TransportResponse>;
322
+ shutdown(transport?: Transport): Promise<TransportResponse>;
323
+ deliveryHealth(): DeliveryHealthSnapshot;
324
+ droppedEvents(): number;
325
+ pendingBytes(): number;
283
326
  previewJson(): string;
284
327
  pendingEvents(): number;
285
328
  trace?: ReactNativeTraceContext;
@@ -295,6 +338,9 @@ export type LogBrewNativeActions = {
295
338
  export declare function createLogBrewReactNativeClient(
296
339
  config: CreateLogBrewReactNativeClientConfig
297
340
  ): LogBrewClient;
341
+ export declare function createReactNativeFetchTransport(
342
+ config?: ReactNativeFetchTransportConfig
343
+ ): Transport;
298
344
  export declare function createReactNativeTraceparent(config?: ReactNativeTraceparentConfig): string;
299
345
  export declare function createReactNativeTraceContext(
300
346
  config?: ReactNativeTraceContextConfig
@@ -378,7 +424,7 @@ export declare function captureReactNativeError(
378
424
  export declare function createAppStateListener(
379
425
  client: LogBrewClient,
380
426
  appState: ReactNativeAppStateLike,
381
- options?: CaptureAppStateChangeOptions
427
+ options?: CreateAppStateListenerOptions
382
428
  ): () => void;
383
429
  export declare function LogBrewNativeProvider(props: LogBrewNativeProviderProps): React.ReactElement;
384
430
  export declare function useLogBrewNative(): LogBrewNativeContextValue;
@@ -413,7 +459,7 @@ export declare function captureDefaultReactNativeError(
413
459
  ): ReactNativeErrorEvent;
414
460
  export declare function createDefaultAppStateListener(
415
461
  client: LogBrewClient,
416
- options?: Omit<CaptureAppStateChangeOptions, "appState">
462
+ options?: Omit<CreateAppStateListenerOptions, "appState">
417
463
  ): () => void;
418
464
 
419
465
  declare const defaultExport: {
@@ -429,6 +475,7 @@ declare const defaultExport: {
429
475
  createAppStateListener: typeof createAppStateListener;
430
476
  createLogBrewReactNativeClient: typeof createLogBrewReactNativeClient;
431
477
  createReactNavigationSpanListener: typeof createReactNavigationSpanListener;
478
+ createReactNativeFetchTransport: typeof createReactNativeFetchTransport;
432
479
  createReactNativeSpanAttributes: typeof createReactNativeSpanAttributes;
433
480
  createReactNativeTraceContext: typeof createReactNativeTraceContext;
434
481
  createReactNativeTraceHeaders: typeof createReactNativeTraceHeaders;
package/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type * as React from "react";
2
2
  import type {
3
3
  ActionAttributes,
4
+ DeliveryHealthSnapshot,
5
+ DroppedEvent,
4
6
  EnvironmentAttributes,
5
7
  IssueAttributes,
6
8
  LogAttributes,
@@ -30,11 +32,42 @@ export type ReactNativeAppStateLike = {
30
32
  };
31
33
 
32
34
  export type CreateLogBrewReactNativeClientConfig = {
35
+ automaticDelivery?: boolean;
33
36
  apiKey?: string;
34
37
  clientKey?: string;
38
+ deliveryIntervalMs?: number;
39
+ deliveryQueueThreshold?: number;
40
+ maxBatchBytes?: number;
41
+ maxBatchEvents?: number;
42
+ maxQueueBytes?: number;
43
+ maxQueueSize?: number;
44
+ maxRetries?: number;
45
+ onEventDropped?: (drop: DroppedEvent) => void;
35
46
  sdkName?: string;
36
47
  sdkVersion?: string;
37
- maxRetries?: number;
48
+ transport?: Transport;
49
+ };
50
+
51
+ export type ReactNativeFetchResponseLike = {
52
+ status: number;
53
+ headers?: {
54
+ get(name: string): string | null | undefined;
55
+ };
56
+ };
57
+
58
+ export type ReactNativeFetchLike = (
59
+ endpoint: string,
60
+ init: {
61
+ body: string;
62
+ headers: Record<string, string>;
63
+ method: "POST";
64
+ }
65
+ ) => Promise<ReactNativeFetchResponseLike> | ReactNativeFetchResponseLike;
66
+
67
+ export type ReactNativeFetchTransportConfig = {
68
+ endpoint?: string;
69
+ fetchImpl?: ReactNativeFetchLike;
70
+ headers?: Record<string, string>;
38
71
  };
39
72
 
40
73
  export type TracePropagationTarget = string | RegExp | ((url: string) => boolean);
@@ -122,6 +155,13 @@ export type CaptureAppStateChangeOptions = ReactNativeContextOptions & {
122
155
  timestamp?: string;
123
156
  };
124
157
 
158
+ export type CreateAppStateListenerOptions = CaptureAppStateChangeOptions & {
159
+ /** Flush the configured client transport when AppState becomes inactive or background. */
160
+ flushOnBackground?: boolean;
161
+ /** Observe a background flush failure without throwing from the AppState callback. */
162
+ onFlushError?: (error: unknown) => void;
163
+ };
164
+
125
165
  export type ReactNativeActionEvent = {
126
166
  id: string;
127
167
  timestamp: string;
@@ -278,8 +318,11 @@ export type LogBrewNativeActions = {
278
318
  log(id: string, timestamp: string, attributes: LogAttributes): void;
279
319
  span(id: string, timestamp: string, attributes: SpanAttributes): void;
280
320
  action(id: string, timestamp: string, attributes: ActionAttributes): void;
281
- flush(transport: Transport): Promise<TransportResponse>;
282
- shutdown(transport: Transport): Promise<TransportResponse>;
321
+ flush(transport?: Transport): Promise<TransportResponse>;
322
+ shutdown(transport?: Transport): Promise<TransportResponse>;
323
+ deliveryHealth(): DeliveryHealthSnapshot;
324
+ droppedEvents(): number;
325
+ pendingBytes(): number;
283
326
  previewJson(): string;
284
327
  pendingEvents(): number;
285
328
  trace?: ReactNativeTraceContext;
@@ -295,6 +338,9 @@ export type LogBrewNativeActions = {
295
338
  export declare function createLogBrewReactNativeClient(
296
339
  config: CreateLogBrewReactNativeClientConfig
297
340
  ): LogBrewClient;
341
+ export declare function createReactNativeFetchTransport(
342
+ config?: ReactNativeFetchTransportConfig
343
+ ): Transport;
298
344
  export declare function createReactNativeTraceparent(config?: ReactNativeTraceparentConfig): string;
299
345
  export declare function createReactNativeTraceContext(
300
346
  config?: ReactNativeTraceContextConfig
@@ -378,7 +424,7 @@ export declare function captureReactNativeError(
378
424
  export declare function createAppStateListener(
379
425
  client: LogBrewClient,
380
426
  appState: ReactNativeAppStateLike,
381
- options?: CaptureAppStateChangeOptions
427
+ options?: CreateAppStateListenerOptions
382
428
  ): () => void;
383
429
  export declare function LogBrewNativeProvider(props: LogBrewNativeProviderProps): React.ReactElement;
384
430
  export declare function useLogBrewNative(): LogBrewNativeContextValue;
@@ -413,7 +459,7 @@ export declare function captureDefaultReactNativeError(
413
459
  ): ReactNativeErrorEvent;
414
460
  export declare function createDefaultAppStateListener(
415
461
  client: LogBrewClient,
416
- options?: Omit<CaptureAppStateChangeOptions, "appState">
462
+ options?: Omit<CreateAppStateListenerOptions, "appState">
417
463
  ): () => void;
418
464
 
419
465
  declare const defaultExport: {
@@ -429,6 +475,7 @@ declare const defaultExport: {
429
475
  createAppStateListener: typeof createAppStateListener;
430
476
  createLogBrewReactNativeClient: typeof createLogBrewReactNativeClient;
431
477
  createReactNavigationSpanListener: typeof createReactNavigationSpanListener;
478
+ createReactNativeFetchTransport: typeof createReactNativeFetchTransport;
432
479
  createReactNativeSpanAttributes: typeof createReactNativeSpanAttributes;
433
480
  createReactNativeTraceContext: typeof createReactNativeTraceContext;
434
481
  createReactNativeTraceHeaders: typeof createReactNativeTraceHeaders;
package/index.js CHANGED
@@ -4,7 +4,8 @@ import {
4
4
  createTraceparent,
5
5
  LogBrewClient,
6
6
  parseTraceparent,
7
- SdkError
7
+ SdkError,
8
+ TransportError
8
9
  } from "@logbrew/sdk";
9
10
  import {
10
11
  runtimeReactNativeDebugIdMap,
@@ -14,22 +15,83 @@ import {
14
15
 
15
16
  const DEFAULT_SDK_NAME = "logbrew-react-native";
16
17
  const DEFAULT_SDK_VERSION = "0.1.0";
18
+ const DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events";
19
+ const BACKGROUND_APP_STATES = new Set(["background", "inactive"]);
17
20
  const LogBrewNativeContext = React.createContext(null);
18
21
  const activeTraceScopes = [];
19
22
  let nextTraceScopeId = 0;
20
23
 
21
24
  export function createLogBrewReactNativeClient({
25
+ automaticDelivery,
22
26
  apiKey,
23
27
  clientKey,
28
+ deliveryIntervalMs,
29
+ deliveryQueueThreshold,
30
+ maxBatchBytes,
31
+ maxBatchEvents,
32
+ maxQueueBytes,
33
+ maxQueueSize,
34
+ onEventDropped,
24
35
  sdkName = DEFAULT_SDK_NAME,
25
36
  sdkVersion = DEFAULT_SDK_VERSION,
26
- maxRetries = 2
37
+ maxRetries = 2,
38
+ transport
27
39
  }) {
28
40
  const authKey = clientKey ?? apiKey;
29
41
  if (!authKey) {
30
42
  throw new SdkError("configuration_error", "createLogBrewReactNativeClient requires clientKey or apiKey");
31
43
  }
32
- return LogBrewClient.create({ apiKey: authKey, sdkName, sdkVersion, maxRetries });
44
+ return LogBrewClient.create({
45
+ apiKey: authKey,
46
+ automaticDelivery,
47
+ deliveryIntervalMs,
48
+ deliveryQueueThreshold,
49
+ maxBatchBytes,
50
+ maxBatchEvents,
51
+ maxQueueBytes,
52
+ maxQueueSize,
53
+ maxRetries,
54
+ onEventDropped,
55
+ sdkName,
56
+ sdkVersion,
57
+ transport
58
+ });
59
+ }
60
+
61
+ export function createReactNativeFetchTransport({
62
+ endpoint = DEFAULT_ENDPOINT,
63
+ fetchImpl = defaultFetch(),
64
+ headers = {}
65
+ } = {}) {
66
+ validateDeliveryEndpoint(endpoint);
67
+ if (typeof fetchImpl !== "function") {
68
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport requires fetch");
69
+ }
70
+ if (!headers || Array.isArray(headers) || typeof headers !== "object") {
71
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport headers must be an object");
72
+ }
73
+
74
+ return Object.freeze({
75
+ async send(apiKey, body) {
76
+ try {
77
+ const response = await fetchImpl(endpoint, {
78
+ body,
79
+ headers: {
80
+ ...headers,
81
+ authorization: `Bearer ${apiKey}`,
82
+ "content-type": "application/json"
83
+ },
84
+ method: "POST"
85
+ });
86
+ const retryAfterMs = retryAfterMsFromHeaders(response?.headers);
87
+ return retryAfterMs === undefined
88
+ ? { statusCode: response?.status, attempts: 1 }
89
+ : { statusCode: response?.status, attempts: 1, retryAfterMs };
90
+ } catch {
91
+ throw TransportError.network("LogBrew React Native delivery failed");
92
+ }
93
+ }
94
+ });
33
95
  }
34
96
 
35
97
  export function createReactNativeTraceparent({ randomValues = defaultRandomValues, spanId, traceFlags = "01", traceId } = {}) {
@@ -563,12 +625,34 @@ export function createAppStateListener(client, appState, options = {}) {
563
625
  if (!appState || typeof appState.addEventListener !== "function") {
564
626
  throw new SdkError("configuration_error", "createAppStateListener requires AppState.addEventListener");
565
627
  }
628
+ const {
629
+ flushOnBackground = false,
630
+ onFlushError,
631
+ ...captureOptions
632
+ } = options;
633
+ if (typeof flushOnBackground !== "boolean") {
634
+ throw new SdkError("configuration_error", "createAppStateListener flushOnBackground must be a boolean");
635
+ }
636
+ if (onFlushError !== undefined && typeof onFlushError !== "function") {
637
+ throw new SdkError("configuration_error", "createAppStateListener onFlushError must be a function");
638
+ }
566
639
 
567
640
  const subscription = appState.addEventListener("change", (nextState) => {
568
641
  captureAppStateChange(client, nextState, {
569
- ...options,
642
+ ...captureOptions,
570
643
  appState
571
644
  });
645
+ if (flushOnBackground && BACKGROUND_APP_STATES.has(nextState)) {
646
+ void client.flush().catch((error) => {
647
+ if (typeof onFlushError === "function") {
648
+ try {
649
+ onFlushError(error);
650
+ } catch {
651
+ // Delivery diagnostics must not interrupt the app-state callback.
652
+ }
653
+ }
654
+ });
655
+ }
572
656
  });
573
657
 
574
658
  return subscriptionRemover(subscription);
@@ -605,6 +689,9 @@ export function useLogBrewNativeActions() {
605
689
  action: (id, timestamp, attributes) => client.action(id, timestamp, attributesWithTrace(attributes, trace)),
606
690
  flush: client.flush.bind(client),
607
691
  shutdown: client.shutdown.bind(client),
692
+ deliveryHealth: client.deliveryHealth.bind(client),
693
+ droppedEvents: client.droppedEvents.bind(client),
694
+ pendingBytes: client.pendingBytes.bind(client),
608
695
  previewJson: client.previewJson.bind(client),
609
696
  pendingEvents: client.pendingEvents.bind(client),
610
697
  trace,
@@ -688,6 +775,44 @@ function errorMessage(error) {
688
775
  return String(error ?? "unknown error");
689
776
  }
690
777
 
778
+ function validateDeliveryEndpoint(endpoint) {
779
+ if (typeof endpoint !== "string" || endpoint.trim() === "") {
780
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport requires a non-empty endpoint");
781
+ }
782
+ let parsed;
783
+ try {
784
+ parsed = new URL(endpoint);
785
+ } catch {
786
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport endpoint must be an absolute URL");
787
+ }
788
+ if (parsed.protocol !== "https:") {
789
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport endpoint must use HTTPS");
790
+ }
791
+ if (endpoint !== `${parsed.origin}${parsed.pathname}`) {
792
+ throw new SdkError("configuration_error", "createReactNativeFetchTransport endpoint must be a plain HTTPS path");
793
+ }
794
+ }
795
+
796
+ function retryAfterMsFromHeaders(headers) {
797
+ if (!headers || typeof headers.get !== "function") {
798
+ return undefined;
799
+ }
800
+ return retryAfterMsFromHeader(headers.get("retry-after"));
801
+ }
802
+
803
+ function retryAfterMsFromHeader(value, now = Date.now()) {
804
+ if (typeof value !== "string" || value.trim() === "") {
805
+ return undefined;
806
+ }
807
+ const trimmed = value.trim();
808
+ if (/^\d+$/u.test(trimmed)) {
809
+ const milliseconds = Number(trimmed) * 1000;
810
+ return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
811
+ }
812
+ const timestamp = Date.parse(trimmed);
813
+ return Number.isFinite(timestamp) ? Math.max(0, timestamp - now) : undefined;
814
+ }
815
+
691
816
  function defaultErrorEventId({ message, screen }) {
692
817
  return `evt_native_error_${slugify(`${screen ?? "app"}_${message}`)}`;
693
818
  }
@@ -966,7 +1091,8 @@ export default {
966
1091
  LogBrewNativeProvider, captureAppStateChange, captureReactNativeAction, captureReactNativeError,
967
1092
  captureReactNativeNetwork, captureReactNativeNavigationSpan, captureReactNativeResourceSpan, captureScreenView,
968
1093
  bindLogBrewTrace, createAppStateListener, createLogBrewReactNativeClient, createReactNavigationSpanListener,
969
- createReactNativeSpanAttributes, createReactNativeTraceContext, createReactNativeTraceHeaders, createReactNativeActionEvent,
1094
+ createReactNativeFetchTransport, createReactNativeSpanAttributes, createReactNativeTraceContext,
1095
+ createReactNativeTraceHeaders, createReactNativeActionEvent,
970
1096
  createReactNativeErrorEvent, createReactNativeNetworkEvent, createReactNativeNavigationSpanEvent,
971
1097
  createReactNativeResourceSpanEvent, createReactNativeTraceparent, createTraceparentFetch, getActiveLogBrewTrace,
972
1098
  getReactNativeContext, getReactNativeTraceMetadata, shouldPropagateTraceparent, useLogBrewNative,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logbrew/react-native",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "React Native screen, error, trace, action, and network timeline helpers for LogBrew.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",
@@ -136,6 +136,7 @@
136
136
  "lifecycle.d.ts",
137
137
  "lifecycle.d.cts",
138
138
  "global-errors.cjs",
139
+ "promise-rejections.cjs",
139
140
  "fatal-replay.cjs",
140
141
  "global-errors.js",
141
142
  "global-errors.native.js",
@@ -207,6 +208,6 @@
207
208
  "react-native": ">=0.72"
208
209
  },
209
210
  "scripts": {
210
- "test": "node --check index.js && node --check index.cjs && node --check index.native.js && node --check apollo.js && node --check apollo.cjs && node --check instrumentation.js && node --check instrumentation.cjs && node --check lifecycle.js && node --check lifecycle.cjs && node --check global-errors.js && node --check global-errors.native.js && node --check global-errors.cjs && node --check fatal-replay.cjs && node --check metadata.js && node --check metadata.cjs && node --check native-bridge.js && node --check native-bridge.cjs && node --check resource-fetch.js && node --check resource-fetch.cjs && node --check release-artifacts.js && node --check release-artifacts.cjs && node --check metro.js && node --check metro.cjs && node --check examples/index.mjs && node --check examples/apollo-link-spans.mjs && node --check examples/instrumentation-kit.mjs && node --check examples/lifecycle-spans.mjs && node --check examples/native-bridge-scope.mjs && node --check examples/navigation-resource-spans.mjs && node --check examples/readme-example.mjs && node --check examples/real-user-smoke.mjs && node --check examples/resource-fetch-spans.mjs && node --check examples/trace-correlation.mjs && node --test"
211
+ "test": "node --check index.js && node --check index.cjs && node --check index.native.js && node --check apollo.js && node --check apollo.cjs && node --check instrumentation.js && node --check instrumentation.cjs && node --check lifecycle.js && node --check lifecycle.cjs && node --check global-errors.js && node --check global-errors.native.js && node --check global-errors.cjs && node --check promise-rejections.cjs && node --check fatal-replay.cjs && node --check metadata.js && node --check metadata.cjs && node --check native-bridge.js && node --check native-bridge.cjs && node --check resource-fetch.js && node --check resource-fetch.cjs && node --check release-artifacts.js && node --check release-artifacts.cjs && node --check metro.js && node --check metro.cjs && node --check examples/index.mjs && node --check examples/apollo-link-spans.mjs && node --check examples/instrumentation-kit.mjs && node --check examples/lifecycle-spans.mjs && node --check examples/native-bridge-scope.mjs && node --check examples/navigation-resource-spans.mjs && node --check examples/readme-example.mjs && node --check examples/real-user-smoke.mjs && node --check examples/resource-fetch-spans.mjs && node --check examples/trace-correlation.mjs && node --test"
211
212
  }
212
213
  }
@@ -0,0 +1,271 @@
1
+ "use strict";
2
+
3
+ const { createReactNativeErrorEvent } = require("./index.cjs");
4
+
5
+ const AUTOMATIC_MESSAGE = "Unhandled Promise rejection";
6
+ const DEFAULT_MAX_TRACKED_REJECTIONS = 128;
7
+ const MAX_TRACKED_REJECTIONS = 1024;
8
+ const MAX_STRING_ID_LENGTH = 128;
9
+ let nextEventSequence = 0;
10
+
11
+ function createLogBrewReactNativePromiseRejectionHandlers(options = {}) {
12
+ const input = isObjectLike(options) ? options : {};
13
+ const client = safeReadProperty(input, "client");
14
+ const issue = safeFunction(client, "issue");
15
+ const onDiagnostic = safeFunction(input, "onDiagnostic");
16
+ if (!issue) {
17
+ emitDiagnostic(onDiagnostic, "promise_rejection_handler_unavailable");
18
+ return inactiveHandlers();
19
+ }
20
+
21
+ const configuredMaximum = safeReadProperty(input, "maxTrackedRejections");
22
+ const maxTrackedRejections = validMaximum(configuredMaximum)
23
+ ? configuredMaximum
24
+ : DEFAULT_MAX_TRACKED_REJECTIONS;
25
+ if (configuredMaximum !== undefined && maxTrackedRejections !== configuredMaximum) {
26
+ emitDiagnostic(onDiagnostic, "promise_rejection_configuration_invalid");
27
+ }
28
+
29
+ const tracked = new Map();
30
+ const state = {
31
+ capturedEvents: 0,
32
+ emittingDiagnostic: false,
33
+ evictedRejections: 0,
34
+ handledLaterEvents: 0,
35
+ handling: false,
36
+ lastOutcome: "idle",
37
+ suppressedEvents: 0,
38
+ unknownHandledEvents: 0,
39
+ untrackedEvents: 0
40
+ };
41
+ let emittedEvictionDiagnostic = false;
42
+
43
+ return Object.freeze({
44
+ health() {
45
+ return healthSnapshot(state, tracked.size, maxTrackedRejections);
46
+ },
47
+ onHandled(runtimeRejectionId) {
48
+ const key = rejectionKey(runtimeRejectionId);
49
+ const record = key === undefined ? undefined : tracked.get(key);
50
+ if (!record) {
51
+ state.unknownHandledEvents = incrementBounded(state.unknownHandledEvents);
52
+ state.lastOutcome = "handled_unknown";
53
+ return;
54
+ }
55
+ if (record.handled) {
56
+ recordSuppression(
57
+ state,
58
+ onDiagnostic,
59
+ "promise_rejection_duplicate_suppressed",
60
+ "duplicate_suppressed"
61
+ );
62
+ return;
63
+ }
64
+ record.handled = true;
65
+ state.handledLaterEvents = incrementBounded(state.handledLaterEvents);
66
+ state.lastOutcome = "handled_later";
67
+ },
68
+ onUnhandled(runtimeRejectionId) {
69
+ if (state.handling) {
70
+ recordSuppression(
71
+ state,
72
+ onDiagnostic,
73
+ "promise_rejection_recursive_capture_suppressed",
74
+ "recursive_suppressed"
75
+ );
76
+ return;
77
+ }
78
+
79
+ const key = rejectionKey(runtimeRejectionId);
80
+ if (key !== undefined && tracked.has(key)) {
81
+ recordSuppression(
82
+ state,
83
+ onDiagnostic,
84
+ "promise_rejection_duplicate_suppressed",
85
+ "duplicate_suppressed"
86
+ );
87
+ return;
88
+ }
89
+
90
+ state.handling = true;
91
+ try {
92
+ if (key === undefined) {
93
+ emitStateDiagnostic(state, onDiagnostic, "promise_rejection_id_unavailable");
94
+ }
95
+ const event = createEvent();
96
+ issue.call(client, event.id, event.timestamp, event.attributes);
97
+ state.capturedEvents = incrementBounded(state.capturedEvents);
98
+
99
+ if (key === undefined) {
100
+ state.untrackedEvents = incrementBounded(state.untrackedEvents);
101
+ state.lastOutcome = "captured_untracked";
102
+ return;
103
+ }
104
+
105
+ let evicted = false;
106
+ if (tracked.size === maxTrackedRejections) {
107
+ tracked.delete(tracked.keys().next().value);
108
+ state.evictedRejections = incrementBounded(state.evictedRejections);
109
+ evicted = true;
110
+ if (!emittedEvictionDiagnostic) {
111
+ emittedEvictionDiagnostic = true;
112
+ emitStateDiagnostic(state, onDiagnostic, "promise_rejection_tracking_evicted");
113
+ }
114
+ }
115
+ tracked.set(key, { handled: false });
116
+ state.lastOutcome = evicted ? "captured_with_eviction" : "captured";
117
+ } catch {
118
+ state.lastOutcome = "capture_failed";
119
+ emitStateDiagnostic(state, onDiagnostic, "promise_rejection_capture_failed");
120
+ } finally {
121
+ state.handling = false;
122
+ }
123
+ }
124
+ });
125
+ }
126
+
127
+ function createEvent() {
128
+ const error = new Error(AUTOMATIC_MESSAGE);
129
+ delete error.stack;
130
+ const event = createReactNativeErrorEvent(error, {
131
+ id: nextEventId(),
132
+ includeStack: false
133
+ });
134
+ return {
135
+ ...event,
136
+ attributes: {
137
+ ...event.attributes,
138
+ title: AUTOMATIC_MESSAGE,
139
+ message: AUTOMATIC_MESSAGE,
140
+ metadata: {
141
+ ...event.attributes.metadata,
142
+ automatic: true,
143
+ fatal: false,
144
+ handled: false,
145
+ mechanism: "app_owned_promise_rejection_tracker",
146
+ source: "react-native.promise_rejection"
147
+ }
148
+ }
149
+ };
150
+ }
151
+
152
+ function rejectionKey(value) {
153
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
154
+ return `number:${value}`;
155
+ }
156
+ if (typeof value === "string"
157
+ && value.length > 0
158
+ && value.length <= MAX_STRING_ID_LENGTH
159
+ && !hasControlCharacter(value)) {
160
+ return `string:${value}`;
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ function validMaximum(value) {
166
+ return Number.isInteger(value) && value >= 1 && value <= MAX_TRACKED_REJECTIONS;
167
+ }
168
+
169
+ function nextEventId() {
170
+ nextEventSequence = incrementBounded(nextEventSequence);
171
+ return `evt_rn_promise_${Date.now().toString(36)}_${nextEventSequence.toString(36)}`;
172
+ }
173
+
174
+ function incrementBounded(value) {
175
+ return value >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : value + 1;
176
+ }
177
+
178
+ function recordSuppression(state, onDiagnostic, code, outcome) {
179
+ state.suppressedEvents = incrementBounded(state.suppressedEvents);
180
+ state.lastOutcome = outcome;
181
+ emitStateDiagnostic(state, onDiagnostic, code);
182
+ }
183
+
184
+ function emitStateDiagnostic(state, onDiagnostic, code) {
185
+ if (state.emittingDiagnostic) {
186
+ return;
187
+ }
188
+ state.emittingDiagnostic = true;
189
+ try {
190
+ emitDiagnostic(onDiagnostic, code);
191
+ } finally {
192
+ state.emittingDiagnostic = false;
193
+ }
194
+ }
195
+
196
+ function emitDiagnostic(onDiagnostic, code) {
197
+ if (typeof onDiagnostic !== "function") {
198
+ return;
199
+ }
200
+ try {
201
+ onDiagnostic(Object.freeze({ code }));
202
+ } catch {
203
+ // Diagnostics must never interfere with application rejection handling.
204
+ }
205
+ }
206
+
207
+ function inactiveHandlers() {
208
+ const snapshot = Object.freeze({
209
+ available: false,
210
+ capturedEvents: 0,
211
+ evictedRejections: 0,
212
+ handledLaterEvents: 0,
213
+ lastOutcome: "unavailable",
214
+ maxTrackedRejections: 0,
215
+ suppressedEvents: 0,
216
+ trackedRejections: 0,
217
+ unknownHandledEvents: 0,
218
+ untrackedEvents: 0
219
+ });
220
+ return Object.freeze({
221
+ health: () => snapshot,
222
+ onHandled: () => {},
223
+ onUnhandled: () => {}
224
+ });
225
+ }
226
+
227
+ function healthSnapshot(state, trackedRejections, maxTrackedRejections) {
228
+ return Object.freeze({
229
+ available: true,
230
+ capturedEvents: state.capturedEvents,
231
+ evictedRejections: state.evictedRejections,
232
+ handledLaterEvents: state.handledLaterEvents,
233
+ lastOutcome: state.lastOutcome,
234
+ maxTrackedRejections,
235
+ suppressedEvents: state.suppressedEvents,
236
+ trackedRejections,
237
+ unknownHandledEvents: state.unknownHandledEvents,
238
+ untrackedEvents: state.untrackedEvents
239
+ });
240
+ }
241
+
242
+ function safeReadProperty(value, property) {
243
+ if (!isObjectLike(value)) {
244
+ return undefined;
245
+ }
246
+ try {
247
+ return value[property];
248
+ } catch {
249
+ return undefined;
250
+ }
251
+ }
252
+
253
+ function safeFunction(value, property) {
254
+ const candidate = safeReadProperty(value, property);
255
+ return typeof candidate === "function" ? candidate : undefined;
256
+ }
257
+
258
+ function hasControlCharacter(value) {
259
+ return Array.from(value).some((character) => {
260
+ const code = character.codePointAt(0);
261
+ return code !== undefined && (code <= 31 || code === 127);
262
+ });
263
+ }
264
+
265
+ function isObjectLike(value) {
266
+ return value !== null && (typeof value === "object" || typeof value === "function");
267
+ }
268
+
269
+ module.exports = {
270
+ createLogBrewReactNativePromiseRejectionHandlers
271
+ };