@scalebun/react-native 1.10.5 → 1.10.6

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.
@@ -605,7 +605,7 @@ var SDK_VERSION;
605
605
  var init_version = __esm({
606
606
  "lib/module/core/constants/version.js"() {
607
607
  "use strict";
608
- SDK_VERSION = "1.10.5";
608
+ SDK_VERSION = "1.10.6";
609
609
  }
610
610
  });
611
611
 
@@ -647,6 +647,15 @@ function staticDeviceContext() {
647
647
  if (c.Model) out.device_model = String(c.Model);
648
648
  if (c.Manufacturer) out.manufacturer = String(c.Manufacturer);
649
649
  else if (c.Brand) out.manufacturer = String(c.Brand);
650
+ else if (out.platform_os === "ios") out.manufacturer = "Apple";
651
+ return out;
652
+ }
653
+ function nativeDeviceContext(info) {
654
+ const out = {};
655
+ if (!info) return out;
656
+ if (info.osVersion) out.os_version = info.osVersion;
657
+ if (info.deviceModel) out.device_model = info.deviceModel;
658
+ if (info.manufacturer) out.manufacturer = info.manufacturer;
650
659
  return out;
651
660
  }
652
661
  function mergeDeviceContext(configured, detected = staticDeviceContext()) {
@@ -11232,6 +11241,27 @@ var init_EventTracker = __esm({
11232
11241
  }
11233
11242
  this.persistQueue();
11234
11243
  }
11244
+ /**
11245
+ * Fold in device facts that were not knowable at init.
11246
+ *
11247
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
11248
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
11249
+ * async and may not be installed at all. Without this the iOS half of every install reported no
11250
+ * model whatsoever.
11251
+ *
11252
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
11253
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
11254
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
11255
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
11256
+ * cannot blank a value the static path already found.
11257
+ */
11258
+ applyDetectedContext(patch) {
11259
+ if (!patch || Object.keys(patch).length === 0) return;
11260
+ this.cfg.context = {
11261
+ ...this.cfg.context ?? {},
11262
+ ...patch
11263
+ };
11264
+ }
11235
11265
  // ─── internals ─────────────────────────────────────────────────────────────
11236
11266
  buildEnvelope(eventName, properties) {
11237
11267
  const ctx = this.cfg.context ?? {};
@@ -17408,6 +17438,7 @@ var SDKBootstrapper = class {
17408
17438
 
17409
17439
  // lib/module/public/ScaleBunFacade.js
17410
17440
  init_EventTracker();
17441
+ init_bridgeAdapter();
17411
17442
  init_automaticEvents();
17412
17443
 
17413
17444
  // lib/module/analytics/eventLane.js
@@ -19925,6 +19956,8 @@ var ScaleBunFacade = class {
19925
19956
  * the whole time. The SDK knows the device; the integrator is guessing at it.
19926
19957
  * Everything outside the detected keys is still theirs.
19927
19958
  */
19959
+ // Everything Platform.constants knows. On iOS that is neither the model nor the
19960
+ // manufacturer, so the bridge completes it below once it answers.
19928
19961
  context: mergeDeviceContext(rawConfig?.context),
19929
19962
  // Use the same foreground id as replay/journey capture. The
19930
19963
  // callback is resolved per event because the session subsystem
@@ -19938,6 +19971,7 @@ var ScaleBunFacade = class {
19938
19971
  });
19939
19972
  this._eventTracker.start();
19940
19973
  __DEV__ && logger.info("[ScaleBun] Envelope event tracking started \u2192 /v1/batch.");
19974
+ this._applyNativeDeviceContext(this._eventTracker);
19941
19975
  this._captureInstallReferrer(this._eventTracker);
19942
19976
  const tracker = this._eventTracker;
19943
19977
  this._configManager = new ConfigManager({
@@ -19956,6 +19990,25 @@ var ScaleBunFacade = class {
19956
19990
  logger.warn("[ScaleBun] Failed to start envelope event tracking:", err?.message);
19957
19991
  }
19958
19992
  }
19993
+ /**
19994
+ * Fold the native bridge's device facts into the analytics context once it answers.
19995
+ *
19996
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
19997
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
19998
+ * cannot answer leaves whatever `Platform.constants` already found.
19999
+ */
20000
+ _applyNativeDeviceContext(tracker) {
20001
+ Promise.resolve().then(() => bridgeAdapter.getDeviceInfo()).then((info) => {
20002
+ if (!info) return;
20003
+ tracker.applyDetectedContext(nativeDeviceContext({
20004
+ osVersion: info.osVersion,
20005
+ deviceModel: info.deviceModel
20006
+ // iOS ships no manufacturer over the bridge; it is a constant there and the
20007
+ // static path already set it. Android's arrives via Platform.constants.
20008
+ }));
20009
+ }).catch(() => {
20010
+ });
20011
+ }
19959
20012
  /**
19960
20013
  * Android-only: read the Play Install Referrer once per install and route it into
19961
20014
  * attribution. `scalebun_click_id` → the deterministic click path; the full
@@ -600,7 +600,7 @@ var SDK_VERSION;
600
600
  var init_version = __esm({
601
601
  "lib/module/core/constants/version.js"() {
602
602
  "use strict";
603
- SDK_VERSION = "1.10.5";
603
+ SDK_VERSION = "1.10.6";
604
604
  }
605
605
  });
606
606
 
@@ -642,6 +642,15 @@ function staticDeviceContext() {
642
642
  if (c.Model) out.device_model = String(c.Model);
643
643
  if (c.Manufacturer) out.manufacturer = String(c.Manufacturer);
644
644
  else if (c.Brand) out.manufacturer = String(c.Brand);
645
+ else if (out.platform_os === "ios") out.manufacturer = "Apple";
646
+ return out;
647
+ }
648
+ function nativeDeviceContext(info) {
649
+ const out = {};
650
+ if (!info) return out;
651
+ if (info.osVersion) out.os_version = info.osVersion;
652
+ if (info.deviceModel) out.device_model = info.deviceModel;
653
+ if (info.manufacturer) out.manufacturer = info.manufacturer;
645
654
  return out;
646
655
  }
647
656
  function mergeDeviceContext(configured, detected = staticDeviceContext()) {
@@ -6528,6 +6537,27 @@ var init_EventTracker = __esm({
6528
6537
  }
6529
6538
  this.persistQueue();
6530
6539
  }
6540
+ /**
6541
+ * Fold in device facts that were not knowable at init.
6542
+ *
6543
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
6544
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
6545
+ * async and may not be installed at all. Without this the iOS half of every install reported no
6546
+ * model whatsoever.
6547
+ *
6548
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
6549
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
6550
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
6551
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
6552
+ * cannot blank a value the static path already found.
6553
+ */
6554
+ applyDetectedContext(patch) {
6555
+ if (!patch || Object.keys(patch).length === 0) return;
6556
+ this.cfg.context = {
6557
+ ...this.cfg.context ?? {},
6558
+ ...patch
6559
+ };
6560
+ }
6531
6561
  // ─── internals ─────────────────────────────────────────────────────────────
6532
6562
  buildEnvelope(eventName, properties) {
6533
6563
  const ctx = this.cfg.context ?? {};
@@ -14198,6 +14228,7 @@ var SDKBootstrapper = class {
14198
14228
 
14199
14229
  // lib/module/public/ScaleBunFacade.js
14200
14230
  init_EventTracker();
14231
+ init_bridgeAdapter();
14201
14232
  init_automaticEvents();
14202
14233
 
14203
14234
  // lib/module/analytics/eventLane.js
@@ -16714,6 +16745,8 @@ var ScaleBunFacade = class {
16714
16745
  * the whole time. The SDK knows the device; the integrator is guessing at it.
16715
16746
  * Everything outside the detected keys is still theirs.
16716
16747
  */
16748
+ // Everything Platform.constants knows. On iOS that is neither the model nor the
16749
+ // manufacturer, so the bridge completes it below once it answers.
16717
16750
  context: mergeDeviceContext(rawConfig?.context),
16718
16751
  // Use the same foreground id as replay/journey capture. The
16719
16752
  // callback is resolved per event because the session subsystem
@@ -16727,6 +16760,7 @@ var ScaleBunFacade = class {
16727
16760
  });
16728
16761
  this._eventTracker.start();
16729
16762
  __DEV__ && logger.info("[ScaleBun] Envelope event tracking started \u2192 /v1/batch.");
16763
+ this._applyNativeDeviceContext(this._eventTracker);
16730
16764
  this._captureInstallReferrer(this._eventTracker);
16731
16765
  const tracker = this._eventTracker;
16732
16766
  this._configManager = new ConfigManager({
@@ -16745,6 +16779,25 @@ var ScaleBunFacade = class {
16745
16779
  logger.warn("[ScaleBun] Failed to start envelope event tracking:", err?.message);
16746
16780
  }
16747
16781
  }
16782
+ /**
16783
+ * Fold the native bridge's device facts into the analytics context once it answers.
16784
+ *
16785
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
16786
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
16787
+ * cannot answer leaves whatever `Platform.constants` already found.
16788
+ */
16789
+ _applyNativeDeviceContext(tracker) {
16790
+ Promise.resolve().then(() => bridgeAdapter.getDeviceInfo()).then((info) => {
16791
+ if (!info) return;
16792
+ tracker.applyDetectedContext(nativeDeviceContext({
16793
+ osVersion: info.osVersion,
16794
+ deviceModel: info.deviceModel
16795
+ // iOS ships no manufacturer over the bridge; it is a constant there and the
16796
+ // static path already set it. Android's arrives via Platform.constants.
16797
+ }));
16798
+ }).catch(() => {
16799
+ });
16800
+ }
16748
16801
  /**
16749
16802
  * Android-only: read the Play Install Referrer once per install and route it into
16750
16803
  * attribution. `scalebun_click_id` → the deterministic click path; the full
@@ -0,0 +1,50 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * The ONE place that answers "which iPhone is this?".
5
+ *
6
+ * iOS ships no API for the name a person would recognise. `UIDevice.current.model` returns the
7
+ * FAMILY — the literal string "iPhone" or "iPad" — which is identical on an iPhone 6 and an
8
+ * iPhone 17 Pro, so a dashboard grouping by it sees one bucket holding every iOS device ever.
9
+ * `ReplaySdk.getDeviceInfo` sent exactly that, which is why the replay lane could never tell two
10
+ * iPhones apart.
11
+ *
12
+ * The hardware identifier from `uname()` — "iPhone16,2" — is the only thing the OS will tell us,
13
+ * and it is unique per model. It is not the marketing name and this module does not pretend
14
+ * otherwise: turning "iPhone16,2" into "iPhone 15 Pro Max" needs a lookup table, and that table
15
+ * belongs on the server, where a device released after this binary shipped can be named without
16
+ * waiting for every app to adopt a new SDK and ship to the App Store.
17
+ *
18
+ * `ScaleBunProfilerModule` already had this function privately; both lanes now share it so they
19
+ * cannot drift into reporting two different models for one device.
20
+ */
21
+ @objc(ScaleBunDeviceIdentity)
22
+ public final class ScaleBunDeviceIdentity: NSObject {
23
+
24
+ /**
25
+ * The hardware identifier, e.g. "iPhone16,2" / "iPad14,3".
26
+ *
27
+ * On the simulator `uname` reports the HOST architecture ("x86_64", "arm64"), so the
28
+ * simulator's own hint is preferred when present — otherwise every developer's simulator
29
+ * session lands in the dashboard as a device model named after a CPU.
30
+ */
31
+ @objc public static func modelIdentifier() -> String {
32
+ if let simulator = ProcessInfo.processInfo
33
+ .environment["SIMULATOR_MODEL_IDENTIFIER"], !simulator.isEmpty {
34
+ return simulator
35
+ }
36
+ var systemInfo = utsname()
37
+ uname(&systemInfo)
38
+ let identifier = withUnsafePointer(to: &systemInfo.machine) {
39
+ $0.withMemoryRebound(to: CChar.self, capacity: 1) {
40
+ String(validatingUTF8: $0) ?? ""
41
+ }
42
+ }
43
+ // Empty rather than "Unknown": an absent measurement must not arrive looking like a value.
44
+ return identifier
45
+ }
46
+
47
+ // No manufacturer constant here on purpose. iOS's manufacturer is answerable without the
48
+ // bridge — the platform being iOS entails it — so staticDeviceContext() sets it on the FIRST
49
+ // event rather than waiting for a native round-trip that may never come back.
50
+ }
@@ -392,15 +392,9 @@ class ScaleBunProfilerModule: RCTEventEmitter {
392
392
  sendEvent(withName: "ScaleBunProfiler_Capabilities", body: buildCapabilities())
393
393
  }
394
394
 
395
+ /** Shared with the replay lane so the two cannot report different models for one device. */
395
396
  private func deviceModel() -> String {
396
- var systemInfo = utsname()
397
- uname(&systemInfo)
398
- let modelCode = withUnsafePointer(to: &systemInfo.machine) {
399
- $0.withMemoryRebound(to: CChar.self, capacity: 1) {
400
- String(validatingUTF8: $0) ?? "Unknown"
401
- }
402
- }
403
- return modelCode
397
+ return ScaleBunDeviceIdentity.modelIdentifier()
404
398
  }
405
399
 
406
400
  private func isDebugBuild() -> Bool {
@@ -572,7 +572,9 @@ class ReplaySdk: RCTEventEmitter {
572
572
  resolve([
573
573
  "platform": "ios",
574
574
  "osVersion": device.systemVersion,
575
- "deviceModel": device.model,
575
+ // NOT device.model — that is the family ("iPhone"), identical on every iPhone ever
576
+ // made. ScaleBunDeviceIdentity reports the hardware identifier, which is unique per model.
577
+ "deviceModel": ScaleBunDeviceIdentity.modelIdentifier(),
576
578
  "screenWidth": Int(screen.bounds.width * screen.scale),
577
579
  "screenHeight": Int(screen.bounds.height * screen.scale),
578
580
  "pixelDensity": screen.scale,
@@ -341,6 +341,28 @@ class EventTracker {
341
341
  this.persistQueue();
342
342
  }
343
343
 
344
+ /**
345
+ * Fold in device facts that were not knowable at init.
346
+ *
347
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
348
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
349
+ * async and may not be installed at all. Without this the iOS half of every install reported no
350
+ * model whatsoever.
351
+ *
352
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
353
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
354
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
355
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
356
+ * cannot blank a value the static path already found.
357
+ */
358
+ applyDetectedContext(patch) {
359
+ if (!patch || Object.keys(patch).length === 0) return;
360
+ this.cfg.context = {
361
+ ...(this.cfg.context ?? {}),
362
+ ...patch
363
+ };
364
+ }
365
+
344
366
  // ─── internals ─────────────────────────────────────────────────────────────
345
367
 
346
368
  buildEnvelope(eventName, properties) {
@@ -9,5 +9,5 @@ exports.SDK_VERSION = void 0;
9
9
  * can attribute telemetry to the SDK build that produced it.
10
10
  * Keep in sync with package.json "version".
11
11
  */
12
- const SDK_VERSION = exports.SDK_VERSION = '1.10.5';
12
+ const SDK_VERSION = exports.SDK_VERSION = '1.10.6';
13
13
  //# sourceMappingURL=version.js.map
@@ -104,6 +104,10 @@ function staticDeviceContext() {
104
104
  if (osVersion) out.os_version = String(osVersion);
105
105
  if (c.Model) out.device_model = String(c.Model);
106
106
  if (c.Manufacturer) out.manufacturer = String(c.Manufacturer);else if (c.Brand) out.manufacturer = String(c.Brand);
107
+ // iOS exposes no manufacturer because it does not need to: every device running iOS is Apple's.
108
+ // A tautology, not a guess — and it means the field is populated on event #1 rather than waiting
109
+ // for a bridge that may never answer.
110
+ else if (out.platform_os === 'ios') out.manufacturer = 'Apple';
107
111
  return out;
108
112
  }
109
113
 
@@ -113,6 +117,7 @@ function nativeDeviceContext(info) {
113
117
  if (!info) return out;
114
118
  if (info.osVersion) out.os_version = info.osVersion;
115
119
  if (info.deviceModel) out.device_model = info.deviceModel;
120
+ if (info.manufacturer) out.manufacturer = info.manufacturer;
116
121
  return out;
117
122
  }
118
123
 
@@ -20,6 +20,7 @@ var _debug = require("../debug");
20
20
  var _api = require("../features/replay/public/api");
21
21
  var _SDKBootstrapper = require("../bootstrap/SDKBootstrapper");
22
22
  var _EventTracker = require("../analytics/EventTracker");
23
+ var _bridgeAdapter = require("../features/replay/bridge/adapters/bridgeAdapter");
23
24
  var _automaticEvents = require("../analytics/automaticEvents");
24
25
  var _eventLane = require("../analytics/eventLane");
25
26
  var _ConfigManager = require("../config/ConfigManager");
@@ -313,6 +314,8 @@ class ScaleBunFacade {
313
314
  * the whole time. The SDK knows the device; the integrator is guessing at it.
314
315
  * Everything outside the detected keys is still theirs.
315
316
  */
317
+ // Everything Platform.constants knows. On iOS that is neither the model nor the
318
+ // manufacturer, so the bridge completes it below once it answers.
316
319
  context: (0, _device.mergeDeviceContext)(rawConfig?.context),
317
320
  // Use the same foreground id as replay/journey capture. The
318
321
  // callback is resolved per event because the session subsystem
@@ -327,6 +330,19 @@ class ScaleBunFacade {
327
330
  this._eventTracker.start();
328
331
  __DEV__ && _internalLogger.logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
329
332
 
333
+ // Complete the device context from the native bridge.
334
+ //
335
+ // `Platform.constants` answers Android synchronously — model and manufacturer ride the
336
+ // first event. It answers NEITHER on iOS, so without this every iOS installation
337
+ // reported no device model at all: `nativeDeviceContext` existed for exactly this and
338
+ // had no caller, and the bridge's own answer was `UIDevice.current.model` — the string
339
+ // "iPhone", identical on every iPhone ever made.
340
+ //
341
+ // Fire-and-forget on purpose. The bridge is async and absent entirely on Expo Go or an
342
+ // older native binary; waiting on it would delay the first event, and failing on it
343
+ // would take analytics down over a nice-to-have field.
344
+ this._applyNativeDeviceContext(this._eventTracker);
345
+
330
346
  // Android: capture the Play Install Referrer once per install → feeds the
331
347
  // deterministic click_id path + a one-off install_referrer event (UTM/gclid).
332
348
  // Fire-and-forget; no-op off Android.
@@ -351,6 +367,24 @@ class ScaleBunFacade {
351
367
  }
352
368
  }
353
369
 
370
+ /**
371
+ * Fold the native bridge's device facts into the analytics context once it answers.
372
+ *
373
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
374
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
375
+ * cannot answer leaves whatever `Platform.constants` already found.
376
+ */
377
+ _applyNativeDeviceContext(tracker) {
378
+ Promise.resolve().then(() => _bridgeAdapter.bridgeAdapter.getDeviceInfo()).then(info => {
379
+ if (!info) return;
380
+ tracker.applyDetectedContext((0, _device.nativeDeviceContext)({
381
+ osVersion: info.osVersion,
382
+ deviceModel: info.deviceModel
383
+ // iOS ships no manufacturer over the bridge; it is a constant there and the
384
+ // static path already set it. Android's arrives via Platform.constants.
385
+ }));
386
+ }).catch(() => {/* bridge unavailable — the static context stands */});
387
+ }
354
388
  /**
355
389
  * Android-only: read the Play Install Referrer once per install and route it into
356
390
  * attribution. `scalebun_click_id` → the deterministic click path; the full
@@ -336,6 +336,28 @@ export class EventTracker {
336
336
  this.persistQueue();
337
337
  }
338
338
 
339
+ /**
340
+ * Fold in device facts that were not knowable at init.
341
+ *
342
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
343
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
344
+ * async and may not be installed at all. Without this the iOS half of every install reported no
345
+ * model whatsoever.
346
+ *
347
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
348
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
349
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
350
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
351
+ * cannot blank a value the static path already found.
352
+ */
353
+ applyDetectedContext(patch) {
354
+ if (!patch || Object.keys(patch).length === 0) return;
355
+ this.cfg.context = {
356
+ ...(this.cfg.context ?? {}),
357
+ ...patch
358
+ };
359
+ }
360
+
339
361
  // ─── internals ─────────────────────────────────────────────────────────────
340
362
 
341
363
  buildEnvelope(eventName, properties) {
@@ -3,5 +3,5 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export const SDK_VERSION = '1.10.5';
6
+ export const SDK_VERSION = '1.10.6';
7
7
  //# sourceMappingURL=version.js.map
@@ -92,6 +92,10 @@ export function staticDeviceContext() {
92
92
  if (osVersion) out.os_version = String(osVersion);
93
93
  if (c.Model) out.device_model = String(c.Model);
94
94
  if (c.Manufacturer) out.manufacturer = String(c.Manufacturer);else if (c.Brand) out.manufacturer = String(c.Brand);
95
+ // iOS exposes no manufacturer because it does not need to: every device running iOS is Apple's.
96
+ // A tautology, not a guess — and it means the field is populated on event #1 rather than waiting
97
+ // for a bridge that may never answer.
98
+ else if (out.platform_os === 'ios') out.manufacturer = 'Apple';
95
99
  return out;
96
100
  }
97
101
 
@@ -101,6 +105,7 @@ export function nativeDeviceContext(info) {
101
105
  if (!info) return out;
102
106
  if (info.osVersion) out.os_version = info.osVersion;
103
107
  if (info.deviceModel) out.device_model = info.deviceModel;
108
+ if (info.manufacturer) out.manufacturer = info.manufacturer;
104
109
  return out;
105
110
  }
106
111
 
@@ -18,13 +18,14 @@ import { BugReportFeature } from '../features/bugreport/BugReportFeature';
18
18
  import { PersistentQueue } from '../pipeline/queue/persistentQueue';
19
19
  import { MemoryBackend, createStorageBackend } from '../storage/StorageBackend';
20
20
  import { getDeviceId } from '../core/id/deviceId';
21
- import { mergeDeviceContext, resolveEventPlatform } from '../core/context/device';
21
+ import { mergeDeviceContext, nativeDeviceContext, resolveEventPlatform } from '../core/context/device';
22
22
  import { coreContainer } from '../core/di/container';
23
23
  import { flushQueue } from '../pipeline/queue/flushQueue';
24
24
  import { enableDebug as _enableDebug, disableDebug as _disableDebug, isDebugEnabled, addBreadcrumb as _addBreadcrumb, ping as _ping, _getStream, _setFlushFn, getPerformanceFeature as _getPerformanceFeature, requestExport as _requestExport, buildDebugConfigFromInitConfig, applyFeatureGatesToDebugConfig, sendDebugMetric } from '../debug';
25
25
  import { ReplaySdk } from '../features/replay/public/api';
26
26
  import { SDKBootstrapper } from '../bootstrap/SDKBootstrapper';
27
27
  import { EventTracker } from '../analytics/EventTracker';
28
+ import { bridgeAdapter } from '../features/replay/bridge/adapters/bridgeAdapter';
28
29
  import { emitAutomaticEvent } from '../analytics/automaticEvents';
29
30
  import { resolveEventLane, isUploadedSessionEvent } from '../analytics/eventLane';
30
31
  import { ConfigManager } from '../config/ConfigManager';
@@ -307,6 +308,8 @@ class ScaleBunFacade {
307
308
  * the whole time. The SDK knows the device; the integrator is guessing at it.
308
309
  * Everything outside the detected keys is still theirs.
309
310
  */
311
+ // Everything Platform.constants knows. On iOS that is neither the model nor the
312
+ // manufacturer, so the bridge completes it below once it answers.
310
313
  context: mergeDeviceContext(rawConfig?.context),
311
314
  // Use the same foreground id as replay/journey capture. The
312
315
  // callback is resolved per event because the session subsystem
@@ -321,6 +324,19 @@ class ScaleBunFacade {
321
324
  this._eventTracker.start();
322
325
  __DEV__ && logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
323
326
 
327
+ // Complete the device context from the native bridge.
328
+ //
329
+ // `Platform.constants` answers Android synchronously — model and manufacturer ride the
330
+ // first event. It answers NEITHER on iOS, so without this every iOS installation
331
+ // reported no device model at all: `nativeDeviceContext` existed for exactly this and
332
+ // had no caller, and the bridge's own answer was `UIDevice.current.model` — the string
333
+ // "iPhone", identical on every iPhone ever made.
334
+ //
335
+ // Fire-and-forget on purpose. The bridge is async and absent entirely on Expo Go or an
336
+ // older native binary; waiting on it would delay the first event, and failing on it
337
+ // would take analytics down over a nice-to-have field.
338
+ this._applyNativeDeviceContext(this._eventTracker);
339
+
324
340
  // Android: capture the Play Install Referrer once per install → feeds the
325
341
  // deterministic click_id path + a one-off install_referrer event (UTM/gclid).
326
342
  // Fire-and-forget; no-op off Android.
@@ -345,6 +361,24 @@ class ScaleBunFacade {
345
361
  }
346
362
  }
347
363
 
364
+ /**
365
+ * Fold the native bridge's device facts into the analytics context once it answers.
366
+ *
367
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
368
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
369
+ * cannot answer leaves whatever `Platform.constants` already found.
370
+ */
371
+ _applyNativeDeviceContext(tracker) {
372
+ Promise.resolve().then(() => bridgeAdapter.getDeviceInfo()).then(info => {
373
+ if (!info) return;
374
+ tracker.applyDetectedContext(nativeDeviceContext({
375
+ osVersion: info.osVersion,
376
+ deviceModel: info.deviceModel
377
+ // iOS ships no manufacturer over the bridge; it is a constant there and the
378
+ // static path already set it. Android's arrives via Platform.constants.
379
+ }));
380
+ }).catch(() => {/* bridge unavailable — the static context stands */});
381
+ }
348
382
  /**
349
383
  * Android-only: read the Play Install Referrer once per install and route it into
350
384
  * attribution. `scalebun_click_id` → the deterministic click path; the full
@@ -134,6 +134,21 @@ export declare class EventTracker {
134
134
  }): void;
135
135
  flush(): Promise<void>;
136
136
  stop(): void;
137
+ /**
138
+ * Fold in device facts that were not knowable at init.
139
+ *
140
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
141
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
142
+ * async and may not be installed at all. Without this the iOS half of every install reported no
143
+ * model whatsoever.
144
+ *
145
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
146
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
147
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
148
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
149
+ * cannot blank a value the static path already found.
150
+ */
151
+ applyDetectedContext(patch: Record<string, string>): void;
137
152
  private buildEnvelope;
138
153
  private enqueue;
139
154
  private scheduleFlush;
@@ -3,5 +3,5 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export declare const SDK_VERSION = "1.10.5";
6
+ export declare const SDK_VERSION = "1.10.6";
7
7
  //# sourceMappingURL=version.d.ts.map
@@ -34,6 +34,7 @@ export declare function nativeDeviceContext(info: {
34
34
  platform?: string;
35
35
  osVersion?: string;
36
36
  deviceModel?: string;
37
+ manufacturer?: string;
37
38
  } | null): Record<string, string>;
38
39
  /**
39
40
  * Merge the integrator's context with what we detected — DETECTED WINS on the keys above.
@@ -80,6 +80,14 @@ declare class ScaleBunFacade {
80
80
  private _autoEnableDebug;
81
81
  /** Boot the Phase 1 envelope tracking lane if an appId is configured. */
82
82
  private _maybeStartEventTracker;
83
+ /**
84
+ * Fold the native bridge's device facts into the analytics context once it answers.
85
+ *
86
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
87
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
88
+ * cannot answer leaves whatever `Platform.constants` already found.
89
+ */
90
+ private _applyNativeDeviceContext;
83
91
  /**
84
92
  * Android-only: read the Play Install Referrer once per install and route it into
85
93
  * attribution. `scalebun_click_id` → the deterministic click path; the full
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalebun/react-native",
3
- "version": "1.10.5",
3
+ "version": "1.10.6",
4
4
  "description": "React Native SDK for ScaleBun",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -387,6 +387,25 @@ export class EventTracker {
387
387
  this.persistQueue();
388
388
  }
389
389
 
390
+ /**
391
+ * Fold in device facts that were not knowable at init.
392
+ *
393
+ * `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
394
+ * very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
395
+ * async and may not be installed at all. Without this the iOS half of every install reported no
396
+ * model whatsoever.
397
+ *
398
+ * `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
399
+ * the next one onward. Detected keys WIN over whatever the integrator configured, for the same
400
+ * reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
401
+ * overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
402
+ * cannot blank a value the static path already found.
403
+ */
404
+ applyDetectedContext(patch: Record<string, string>): void {
405
+ if (!patch || Object.keys(patch).length === 0) return;
406
+ this.cfg.context = { ...(this.cfg.context ?? {}), ...patch };
407
+ }
408
+
390
409
  // ─── internals ─────────────────────────────────────────────────────────────
391
410
 
392
411
  private buildEnvelope(eventName: string, properties?: Record<string, any>): Envelope {
@@ -3,4 +3,4 @@
3
3
  * can attribute telemetry to the SDK build that produced it.
4
4
  * Keep in sync with package.json "version".
5
5
  */
6
- export const SDK_VERSION = '1.10.5';
6
+ export const SDK_VERSION = '1.10.6';
@@ -114,6 +114,10 @@ export function staticDeviceContext(): Record<string, string> {
114
114
  if (c.Model) out.device_model = String(c.Model);
115
115
  if (c.Manufacturer) out.manufacturer = String(c.Manufacturer);
116
116
  else if (c.Brand) out.manufacturer = String(c.Brand);
117
+ // iOS exposes no manufacturer because it does not need to: every device running iOS is Apple's.
118
+ // A tautology, not a guess — and it means the field is populated on event #1 rather than waiting
119
+ // for a bridge that may never answer.
120
+ else if (out.platform_os === 'ios') out.manufacturer = 'Apple';
117
121
  return out;
118
122
  }
119
123
 
@@ -122,11 +126,13 @@ export function nativeDeviceContext(info: {
122
126
  platform?: string;
123
127
  osVersion?: string;
124
128
  deviceModel?: string;
129
+ manufacturer?: string;
125
130
  } | null): Record<string, string> {
126
131
  const out: Record<string, string> = {};
127
132
  if (!info) return out;
128
133
  if (info.osVersion) out.os_version = info.osVersion;
129
134
  if (info.deviceModel) out.device_model = info.deviceModel;
135
+ if (info.manufacturer) out.manufacturer = info.manufacturer;
130
136
  return out;
131
137
  }
132
138
 
@@ -20,7 +20,7 @@ import { PersistentQueue } from '../pipeline/queue/persistentQueue';
20
20
  import { MemoryBackend, createStorageBackend } from '../storage/StorageBackend';
21
21
  import { HttpClient } from '../transport/http/httpClient';
22
22
  import { getDeviceId } from '../core/id/deviceId';
23
- import { mergeDeviceContext, resolveEventPlatform } from '../core/context/device';
23
+ import { mergeDeviceContext, nativeDeviceContext, resolveEventPlatform } from '../core/context/device';
24
24
  import { coreContainer } from '../core/di/container';
25
25
  import { flushQueue } from '../pipeline/queue/flushQueue';
26
26
  import {
@@ -48,6 +48,7 @@ import type {
48
48
  } from '../features/replay/public/types';
49
49
  import { SDKBootstrapper } from '../bootstrap/SDKBootstrapper';
50
50
  import { EventTracker } from '../analytics/EventTracker';
51
+ import { bridgeAdapter } from '../features/replay/bridge/adapters/bridgeAdapter';
51
52
  import {
52
53
  emitAutomaticEvent,
53
54
  type AutomaticEventName,
@@ -368,6 +369,8 @@ class ScaleBunFacade {
368
369
  * the whole time. The SDK knows the device; the integrator is guessing at it.
369
370
  * Everything outside the detected keys is still theirs.
370
371
  */
372
+ // Everything Platform.constants knows. On iOS that is neither the model nor the
373
+ // manufacturer, so the bridge completes it below once it answers.
371
374
  context: mergeDeviceContext(rawConfig?.context),
372
375
  // Use the same foreground id as replay/journey capture. The
373
376
  // callback is resolved per event because the session subsystem
@@ -384,6 +387,19 @@ class ScaleBunFacade {
384
387
  this._eventTracker.start();
385
388
  __DEV__ && logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
386
389
 
390
+ // Complete the device context from the native bridge.
391
+ //
392
+ // `Platform.constants` answers Android synchronously — model and manufacturer ride the
393
+ // first event. It answers NEITHER on iOS, so without this every iOS installation
394
+ // reported no device model at all: `nativeDeviceContext` existed for exactly this and
395
+ // had no caller, and the bridge's own answer was `UIDevice.current.model` — the string
396
+ // "iPhone", identical on every iPhone ever made.
397
+ //
398
+ // Fire-and-forget on purpose. The bridge is async and absent entirely on Expo Go or an
399
+ // older native binary; waiting on it would delay the first event, and failing on it
400
+ // would take analytics down over a nice-to-have field.
401
+ this._applyNativeDeviceContext(this._eventTracker);
402
+
387
403
  // Android: capture the Play Install Referrer once per install → feeds the
388
404
  // deterministic click_id path + a one-off install_referrer event (UTM/gclid).
389
405
  // Fire-and-forget; no-op off Android.
@@ -405,6 +421,30 @@ class ScaleBunFacade {
405
421
  }
406
422
  }
407
423
 
424
+
425
+ /**
426
+ * Fold the native bridge's device facts into the analytics context once it answers.
427
+ *
428
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
429
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
430
+ * cannot answer leaves whatever `Platform.constants` already found.
431
+ */
432
+ private _applyNativeDeviceContext(tracker: EventTracker): void {
433
+ Promise.resolve()
434
+ .then(() => bridgeAdapter.getDeviceInfo())
435
+ .then(info => {
436
+ if (!info) return;
437
+ tracker.applyDetectedContext(
438
+ nativeDeviceContext({
439
+ osVersion: info.osVersion,
440
+ deviceModel: info.deviceModel,
441
+ // iOS ships no manufacturer over the bridge; it is a constant there and the
442
+ // static path already set it. Android's arrives via Platform.constants.
443
+ }),
444
+ );
445
+ })
446
+ .catch(() => { /* bridge unavailable — the static context stands */ });
447
+ }
408
448
  /**
409
449
  * Android-only: read the Play Install Referrer once per install and route it into
410
450
  * attribution. `scalebun_click_id` → the deterministic click path; the full