@lumin-monitor/react-native 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,113 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@lumin-monitor/react-native` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0] - 2026-05-26
9
+
10
+ ### Fixed
11
+
12
+ - **`init()` no longer throws under Metro / Expo** ([report]). The 0.1.0–0.2.0
13
+ releases used a runtime `require("react-native")` inside `detectAppState()`.
14
+ Metro's static dependency scanner doesn't see requires nested in function
15
+ bodies, so `react-native` was excluded from the bundle and the call threw
16
+ `Requiring unknown module "react-native"` at app startup. Replaced with a
17
+ top-level `import { AppState } from "react-native"`, which Metro picks up
18
+ and resolves through the consumer's already-installed peer dep. tsup keeps
19
+ `react-native` externalised so the import survives into the published
20
+ artifact untouched.
21
+
22
+ ### Changed (BREAKING)
23
+
24
+ - **AsyncStorage is no longer auto-detected.** The previous behaviour did a
25
+ runtime `require("@react-native-async-storage/async-storage")`, which has
26
+ the same Metro problem as the `react-native` require above — and forced
27
+ every consumer to declare an unused peer dep even when they didn't want
28
+ persistence. Apps that want persistence now import AsyncStorage themselves
29
+ and pass it through:
30
+
31
+ ```ts
32
+ import AsyncStorage from "@react-native-async-storage/async-storage";
33
+ import { init } from "@lumin-monitor/react-native";
34
+
35
+ const lumin = init({ apiKey, storage: AsyncStorage });
36
+ ```
37
+
38
+ Omitting `storage` falls back to in-memory ids (the same behaviour 0.2.0
39
+ exhibited when the optional peer dep wasn't installed). `@react-native-async-storage/async-storage`
40
+ has been removed from `peerDependencies` and `peerDependenciesMeta`; the
41
+ SDK itself no longer references the package.
42
+
43
+ ### Migration
44
+
45
+ - If you were on 0.2.0 and had `@react-native-async-storage/async-storage`
46
+ installed: import it and pass it as `storage` to `init()`. Two lines of
47
+ app code.
48
+ - If you didn't have it installed: nothing to change. You were already getting
49
+ in-memory ids (silently), which is still the default in 0.3.0.
50
+
51
+ [report]: https://github.com/tamso-labs/lumin/issues <!-- replace with the actual issue link when filed -->
52
+
53
+ ## [0.2.0] - 2026-05-26
54
+
55
+ ### Added
56
+
57
+ - `captureError(err, properties?)` — capture an exception as a Lumin event.
58
+ Accepts a real `Error` (preferred — preserves stack and constructor name as
59
+ `error_type`), a string, or a plain object with a `message` field. `null` /
60
+ `undefined` are silently ignored. The same `Error` instance captured twice
61
+ in one tick is deduped.
62
+ - Automatic capture of unhandled JS errors via `ErrorUtils.setGlobalHandler`.
63
+ Enabled by default; opt out with `captureUnhandledErrors: false`. The
64
+ handler **chains to the previous handler**, so RN's red-box, LogBox, and any
65
+ other error tool already installed still fires — the SDK never swallows the
66
+ throw.
67
+ - Fatal errors (`isFatal: true`) trigger an immediate `flush()` so the error
68
+ row has a chance to leave the device before the RN runtime tears down, and
69
+ are stamped with `properties: { fatal: true }`.
70
+ - `errorUtils` init option — override the auto-detected global ErrorUtils
71
+ (mainly for tests).
72
+ - New wire `EventType` value `"error"` plus optional `error_message`,
73
+ `error_stack`, and `error_type` fields on `WireEvent`.
74
+
75
+ ### Notes
76
+
77
+ - Native iOS / Android crashes are not captured. `ErrorUtils` is JS-only;
78
+ native crash reporting requires a native module (Crashlytics, Sentry-native,
79
+ Bugsnag's native bridge) which is out of scope for this SDK.
80
+ - Source maps are not symbolicated server-side. Stacks ship as the RN bundle's
81
+ minified frames.
82
+ - Requires Lumin API v0.x (this release) or newer — older servers will reject
83
+ `type: "error"` events with a 400.
84
+
85
+ ## [0.1.0] - 2026-05-26
86
+
87
+ Initial release. Mirrors `@lumin-monitor/browser` for React Native; same
88
+ `/v1/events` ingest, same `lmn_pub_*` API key kind, so a single Lumin project
89
+ can hold events from web and mobile and tie them to the same user via
90
+ `identify`.
91
+
92
+ ### Added
93
+
94
+ - `init(options)` returning a `LuminClient` with bound `screen` / `track` /
95
+ `identify` / `flush` / `close` / `getSessionId` / `getAnonymousId` methods.
96
+ - Synchronous `init()` with lazy AsyncStorage id hydration — buffered events
97
+ await hydration before the first flush, so apps can call `screen` /
98
+ `track` immediately after `init`.
99
+ - Persistent `anonymous_id` and `session_id` via
100
+ `@react-native-async-storage/async-storage` (optional peer dep); graceful
101
+ in-memory fallback when the peer dep is missing.
102
+ - Idle-based session rotation (`sessionIdleMs`, default 30 min) — matches the
103
+ GA / Mixpanel convention for mobile sessions.
104
+ - Auto-flush on `AppState` transition to `background` or `inactive`.
105
+ - `screen()` method (RN idiom); emits wire `type: "page"` for cross-platform
106
+ compatibility with the browser SDK.
107
+ - Optional `@lumin-monitor/react-native/react-navigation` subpath exporting
108
+ `useLuminScreenviews(client, navigationRef)` for React Navigation v6+ apps.
109
+ - Ships dual ESM + CJS bundle with full `.d.ts` types.
110
+
111
+ [0.3.0]: https://github.com/tamso-labs/lumin/releases/tag/sdk-react-native-v0.3.0
112
+ [0.2.0]: https://github.com/tamso-labs/lumin/releases/tag/sdk-react-native-v0.2.0
113
+ [0.1.0]: https://github.com/tamso-labs/lumin/releases/tag/sdk-react-native-v0.1.0
package/README.md CHANGED
@@ -13,26 +13,30 @@ the same user.
13
13
  ## Install
14
14
 
15
15
  ```sh
