@coralogix/react-native-plugin 0.10.2 → 0.11.1

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/index.cjs.js CHANGED
@@ -16,6 +16,15 @@ function _extends() {
16
16
  return n;
17
17
  }, _extends.apply(null, arguments);
18
18
  }
19
+ function _objectWithoutPropertiesLoose(r, e) {
20
+ if (null == r) return {};
21
+ var t = {};
22
+ for (var n in r) if ({}.hasOwnProperty.call(r, n)) {
23
+ if (-1 !== e.indexOf(n)) continue;
24
+ t[n] = r[n];
25
+ }
26
+ return t;
27
+ }
19
28
 
20
29
  let CoralogixEventType = /*#__PURE__*/function (CoralogixEventType) {
21
30
  CoralogixEventType["ERROR"] = "error";
@@ -39,6 +48,16 @@ let CoralogixLogSeverity = /*#__PURE__*/function (CoralogixLogSeverity) {
39
48
  return CoralogixLogSeverity;
40
49
  }({});
41
50
 
51
+ /**
52
+ * `interaction_context` as it appears on a user-interaction RUM event — the
53
+ * shape `beforeSend` receives. Built natively (field names differ from the
54
+ * {@link InteractionContext} payload JS hands to the bridge).
55
+ *
56
+ * Intentionally editable in `beforeSend` (except `is_masked_element`): a
57
+ * callback may blank the coordinates in `attributes`, redact text fields, or
58
+ * drop the whole event to enforce a masking policy stricter than the default.
59
+ */
60
+
42
61
  /** Versions of the plugin and the underlying native SDK that produced an event. */
43
62
 
44
63
  /**
@@ -74,11 +93,30 @@ let CoralogixLogSeverity = /*#__PURE__*/function (CoralogixLogSeverity) {
74
93
 
75
94
  /** OTLP JSON-format trace data delivered to the `tracesExporter` callback. */
76
95
 
96
+ /**
97
+ * Interaction payload JS hands to the native bridge.
98
+ *
99
+ * Masking is resolved from three sources whose knowledge is unioned:
100
+ * `is_masked` below, when JS can see the answer in the component tree;
101
+ * {@link InteractionContext.view_tag}, which lets the plugin's native module
102
+ * walk the tapped control's own view tree for a `maskView` mark; and finally
103
+ * the tap coordinates in `attributes`, tested by the SDK against the frame's
104
+ * mask geometry.
105
+ *
106
+ * Scroll and swipe carry no coordinates, so the third source cannot answer for
107
+ * them — but they do carry `view_tag`, so a scroll inside a masked container
108
+ * resolves through the second and reports
109
+ * `interaction_context.is_masked_element: true`.
110
+ */
111
+
77
112
  const ERROR_INSTRUMENTATION_NAME = 'errors';
78
113
  const ERROR_INSTRUMENTATION_VERSION = '1';
79
114
 
80
- let defaultConsoleError = (..._params) => {};
81
115
  const MESSAGE_LIMIT = 1024;
116
+
117
+ // The `console.error` handler installed by this module, so a repeated `enable()` can tell
118
+ // our own handler apart from anyone else's and leave it in place.
119
+ let installedConsoleError;
82
120
  const getCircularReplacer = () => {
83
121
  const seen = new WeakSet();
84
122
  return (_key, value) => {
@@ -101,11 +139,29 @@ class CoralogixErrorInstrumentation extends instrumentation.InstrumentationBase
101
139
  }
102
140
  init() {}
103
141
  listenToConsoleError() {
142
+ // `enable()` runs from the InstrumentationBase constructor, so every `CoralogixRum.init()`
143
+ // reaching instrumentation registration wraps `console.error` again — including a second
144
+ // init that slipped through while the first was still starting up. Nothing to do if the
145
+ // handler currently installed is already ours.
146
+ // `installedConsoleError` is undefined until we install one, so it has to be checked
147
+ // separately: a host whose `console.error` is undefined would otherwise match it and
148
+ // leave console errors uninstrumented.
149
+ if (installedConsoleError && console.error === installedConsoleError) {
150
+ return;
151
+ }
152
+
153
+ // Held by this wrapper alone rather than in module state, so the handler it forwards to
154
+ // can never be reassigned to the wrapper itself. That self-reference is what turned a
155
+ // repeated init into unbounded recursion and killed the app on the next `console.error`
156
+ // with a RangeError ("Maximum call stack size exceeded") inside `onConsoleError`.
157
+ // Falls back to a no-op so a host without a callable `console.error` still gets its
158
+ // errors reported rather than a TypeError from the forward.
159
+ const previousConsoleError = typeof console.error === 'function' ? console.error : () => undefined;
104
160
  const onConsoleError = (...params) => {
105
- defaultConsoleError.apply(console, params);
161
+ previousConsoleError.apply(console, params);
106
162
  this.report(params, false);
107
163
  };
108
- defaultConsoleError = console.error;
164
+ installedConsoleError = onConsoleError;
109
165
  console.error = onConsoleError;
110
166
  }
111
167
  report(arg, isFatal) {
@@ -188,11 +244,130 @@ const USER_INTERACTION_INSTRUMENTATION_VERSION = '1';
188
244
  // If onMomentumScrollBegin fires within this window, it's a swipe instead.
189
245
  const SWIPE_MOMENTUM_TIMEOUT_MS = 80;
190
246
 
247
+ // Replacement for the visible text of a masked element. Must stay byte-identical
248
+ // to the sentinel both native SDKs use for the same field, so a masked
249
+ // interaction reads the same whether JS or native resolved the masking.
250
+ const MASKED_INNER_TEXT = '***';
251
+
191
252
  // Drag speed (px/ms) below which a gesture is classified as a deliberate scroll.
192
253
  // Computed from position delta and elapsed time in onScrollEndDrag; works on all platforms.
193
254
  // ~0.5 px/ms ≈ 300px in 600ms (slow deliberate drag); a fling is typically >1 px/ms.
194
255
  const SWIPE_VELOCITY_THRESHOLD = 0.5;
195
256
 
257
+ var _ref, _ref$MASKED_CONTEXT_K, _ref2, _ref2$MASK_SCOPE_CONT;
258
+
259
+ /**
260
+ * Brands a component type as the `Masked` wrapper.
261
+ *
262
+ * Keyed on a `Symbol.for` rather than element identity (`type === Masked`) so
263
+ * recognition survives a consuming app resolving two copies of the plugin: the
264
+ * symbol comes from the global registry, so a `Masked` rendered by one copy is
265
+ * still recognized by the instrumentation of another.
266
+ */
267
+ const MASKED_COMPONENT_MARKER = Symbol.for('coralogix.masking.masked');
268
+ const MASKED_CONTEXT_KEY = Symbol.for('coralogix.masking.context');
269
+
270
+ /**
271
+ * True anywhere inside a `Masked` subtree.
272
+ *
273
+ * Read it with `MaskedContext.Consumer`, never `useContext`: the plugin's React
274
+ * and the renderer's React are different instances in a consuming app, and hooks
275
+ * resolve through the instance-specific ReactCurrentDispatcher. Non-hook APIs
276
+ * (`createContext`, `forwardRef`, `createElement`) are safe under that
277
+ * constraint — the same one the user-interaction wrappers already work under.
278
+ *
279
+ * Cached on the global registry rather than created per module, for the same
280
+ * reason {@link MASKED_COMPONENT_MARKER} is a `Symbol.for`: an app that resolves
281
+ * two copies of the plugin would otherwise get two distinct context objects, and
282
+ * a `Consumer` from one copy reads its own default `false` no matter what a
283
+ * `Masked` from the other copy provided. That fails open — masking dropped and
284
+ * the real text reported — so the two copies have to share one object.
285
+ */
286
+ const MaskedContext = (_ref$MASKED_CONTEXT_K = (_ref = globalThis)[MASKED_CONTEXT_KEY]) != null ? _ref$MASKED_CONTEXT_K : _ref[MASKED_CONTEXT_KEY] = /*#__PURE__*/React.createContext(false);
287
+
288
+ /**
289
+ * A pressable's record of whether masked content rendered anywhere inside it.
290
+ *
291
+ * The children walk in `extractInnerTextWithMasking` reads the *element* tree —
292
+ * what the caller passed — so it cannot see a `Masked` that a composite renders
293
+ * internally. `<Pressable><SensitiveValue>{card}</SensitiveValue></Pressable>`,
294
+ * where `SensitiveValue` renders `<Masked>` around its children, harvests the
295
+ * card number through `props.children` and never meets the `Masked`. That fails
296
+ * open: the real value ships with the flag omitted.
297
+ *
298
+ * This closes it from the other end. Each instrumented pressable owns one scope,
299
+ * and every `Masked` marks the scope it renders inside, whoever rendered it. The
300
+ * press handler reads the mark, so the verdict comes from what actually
301
+ * rendered rather than from what was passed in.
302
+ *
303
+ * `masked` is sticky. React can re-render a pressable without re-rendering
304
+ * children that did not change, so clearing the mark per render would drop a
305
+ * verdict that nothing goes on to re-assert. Over-masking after a `Masked`
306
+ * genuinely goes away is the direction that leaks nothing.
307
+ */
308
+
309
+ const MASK_SCOPE_CONTEXT_KEY = Symbol.for('coralogix.masking.scope');
310
+
311
+ /**
312
+ * The nearest enclosing pressable's {@link MaskScope}, or null outside one.
313
+ *
314
+ * Cached on the global registry for the same reason {@link MaskedContext} is.
315
+ */
316
+ const MaskScopeContext = (_ref2$MASK_SCOPE_CONT = (_ref2 = globalThis)[MASK_SCOPE_CONTEXT_KEY]) != null ? _ref2$MASK_SCOPE_CONT : _ref2[MASK_SCOPE_CONTEXT_KEY] = /*#__PURE__*/React.createContext(null);
317
+
318
+ /** Marks `scope` and every scope enclosing it as containing masked content. */
319
+ function markScopeMasked(scope) {
320
+ for (let current = scope; current !== null; current = current.parent) {
321
+ current.masked = true;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Owns one {@link MaskScope} and provides it to its subtree.
327
+ *
328
+ * A class, not a function component: the scope has to be the same object across
329
+ * re-renders for a mark made during one render to still be readable at press
330
+ * time, and `useRef` is unavailable here — the plugin's React and the renderer's
331
+ * React are different instances in a consuming app, and hooks resolve through
332
+ * the instance-specific ReactCurrentDispatcher. A class instance needs no
333
+ * dispatcher.
334
+ */
335
+ class MaskScopeBoundary extends React.Component {
336
+ constructor(...args) {
337
+ super(...args);
338
+ this.scope = {
339
+ masked: false,
340
+ parent: null
341
+ };
342
+ }
343
+ componentDidMount() {
344
+ var _this$props$onCommit, _this$props;
345
+ (_this$props$onCommit = (_this$props = this.props).onCommit) == null || _this$props$onCommit.call(_this$props, this.scope);
346
+ }
347
+ componentDidUpdate() {
348
+ var _this$props$onCommit2, _this$props2;
349
+ (_this$props$onCommit2 = (_this$props2 = this.props).onCommit) == null || _this$props$onCommit2.call(_this$props2, this.scope);
350
+ }
351
+ render() {
352
+ var _ref3;
353
+ // Linked in render rather than in the constructor: `this.context` is not
354
+ // reliably populated before the first render, and a subtree can be moved
355
+ // under a different parent.
356
+ this.scope.parent = (_ref3 = this.context) != null ? _ref3 : null;
357
+ return /*#__PURE__*/React.createElement(MaskScopeContext.Provider, {
358
+ value: this.scope
359
+ }, this.props.children(this.scope));
360
+ }
361
+ }
362
+
363
+ /** Whether a React element's `type` is the `Masked` wrapper. */
364
+ MaskScopeBoundary.contextType = MaskScopeContext;
365
+ function isMaskedComponentType(type) {
366
+ if (type === null || type === undefined) return false;
367
+ if (typeof type !== 'function' && typeof type !== 'object') return false;
368
+ return type[MASKED_COMPONENT_MARKER] === true;
369
+ }
370
+
196
371
  // Mirrors Android SDK's direction inference: compare |dx| vs |dy|, sign gives direction.
197
372
  // contentOffset delta: dy > 0 = user scrolled DOWN, dx > 0 = user scrolled RIGHT.
198
373
  function inferDirectionFromDelta(dx, dy) {
@@ -201,24 +376,106 @@ function inferDirectionFromDelta(dx, dy) {
201
376
  }
202
377
  return dy > 0 ? 'down' : 'up';
203
378
  }
204
-
205
- // Shallow-recursive text extraction from React children.
206
- // Handles: string, number, arrays, and React elements with props.children.
207
- function extractInnerText(children) {
208
- var _children$props;
209
- if (children === null || children === undefined) return undefined;
210
- if (typeof children === 'string') return children.trim() || undefined;
211
- if (typeof children === 'number') return String(children);
379
+ // Shallow-recursive text extraction from React children, which doubles as the
380
+ // masking probe: the walk that harvests the text is the walk that decides
381
+ // whether the text is masked, so the two can never disagree.
382
+ //
383
+ // Text from inside a masked subtree is still returned — the caller redacts the
384
+ // whole value (see MASKED_INNER_TEXT). Reporting it here would be the bug this
385
+ // exists to prevent, but discarding it would lose the fact that there *was*
386
+ // text to redact.
387
+ function extractInnerTextWithMasking(children) {
388
+ var _children$props2;
389
+ if (children === null || children === undefined) return {
390
+ masked: false
391
+ };
392
+ if (typeof children === 'string') {
393
+ return {
394
+ text: children.trim() || undefined,
395
+ masked: false
396
+ };
397
+ }
398
+ if (typeof children === 'number') {
399
+ return {
400
+ text: String(children),
401
+ masked: false
402
+ };
403
+ }
212
404
  if (Array.isArray(children)) {
213
- const parts = children.map(extractInnerText).filter(Boolean);
214
- return parts.length > 0 ? parts.join(' ') : undefined;
405
+ const parts = [];
406
+ let masked = false;
407
+ for (const child of children) {
408
+ const extraction = extractInnerTextWithMasking(child);
409
+ if (extraction.masked) masked = true;
410
+ if (extraction.text) parts.push(extraction.text);
411
+ }
412
+ return {
413
+ text: parts.length > 0 ? parts.join(' ') : undefined,
414
+ masked
415
+ };
215
416
  }
216
- if ((children == null || (_children$props = children.props) == null ? void 0 : _children$props.children) !== undefined) {
217
- return extractInnerText(children.props.children);
417
+ if (isMaskedComponentType(children == null ? void 0 : children.type)) {
418
+ var _children$props;
419
+ // A Masked wrapper masks everything under it, so there is no need to keep
420
+ // looking for further wrappers deeper in — only for the text to redact.
421
+ return {
422
+ text: extractInnerTextWithMasking((_children$props = children.props) == null ? void 0 : _children$props.children).text,
423
+ masked: true
424
+ };
425
+ }
426
+ if ((children == null || (_children$props2 = children.props) == null ? void 0 : _children$props2.children) !== undefined) {
427
+ return extractInnerTextWithMasking(children.props.children);
218
428
  }
219
- return undefined;
429
+ return {
430
+ masked: false
431
+ };
220
432
  }
221
433
 
434
+ class Logger {
435
+ constructor(config = {}) {
436
+ var _config$shouldLog, _config$prefix;
437
+ this._shouldLog = void 0;
438
+ this._prefix = void 0;
439
+ this._shouldLog = (_config$shouldLog = config.shouldLog) != null ? _config$shouldLog : false;
440
+ this._prefix = (_config$prefix = config.prefix) != null ? _config$prefix : '[Coralogix React Native SDK] - ';
441
+ }
442
+ set shouldLog(value) {
443
+ this._shouldLog = value;
444
+ }
445
+ debug(message, ...args) {
446
+ if (this._shouldLog) {
447
+ console.debug(`${this._prefix} ${message}`, ...args);
448
+ }
449
+ }
450
+ info(message, ...args) {
451
+ if (this._shouldLog) {
452
+ console.info(`${this._prefix} ${message}`, ...args);
453
+ }
454
+ }
455
+ warn(message, ...args) {
456
+ if (this._shouldLog) {
457
+ console.warn(`${this._prefix} ${message}`, ...args);
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Warns even when debug logging is off.
463
+ *
464
+ * For failures the developer has to know about because nothing else in their
465
+ * app reveals them — masking that did not apply, say, where the cost of
466
+ * staying quiet is sensitive data reaching the backend.
467
+ */
468
+ alwaysWarn(message, ...args) {
469
+ console.warn(`${this._prefix} ${message}`, ...args);
470
+ }
471
+ error(message, ...args) {
472
+ if (this._shouldLog) {
473
+ console.error(`${this._prefix} ${message}`, ...args);
474
+ }
475
+ }
476
+ }
477
+ const logger = new Logger();
478
+
222
479
  let isEnabled = false;
223
480
  // No-op default so reportInteraction never needs a null-check.
224
481
  // Set to the real callback by the constructor and cleared on disable().
@@ -236,165 +493,414 @@ let scrollState = {
236
493
  startTime: null,
237
494
  timeout: null
238
495
  };
496
+
497
+ /**
498
+ * A mounted control's native view tag, behind a callback ref of stable identity.
499
+ *
500
+ * Native resolves masking by coordinate, so it can answer "is this *point*
501
+ * masked?" but not "does this *control* contain anything masked?" — React Native
502
+ * presses go through the JS responder system, leaving no native marker that says
503
+ * where a control begins and ends. Handing native the control's view tag is what
504
+ * lets it walk the real view tree and see an imperative `maskView` that JS cannot.
505
+ */
506
+
507
+ /**
508
+ * One handle per mounted control, keyed on the {@link MaskScope} its
509
+ * {@link MaskScopeBoundary} owns — already exactly one stable object per mounted
510
+ * pressable, which is what a callback ref of stable identity needs and what
511
+ * hooks would otherwise provide (they are unavailable here; see MaskedContext).
512
+ */
513
+ const controlHandles = new WeakMap();
514
+ function assignRef(ref, node) {
515
+ if (typeof ref === 'function') {
516
+ ref(node);
517
+ } else if (ref !== null && typeof ref === 'object') {
518
+ ref.current = node;
519
+ }
520
+ }
521
+ function controlHandleFor(scope, appRef) {
522
+ let handle = controlHandles.get(scope);
523
+ if (!handle) {
524
+ const created = {
525
+ viewTag: null,
526
+ node: null,
527
+ appRef: null,
528
+ pendingRef: null,
529
+ attach: node => {
530
+ created.node = node != null ? node : null;
531
+ try {
532
+ created.viewTag = node == null ? null : reactNative.findNodeHandle(node);
533
+ } catch (error) {
534
+ created.viewTag = null;
535
+ logger.debug('user-interaction: could not resolve the control view tag: ', error);
536
+ }
537
+ // After ours, and outside the try: the app's ref must be written even if
538
+ // resolving the tag throws. Reporting is never worth breaking a ref the
539
+ // app depends on.
540
+ assignRef(created.appRef, node);
541
+ }
542
+ };
543
+ handle = created;
544
+ controlHandles.set(scope, created);
545
+ }
546
+
547
+ // `attach` keeps one identity for the life of the mounted control: a fresh
548
+ // callback ref each render would make React detach and re-attach it on every
549
+ // re-render, handing every app ref a spurious null in between — object refs
550
+ // from `useRef`, the overwhelming majority, included.
551
+ //
552
+ // The cost of that stability is that `attach` does not re-fire when the app
553
+ // swaps the ref it passes — `ref={editing ? a : b}` — which React would
554
+ // otherwise handle by detaching one and attaching the other. So the swap is
555
+ // completed here, and only once a node exists: before that, `attach` writes to
556
+ // whichever ref is current when it runs. A ref the app rebuilds every render
557
+ // (an inline callback ref) therefore still gets called every render, which is
558
+ // exactly what React does with one.
559
+ if (handle.appRef !== appRef) {
560
+ if (handle.node === null) {
561
+ // Nothing attached yet, so there is nothing to write through: recording
562
+ // which ref `attach` should target is a pure assignment, and `attach`
563
+ // itself runs in commit.
564
+ handle.appRef = appRef;
565
+ } else {
566
+ // Mounted, and the app swapped the ref it passes. The write has to
567
+ // happen, but not from here — this runs inside MaskScopeBoundary.render(),
568
+ // and calling an app's callback ref during render lets something as
569
+ // ordinary as `ref={node => setNode(node)}` set state in the middle of
570
+ // another component's render. Recorded now, performed in the boundary's
571
+ // commit by flushControlRef.
572
+ handle.pendingRef = {
573
+ ref: appRef
574
+ };
575
+ }
576
+ }
577
+ return handle;
578
+ }
579
+
580
+ /**
581
+ * Applies a ref swap recorded during render, from the boundary's commit.
582
+ *
583
+ * Passed to every {@link MaskScopeBoundary} as `onCommit`, so it runs at the
584
+ * same point React would have detached one ref and attached the other.
585
+ */
586
+ function flushControlRef(scope) {
587
+ const handle = controlHandles.get(scope);
588
+ const pending = handle == null ? void 0 : handle.pendingRef;
589
+ if (!handle || !pending) return;
590
+ handle.pendingRef = null;
591
+ const previous = handle.appRef;
592
+ handle.appRef = pending.ref;
593
+ if (previous !== pending.ref && handle.node !== null) {
594
+ assignRef(previous, null);
595
+ assignRef(pending.ref, handle.node);
596
+ }
597
+ }
239
598
  function reportInteraction(ctx) {
240
599
  if (!isEnabled) return;
241
600
  onInteraction(ctx);
242
601
  }
243
- function makeInstrumentedPressable(OrigComp, componentName) {
602
+
603
+ /**
604
+ * Runs reporting work so that it can never break the host app.
605
+ *
606
+ * Every wrapper below computes and reports *before* handing control to the app's
607
+ * own handler, so anything that throws on the way — a press event with no
608
+ * `nativeEvent` (accessibility-triggered presses, and any wrapper forwarding
609
+ * `onPress()` with no argument), a getter that throws inside the children walk,
610
+ * a consumer callback that blows up — would swallow the app's handler and leave
611
+ * the control looking dead. Telemetry is never worth that.
612
+ */
613
+ function safely(report) {
614
+ try {
615
+ report();
616
+ } catch (error) {
617
+ logger.debug('user-interaction: reporting failed, ignored: ', error);
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Carries the original's statics onto the wrapper.
623
+ *
624
+ * `React.forwardRef` returns a plain object, so anything hung off the component
625
+ * is lost unless it is copied: `ScrollView.Context`, which VirtualizedList reads
626
+ * and throws without, and `propTypes`, which React validates against in
627
+ * development.
628
+ */
629
+ function copyStatics(target, source) {
630
+ // `length`, `name` and `prototype` are function/class intrinsics, not statics
631
+ // anyone hung off the component. Button and TouchableNativeFeedback are still
632
+ // classes in RN 0.73, so copying their `prototype` onto a forwardRef exotic
633
+ // object would give the wrapper a truthy `prototype.isReactComponent` — a lie
634
+ // about the component kind to anything that probes it (devtools, Animated,
635
+ // consumer code) — and, being non-writable, would make a second copyStatics
636
+ // call throw into the catch below.
637
+ const skipKeys = new Set(['$$typeof', 'render', 'displayName', 'length', 'name', 'prototype']);
638
+ Object.getOwnPropertyNames(source != null ? source : {}).forEach(key => {
639
+ if (skipKeys.has(key)) return;
640
+ try {
641
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
642
+ if (descriptor) Object.defineProperty(target, key, descriptor);
643
+ } catch (_unused) {
644
+ /* ignore non-configurable descriptors */
645
+ }
646
+ });
647
+ }
648
+
649
+ /**
650
+ * @param wrapWithoutHandler Whether to render the masking wrappers even when
651
+ * there is no onPress to report. True for the touchables: branching on the
652
+ * handler changes the element type at this position when a component toggles
653
+ * its handler — a button that disables itself by dropping onPress — and React
654
+ * then unmounts and remounts the native control it wraps, resetting its state
655
+ * and briefly nulling the forwarded ref. False for Text, which has no native
656
+ * state a remount can lose, and where the overwhelming majority of instances
657
+ * carry no handler at all.
658
+ */
659
+ function makeInstrumentedPressable(OrigComp, componentName, wrapWithoutHandler = true) {
244
660
  const InstrumentedComp = /*#__PURE__*/React.forwardRef(function InstrumentedPressable(props, ref) {
245
- const wrappedProps = props.onPress ? _extends({}, props, {
246
- onPress: e => {
247
- var _props$accessibilityL, _extractInnerText;
248
- const {
249
- pageX,
250
- pageY
251
- } = e.nativeEvent;
252
- reportInteraction({
253
- type: 'click',
254
- attributes: {
255
- x: Math.round(pageX * 100) / 100,
256
- y: Math.round(pageY * 100) / 100
257
- },
258
- element_classes: componentName,
259
- target_element: (_props$accessibilityL = props.accessibilityLabel) != null ? _props$accessibilityL : componentName,
260
- target_id: props.testID,
261
- inner_text: (_extractInnerText = extractInnerText(props.children)) != null ? _extractInnerText : props.title
262
- });
263
- props.onPress(e);
264
- }
265
- }) : props;
266
- return /*#__PURE__*/React.createElement(OrigComp, _extends({}, wrappedProps, {
267
- ref
268
- }));
661
+ if (!props.onPress && !wrapWithoutHandler) {
662
+ return /*#__PURE__*/React.createElement(OrigComp, _extends({}, props, {
663
+ ref
664
+ }));
665
+ }
666
+
667
+ // The masking verdict needs three things props alone cannot answer: whether
668
+ // an ancestor is masked (context, flowing down from Masked), whether a
669
+ // descendant is (the children walk), and whether a *composite* descendant
670
+ // rendered a Masked internally — which the children walk cannot see, because
671
+ // it reads the element tree (hence the scope, marked during render; see
672
+ // MaskScope). Only JS has any of that: native sees a ReactViewGroup of
673
+ // ReactTextViews and resolves masking by coordinate, which is why a tap on a
674
+ // touchable's padding around a masked <Text> reads as unmasked there.
675
+ //
676
+ // Consumer rather than useContext: hooks are unavailable in these wrappers
677
+ // (see MaskedContext).
678
+ return /*#__PURE__*/React.createElement(MaskedContext.Consumer, {
679
+ children: isAncestorMasked => /*#__PURE__*/React.createElement(MaskScopeBoundary, {
680
+ onCommit: flushControlRef,
681
+ children: scope => {
682
+ const control = controlHandleFor(scope, ref);
683
+ return /*#__PURE__*/React.createElement(OrigComp, _extends({}, props, props.onPress ? {
684
+ onPress: e => {
685
+ safely(() => {
686
+ var _e$nativeEvent, _extraction$text, _props$accessibilityL;
687
+ const {
688
+ pageX,
689
+ pageY
690
+ } = (_e$nativeEvent = e == null ? void 0 : e.nativeEvent) != null ? _e$nativeEvent : {};
691
+ const extraction = extractInnerTextWithMasking(props.children);
692
+ const isMasked = isAncestorMasked || extraction.masked || scope.masked;
693
+ const innerText = (_extraction$text = extraction.text) != null ? _extraction$text : props.title;
694
+ reportInteraction(_extends({
695
+ type: 'click'
696
+ }, Number.isFinite(pageX) && Number.isFinite(pageY) ? {
697
+ attributes: {
698
+ x: Math.round(pageX * 100) / 100,
699
+ y: Math.round(pageY * 100) / 100
700
+ }
701
+ } : {}, {
702
+ element_classes: componentName,
703
+ target_element: (_props$accessibilityL = props.accessibilityLabel) != null ? _props$accessibilityL : componentName,
704
+ target_id: props.testID,
705
+ // Whole-value redaction, matching the browser SDK: one masked
706
+ // contributor masks the entire string rather than part of it,
707
+ // and the real text never crosses the bridge.
708
+ inner_text: innerText !== undefined && isMasked ? MASKED_INNER_TEXT : innerText
709
+ }, isMasked ? {
710
+ is_masked: true
711
+ } : control.viewTag !== null ? {
712
+ view_tag: control.viewTag
713
+ } : {}));
714
+ });
715
+ props.onPress(e);
716
+ }
717
+ } : {}, {
718
+ // Ours, with the app's ref written through it, rather than the
719
+ // app's directly — this is where the control's view tag comes from.
720
+ ref: control.attach
721
+ }));
722
+ }
723
+ })
724
+ });
269
725
  });
270
726
  InstrumentedComp.displayName = componentName;
727
+ copyStatics(InstrumentedComp, OrigComp);
271
728
  return InstrumentedComp;
272
729
  }
730
+
731
+ // A Switch reports no coordinates, so native masking resolution has nothing to
732
+ // fall back on — without the ancestor verdict from context, a Switch inside a
733
+ // masked container would report false.
734
+ //
735
+ // It carries no view tag either, and so is the one control the native view-tree
736
+ // walk cannot answer for: onValueChange hands over a boolean rather than an
737
+ // event, so the tag would have to come from a ref, and a ref needs the stable
738
+ // per-instance object that only MaskScopeBoundary provides — a boundary a Switch
739
+ // has no other use for, having no children to scope. So an imperative maskView
740
+ // on a Switch's container still reports false. Wrap it in `Masked` instead,
741
+ // which the context below already honours.
273
742
  function makeInstrumentedSwitch(OrigComp) {
274
743
  const InstrumentedSwitch = /*#__PURE__*/React.forwardRef(function InstrumentedSwitch(props, ref) {
275
- const wrappedProps = props.onValueChange ? _extends({}, props, {
276
- onValueChange: value => {
277
- var _props$accessibilityL2;
278
- reportInteraction({
279
- type: 'click',
280
- element_classes: 'Switch',
281
- target_element: (_props$accessibilityL2 = props.accessibilityLabel) != null ? _props$accessibilityL2 : 'Switch',
282
- target_id: props.testID
283
- });
284
- props.onValueChange(value);
285
- }
286
- }) : props;
287
- return /*#__PURE__*/React.createElement(OrigComp, _extends({}, wrappedProps, {
288
- ref
289
- }));
744
+ // Rendered unconditionally, for the reason given in makeInstrumentedPressable.
745
+ return /*#__PURE__*/React.createElement(MaskedContext.Consumer, {
746
+ children: isAncestorMasked => /*#__PURE__*/React.createElement(OrigComp, _extends({}, props, props.onValueChange ? {
747
+ onValueChange: value => {
748
+ safely(() => {
749
+ var _props$accessibilityL2;
750
+ reportInteraction(_extends({
751
+ type: 'click',
752
+ element_classes: 'Switch',
753
+ target_element: (_props$accessibilityL2 = props.accessibilityLabel) != null ? _props$accessibilityL2 : 'Switch',
754
+ target_id: props.testID
755
+ }, isAncestorMasked ? {
756
+ is_masked: true
757
+ } : {}));
758
+ });
759
+ props.onValueChange(value);
760
+ }
761
+ } : {}, {
762
+ ref
763
+ }))
764
+ });
290
765
  });
291
766
  InstrumentedSwitch.displayName = 'Switch';
767
+ copyStatics(InstrumentedSwitch, OrigComp);
292
768
  return InstrumentedSwitch;
293
769
  }
770
+
771
+ // Scroll and swipe payloads carry no coordinates, so the SDK's own coordinate
772
+ // resolution has nothing to test against. They carry the scroll view's tag
773
+ // instead, which the plugin's native module resolves against the view tree — so
774
+ // a scroll inside a masked container reports masked rather than falling back to
775
+ // the documented "cannot resolve" default of false.
776
+ //
777
+ // Container, and only container: native counts descendants for a click, because
778
+ // a click reports the control's whole concatenated text and a masked descendant
779
+ // has to mask it. A scroll reports no text, so descendants are not consulted —
780
+ // otherwise one masked row anywhere in a list would mask every scroll of it.
781
+ //
782
+ // The tag comes from a ref, exactly as it does for a press, and NOT from
783
+ // `nativeEvent.target`. A scroll event is emitted by the scroll view rather than
784
+ // hit-tested to a leaf, so its target would be the right view — but only Android
785
+ // puts one in the event body (ScrollEvent.java stamps `target`; iOS's
786
+ // RCTScrollEvent body carries just the offsets and sizes). Reading it would have
787
+ // worked on Android and silently never fired on iOS.
788
+ //
789
+ // MaskScopeBoundary is here only for the stable per-instance object its scope
790
+ // gives the ref (see controlHandleFor). Nothing reads this scope's `masked`
791
+ // flag: a scroll reports no text, so it has no verdict of its own to compute.
294
792
  function makeInstrumentedScrollView(OrigComp) {
295
793
  const InstrumentedScrollView = /*#__PURE__*/React.forwardRef(function InstrumentedScrollView(props, ref) {
296
- const wrappedProps = _extends({}, props, {
297
- onScrollBeginDrag: e => {
298
- scrollState.startOffset = _extends({}, e.nativeEvent.contentOffset);
299
- scrollState.startTime = Date.now();
300
- props.onScrollBeginDrag == null || props.onScrollBeginDrag(e);
301
- },
302
- onScrollEndDrag: e => {
303
- var _scrollState$timeout;
304
- const endOffset = _extends({}, e.nativeEvent.contentOffset);
305
- // Capture start state immediately — scrollState may be mutated by a
306
- // concurrent ScrollView's onScrollBeginDrag before the timeout fires.
307
- const {
308
- startOffset,
309
- startTime
310
- } = scrollState;
311
- clearTimeout((_scrollState$timeout = scrollState.timeout) != null ? _scrollState$timeout : undefined);
312
-
313
- // Compute drag speed (px/ms) to distinguish a deliberate scroll from a fling.
314
- // Works on both iOS and Android without relying on native velocity fields.
315
- let classified = false;
316
- if (startOffset !== null && startTime !== null) {
317
- const elapsed = Date.now() - startTime;
318
- const dx = endOffset.x - startOffset.x;
319
- const dy = endOffset.y - startOffset.y;
320
- const speed = elapsed > 0 ? Math.sqrt(dx * dx + dy * dy) / elapsed : 0;
321
- if (speed < SWIPE_VELOCITY_THRESHOLD) {
322
- // Slow deliberate drag → report scroll immediately.
323
- reportInteraction({
324
- type: 'scroll',
325
- target_element: 'ScrollView',
326
- direction: inferDirectionFromDelta(dx, dy)
794
+ return /*#__PURE__*/React.createElement(MaskScopeBoundary, {
795
+ onCommit: flushControlRef,
796
+ children: scope => {
797
+ const control = controlHandleFor(scope, ref);
798
+ return /*#__PURE__*/React.createElement(OrigComp, _extends({}, props, {
799
+ onScrollBeginDrag: e => {
800
+ safely(() => {
801
+ scrollState.startOffset = _extends({}, e.nativeEvent.contentOffset);
802
+ scrollState.startTime = Date.now();
327
803
  });
328
- scrollState = {
329
- startOffset: null,
330
- startTime: null,
331
- timeout: null
332
- };
333
- classified = true;
334
- }
335
- }
336
- if (!classified) {
337
- // Fast drag → wait for onMomentumScrollBegin to classify as swipe.
338
- // If momentum doesn't fire within the window, fall back to scroll.
339
- // Uses captured startOffset (not scrollState.startOffset) to avoid
340
- // reading stale state if another scroll begins before the timeout fires.
341
- scrollState.timeout = setTimeout(() => {
342
- if (startOffset) {
343
- reportInteraction({
344
- type: 'scroll',
345
- target_element: 'ScrollView',
346
- direction: inferDirectionFromDelta(endOffset.x - startOffset.x, endOffset.y - startOffset.y)
347
- });
348
- }
349
- scrollState = {
350
- startOffset: null,
351
- startTime: null,
352
- timeout: null
353
- };
354
- }, SWIPE_MOMENTUM_TIMEOUT_MS);
355
- }
356
- props.onScrollEndDrag == null || props.onScrollEndDrag(e);
357
- },
358
- onMomentumScrollBegin: e => {
359
- var _scrollState$timeout2;
360
- clearTimeout((_scrollState$timeout2 = scrollState.timeout) != null ? _scrollState$timeout2 : undefined);
361
- const currentOffset = _extends({}, e.nativeEvent.contentOffset);
362
- const {
363
- startOffset
364
- } = scrollState;
365
- if (startOffset) {
366
- reportInteraction({
367
- type: 'swipe',
368
- target_element: 'ScrollView',
369
- direction: inferDirectionFromDelta(currentOffset.x - startOffset.x, currentOffset.y - startOffset.y)
370
- });
371
- }
372
- scrollState = {
373
- startOffset: null,
374
- startTime: null,
375
- timeout: null
376
- };
377
- props.onMomentumScrollBegin == null || props.onMomentumScrollBegin(e);
804
+ props.onScrollBeginDrag == null || props.onScrollBeginDrag(e);
805
+ },
806
+ onScrollEndDrag: e => {
807
+ safely(() => classifyScrollEndDrag(e, control.viewTag));
808
+ props.onScrollEndDrag == null || props.onScrollEndDrag(e);
809
+ },
810
+ onMomentumScrollBegin: e => {
811
+ safely(() => classifyMomentumScrollBegin(e, control.viewTag));
812
+ props.onMomentumScrollBegin == null || props.onMomentumScrollBegin(e);
813
+ },
814
+ ref: control.attach
815
+ }));
378
816
  }
379
817
  });
380
- return /*#__PURE__*/React.createElement(OrigComp, _extends({}, wrappedProps, {
381
- ref
382
- }));
383
818
  });
384
819
  InstrumentedScrollView.displayName = 'ScrollView';
385
-
386
- // Copy static properties from OrigComp (e.g. ScrollView.Context used by VirtualizedList/FlatList).
387
- // React.forwardRef returns a plain object so these statics are lost without explicit copying.
388
- const skipKeys = new Set(['$$typeof', 'render', 'displayName']);
389
- Object.getOwnPropertyNames(OrigComp).forEach(key => {
390
- if (skipKeys.has(key)) return;
391
- try {
392
- const descriptor = Object.getOwnPropertyDescriptor(OrigComp, key);
393
- if (descriptor) Object.defineProperty(InstrumentedScrollView, key, descriptor);
394
- } catch (_unused) {/* ignore non-configurable descriptors */}
395
- });
820
+ copyStatics(InstrumentedScrollView, OrigComp);
396
821
  return InstrumentedScrollView;
397
822
  }
823
+ function classifyScrollEndDrag(e, viewTag) {
824
+ var _scrollState$timeout;
825
+ const endOffset = _extends({}, e.nativeEvent.contentOffset);
826
+ // Capture start state immediately — scrollState may be mutated by a
827
+ // concurrent ScrollView's onScrollBeginDrag before the timeout fires.
828
+ const {
829
+ startOffset,
830
+ startTime
831
+ } = scrollState;
832
+ clearTimeout((_scrollState$timeout = scrollState.timeout) != null ? _scrollState$timeout : undefined);
833
+
834
+ // Compute drag speed (px/ms) to distinguish a deliberate scroll from a fling.
835
+ // Works on both iOS and Android without relying on native velocity fields.
836
+ let classified = false;
837
+ if (startOffset !== null && startTime !== null) {
838
+ const elapsed = Date.now() - startTime;
839
+ const dx = endOffset.x - startOffset.x;
840
+ const dy = endOffset.y - startOffset.y;
841
+ const speed = elapsed > 0 ? Math.sqrt(dx * dx + dy * dy) / elapsed : 0;
842
+ if (speed < SWIPE_VELOCITY_THRESHOLD) {
843
+ // Slow deliberate drag → report scroll immediately.
844
+ reportInteraction(_extends({
845
+ type: 'scroll',
846
+ target_element: 'ScrollView',
847
+ direction: inferDirectionFromDelta(dx, dy)
848
+ }, viewTag !== null ? {
849
+ view_tag: viewTag
850
+ } : {}));
851
+ scrollState = {
852
+ startOffset: null,
853
+ startTime: null,
854
+ timeout: null
855
+ };
856
+ classified = true;
857
+ }
858
+ }
859
+ if (!classified) {
860
+ // Fast drag → wait for onMomentumScrollBegin to classify as swipe.
861
+ // If momentum doesn't fire within the window, fall back to scroll.
862
+ // Uses captured startOffset (not scrollState.startOffset) to avoid
863
+ // reading stale state if another scroll begins before the timeout fires.
864
+ scrollState.timeout = setTimeout(() => {
865
+ if (startOffset) {
866
+ reportInteraction(_extends({
867
+ type: 'scroll',
868
+ target_element: 'ScrollView',
869
+ direction: inferDirectionFromDelta(endOffset.x - startOffset.x, endOffset.y - startOffset.y)
870
+ }, viewTag !== null ? {
871
+ view_tag: viewTag
872
+ } : {}));
873
+ }
874
+ scrollState = {
875
+ startOffset: null,
876
+ startTime: null,
877
+ timeout: null
878
+ };
879
+ }, SWIPE_MOMENTUM_TIMEOUT_MS);
880
+ }
881
+ }
882
+ function classifyMomentumScrollBegin(e, viewTag) {
883
+ var _scrollState$timeout2;
884
+ clearTimeout((_scrollState$timeout2 = scrollState.timeout) != null ? _scrollState$timeout2 : undefined);
885
+ const currentOffset = _extends({}, e.nativeEvent.contentOffset);
886
+ const {
887
+ startOffset
888
+ } = scrollState;
889
+ if (startOffset) {
890
+ reportInteraction(_extends({
891
+ type: 'swipe',
892
+ target_element: 'ScrollView',
893
+ direction: inferDirectionFromDelta(currentOffset.x - startOffset.x, currentOffset.y - startOffset.y)
894
+ }, viewTag !== null ? {
895
+ view_tag: viewTag
896
+ } : {}));
897
+ }
898
+ scrollState = {
899
+ startOffset: null,
900
+ startTime: null,
901
+ timeout: null
902
+ };
903
+ }
398
904
 
399
905
  // react-native is in Metro's nonInlinedRequires list, so app code reads properties
400
906
  // like _rn.Pressable from the shared module object at each render. Mutating the
@@ -413,11 +919,16 @@ const originals = {
413
919
  TouchableNativeFeedback: RN.TouchableNativeFeedback,
414
920
  TouchableWithoutFeedback: RN.TouchableWithoutFeedback,
415
921
  Switch: RN.Switch,
416
- ScrollView: RN.ScrollView
922
+ ScrollView: RN.ScrollView,
923
+ Text: RN.Text
417
924
  };
418
925
 
419
- // Wrapper components are created once from the originals captured above.
420
- // They are patched into / restored from the RN module in enable() / disable().
926
+ // Wrapper components are created once from the originals captured above, and
927
+ // swapped into the RN module at load (see patchComponents below). They stay
928
+ // there: `disable()` leaves them in place because they are inert while reporting
929
+ // is off, and removing them would strand anything mounted meanwhile. The one
930
+ // path that puts the originals back is an explicit opt-out — see
931
+ // restoreUserInteractionComponents.
421
932
  const instrumented = {
422
933
  Button: makeInstrumentedPressable(originals.Button, 'Button'),
423
934
  Pressable: makeInstrumentedPressable(originals.Pressable, 'Pressable'),
@@ -426,7 +937,23 @@ const instrumented = {
426
937
  TouchableNativeFeedback: makeInstrumentedPressable(originals.TouchableNativeFeedback, 'TouchableNativeFeedback'),
427
938
  TouchableWithoutFeedback: makeInstrumentedPressable(originals.TouchableWithoutFeedback, 'TouchableWithoutFeedback'),
428
939
  Switch: makeInstrumentedSwitch(originals.Switch),
429
- ScrollView: makeInstrumentedScrollView(originals.ScrollView)
940
+ ScrollView: makeInstrumentedScrollView(originals.ScrollView),
941
+ // Text is the one press target that is not a touchable: TextProps declares
942
+ // onPress, so a link-style `<Text onPress>` reported nothing at all. The
943
+ // pressable wrapper fits it as-is — the press event carries the same
944
+ // pageX/pageY, and the text to report is its own children.
945
+ //
946
+ // A Text without an onPress renders the original component directly, with no
947
+ // Consumer and no scope boundary: Text is the most numerous component in a
948
+ // typical app and the great majority carry no handler, so the wrappers would be
949
+ // pure overhead on the hottest path. Safe for Text specifically, because a Text
950
+ // that gains or loses a handler remounts without losing native state — the
951
+ // reason the touchables wrap unconditionally.
952
+ //
953
+ // One thing to know: `RN.Text` is compared by identity in react-native's own
954
+ // `ReactNativeTestTools`, so a consumer using those helpers sees our wrapper
955
+ // instead — nothing in production code does this.
956
+ Text: makeInstrumentedPressable(originals.Text, 'Text', false)
430
957
  };
431
958
  function defineComp(key, value) {
432
959
  Object.defineProperty(RN, key, {
@@ -445,8 +972,27 @@ function patchComponents() {
445
972
  defineComp('TouchableWithoutFeedback', instrumented.TouchableWithoutFeedback);
446
973
  defineComp('Switch', instrumented.Switch);
447
974
  defineComp('ScrollView', instrumented.ScrollView);
975
+ defineComp('Text', instrumented.Text);
448
976
  }
449
- function restoreComponents() {
977
+
978
+ // Patched at module load, not in enable(). An element captures its component
979
+ // type when it is created, so anything already mounted keeps pointing at the
980
+ // original: `CoralogixRum.init()` is async and typically runs from a useEffect,
981
+ // so patching in enable() left every control on the first screen uninstrumented
982
+ // until something happened to re-render it — losing, among other things, the
983
+ // first press on it. Reporting stays gated on `isEnabled` (see
984
+ // reportInteraction), so the wrappers are inert until the SDK is initialized.
985
+ patchComponents();
986
+
987
+ /**
988
+ * Puts the original RN components back.
989
+ *
990
+ * For one caller: a customer who opted out with
991
+ * `instrumentations: { user_interaction: false }`. Patching happens at module
992
+ * load, before any options exist to consult, so the opt-out is honored by undoing
993
+ * it once the options are known. `disable()` deliberately does not do this.
994
+ */
995
+ function restoreUserInteractionComponents() {
450
996
  defineComp('Button', originals.Button);
451
997
  defineComp('Pressable', originals.Pressable);
452
998
  defineComp('TouchableOpacity', originals.TouchableOpacity);
@@ -455,10 +1001,13 @@ function restoreComponents() {
455
1001
  defineComp('TouchableWithoutFeedback', originals.TouchableWithoutFeedback);
456
1002
  defineComp('Switch', originals.Switch);
457
1003
  defineComp('ScrollView', originals.ScrollView);
1004
+ defineComp('Text', originals.Text);
458
1005
  }
459
1006
  class UserInteractionInstrumentation extends instrumentation.InstrumentationBase {
460
1007
  constructor(config) {
461
1008
  super(USER_INTERACTION_INSTRUMENTATION_NAME, USER_INTERACTION_INSTRUMENTATION_VERSION, {});
1009
+ this.report = void 0;
1010
+ this.report = config.onInteraction;
462
1011
  onInteraction = config.onInteraction;
463
1012
  }
464
1013
 
@@ -466,12 +1015,26 @@ class UserInteractionInstrumentation extends instrumentation.InstrumentationBase
466
1015
  init() {}
467
1016
  enable() {
468
1017
  isEnabled = true;
1018
+ // Re-attached, not just flipped: disable() drops the callback, so an
1019
+ // instance that is disabled and enabled again would otherwise report
1020
+ // nothing for the rest of its life.
1021
+ //
1022
+ // Guarded because InstrumentationBase's constructor calls enable() from
1023
+ // inside super(), before `this.report` is assigned — so the first call would
1024
+ // otherwise install `undefined` as the module-level callback. Today the
1025
+ // constructor's next statement overwrites it with no yield point in between,
1026
+ // but that is an ordering this class should not depend on.
1027
+ if (this.report) {
1028
+ onInteraction = this.report;
1029
+ }
469
1030
  patchComponents();
470
1031
  }
471
1032
  disable() {
472
1033
  isEnabled = false;
473
1034
  onInteraction = () => {};
474
- restoreComponents();
1035
+ // The wrappers stay installed. They are inert while `isEnabled` is false,
1036
+ // and putting the originals back would strand every control mounted in the
1037
+ // meantime the same way patching late did.
475
1038
  if (scrollState.timeout) {
476
1039
  clearTimeout(scrollState.timeout);
477
1040
  }
@@ -577,44 +1140,10 @@ function stopJsRefreshRateSampler() {
577
1140
  appStateSub = null;
578
1141
  }
579
1142
 
580
- var version = "0.10.2";
1143
+ var version = "0.11.1";
581
1144
  var pkg = {
582
1145
  version: version};
583
1146
 
584
- class Logger {
585
- constructor(config = {}) {
586
- var _config$shouldLog, _config$prefix;
587
- this._shouldLog = void 0;
588
- this._prefix = void 0;
589
- this._shouldLog = (_config$shouldLog = config.shouldLog) != null ? _config$shouldLog : false;
590
- this._prefix = (_config$prefix = config.prefix) != null ? _config$prefix : '[Coralogix React Native SDK] - ';
591
- }
592
- set shouldLog(value) {
593
- this._shouldLog = value;
594
- }
595
- debug(message, ...args) {
596
- if (this._shouldLog) {
597
- console.debug(`${this._prefix} ${message}`, ...args);
598
- }
599
- }
600
- info(message, ...args) {
601
- if (this._shouldLog) {
602
- console.info(`${this._prefix} ${message}`, ...args);
603
- }
604
- }
605
- warn(message, ...args) {
606
- if (this._shouldLog) {
607
- console.warn(`${this._prefix} ${message}`, ...args);
608
- }
609
- }
610
- error(message, ...args) {
611
- if (this._shouldLog) {
612
- console.error(`${this._prefix} ${message}`, ...args);
613
- }
614
- }
615
- }
616
- const logger = new Logger();
617
-
618
1147
  let activeGlobalSpanId = null;
619
1148
  const spanTraceIds = new Map();
620
1149
  let ignoredInstruments = new Set();
@@ -1010,6 +1539,79 @@ function isSessionReplayOptionsValid(options) {
1010
1539
  return scaleValid && sampleRateValid && qualityValid;
1011
1540
  }
1012
1541
 
1542
+ /**
1543
+ * The native module, or a proxy that throws on first use when it is missing.
1544
+ *
1545
+ * Lives in its own leaf module so anything needing the bridge — the masking
1546
+ * components included — can reach it without importing the SDK entry point and
1547
+ * cycling back through it. `CxSdkClient` is imported as a type only, so the
1548
+ * declaration site stays in `index.ts` with nothing cycling at runtime.
1549
+ */
1550
+
1551
+ const LINKING_ERROR = `The package 'cx-plugin' doesn't seem to be linked. Make sure: \n\n` + reactNative.Platform.select({
1552
+ ios: "- You have run 'pod install'\n",
1553
+ default: ''
1554
+ }) + '- You rebuilt the app after installing the package\n' + '- You are not using Expo Go\n';
1555
+ function createErrorProxy(errorMessage) {
1556
+ return new Proxy({}, {
1557
+ get() {
1558
+ throw new Error(errorMessage);
1559
+ }
1560
+ });
1561
+ }
1562
+ const CxSdk = reactNative.NativeModules.CxSdk ? reactNative.NativeModules.CxSdk : createErrorProxy(LINKING_ERROR);
1563
+
1564
+ const reportFailure = viewTag => error => {
1565
+ // Dropped from the registry so a later layout pass retries rather than
1566
+ // being deduped against a registration that did not take.
1567
+ registeredViewTags.delete(viewTag);
1568
+ logger.alwaysWarn(`masking: the native SDK could not mask the view for tag ${viewTag}, so nothing was masked: `, error);
1569
+ };
1570
+
1571
+ /**
1572
+ * Native view tags already registered for masking.
1573
+ *
1574
+ * `onLayout` fires on every re-measure, not just the first, and marking a view
1575
+ * natively is idempotent — so without this, a screen of `Masked` rows repeats
1576
+ * the bridge hop once per row per layout pass, and a view whose registration
1577
+ * keeps failing emits one ungated warning per layout event rather than one per
1578
+ * view, burying LogBox.
1579
+ *
1580
+ * Safe to hold for the life of the JS context: React Native allocates view tags
1581
+ * monotonically within a context and does not reuse them, so a tag in here
1582
+ * always refers to the same view.
1583
+ */
1584
+ const registeredViewTags = new Set();
1585
+
1586
+ /**
1587
+ * Registers the view that fired the layout event for masking.
1588
+ *
1589
+ * Failures warn rather than pass silently: masking that does not apply leaves
1590
+ * sensitive content in the replay, which is not something a caller can notice
1591
+ * by looking at their own code.
1592
+ */
1593
+ function maskView(event) {
1594
+ logger.debug('masking: maskView called');
1595
+ const viewTag = reactNative.findNodeHandle(event.target);
1596
+ if (!viewTag) {
1597
+ logger.alwaysWarn('masking: this layout event carried no resolvable native view, so nothing was masked. Pass `maskView` to the `onLayout` prop of a host component (`View`, `Text`, …).');
1598
+ return;
1599
+ }
1600
+ if (registeredViewTags.has(viewTag)) return;
1601
+ registeredViewTags.add(viewTag);
1602
+
1603
+ // Both platforms bridge this as a promise and reject when the view cannot be
1604
+ // resolved. The call itself is in the try because an unlinked native module is
1605
+ // an error-throwing proxy: reading `maskViewByTag` off it throws
1606
+ // synchronously, before there is any promise to attach a handler to, and that
1607
+ // would surface as an exception out of a layout handler rather than a warning.
1608
+ try {
1609
+ Promise.resolve(CxSdk.maskViewByTag(viewTag)).catch(reportFailure(viewTag));
1610
+ } catch (error) {
1611
+ reportFailure(viewTag)(error);
1612
+ }
1613
+ }
1614
+
1013
1615
  let lastRouteName;
1014
1616
  function getLastNavigationRouteDetected() {
1015
1617
  return lastRouteName;
@@ -1051,22 +1653,90 @@ let CoralogixDomain = /*#__PURE__*/function (CoralogixDomain) {
1051
1653
  return CoralogixDomain;
1052
1654
  }({});
1053
1655
 
1054
- const LINKING_ERROR = `The package 'cx-plugin' doesn't seem to be linked. Make sure: \n\n` + reactNative.Platform.select({
1055
- ios: "- You have run 'pod install'\n",
1056
- default: ''
1057
- }) + '- You rebuilt the app after installing the package\n' + '- You are not using Expo Go\n';
1656
+ const _excluded = ["children", "onLayout"];
1657
+
1658
+ /**
1659
+ * Props of `Masked`. It renders a real `View`, so it accepts everything a
1660
+ * `View` does — `style` included. `onLayout` is chained, not replaced: the
1661
+ * handler runs after the view is registered for masking.
1662
+ *
1663
+ * `collapsable` is not among them: masking depends on this view surviving RN's
1664
+ * view flattening, so the component pins it to `false` and the prop would be
1665
+ * ignored. Omitted rather than accepted-and-dropped, so the type says so.
1666
+ */
1667
+
1668
+ /**
1669
+ * Masks its subtree in session replay, and tells the user-interaction
1670
+ * instrumentation that everything inside is masked.
1671
+ *
1672
+ * The imperative `SessionReplay.maskView` masks pixels just as well, but it can
1673
+ * only be resolved by coordinate: native sees `<Pressable><Text>5</Text></Pressable>`
1674
+ * as a ReactViewGroup wrapping a ReactTextView, so masking the inner `<Text>`
1675
+ * alone leaves a tap on the pressable's padding resolving as unmasked, shipping
1676
+ * the real text. This wrapper closes that gap by making the verdict visible in
1677
+ * JS, where the component tree is.
1678
+ *
1679
+ * Renders a real `View` — it participates in layout, and forwards `style` and
1680
+ * the rest of its props. `collapsable={false}` keeps RN view flattening from
1681
+ * removing the very view being masked.
1682
+ *
1683
+ * ```tsx
1684
+ * <Pressable onPress={pay}>
1685
+ * <Masked>
1686
+ * <Text>{cardNumber}</Text>
1687
+ * </Masked>
1688
+ * </Pressable>
1689
+ * ```
1690
+ */
1691
+ const Masked = /*#__PURE__*/React.forwardRef(function Masked(props, ref) {
1692
+ const {
1693
+ children,
1694
+ onLayout
1695
+ } = props,
1696
+ viewProps = _objectWithoutPropertiesLoose(props, _excluded);
1697
+
1698
+ // Consumed, not just provided: the enclosing pressable's children walk reads
1699
+ // the element tree and cannot see a `Masked` that a composite renders
1700
+ // internally, so this tells the pressable directly. Marking during render is
1701
+ // deliberate — it has to happen before the press it protects, and it is
1702
+ // idempotent, so a StrictMode double render is harmless.
1703
+ return /*#__PURE__*/React.createElement(MaskScopeContext.Consumer, {
1704
+ children: scope => {
1705
+ markScopeMasked(scope);
1706
+ return /*#__PURE__*/React.createElement(MaskedContext.Provider, {
1707
+ value: true
1708
+ }, /*#__PURE__*/React.createElement(reactNative.View, _extends({}, viewProps, {
1709
+ ref,
1710
+ // Set after the spread: masking depends on this view surviving
1711
+ // flattening, so it is not the caller's to override.
1712
+ collapsable: false,
1713
+ onLayout: event => {
1714
+ maskView(event);
1715
+ onLayout == null || onLayout(event);
1716
+ }
1717
+ }), children));
1718
+ }
1719
+ });
1720
+ });
1721
+
1722
+ // Brands the component for the instrumentation's children walk. Assigned rather
1723
+ // than declared because forwardRef returns an exotic object, not a class.
1724
+ Masked[MASKED_COMPONENT_MARKER] = true;
1725
+
1726
+ /**
1727
+ * The bridge contract, and the source of truth for it: every method here must
1728
+ * exist in `ios/CxSdk.swift` and in the Android module with a matching
1729
+ * signature. Exported as a type so `nativeModule.ts` can hold the instance
1730
+ * without the declaration leaving this file.
1731
+ */
1732
+
1058
1733
  let beforeSendCallback;
1059
1734
  let tracesExporterSubscription;
1060
1735
  let _deregisterInstrumentations;
1061
- const CxSdk = reactNative.NativeModules.CxSdk ? reactNative.NativeModules.CxSdk : createErrorProxy(LINKING_ERROR);
1062
- function createErrorProxy(errorMessage) {
1063
- return new Proxy({}, {
1064
- get() {
1065
- throw new Error(errorMessage);
1066
- }
1067
- });
1068
- }
1069
1736
  let isInited = false;
1737
+ // Set for the duration of an in-flight `init()` so concurrent callers join it
1738
+ // instead of starting a second initialization. See `CoralogixRum.init`.
1739
+ let initPromise;
1070
1740
  // Whether native, rather than JS, rolled the sampling for this init. Only in that mode can
1071
1741
  // `CoralogixRum.isInited` be true while native has no RUM behind it.
1072
1742
  let nativeOwnsSamplingRoll = false;
@@ -1081,79 +1751,149 @@ function toSafeRecord(name, value) {
1081
1751
  }
1082
1752
  return value;
1083
1753
  }
1754
+
1755
+ /**
1756
+ * The real init work. Runs at most once per in-flight/finished init: `CoralogixRum.init`
1757
+ * memoizes the promise this returns, so concurrent callers share this single run.
1758
+ */
1759
+ async function runInit(options,
1760
+ // Per call rather than module state: `logger.warn` below runs an app's console handler,
1761
+ // which can call `init()` again, and a shared flag would let that nested call answer for
1762
+ // this one.
1763
+ progress) {
1764
+ var _options$debug, _resolvedOptions$excl, _resolvedOptions$excl2;
1765
+ // Configure the logger before anything that can warn: the `public_key` check below, and
1766
+ // resolving the options, which normalizes `sessionSampleRate` and warns about an invalid
1767
+ // rate. Setting it any later swallowed those warnings on the first init — exactly when
1768
+ // the caller needs to see them.
1769
+ logger.shouldLog = (_options$debug = options == null ? void 0 : options.debug) != null ? _options$debug : false;
1770
+ if (typeof (options == null ? void 0 : options.public_key) !== 'string' || options.public_key.length === 0) {
1771
+ logger.warn('`public_key` is required and must be a non-empty string; initialization aborted.');
1772
+ return;
1773
+ }
1774
+ const resolvedOptions = resolveCoralogixOtelWebConfig(options);
1775
+
1776
+ // The rate has to be applied exactly *once*. Rolling it on both sides squared it:
1777
+ // `sessionSampleRate: 1` kept ~0.01% of sessions instead of 1%.
1778
+ //
1779
+ // Without `excludeFromSampling`, JS rolls and the rate sent to native is pinned to 100
1780
+ // so native does not roll again; a losing roll returns here, which also keeps JS
1781
+ // instrumentation off a session that would emit nothing. With `excludeFromSampling`,
1782
+ // only native can honour "sampled out, but still emit these categories", so JS does not
1783
+ // roll and forwards the real rate — pinning to 100 there would sample every session in.
1784
+ const hasExcludeOverrides = ((_resolvedOptions$excl = (_resolvedOptions$excl2 = resolvedOptions.excludeFromSampling) == null ? void 0 : _resolvedOptions$excl2.length) != null ? _resolvedOptions$excl : 0) > 0;
1785
+ if (!hasExcludeOverrides && !isSamplingOn(resolvedOptions.sessionSampleRate)) {
1786
+ logger.debug('CoralogixRum: Session tracking is disabled');
1787
+ return;
1788
+ }
1789
+ nativeOwnsSamplingRoll = hasExcludeOverrides;
1790
+ const nativeSessionSampleRate = hasExcludeOverrides ? resolvedOptions.sessionSampleRate : 100;
1791
+
1792
+ // Past every bail-out above: this init is really happening, and only from here is there
1793
+ // an in-flight run for other callers to join. Everything above returns synchronously, so
1794
+ // `init` can read this the moment it calls us. Keep this immediately before the first
1795
+ // `await` — moving it after one would leave concurrent callers un-deduplicated.
1796
+ progress.reachedAsyncWork = true;
1797
+ await registerCoralogixInstrumentations(resolvedOptions);
1798
+ let finalOptions;
1799
+ const lastNavRoute = getLastNavigationRouteDetected();
1800
+ if (lastNavRoute) {
1801
+ finalOptions = _extends({}, resolvedOptions, {
1802
+ view_context: {
1803
+ view: lastNavRoute
1804
+ }
1805
+ });
1806
+ } else {
1807
+ finalOptions = resolvedOptions;
1808
+ }
1809
+ if (finalOptions.tracesExporter) {
1810
+ const callback = finalOptions.tracesExporter;
1811
+ tracesExporterSubscription = eventEmitter.addListener('onTracesExport', jsonString => {
1812
+ let data;
1813
+ try {
1814
+ data = JSON.parse(jsonString);
1815
+ } catch (e) {
1816
+ logger.warn('Error parsing onTracesExport payload:', e);
1817
+ return;
1818
+ }
1819
+ try {
1820
+ callback(data);
1821
+ } catch (e) {
1822
+ logger.warn('tracesExporter callback threw:', e);
1823
+ }
1824
+ });
1825
+ }
1826
+ await CxSdk.initialize(_extends({}, finalOptions, {
1827
+ sessionSampleRate: nativeSessionSampleRate,
1828
+ frameworkVersion: pkg.version,
1829
+ networkExtraConfig: finalOptions.networkExtraConfig ? serializeNetworkCaptureRules(finalOptions.networkExtraConfig) : undefined,
1830
+ hasBeforeSend: !!finalOptions.beforeSend,
1831
+ hasTracesExporter: !!finalOptions.tracesExporter
1832
+ }));
1833
+ isInited = true;
1834
+ }
1835
+
1836
+ /**
1837
+ * Undo what a failed init attempt registered. `shutdown()` early-returns while `isInited`
1838
+ * is false, and the next `init()` overwrites these handles, so without this the failed
1839
+ * attempt's instrumentations stay registered and reporting for the rest of the session and
1840
+ * a retry ends up running two sets of them.
1841
+ */
1842
+ function rollbackFailedInit() {
1843
+ _deregisterInstrumentations == null || _deregisterInstrumentations();
1844
+ _deregisterInstrumentations = undefined;
1845
+ if (tracesExporterSubscription) {
1846
+ tracesExporterSubscription.remove();
1847
+ tracesExporterSubscription = undefined;
1848
+ }
1849
+ cleanupResources();
1850
+ }
1084
1851
  const CoralogixRum = {
1085
1852
  get isInited() {
1086
1853
  return isInited;
1087
1854
  },
1088
- init: async function (options) {
1089
- var _options$debug, _resolvedOptions$excl, _resolvedOptions$excl2;
1855
+ init: function (options) {
1090
1856
  if (isInited) {
1091
- console.warn('[Coralogix React Native SDK] - already initialized');
1092
- return;
1093
- }
1094
- if (typeof (options == null ? void 0 : options.public_key) !== 'string' || options.public_key.length === 0) {
1095
- console.warn('[Coralogix React Native SDK] - `public_key` is required and must be a non-empty string; initialization aborted.');
1096
- return;
1857
+ logger.warn('already initialized');
1858
+ return Promise.resolve();
1097
1859
  }
1098
1860
 
1099
- // Configure the logger first: resolving the options normalizes `sessionSampleRate`,
1100
- // which warns about an invalid rate. Doing it the other way round swallowed that
1101
- // warning on the first init — exactly when the caller needs to see it.
1102
- logger.shouldLog = (_options$debug = options.debug) != null ? _options$debug : false;
1103
- const resolvedOptions = resolveCoralogixOtelWebConfig(options);
1104
-
1105
- // The rate has to be applied exactly *once*. Rolling it on both sides squared it:
1106
- // `sessionSampleRate: 1` kept ~0.01% of sessions instead of 1%.
1107
- //
1108
- // Without `excludeFromSampling`, JS rolls and the rate sent to native is pinned to 100
1109
- // so native does not roll again; a losing roll returns here, which also keeps JS
1110
- // instrumentation off a session that would emit nothing. With `excludeFromSampling`,
1111
- // only native can honour "sampled out, but still emit these categories", so JS does not
1112
- // roll and forwards the real rate — pinning to 100 there would sample every session in.
1113
- const hasExcludeOverrides = ((_resolvedOptions$excl = (_resolvedOptions$excl2 = resolvedOptions.excludeFromSampling) == null ? void 0 : _resolvedOptions$excl2.length) != null ? _resolvedOptions$excl : 0) > 0;
1114
- if (!hasExcludeOverrides && !isSamplingOn(resolvedOptions.sessionSampleRate)) {
1115
- logger.debug('CoralogixRum: Session tracking is disabled');
1116
- return;
1117
- }
1118
- nativeOwnsSamplingRoll = hasExcludeOverrides;
1119
- const nativeSessionSampleRate = hasExcludeOverrides ? resolvedOptions.sessionSampleRate : 100;
1120
- await registerCoralogixInstrumentations(resolvedOptions);
1121
- let finalOptions;
1122
- const lastNavRoute = getLastNavigationRouteDetected();
1123
- if (lastNavRoute) {
1124
- finalOptions = _extends({}, resolvedOptions, {
1125
- view_context: {
1126
- view: lastNavRoute
1127
- }
1128
- });
1129
- } else {
1130
- finalOptions = resolvedOptions;
1861
+ // `isInited` flips only once native init has resolved, which is a couple of bridge
1862
+ // round trips away — hundreds of ms on a dev/debug startup, where the JS and main
1863
+ // threads are busy. A caller that guards on `CoralogixRum.isInited` therefore still
1864
+ // reads `false` for that whole window, so a second `init()` from another bootstrap
1865
+ // path used to walk past the guard above and initialize everything a second time:
1866
+ // duplicate instrumentations, a leaked `_deregisterInstrumentations`, a leaked
1867
+ // `onTracesExport` listener and a second native session. Hand every caller inside
1868
+ // the window the same in-flight promise instead — one init, and awaiting any of the
1869
+ // calls waits for that one to finish.
1870
+ if (initPromise) {
1871
+ logger.warn('initialization already in progress');
1872
+ return initPromise;
1131
1873
  }
1132
- if (finalOptions.tracesExporter) {
1133
- const callback = finalOptions.tracesExporter;
1134
- tracesExporterSubscription = eventEmitter.addListener('onTracesExport', jsonString => {
1135
- let data;
1136
- try {
1137
- data = JSON.parse(jsonString);
1138
- } catch (e) {
1139
- logger.warn('Error parsing onTracesExport payload:', e);
1140
- return;
1141
- }
1142
- try {
1143
- callback(data);
1144
- } catch (e) {
1145
- logger.warn('tracesExporter callback threw:', e);
1146
- }
1147
- });
1874
+ const progress = {
1875
+ reachedAsyncWork: false
1876
+ };
1877
+ const run = runInit(options, progress);
1878
+
1879
+ // `runInit` runs synchronously until its first `await`, so this already tells us
1880
+ // whether it bailed out — invalid key, or a session sampled out. Such a run is
1881
+ // finished and must not be memoized: its cleanup below only lands on a later
1882
+ // microtask, so a valid `init()` in this same tick would otherwise join the aborted
1883
+ // promise and never initialize at all.
1884
+ if (!progress.reachedAsyncWork) {
1885
+ return run;
1148
1886
  }
1149
- await CxSdk.initialize(_extends({}, finalOptions, {
1150
- sessionSampleRate: nativeSessionSampleRate,
1151
- frameworkVersion: pkg.version,
1152
- networkExtraConfig: finalOptions.networkExtraConfig ? serializeNetworkCaptureRules(finalOptions.networkExtraConfig) : undefined,
1153
- hasBeforeSend: !!finalOptions.beforeSend,
1154
- hasTracesExporter: !!finalOptions.tracesExporter
1155
- }));
1156
- isInited = true;
1887
+
1888
+ // Cleared once it settles: on success `isInited` takes over as the guard, and on
1889
+ // failure a later `init()` can try again exactly as before.
1890
+ initPromise = run.catch(error => {
1891
+ rollbackFailedInit();
1892
+ throw error;
1893
+ }).finally(() => {
1894
+ initPromise = undefined;
1895
+ });
1896
+ return initPromise;
1157
1897
  },
1158
1898
  shutdown: () => {
1159
1899
  if (!isInited) {
@@ -1373,13 +2113,7 @@ const SessionReplay = {
1373
2113
  logger.debug("session replay: stopSessionRecording called");
1374
2114
  CxSdk.stopSessionRecording();
1375
2115
  },
1376
- maskView: event => {
1377
- logger.debug("session replay: maskViewByTag called");
1378
- const viewTag = reactNative.findNodeHandle(event.target);
1379
- if (viewTag) {
1380
- CxSdk.maskViewByTag(viewTag);
1381
- }
1382
- }
2116
+ maskView
1383
2117
  };
1384
2118
  function trackMobileVitals(options) {
1385
2119
  var _options$mobileVitals;
@@ -1467,6 +2201,13 @@ async function registerCoralogixInstrumentations(options) {
1467
2201
  CxSdk.reportUserInteraction(ctx);
1468
2202
  }
1469
2203
  }));
2204
+ } else {
2205
+ // Patching happens when the instrumentation module loads, because an element
2206
+ // captures its component type when it is created and a later swap cannot
2207
+ // reach an already-mounted tree. That is before any options exist to read,
2208
+ // so an explicit opt-out is honored here instead, by putting the originals
2209
+ // back now that we know.
2210
+ restoreUserInteractionComponents();
1470
2211
  }
1471
2212
 
1472
2213
  // Register Instrumentations
@@ -1539,6 +2280,12 @@ const EDITABLE_SESSION_FIELDS = ['user_id', 'user_name', 'user_email', 'user_met
1539
2280
  * Mirrors the native SDKs' read-only set. `view_number` and `isNavigationEvent`
1540
2281
  * are included for the same reason they are natively: they record what the SDK
1541
2282
  * saw at build time, not a property derived from the (possibly relabelled) event.
2283
+ *
2284
+ * `interaction_context` is intentionally NOT here: a callback blanking the tap
2285
+ * coordinates or dropping a masked interaction outright is the supported escape
2286
+ * hatch for masking policies stricter than the default. Its one SDK-owned field,
2287
+ * `is_masked_element`, is restored by {@link restoreInteractionMaskFlag}, not
2288
+ * by this list.
1542
2289
  */
1543
2290
  const READ_ONLY_CX_RUM_KEYS = ['snapshot_context', 'isSnapshotEvent', 'mobile_sdk', 'timestamp', 'traceId', 'spanId', 'fingerPrint', 'prev_session', 'platform', 'view_number', 'isNavigationEvent'];
1544
2291
  const isPlainObject = value => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -1592,6 +2339,62 @@ const rebuildSessionContext = (merged, original) => {
1592
2339
  }
1593
2340
  return rebuilt;
1594
2341
  };
2342
+ const IS_MASKED_ELEMENT_KEY = 'is_masked_element';
2343
+
2344
+ /**
2345
+ * Puts the SDK's `is_masked_element` back on `interaction_context`, in place.
2346
+ *
2347
+ * The flag is deliberately VISIBLE to the callback — reading it is how a
2348
+ * customer enforces a masking policy stricter than the default — but it is the
2349
+ * SDK's own observation, so visibility must not make it writable: a forged
2350
+ * value would corrupt the audit trail the flag exists to provide. Same
2351
+ * visible-read-only treatment as `session_context.isSessionSampledIn`, and the
2352
+ * same enforcement point: native uploads the batch JS hands back verbatim, so
2353
+ * the bridge is the only place a rewrite can be caught.
2354
+ *
2355
+ * The rest of `interaction_context` stays editable — blanking coordinates or
2356
+ * redacting text fields is the supported escape hatch, and so is dropping the
2357
+ * whole container, which keeps the flag alone rather than resurrecting the
2358
+ * fields the callback removed.
2359
+ *
2360
+ * @param merged `cx_rum` as it stands after the callback ran.
2361
+ * @param originalInteraction `interaction_context` snapshotted *before* the
2362
+ * callback ran.
2363
+ */
2364
+ const restoreInteractionMaskFlag = (merged, originalInteraction) => {
2365
+ const mergedInteraction = merged['interaction_context'];
2366
+ if (!isPlainObject(originalInteraction)) {
2367
+ // Native never sent an interaction_context, so a callback must not be able
2368
+ // to stamp the SDK-observation flag on one it invented.
2369
+ if (isPlainObject(mergedInteraction)) {
2370
+ delete mergedInteraction[IS_MASKED_ELEMENT_KEY];
2371
+ }
2372
+ return;
2373
+ }
2374
+
2375
+ // A callback that dropped interaction_context, or replaced it with something
2376
+ // that is not an object, keeps only the flag — on a fresh minimal object.
2377
+ //
2378
+ // Restoring the SDK's whole object here would re-ship exactly what the
2379
+ // callback was trying to get rid of: `delete event.interaction_context` is a
2380
+ // very plausible reading of "drop this interaction's details", and it would
2381
+ // silently put the coordinates and target_element_inner_text back. The flag
2382
+ // still has to survive, or erasing the container would be a way to erase an
2383
+ // SDK observation, so it is carried over on its own and nothing else is.
2384
+ if (!isPlainObject(mergedInteraction)) {
2385
+ merged['interaction_context'] = IS_MASKED_ELEMENT_KEY in originalInteraction ? {
2386
+ [IS_MASKED_ELEMENT_KEY]: originalInteraction[IS_MASKED_ELEMENT_KEY]
2387
+ } : {};
2388
+ return;
2389
+ }
2390
+ if (IS_MASKED_ELEMENT_KEY in originalInteraction) {
2391
+ mergedInteraction[IS_MASKED_ELEMENT_KEY] = originalInteraction[IS_MASKED_ELEMENT_KEY];
2392
+ } else {
2393
+ // An older native SDK omits the flag; the SDK never invents one, so neither
2394
+ // may a callback.
2395
+ delete mergedInteraction[IS_MASKED_ELEMENT_KEY];
2396
+ }
2397
+ };
1595
2398
 
1596
2399
  /** Puts the SDK's values back on the read-only top-level keys, in place. */
1597
2400
  const restoreReadOnlyCxRumKeys = (merged, protectedValues) => {
@@ -1640,6 +2443,7 @@ const subscription = eventEmitter.addListener('onBeforeSend', events => {
1640
2443
  }
1641
2444
  }
1642
2445
  const protectedSessionContext = cloneBridgeValue(event == null ? void 0 : event.session_context);
2446
+ const protectedInteractionContext = cloneBridgeValue(event == null ? void 0 : event.interaction_context);
1643
2447
  const res = beforeSendCallback(event);
1644
2448
  if (!res) {
1645
2449
  continue;
@@ -1660,6 +2464,7 @@ const subscription = eventEmitter.addListener('onBeforeSend', events => {
1660
2464
  const mergedCxRum = _extends({}, fullEvent.text.cx_rum, edits);
1661
2465
  mergedCxRum.session_context = rebuildSessionContext(mergedCxRum.session_context, protectedSessionContext);
1662
2466
  restoreReadOnlyCxRumKeys(mergedCxRum, protectedValues);
2467
+ restoreInteractionMaskFlag(mergedCxRum, protectedInteractionContext);
1663
2468
  const mergeEvent = _extends({}, fullEvent, {
1664
2469
  text: _extends({}, fullEvent.text, {
1665
2470
  cx_rum: mergedCxRum
@@ -1687,6 +2492,7 @@ exports.CoralogixGlobalSpan = CoralogixGlobalSpan;
1687
2492
  exports.CoralogixLogSeverity = CoralogixLogSeverity;
1688
2493
  exports.CoralogixRum = CoralogixRum;
1689
2494
  exports.CxSdk = CxSdk;
2495
+ exports.Masked = Masked;
1690
2496
  exports.SessionReplay = SessionReplay;
1691
2497
  exports.attachReactNavigationObserver = attachReactNavigationObserver;
1692
2498
  exports.parseErrorStack = parseErrorStack;