@coralogix/react-native-plugin 0.10.2 → 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/CHANGELOG.md +27 -0
- package/README.md +42 -7
- package/android/build.gradle +4 -1
- package/android/src/main/java/com/cxsdk/CxSdkModule.kt +133 -4
- package/android/src/main/java/com/cxsdk/SessionReplayClient.kt +1 -1
- package/index.cjs.js +1081 -275
- package/index.esm.js +1082 -277
- package/ios/CxSdk.swift +98 -23
- package/package.json +1 -1
- package/src/index.d.ts +11 -4
- package/src/instrumentations/user-interaction/UserInteractionInstrumentation.d.ts +10 -0
- package/src/instrumentations/user-interaction/consts.d.ts +1 -0
- package/src/instrumentations/user-interaction/gestureUtils.d.ts +7 -1
- package/src/instrumentations/user-interaction/index.d.ts +1 -1
- package/src/logger.d.ts +8 -0
- package/src/masking/Masked.d.ts +39 -0
- package/src/masking/maskView.d.ts +9 -0
- package/src/masking/maskedContext.d.ts +90 -0
- package/src/model/Types.d.ts +109 -0
- package/src/nativeModule.d.ts +2 -0
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
|
-
|
|
161
|
+
previousConsoleError.apply(console, params);
|
|
106
162
|
this.report(params, false);
|
|
107
163
|
};
|
|
108
|
-
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 =
|
|
214
|
-
|
|
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
|
|
217
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1143
|
+
var version = "0.11.0";
|
|
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
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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:
|
|
1089
|
-
var _options$debug, _resolvedOptions$excl, _resolvedOptions$excl2;
|
|
1855
|
+
init: function (options) {
|
|
1090
1856
|
if (isInited) {
|
|
1091
|
-
|
|
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
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
}))
|
|
1156
|
-
|
|
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
|
|
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;
|