@lumin-monitor/react-native 0.3.0 → 0.4.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 CHANGED
@@ -5,6 +5,31 @@ All notable changes to `@lumin-monitor/react-native` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-05-26
9
+
10
+ ### Added
11
+
12
+ - **Mobile device classification.** The SDK now sends an `X-Lumin-Client`
13
+ header on every batch — `sdk=rn/0.4.0; os=ios; os_version=17.4` plus
14
+ optional `device=<model>` when the new `deviceInfo` init option is
15
+ provided. The Lumin API uses the header to fill the same `ua_os` /
16
+ `ua_device_type` columns the browser SDK has always populated via the
17
+ User-Agent header, so mobile sessions stop showing up as empty / desktop
18
+ in the sessions UI. A new `ua_device` column captures the device model.
19
+ - `init({ deviceInfo })` — pass an instance of `react-native-device-info`
20
+ (or any `DeviceInfoLike` shim) to include the device model. Opt-in;
21
+ omit it and the SDK still reports `os` + `os_version` from React Native's
22
+ built-in `Platform` constants. Not auto-detected — same opt-in policy as
23
+ `storage` (0.3.0), to keep Metro bundling clean.
24
+ - Exported `DeviceInfoLike` type for consumers writing their own shims.
25
+
26
+ ### Server compatibility
27
+
28
+ Requires Lumin API at or after the matching server release (which adds the
29
+ `X-Lumin-Client` parser and the `ua_device` column migration). Older
30
+ servers will ignore the header — events still ingest, mobile rows just
31
+ keep landing with empty `ua_*` columns.
32
+
8
33
  ## [0.3.0] - 2026-05-26
9
34
 
10
35
  ### Fixed
@@ -108,6 +133,7 @@ can hold events from web and mobile and tie them to the same user via
108
133
  `useLuminScreenviews(client, navigationRef)` for React Navigation v6+ apps.
109
134
  - Ships dual ESM + CJS bundle with full `.d.ts` types.
110
135
 
136
+ [0.4.0]: https://github.com/tamso-labs/lumin/releases/tag/sdk-react-native-v0.4.0
111
137
  [0.3.0]: https://github.com/tamso-labs/lumin/releases/tag/sdk-react-native-v0.3.0
112
138
  [0.2.0]: https://github.com/tamso-labs/lumin/releases/tag/sdk-react-native-v0.2.0
113
139
  [0.1.0]: https://github.com/tamso-labs/lumin/releases/tag/sdk-react-native-v0.1.0
package/README.md CHANGED
@@ -16,6 +16,8 @@ the same user.
16
16
  pnpm add @lumin-monitor/react-native
17
17
  # plus, if you want persistent ids across app launches (recommended):
18
18
  pnpm add @react-native-async-storage/async-storage