16
- pnpm add @lumin-monitor/react-native @react-native-async-storage/async-storage
17
- # or: npm install / yarn add
16
+ pnpm add @lumin-monitor/react-native
17
+ # plus, if you want persistent ids across app launches (recommended):
18
+ pnpm add @react-native-async-storage/async-storage
18
19
  ```
19
20
 
20
- `@react-native-async-storage/async-storage` is an optional peer dep. The
21
- SDK uses it to persist `anonymous_id` across app launches and to expire
22
- `session_id` after 30 minutes of inactivity. If it is not installed, the
23
- SDK falls back to in-memory ids that reset on every cold start — useful
24
- for prototypes, but you almost certainly want the peer dep in production.
21
+ `@react-native-async-storage/async-storage` is no longer auto-detected
22
+ (it broke Metro bundling see [CHANGELOG 0.3.0](./CHANGELOG.md)). Install
23
+ it and pass it to `init({ storage })` to persist `anonymous_id` across app
24
+ launches and let the 30-minute idle session window survive a cold start.
25
+ Omit `storage` (or pass `null`) to use in-memory ids that reset on every
26
+ cold start — fine for prototypes.
25
27
 
26
- React Navigation is also an optional peer dep, only needed if you import
28
+ React Navigation is an optional peer dep, only needed if you import
27
29
  `@lumin-monitor/react-native/react-navigation`.
28
30
 
29
31
  ## Quick start
30
32
 
31
33
  ```ts
34
+ import AsyncStorage from "@react-native-async-storage/async-storage";
32
35
  import { init } from "@lumin-monitor/react-native";
33
36
 
34
37
  export const lumin = init({
35
38
  apiKey: process.env.EXPO_PUBLIC_LUMIN_BROWSER_API_KEY!,
39
+ storage: AsyncStorage,
36
40
  });
37
41
 
38
42
  // Bound methods, safe to destructure:
@@ -74,6 +78,8 @@ hydration to complete before sending.
74
78
  | `fetch` | global `fetch` | Override for tests. |
75
79
  | `storage` | auto-detect AsyncStorage | Pass a custom `AsyncStorageLike`, or `null` to opt out (in-memory ids). |
76
80
  | `appState` | auto-detect react-native | Pass `null` to disable the background-flush listener. |
81
+ | `captureUnhandledErrors` | `true` | Install an `ErrorUtils.setGlobalHandler` chain. See *Error capture* below. |
82
+ | `errorUtils` | auto-detect global ErrorUtils | Override the ErrorUtils-like object the SDK installs into. |
77
83
 
78
84
  ### `screen(name?, properties?)`
79
85
 
@@ -115,6 +121,50 @@ identify("user_abc123", { plan: "indie", signedUpAt: "2026-05-01" });
115
121
  Re-call on every app launch while the user is signed in. It is cheap
116
122
  and ensures a cold start still binds the session.
117
123
 
124
+ ### `captureError(err, properties?)`
125
+
126
+ Capture an error. The auto-installed global handler catches uncaught JS
127
+ throws (and rejected promises that bubble up to RN); call this directly
128
+ from `try/catch` blocks where you'd otherwise swallow the failure.
129
+
130
+ ```ts
131
+ try {
132
+ await applyDiscount(code);
133
+ } catch (err) {
134
+ captureError(err, { code, step: "checkout" });
135
+ showToast("Discount didn't apply");
136
+ }
137
+ ```
138
+
139
+ Accepts a real `Error` (preferred — preserves stack and constructor name
140
+ as `error_type`), or any value that will be stringified for the message.
141
+ `null` / `undefined` are silently ignored. The same `Error` object
142
+ captured twice in the same tick is deduped.
143
+
144
+ ### Auto error capture
145
+
146
+ When `captureUnhandledErrors` is true (the default), the SDK installs a
147
+ handler via `ErrorUtils.setGlobalHandler` that **chains to the previous
148
+ handler**, so RN's red-box, the LogBox, and any other error tool already
149
+ installed still fire. The SDK does not swallow the throw.
150
+
151
+ Fatal errors (`isFatal: true`) trigger an immediate `flush()` so the
152
+ error row has a chance to leave the device before the RN runtime tears
153
+ down. Best-effort: the OS may suspend the JS thread before the request
154
+ lands, but the standard ~30 s background grace window covers the
155
+ common case.
156
+
157
+ **What is NOT captured**:
158
+ - **Native iOS / Android crashes.** RN's `ErrorUtils` is JS-only. Native
159
+ crashes need a native module (Crashlytics, Sentry-native, Bugsnag's
160
+ native bridge) — out of scope for this SDK.
161
+ - **Source-mapped stacks.** Stacks ship as the minified RN bundle frames
162
+ ship them. Symbolication against the bundle's source maps is a v2
163
+ concern.
164
+
165
+ Opt out with `captureUnhandledErrors: false` if another tool already
166
+ owns the global handler; manual `captureError(err)` still works.
167
+
118
168
  ### `flush(): Promise<void>`
