@coralogix/react-native-plugin 0.10.1 → 0.11.0

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