19
+ # plus, if you want device-model tracking in the sessions UI:
20
+ pnpm add react-native-device-info
19
21
  ```
20
22
 
21
23
  `@react-native-async-storage/async-storage` is no longer auto-detected
@@ -25,6 +27,10 @@ launches and let the 30-minute idle session window survive a cold start.
25
27
  Omit `storage` (or pass `null`) to use in-memory ids that reset on every
26
28
  cold start — fine for prototypes.
27
29
 
30
+ `react-native-device-info` is also opt-in (same reason). Without it the
31
+ SDK still reports `os` + `os_version` via the `X-Lumin-Client` header —
32
+ see [Device classification](#device-classification).
33
+
28
34
  React Navigation is an optional peer dep, only needed if you import
29
35
  `@lumin-monitor/react-native/react-navigation`.
30
36
 
@@ -76,10 +82,11 @@ hydration to complete before sending.
76
82
  | `sessionIdleMs` | `1800000` (30 min) | A new session id is minted on the next event after this much inactivity. |
77
83
  | `onError` | `console.warn` | Called as `(err, droppedCount)` when a batch fails. |
78
84
  | `fetch` | global `fetch` | Override for tests. |
79
- | `storage` | auto-detect AsyncStorage | Pass a custom `AsyncStorageLike`, or `null` to opt out (in-memory ids). |
85
+ | `storage` | `null` (in-memory ids) | Pass `AsyncStorage` for persistence across launches. See *Install* above. |
80
86
  | `appState` | auto-detect react-native | Pass `null` to disable the background-flush listener. |
81
87
  | `captureUnhandledErrors` | `true` | Install an `ErrorUtils.setGlobalHandler` chain. See *Error capture* below. |
82
88
  | `errorUtils` | auto-detect global ErrorUtils | Override the ErrorUtils-like object the SDK installs into. |
89
+ | `deviceInfo` | `null` (no device model) | Pass `DeviceInfo` from `react-native-device-info` to include the device model in the `X-Lumin-Client` header. See *Device classification* below. |
83
90
 
84
91
  ### `screen(name?, properties?)`
85
92
 
@@ -211,6 +218,45 @@ tracking, call `screen()` manually from the route's effect.
211
218
  Peer deps: `react >= 18`, `@react-navigation/native >= 6`. Both are
212
219
  optional — the subpath only loads them when imported.
213
220
 
221
+ ## Device classification
222
+
223
+ Browser SDKs send a meaningful `User-Agent`; the Lumin server parses it
224
+ into `ua_browser` / `ua_os` / `ua_device_type` columns that the sessions
225
+ UI groups by. React Native's `fetch` sends `okhttp/…` or `CFNetwork/…`,
226
+ which carries no device info, so the SDK ships an `X-Lumin-Client` header
227
+ on every batch instead:
228
+
229
+ ```
230
+ X-Lumin-Client: sdk=rn/0.4.0; os=ios; os_version=17.4
231
+ ```
232
+
233
+ `os` and `os_version` come from React Native's built-in `Platform`
234
+ constants — no peer dep required. The server folds the header into the
235
+ same `ua_os` / `ua_device_type` columns the browser SDK has always used,
236
+ so mobile sessions show up in the UI alongside web sessions.
237
+
238
+ For the device model (`iPhone15,3`, `Pixel 7`), install
239
+ [`react-native-device-info`](https://www.npmjs.com/package/react-native-device-info)
240
+ and pass it through:
241
+
242
+ ```ts
243
+ import DeviceInfo from "react-native-device-info";
244
+ import AsyncStorage from "@react-native-async-storage/async-storage";
245
+ import { init } from "@lumin-monitor/react-native";
246
+
247
+ export const lumin = init({
248
+ apiKey: process.env.EXPO_PUBLIC_LUMIN_BROWSER_API_KEY!,
249
+ storage: AsyncStorage,
250
+ deviceInfo: DeviceInfo, // populates X-Lumin-Client: device=<model>
251
+ });
252
+ ```
253
+
254
+ The SDK never `require()`s `react-native-device-info` — same opt-in
255
+ policy as `storage`, to keep Metro bundling clean (see CHANGELOG 0.3.0).
256
+ If the installed version of `react-native-device-info` ever changes its
257
+ shape, ship a tiny adapter that satisfies `DeviceInfoLike`
258
+ (`{ getModel(): string }`).
259
+
214
260
  ## Session semantics
215
261
 
216
262
  Sessions are **idle-based**, not tied to a single foreground period:
package/dist/index.cjs CHANGED
@@ -97,6 +97,7 @@ var DEFAULT_FLUSH_MS = 500;
97
97
  var DEFAULT_SESSION_IDLE_MS = 30 * 60 * 1e3;
98
98
  var SERVER_MAX_BATCH = 1e3;
99
99
  var DEFAULT_ENDPOINT = "https://api.getlumin.dev";
100
+ var SDK_VERSION = "0.4.0";
100
101
  var Client = class {
101
102
  constructor(opts) {
102
103
  this.prevErrorHandler = void 0;
@@ -129,6 +130,7 @@ var Client = class {
129
130
  this.storage = opts.storage ?? memoryStorage();
130
131
  this.appState = opts.appState === void 0 ? detectAppState() : opts.appState;
131
132
  this.errorUtils = opts.errorUtils === void 0 ? detectErrorUtils() : opts.errorUtils;
133
+ this.clientHeader = buildClientHeader(opts.deviceInfo ?? null);
132
134
  this.idsReady = this.hydrateIds();
133
135
  this.installBackgroundFlush();
134
136
  if (opts.captureUnhandledErrors !== false) {
@@ -291,13 +293,15 @@ var Client = class {
291
293
  return;
292
294
  }
293
295
  const body = JSON.stringify({ events: batch });
296
+ const headers = {
297
+ "Content-Type": "application/json",
298
+ Authorization: "Bearer " + this.apiKey
299
+ };
300
+ if (this.clientHeader) headers["X-Lumin-Client"] = this.clientHeader;
294
301
  try {
295
302
  const res = await this.fetchImpl(this.endpoint + "/v1/events", {
296
303
  method: "POST",
297
- headers: {
298
- "Content-Type": "application/json",
299
- Authorization: "Bearer " + this.apiKey
300
- },
304
+ headers,
301
305
  body
302
306
  });
303
307
  if (!res.ok) {
@@ -405,6 +409,28 @@ function clampBatch(n) {
405
409
  if (n > SERVER_MAX_BATCH) return SERVER_MAX_BATCH;
406
410
  return Math.floor(n);
407
411
  }
412
+ function buildClientHeader(deviceInfo) {
413
+ const parts = [`sdk=rn/${SDK_VERSION}`];
414
+ const platform = import_react_native.Platform;
415
+ const osRaw = platform?.OS;
416
+ if (typeof osRaw === "string" && osRaw) {
417
+ parts.push(`os=${encodeURIComponent(osRaw)}`);
418
+ } else {
419
+ return "";
420
+ }
421
+ const versionRaw = platform?.Version;
422
+ if (versionRaw !== void 0 && versionRaw !== null && versionRaw !== "") {
423
+ parts.push(`os_version=${encodeURIComponent(String(versionRaw))}`);
424
+ }
425
+ if (deviceInfo && typeof deviceInfo.getModel === "function") {
426
+ try {
427
+ const model = deviceInfo.getModel();
428
+ if (model) parts.push(`device=${encodeURIComponent(model)}`);
429
+ } catch {
430
+ }
431
+ }
432
+ return parts.join("; ");
433
+ }
408
434
  function detectAppState() {
409
435
  const candidate = import_react_native.AppState;
410
436
  if (candidate && typeof candidate.addEventListener === "function") {
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
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';
1
+ import { L as LuminClient, I as InitOptions } from './types-B6rqS9Vp.cjs';
2
+ export { A as AppStateLike, a as AsyncStorageLike, D as DeviceInfoLike, E as ErrorUtilsLike, b as EventType, W as WireEvent } from './types-B6rqS9Vp.cjs';
3
3
 
4
4
  declare class Client implements LuminClient {
5
5
  private readonly endpoint;
@@ -12,6 +12,7 @@ declare class Client implements LuminClient {
12
12
  private readonly storage;
13
13
  private readonly appState;
14
14
  private readonly errorUtils;
15
+ private readonly clientHeader;
15
16
  private prevErrorHandler;
16
17
  private errorHandlerInstalled;
17
18
  private readonly recentErrors;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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';
1
+ import { L as LuminClient, I as InitOptions } from './types-B6rqS9Vp.js';
2
+ export { A as AppStateLike, a as AsyncStorageLike, D as DeviceInfoLike, E as ErrorUtilsLike, b as EventType, W as WireEvent } from './types-B6rqS9Vp.js';
3
3
 
4
4
  declare class Client implements LuminClient {
5
5
  private readonly endpoint;
@@ -12,6 +12,7 @@ declare class Client implements LuminClient {
12
12
  private readonly storage;
13
13
  private readonly appState;
14
14
  private readonly errorUtils;
15
+ private readonly clientHeader;
15
16
  private prevErrorHandler;
16
17
  private errorHandlerInstalled;
17
18
  private readonly recentErrors;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/client.ts
2
- import { AppState as RNAppState } from "react-native";
2
+ import { AppState as RNAppState, Platform as RNPlatform } from "react-native";
3
3
 
4
4
  // src/ids.ts
5
5
  function uuidv4() {
@@ -70,6 +70,7 @@ var DEFAULT_FLUSH_MS = 500;
70
70
  var DEFAULT_SESSION_IDLE_MS = 30 * 60 * 1e3;
71
71
  var SERVER_MAX_BATCH = 1e3;
72
72
  var DEFAULT_ENDPOINT = "https://api.getlumin.dev";
73
+ var SDK_VERSION = "0.4.0";
73
74
  var Client = class {
74
75
  constructor(opts) {
75
76
  this.prevErrorHandler = void 0;
@@ -102,6 +103,7 @@ var Client = class {
102
103
  this.storage = opts.storage ?? memoryStorage();
103
104
  this.appState = opts.appState === void 0 ? detectAppState() : opts.appState;
104
105
  this.errorUtils = opts.errorUtils === void 0 ? detectErrorUtils() : opts.errorUtils;
106
+ this.clientHeader = buildClientHeader(opts.deviceInfo ?? null);
105
107
  this.idsReady = this.hydrateIds();
106
108
  this.installBackgroundFlush();
107
109
  if (opts.captureUnhandledErrors !== false) {
@@ -264,13 +266,15 @@ var Client = class {
264
266
  return;
265
267
  }
266
268
  const body = JSON.stringify({ events: batch });
269
+ const headers = {
270
+ "Content-Type": "application/json",
271
+ Authorization: "Bearer " + this.apiKey
272
+ };
273
+ if (this.clientHeader) headers["X-Lumin-Client"] = this.clientHeader;
267
274
  try {
268
275
  const res = await this.fetchImpl(this.endpoint + "/v1/events", {
269
276
  method: "POST",
270
- headers: {
271
- "Content-Type": "application/json",
272
- Authorization: "Bearer " + this.apiKey
273
- },
277
+ headers,
274
278
  body
275
279
  });
276
280
  if (!res.ok) {
@@ -378,6 +382,28 @@ function clampBatch(n) {
378
382
  if (n > SERVER_MAX_BATCH) return SERVER_MAX_BATCH;
379
383
  return Math.floor(n);
380
384
  }
385
+ function buildClientHeader(deviceInfo) {
386
+ const parts = [`sdk=rn/${SDK_VERSION}`];
387
+ const platform = RNPlatform;
388
+ const osRaw = platform?.OS;
389
+ if (typeof osRaw === "string" && osRaw) {
390
+ parts.push(`os=${encodeURIComponent(osRaw)}`);
391
+ } else {
392
+ return "";
393
+ }
394
+ const versionRaw = platform?.Version;
395
+ if (versionRaw !== void 0 && versionRaw !== null && versionRaw !== "") {
396
+ parts.push(`os_version=${encodeURIComponent(String(versionRaw))}`);
397
+ }
398
+ if (deviceInfo && typeof deviceInfo.getModel === "function") {
399
+ try {
400
+ const model = deviceInfo.getModel();
401
+ if (model) parts.push(`device=${encodeURIComponent(model)}`);
402
+ } catch {
403
+ }
404
+ }
405
+ return parts.join("; ");
406
+ }
381
407
  function detectAppState() {
382
408
  const candidate = RNAppState;
383
409
  if (candidate && typeof candidate.addEventListener === "function") {
@@ -1,4 +1,4 @@
1
- import { L as LuminClient } from './types-DMUQWor6.cjs';
1
+ import { L as LuminClient } from './types-B6rqS9Vp.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-DMUQWor6.js';
1
+ import { L as LuminClient } from './types-B6rqS9Vp.js';
2
2
 
3
3
  /**
4
4
  * Subset of @react-navigation/native's NavigationContainerRef the hook needs.
@@ -99,6 +99,22 @@ interface InitOptions {
99
99
  * runtime; tests pass a stub.
100
100
  */
101
101
  errorUtils?: ErrorUtilsLike | null;
102
+ /**
103
+ * Optional `react-native-device-info` instance (or any object satisfying
104
+ * `DeviceInfoLike`). When passed, the SDK adds `device=<model>` to the
105
+ * `X-Lumin-Client` header so the server can surface the device model in
106
+ * the sessions UI.
107
+ *
108
+ * Not auto-detected — passing it is opt-in, matching the `storage`
109
+ * pattern. The SDK never `require()`s the package, which keeps Metro
110
+ * bundling clean.
111
+ *
112
+ * ```ts
113
+ * import DeviceInfo from "react-native-device-info";
114
+ * init({ apiKey, deviceInfo: DeviceInfo });
115
+ * ```
116
+ */
117
+ deviceInfo?: DeviceInfoLike | null;
102
118
  }
103
119
  /**
104
120
  * Minimal shape of React Native's ErrorUtils. The SDK uses these three
@@ -108,6 +124,19 @@ interface ErrorUtilsLike {
108
124
  setGlobalHandler(handler: (err: unknown, isFatal?: boolean) => void): void;
109
125
  getGlobalHandler?(): ((err: unknown, isFatal?: boolean) => void) | undefined;
110
126
  }
127
+ /**
128
+ * Minimal shape the SDK consumes from `react-native-device-info` (or any
129
+ * compatible shim). Structural so we don't pull react-native-device-info
130
+ * types into the SDK's public surface — the app already has them. Only
131
+ * `getModel` is required; future fields can be added the same way.
132
+ *
133
+ * Passed via `init({ deviceInfo: DeviceInfo })`. If omitted, the SDK still
134
+ * reports `os` + `os_version` from React Native's `Platform` constants —
135
+ * device model is the only thing that needs the optional peer dep.
136
+ */
137
+ interface DeviceInfoLike {
138
+ getModel(): string;
139
+ }
111
140
  /**
112
141
  * Minimal AppState shape the SDK consumes (subset of react-native's AppState).
113
142
  * The SDK auto-detects `react-native` at runtime; pass this only for tests or
@@ -137,4 +166,4 @@ interface LuminClient {
137
166
  getAnonymousId(): Promise<string>;
138
167
  }
139
168
 
140
- export type { AppStateLike as A, ErrorUtilsLike as E, InitOptions as I, LuminClient as L, WireEvent as W, AsyncStorageLike as a, EventType as b };
169
+ export type { AppStateLike as A, DeviceInfoLike as D, ErrorUtilsLike as E, InitOptions as I, LuminClient as L, WireEvent as W, AsyncStorageLike as a, EventType as b };
@@ -99,6 +99,22 @@ interface InitOptions {
99
99
  * runtime; tests pass a stub.
100
100
  */
101
101
  errorUtils?: ErrorUtilsLike | null;
102
+ /**
103
+ * Optional `react-native-device-info` instance (or any object satisfying
104
+ * `DeviceInfoLike`). When passed, the SDK adds `device=<model>` to the
105
+ * `X-Lumin-Client` header so the server can surface the device model in
106
+ * the sessions UI.
107
+ *
108
+ * Not auto-detected — passing it is opt-in, matching the `storage`
109
+ * pattern. The SDK never `require()`s the package, which keeps Metro
110
+ * bundling clean.
111
+ *
112
+ * ```ts
113
+ * import DeviceInfo from "react-native-device-info";
114
+ * init({ apiKey, deviceInfo: DeviceInfo });
115
+ * ```
116
+ */
117
+ deviceInfo?: DeviceInfoLike | null;
102
118
  }
103
119
  /**
104
120
  * Minimal shape of React Native's ErrorUtils. The SDK uses these three
@@ -108,6 +124,19 @@ interface ErrorUtilsLike {
108
124
  setGlobalHandler(handler: (err: unknown, isFatal?: boolean) => void): void;
109
125
  getGlobalHandler?(): ((err: unknown, isFatal?: boolean) => void) | undefined;
110
126
  }
127
+ /**
128
+ * Minimal shape the SDK consumes from `react-native-device-info` (or any
129
+ * compatible shim). Structural so we don't pull react-native-device-info
130
+ * types into the SDK's public surface — the app already has them. Only
131
+ * `getModel` is required; future fields can be added the same way.
132
+ *
133
+ * Passed via `init({ deviceInfo: DeviceInfo })`. If omitted, the SDK still
134
+ * reports `os` + `os_version` from React Native's `Platform` constants —
135
+ * device model is the only thing that needs the optional peer dep.
136
+ */
137
+ interface DeviceInfoLike {
138
+ getModel(): string;
139
+ }
111
140
  /**
112
141
  * Minimal AppState shape the SDK consumes (subset of react-native's AppState).
113
142
  * The SDK auto-detects `react-native` at runtime; pass this only for tests or
@@ -137,4 +166,4 @@ interface LuminClient {
137
166
  getAnonymousId(): Promise<string>;
138
167
  }
139
168
 
140
- export type { AppStateLike as A, ErrorUtilsLike as E, InitOptions as I, LuminClient as L, WireEvent as W, AsyncStorageLike as a, EventType as b };
169
+ export type { AppStateLike as A, DeviceInfoLike as D, 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.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "React Native SDK for Lumin: screen / track / identify with batched ingest.",
5
5
  "keywords": [
6
6
  "lumin",