119
169
 
120
170
  Force a flush of any buffered events. The SDK auto-flushes on AppState
package/dist/index.cjs CHANGED
@@ -25,6 +25,9 @@ __export(src_exports, {
25
25
  });
26
26
  module.exports = __toCommonJS(src_exports);
27
27
 
28
+ // src/client.ts
29
+ var import_react_native = require("react-native");
30
+
28
31
  // src/ids.ts
29
32
  function uuidv4() {
30
33
  const c = globalThis.crypto;
@@ -41,19 +44,6 @@ function uuidv4() {
41
44
  var ANON_KEY = "lumin.anonymous_id";
42
45
  var SESSION_KEY = "lumin.session_id";
43
46
  var SESSION_LAST_SEEN_KEY = "lumin.session_last_seen";
44
- function detectAsyncStorage() {
45
- try {
46
- if (typeof require === "undefined") return null;
47
- const mod = require("@react-native-async-storage/async-storage");
48
- const candidate = mod?.default ?? mod;
49
- if (candidate && typeof candidate.getItem === "function" && typeof candidate.setItem === "function") {
50
- return candidate;
51
- }
52
- return null;
53
- } catch {
54
- return null;
55
- }
56
- }
57
47
  function memoryStorage() {
58
48
  const m = /* @__PURE__ */ new Map();
59
49
  return {
@@ -109,6 +99,11 @@ var SERVER_MAX_BATCH = 1e3;
109
99
  var DEFAULT_ENDPOINT = "https://api.getlumin.dev";
110
100
  var Client = class {
111
101
  constructor(opts) {
102
+ this.prevErrorHandler = void 0;
103
+ this.errorHandlerInstalled = false;
104
+ // Dedup the same Error captured twice (e.g. via captureError + the global
105
+ // handler firing on the same throw). WeakSet so we don't pin GC.
106
+ this.recentErrors = /* @__PURE__ */ new WeakSet();
112
107
  this.userId = null;
113
108
  this.cachedIds = null;
114
109
  // Pending events queued before ids hydrate. `user_id` is stamped at enqueue
@@ -131,11 +126,14 @@ var Client = class {
131
126
  console.warn("[lumin] flush failed", { err, dropped });
132
127
  });
133
128
  this.fetchImpl = opts.fetch ?? fetch.bind(globalThis);
134
- const resolvedStorage = opts.storage === null ? memoryStorage() : opts.storage ?? detectAsyncStorage() ?? memoryStorage();
135
- this.storage = resolvedStorage;
129
+ this.storage = opts.storage ?? memoryStorage();
136
130
  this.appState = opts.appState === void 0 ? detectAppState() : opts.appState;
131
+ this.errorUtils = opts.errorUtils === void 0 ? detectErrorUtils() : opts.errorUtils;
137
132
  this.idsReady = this.hydrateIds();
138
133
  this.installBackgroundFlush();
134
+ if (opts.captureUnhandledErrors !== false) {
135
+ this.installErrorHandler();
136
+ }
139
137
  }
140
138
  screen(name, properties) {
141
139
  if (this.closed) return;
@@ -153,6 +151,22 @@ var Client = class {
153
151
  }
154
152
  this.enqueue({ type: "track", name, ...properties !== void 0 ? { properties } : {} });
155
153
  }
154
+ captureError(err, properties) {
155
+ if (this.closed) return;
156
+ const e = normalizeError(err);
157
+ if (!e) return;
158
+ if (typeof err === "object" && err !== null) {
159
+ if (this.recentErrors.has(err)) return;
160
+ this.recentErrors.add(err);
161
+ }
162
+ this.enqueue({
163
+ type: "error",
164
+ ...properties !== void 0 ? { properties } : {},
165
+ error_message: e.message,
166
+ ...e.stack ? { error_stack: e.stack } : {},
167
+ ...e.type ? { error_type: e.type } : {}
168
+ });
169
+ }
156
170
  identify(userId, traits) {
157
171
  if (this.closed) return;
158
172
  if (!userId) {
@@ -189,6 +203,7 @@ var Client = class {
189
203
  if (this.closed) return;
190
204
  this.closed = true;
191
205
  this.removeBackgroundFlush();
206
+ this.restoreErrorHandler();
192
207
  await this.flush();
193
208
  await Promise.allSettled(this.inflight);
194
209
  }
@@ -314,24 +329,88 @@ var Client = class {
314
329
  this.appStateSub = null;
315
330
  }
316
331
  }
332
+ // --- error handler -----------------------------------------------------
333
+ /**
334
+ * Install a global JS error handler via ErrorUtils.setGlobalHandler that
335
+ * captures + chains to whatever handler was already in place. RN's
336
+ * red-box and any other observability tool that installed before us
337
+ * still fires — we do not swallow the throw.
338
+ */
339
+ installErrorHandler() {
340
+ if (!this.errorUtils) return;
341
+ this.prevErrorHandler = this.errorUtils.getGlobalHandler?.();
342
+ const handler = (err, isFatal) => {
343
+ try {
344
+ this.captureError(err, isFatal ? { fatal: true } : void 0);
345
+ if (isFatal) void this.flush();
346
+ } catch {
347
+ }
348
+ if (this.prevErrorHandler) {
349
+ this.prevErrorHandler(err, isFatal);
350
+ }
351
+ };
352
+ this.errorUtils.setGlobalHandler(handler);
353
+ this.errorHandlerInstalled = true;
354
+ }
355
+ restoreErrorHandler() {
356
+ if (!this.errorHandlerInstalled || !this.errorUtils) return;
357
+ this.errorUtils.setGlobalHandler(
358
+ this.prevErrorHandler ?? defaultNoopErrorHandler
359
+ );
360
+ this.errorHandlerInstalled = false;
361
+ }
317
362
  };
363
+ function defaultNoopErrorHandler() {
364
+ }
365
+ function detectErrorUtils() {
366
+ const g = globalThis;
367
+ const eu = g.ErrorUtils;
368
+ if (eu && typeof eu.setGlobalHandler === "function") {
369
+ return eu;
370
+ }
371
+ return null;
372
+ }
373
+ function normalizeError(raw) {
374
+ if (raw == null) return null;
375
+ if (raw instanceof Error) {
376
+ return {
377
+ message: raw.message || raw.name || "Error",
378
+ ...raw.stack ? { stack: raw.stack } : {},
379
+ type: raw.name || raw.constructor?.name
380
+ };
381
+ }
382
+ if (typeof raw === "string") {
383
+ return { message: raw };
384
+ }
385
+ if (typeof raw === "object") {
386
+ const obj = raw;
387
+ const message = typeof obj.message === "string" ? obj.message : safeStringify(raw);
388
+ return {
389
+ message,
390
+ ...typeof obj.stack === "string" ? { stack: obj.stack } : {},
391
+ ...typeof obj.name === "string" ? { type: obj.name } : {}
392
+ };
393
+ }
394
+ return { message: String(raw) };
395
+ }
396
+ function safeStringify(v) {
397
+ try {
398
+ return JSON.stringify(v) ?? String(v);
399
+ } catch {
400
+ return String(v);
401
+ }
402
+ }
318
403
  function clampBatch(n) {
319
404
  if (!Number.isFinite(n) || n < 1) return 1;
320
405
  if (n > SERVER_MAX_BATCH) return SERVER_MAX_BATCH;
321
406
  return Math.floor(n);
322
407
  }
323
408
  function detectAppState() {
324
- try {
325
- if (typeof require === "undefined") return null;
326
- const rn = require("react-native");
327
- const candidate = rn?.AppState;
328
- if (candidate && typeof candidate.addEventListener === "function") {
329
- return candidate;
330
- }
331
- return null;
332
- } catch {
333
- return null;
409
+ const candidate = import_react_native.AppState;
410
+ if (candidate && typeof candidate.addEventListener === "function") {
411
+ return candidate;
334
412
  }
413
+ return null;
335
414
  }
336
415
  function validateEndpoint(raw) {
337
416
  let url;
@@ -369,6 +448,7 @@ function init(opts) {
369
448
  screen: c.screen.bind(c),
370
449
  track: c.track.bind(c),
371
450
  identify: c.identify.bind(c),
451
+ captureError: c.captureError.bind(c),
372
452
  flush: c.flush.bind(c),
373
453
  close: c.close.bind(c),
374
454
  getSessionId: c.getSessionId.bind(c),
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { L as LuminClient, I as InitOptions } from './types-BeHbtR1J.cjs';
2
- export { A as AppStateLike, a as AsyncStorageLike, E as EventType, W as WireEvent } from './types-BeHbtR1J.cjs';
1
+ import { L as LuminClient, I as InitOptions } from './types-DMUQWor6.cjs';
2
+ export { A as AppStateLike, a as AsyncStorageLike, E as ErrorUtilsLike, b as EventType, W as WireEvent } from './types-DMUQWor6.cjs';
3
3
 
4
4
  declare class Client implements LuminClient {
5
5
  private readonly endpoint;
@@ -11,6 +11,10 @@ declare class Client implements LuminClient {
11
11
  private readonly fetchImpl;
12
12
  private readonly storage;
13
13
  private readonly appState;
14
+ private readonly errorUtils;
15
+ private prevErrorHandler;
16
+ private errorHandlerInstalled;
17
+ private readonly recentErrors;
14
18
  private userId;
15
19
  private idsReady;
16
20
  private cachedIds;
@@ -23,6 +27,7 @@ declare class Client implements LuminClient {
23
27
  constructor(opts: InitOptions);
24
28
  screen(name?: string, properties?: Record<string, unknown>): void;
25
29
  track(name: string, properties?: Record<string, unknown>): void;
30
+ captureError(err: unknown, properties?: Record<string, unknown>): void;
26
31
  identify(userId: string, traits?: Record<string, unknown>): void;
27
32
  /**
28
33
  * Drain the buffer to the server. Returns when the in-flight request
@@ -59,6 +64,14 @@ declare class Client implements LuminClient {
59
64
  */
60
65
  private installBackgroundFlush;
61
66
  private removeBackgroundFlush;
67
+ /**
68
+ * Install a global JS error handler via ErrorUtils.setGlobalHandler that
69
+ * captures + chains to whatever handler was already in place. RN's
70
+ * red-box and any other observability tool that installed before us
71
+ * still fires — we do not swallow the throw.
72
+ */
73
+ private installErrorHandler;
74
+ private restoreErrorHandler;
62
75
  }
63
76
 
64
77
  /**
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { L as LuminClient, I as InitOptions } from './types-BeHbtR1J.js';
2
- export { A as AppStateLike, a as AsyncStorageLike, E as EventType, W as WireEvent } from './types-BeHbtR1J.js';
1
+ import { L as LuminClient, I as InitOptions } from './types-DMUQWor6.js';
2
+ export { A as AppStateLike, a as AsyncStorageLike, E as ErrorUtilsLike, b as EventType, W as WireEvent } from './types-DMUQWor6.js';
3
3
 
4
4
  declare class Client implements LuminClient {
5
5
  private readonly endpoint;
@@ -11,6 +11,10 @@ declare class Client implements LuminClient {
11
11
  private readonly fetchImpl;
12
12
  private readonly storage;
13
13
  private readonly appState;
14
+ private readonly errorUtils;
15
+ private prevErrorHandler;
16
+ private errorHandlerInstalled;
17
+ private readonly recentErrors;
14
18
  private userId;
15
19
  private idsReady;
16
20
  private cachedIds;
@@ -23,6 +27,7 @@ declare class Client implements LuminClient {
23
27
  constructor(opts: InitOptions);
24
28
  screen(name?: string, properties?: Record<string, unknown>): void;
25
29
  track(name: string, properties?: Record<string, unknown>): void;
30
+ captureError(err: unknown, properties?: Record<string, unknown>): void;
26
31
  identify(userId: string, traits?: Record<string, unknown>): void;
27
32
  /**
28
33
  * Drain the buffer to the server. Returns when the in-flight request
@@ -59,6 +64,14 @@ declare class Client implements LuminClient {
59
64
  */
60
65
  private installBackgroundFlush;
61
66
  private removeBackgroundFlush;
67
+ /**
68
+ * Install a global JS error handler via ErrorUtils.setGlobalHandler that
69
+ * captures + chains to whatever handler was already in place. RN's
70
+ * red-box and any other observability tool that installed before us
71
+ * still fires — we do not swallow the throw.
72
+ */
73
+ private installErrorHandler;
74
+ private restoreErrorHandler;
62
75
  }
63
76
 
64
77
  /**
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
- import {
2
- __require
3
- } from "./chunk-3RG5ZIWI.js";
1
+ // src/client.ts
2
+ import { AppState as RNAppState } from "react-native";
4
3
 
5
4
  // src/ids.ts
6
5
  function uuidv4() {
@@ -18,19 +17,6 @@ function uuidv4() {
18
17
  var ANON_KEY = "lumin.anonymous_id";
19
18
  var SESSION_KEY = "lumin.session_id";
20
19
  var SESSION_LAST_SEEN_KEY = "lumin.session_last_seen";
21
- function detectAsyncStorage() {
22
- try {
23
- if (typeof __require === "undefined") return null;
24
- const mod = __require("@react-native-async-storage/async-storage");
25
- const candidate = mod?.default ?? mod;
26
- if (candidate && typeof candidate.getItem === "function" && typeof candidate.setItem === "function") {
27
- return candidate;
28
- }
29
- return null;
30
- } catch {
31
- return null;
32
- }
33
- }
34
20
  function memoryStorage() {
35
21
  const m = /* @__PURE__ */ new Map();
36
22
  return {
@@ -86,6 +72,11 @@ var SERVER_MAX_BATCH = 1e3;
86
72
  var DEFAULT_ENDPOINT = "https://api.getlumin.dev";
87
73
  var Client = class {
88
74
  constructor(opts) {
75
+ this.prevErrorHandler = void 0;
76
+ this.errorHandlerInstalled = false;
77
+ // Dedup the same Error captured twice (e.g. via captureError + the global
78
+ // handler firing on the same throw). WeakSet so we don't pin GC.
79
+ this.recentErrors = /* @__PURE__ */ new WeakSet();
89
80
  this.userId = null;
90
81
  this.cachedIds = null;
91
82
  // Pending events queued before ids hydrate. `user_id` is stamped at enqueue
@@ -108,11 +99,14 @@ var Client = class {
108
99
  console.warn("[lumin] flush failed", { err, dropped });
109
100
  });
110
101
  this.fetchImpl = opts.fetch ?? fetch.bind(globalThis);
111
- const resolvedStorage = opts.storage === null ? memoryStorage() : opts.storage ?? detectAsyncStorage() ?? memoryStorage();
112
- this.storage = resolvedStorage;
102
+ this.storage = opts.storage ?? memoryStorage();
113
103
  this.appState = opts.appState === void 0 ? detectAppState() : opts.appState;
104
+ this.errorUtils = opts.errorUtils === void 0 ? detectErrorUtils() : opts.errorUtils;
114
105
  this.idsReady = this.hydrateIds();
115
106
  this.installBackgroundFlush();
107
+ if (opts.captureUnhandledErrors !== false) {
108
+ this.installErrorHandler();
109
+ }
116
110
  }
117
111
  screen(name, properties) {
118
112
  if (this.closed) return;
@@ -130,6 +124,22 @@ var Client = class {
130
124
  }
131
125
  this.enqueue({ type: "track", name, ...properties !== void 0 ? { properties } : {} });
132
126
  }
127
+ captureError(err, properties) {
128
+ if (this.closed) return;
129
+ const e = normalizeError(err);
130
+ if (!e) return;
131
+ if (typeof err === "object" && err !== null) {
132
+ if (this.recentErrors.has(err)) return;
133
+ this.recentErrors.add(err);
134
+ }
135
+ this.enqueue({
136
+ type: "error",
137
+ ...properties !== void 0 ? { properties } : {},
138
+ error_message: e.message,
139
+ ...e.stack ? { error_stack: e.stack } : {},
140
+ ...e.type ? { error_type: e.type } : {}
141
+ });
142
+ }
133
143
  identify(userId, traits) {
134
144
  if (this.closed) return;
135
145
  if (!userId) {
@@ -166,6 +176,7 @@ var Client = class {
166
176
  if (this.closed) return;
167
177
  this.closed = true;
168
178
  this.removeBackgroundFlush();
179
+ this.restoreErrorHandler();
169
180
  await this.flush();
170
181
  await Promise.allSettled(this.inflight);
171
182
  }
@@ -291,24 +302,88 @@ var Client = class {
291
302
  this.appStateSub = null;
292
303
  }
293
304
  }
305
+ // --- error handler -----------------------------------------------------
306
+ /**
307
+ * Install a global JS error handler via ErrorUtils.setGlobalHandler that
308
+ * captures + chains to whatever handler was already in place. RN's
309
+ * red-box and any other observability tool that installed before us
310
+ * still fires — we do not swallow the throw.
311
+ */
312
+ installErrorHandler() {
313
+ if (!this.errorUtils) return;
314
+ this.prevErrorHandler = this.errorUtils.getGlobalHandler?.();
315
+ const handler = (err, isFatal) => {
316
+ try {
317
+ this.captureError(err, isFatal ? { fatal: true } : void 0);
318
+ if (isFatal) void this.flush();
319
+ } catch {
320
+ }
321
+ if (this.prevErrorHandler) {
322
+ this.prevErrorHandler(err, isFatal);
323
+ }
324
+ };
325
+ this.errorUtils.setGlobalHandler(handler);
326
+ this.errorHandlerInstalled = true;
327
+ }
328
+ restoreErrorHandler() {
329
+ if (!this.errorHandlerInstalled || !this.errorUtils) return;
330
+ this.errorUtils.setGlobalHandler(
331
+ this.prevErrorHandler ?? defaultNoopErrorHandler
332
+ );
333
+ this.errorHandlerInstalled = false;
334
+ }
294
335
  };
336
+ function defaultNoopErrorHandler() {
337
+ }
338
+ function detectErrorUtils() {
339
+ const g = globalThis;
340
+ const eu = g.ErrorUtils;
341
+ if (eu && typeof eu.setGlobalHandler === "function") {
342
+ return eu;
343
+ }
344
+ return null;
345
+ }
346
+ function normalizeError(raw) {
347
+ if (raw == null) return null;
348
+ if (raw instanceof Error) {
349
+ return {
350
+ message: raw.message || raw.name || "Error",
351
+ ...raw.stack ? { stack: raw.stack } : {},
352
+ type: raw.name || raw.constructor?.name
353
+ };
354
+ }
355
+ if (typeof raw === "string") {
356
+ return { message: raw };
357
+ }
358
+ if (typeof raw === "object") {
359
+ const obj = raw;
360
+ const message = typeof obj.message === "string" ? obj.message : safeStringify(raw);
361
+ return {
362
+ message,
363
+ ...typeof obj.stack === "string" ? { stack: obj.stack } : {},
364
+ ...typeof obj.name === "string" ? { type: obj.name } : {}
365
+ };
366
+ }
367
+ return { message: String(raw) };
368
+ }
369
+ function safeStringify(v) {
370
+ try {
371
+ return JSON.stringify(v) ?? String(v);
372
+ } catch {
373
+ return String(v);
374
+ }
375
+ }
295
376
  function clampBatch(n) {
296
377
  if (!Number.isFinite(n) || n < 1) return 1;
297
378
  if (n > SERVER_MAX_BATCH) return SERVER_MAX_BATCH;
298
379
  return Math.floor(n);
299
380
  }
300
381
  function detectAppState() {
301
- try {
302
- if (typeof __require === "undefined") return null;
303
- const rn = __require("react-native");
304
- const candidate = rn?.AppState;
305
- if (candidate && typeof candidate.addEventListener === "function") {
306
- return candidate;
307
- }
308
- return null;
309
- } catch {
310
- return null;
382
+ const candidate = RNAppState;
383
+ if (candidate && typeof candidate.addEventListener === "function") {
384
+ return candidate;
311
385
  }
386
+ return null;
312
387
  }
313
388
  function validateEndpoint(raw) {
314
389
  let url;
@@ -346,6 +421,7 @@ function init(opts) {
346
421
  screen: c.screen.bind(c),
347
422
  track: c.track.bind(c),
348
423
  identify: c.identify.bind(c),
424
+ captureError: c.captureError.bind(c),
349
425
  flush: c.flush.bind(c),
350
426
  close: c.close.bind(c),
351
427
  getSessionId: c.getSessionId.bind(c),
@@ -1,4 +1,4 @@
1
- import { L as LuminClient } from './types-BeHbtR1J.cjs';
1
+ import { L as LuminClient } from './types-DMUQWor6.cjs';
2
2
 
3
3
  /**
4
4
  * Subset of @react-navigation/native's NavigationContainerRef the hook needs.
@@ -1,4 +1,4 @@
1
- import { L as LuminClient } from './types-BeHbtR1J.js';
1
+ import { L as LuminClient } from './types-DMUQWor6.js';
2
2
 
3
3
  /**
4
4
  * Subset of @react-navigation/native's NavigationContainerRef the hook needs.
@@ -1,5 +1,3 @@
1
- import "./chunk-3RG5ZIWI.js";
2
-
3
1
  // src/react-navigation.ts
4
2
  import { useEffect, useRef } from "react";
5
3
  function useLuminScreenviews(client, navigationRef) {
@@ -1,4 +1,4 @@
1
- type EventType = "page" | "track" | "identify";
1
+ type EventType = "page" | "track" | "identify" | "error";
2
2
  interface WireEvent {
3
3
  ts?: number;
4
4
  session_id: string;
@@ -10,6 +10,9 @@ interface WireEvent {
10
10
  referrer?: string;
11
11
  properties?: Record<string, unknown>;
12
12
  trace_id?: string;
13
+ error_message?: string;
14
+ error_stack?: string;
15
+ error_type?: string;
13
16
  }
14
17
  /**
15
18
  * Minimal AsyncStorage shape the SDK actually consumes. Matches both
@@ -58,11 +61,20 @@ interface InitOptions {
58
61
  */
59
62
  fetch?: typeof fetch;
60
63
  /**
61
- * Override the AsyncStorage implementation. The SDK auto-detects
62
- * `@react-native-async-storage/async-storage` when the peer dep is
63
- * installed; pass this only when you bring a different storage backend
64
- * (e.g. an MMKV-backed shim) or to disable persistence in tests.
65
- * Pass `null` explicitly to opt out of storage entirely (in-memory ids).
64
+ * Storage backend for the persistent `anonymous_id` and `session_id`.
65
+ * Pass `AsyncStorage` from `@react-native-async-storage/async-storage`
66
+ * (recommended) or any object satisfying `AsyncStorageLike`. Omit /
67
+ * pass `null` for ephemeral in-memory ids that reset on every cold
68
+ * start.
69
+ *
70
+ * The SDK no longer auto-detects AsyncStorage as of 0.3.0 — the
71
+ * runtime require broke Metro bundling, and forcing every consumer
72
+ * to install an unused peer dep was the wrong trade. See CHANGELOG.
73
+ *
74
+ * ```ts
75
+ * import AsyncStorage from "@react-native-async-storage/async-storage";
76
+ * init({ apiKey, storage: AsyncStorage });
77
+ * ```
66
78
  */
67
79
  storage?: AsyncStorageLike | null;
68
80
  /**
@@ -70,6 +82,31 @@ interface InitOptions {
70
82
  * AppState; tests pass a stub.
71
83
  */
72
84
  appState?: AppStateLike | null;
85
+ /**
86
+ * Auto-capture unhandled JS errors via ErrorUtils.setGlobalHandler.
87
+ * Default true. Set false to disable (e.g. when another tool like Sentry
88
+ * already owns the global handler). When enabled, the SDK chains to any
89
+ * previously installed handler so RN's red-box and other reporters
90
+ * still fire.
91
+ *
92
+ * Native crashes (iOS / Android) are NOT captured — that requires native
93
+ * modules outside this SDK's scope.
94
+ */
95
+ captureUnhandledErrors?: boolean;
96
+ /**
97
+ * Override the global ErrorUtils-like object the SDK installs into.
98
+ * The SDK auto-detects React Native's ErrorUtils on globalThis at
99
+ * runtime; tests pass a stub.
100
+ */
101
+ errorUtils?: ErrorUtilsLike | null;
102
+ }
103
+ /**
104
+ * Minimal shape of React Native's ErrorUtils. The SDK uses these three
105
+ * methods to chain into any previously installed handler.
106
+ */
107
+ interface ErrorUtilsLike {
108
+ setGlobalHandler(handler: (err: unknown, isFatal?: boolean) => void): void;
109
+ getGlobalHandler?(): ((err: unknown, isFatal?: boolean) => void) | undefined;
73
110
  }
74
111
  /**
75
112
  * Minimal AppState shape the SDK consumes (subset of react-native's AppState).
@@ -85,6 +122,13 @@ interface LuminClient {
85
122
  screen(name?: string, properties?: Record<string, unknown>): void;
86
123
  track(name: string, properties?: Record<string, unknown>): void;
87
124
  identify(userId: string, traits?: Record<string, unknown>): void;
125
+ /**
126
+ * Capture an error. Accepts a real Error (preferred — preserves stack +
127
+ * constructor name as error_type), or any value that will be stringified
128
+ * for error_message. `properties` is forwarded verbatim so callers can
129
+ * attach context (route, user action, request id).
130
+ */
131
+ captureError(err: unknown, properties?: Record<string, unknown>): void;
88
132
  flush(): Promise<void>;
89
133
  close(): Promise<void>;
90
134
  /** Current session id. Resolves once AsyncStorage hydration finishes. */
@@ -93,4 +137,4 @@ interface LuminClient {
93
137
  getAnonymousId(): Promise<string>;
94
138
  }
95
139
 
96
- export type { AppStateLike as A, EventType as E, InitOptions as I, LuminClient as L, WireEvent as W, AsyncStorageLike as a };
140
+ export type { AppStateLike as A, ErrorUtilsLike as E, InitOptions as I, LuminClient as L, WireEvent as W, AsyncStorageLike as a, EventType as b };
@@ -1,4 +1,4 @@
1
- type EventType = "page" | "track" | "identify";
1
+ type EventType = "page" | "track" | "identify" | "error";
2
2
  interface WireEvent {
3
3
  ts?: number;
4
4
  session_id: string;
@@ -10,6 +10,9 @@ interface WireEvent {
10
10
  referrer?: string;
11
11
  properties?: Record<string, unknown>;
12
12
  trace_id?: string;
13
+ error_message?: string;
14
+ error_stack?: string;
15
+ error_type?: string;
13
16
  }
14
17
  /**
15
18
  * Minimal AsyncStorage shape the SDK actually consumes. Matches both
@@ -58,11 +61,20 @@ interface InitOptions {
58
61
  */
59
62
  fetch?: typeof fetch;
60
63
  /**
61
- * Override the AsyncStorage implementation. The SDK auto-detects
62
- * `@react-native-async-storage/async-storage` when the peer dep is
63
- * installed; pass this only when you bring a different storage backend
64
- * (e.g. an MMKV-backed shim) or to disable persistence in tests.
65
- * Pass `null` explicitly to opt out of storage entirely (in-memory ids).
64
+ * Storage backend for the persistent `anonymous_id` and `session_id`.
65
+ * Pass `AsyncStorage` from `@react-native-async-storage/async-storage`
66
+ * (recommended) or any object satisfying `AsyncStorageLike`. Omit /
67
+ * pass `null` for ephemeral in-memory ids that reset on every cold
68
+ * start.
69
+ *
70
+ * The SDK no longer auto-detects AsyncStorage as of 0.3.0 — the
71
+ * runtime require broke Metro bundling, and forcing every consumer
72
+ * to install an unused peer dep was the wrong trade. See CHANGELOG.
73
+ *
74
+ * ```ts
75
+ * import AsyncStorage from "@react-native-async-storage/async-storage";
76
+ * init({ apiKey, storage: AsyncStorage });
77
+ * ```
66
78
  */
67
79
  storage?: AsyncStorageLike | null;
68
80
  /**
@@ -70,6 +82,31 @@ interface InitOptions {
70
82
  * AppState; tests pass a stub.
71
83
  */
72
84
  appState?: AppStateLike | null;
85
+ /**
86
+ * Auto-capture unhandled JS errors via ErrorUtils.setGlobalHandler.
87
+ * Default true. Set false to disable (e.g. when another tool like Sentry
88
+ * already owns the global handler). When enabled, the SDK chains to any
89
+ * previously installed handler so RN's red-box and other reporters
90
+ * still fire.
91
+ *
92
+ * Native crashes (iOS / Android) are NOT captured — that requires native
93
+ * modules outside this SDK's scope.
94
+ */
95
+ captureUnhandledErrors?: boolean;
96
+ /**
97
+ * Override the global ErrorUtils-like object the SDK installs into.
98
+ * The SDK auto-detects React Native's ErrorUtils on globalThis at
99
+ * runtime; tests pass a stub.
100
+ */
101
+ errorUtils?: ErrorUtilsLike | null;
102
+ }
103
+ /**
104
+ * Minimal shape of React Native's ErrorUtils. The SDK uses these three
105
+ * methods to chain into any previously installed handler.
106
+ */
107
+ interface ErrorUtilsLike {
108
+ setGlobalHandler(handler: (err: unknown, isFatal?: boolean) => void): void;
109
+ getGlobalHandler?(): ((err: unknown, isFatal?: boolean) => void) | undefined;
73
110
  }
74
111
  /**
75
112
  * Minimal AppState shape the SDK consumes (subset of react-native's AppState).
@@ -85,6 +122,13 @@ interface LuminClient {
85
122
  screen(name?: string, properties?: Record<string, unknown>): void;
86
123
  track(name: string, properties?: Record<string, unknown>): void;
87
124
  identify(userId: string, traits?: Record<string, unknown>): void;
125
+ /**
126
+ * Capture an error. Accepts a real Error (preferred — preserves stack +
127
+ * constructor name as error_type), or any value that will be stringified
128
+ * for error_message. `properties` is forwarded verbatim so callers can
129
+ * attach context (route, user action, request id).
130
+ */
131
+ captureError(err: unknown, properties?: Record<string, unknown>): void;
88
132
  flush(): Promise<void>;
89
133
  close(): Promise<void>;
90
134
  /** Current session id. Resolves once AsyncStorage hydration finishes. */
@@ -93,4 +137,4 @@ interface LuminClient {
93
137
  getAnonymousId(): Promise<string>;
94
138
  }
95
139
 
96
- export type { AppStateLike as A, EventType as E, InitOptions as I, LuminClient as L, WireEvent as W, AsyncStorageLike as a };
140
+ export type { AppStateLike as A, ErrorUtilsLike as E, InitOptions as I, LuminClient as L, WireEvent as W, AsyncStorageLike as a, EventType as b };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumin-monitor/react-native",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "React Native SDK for Lumin: screen / track / identify with batched ingest.",
5
5
  "keywords": [
6
6
  "lumin",
@@ -50,18 +50,15 @@
50
50
  "files": [
51
51
  "dist",
52
52
  "LICENSE",
53
- "README.md"
53
+ "README.md",
54
+ "CHANGELOG.md"
54
55
  ],
55
56
  "peerDependencies": {
56
- "@react-native-async-storage/async-storage": ">=1.21.0",
57
57
  "@react-navigation/native": ">=6",
58
58
  "react": ">=18",
59
59
  "react-native": ">=0.72"
60
60
  },
61
61
  "peerDependenciesMeta": {
62
- "@react-native-async-storage/async-storage": {
63
- "optional": true
64
- },
65
62
  "@react-navigation/native": {
66
63
  "optional": true
67
64
  }
@@ -1,10 +0,0 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined") return require.apply(this, arguments);
5
- throw Error('Dynamic require of "' + x + '" is not supported');
6
- });
7
-
8
- export {
9
- __require
10
- };