@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.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AppState, Platform, NativeModules,
|
|
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
|
-
|
|
159
|
+
previousConsoleError.apply(console, params);
|
|
104
160
|
this.report(params, false);
|
|
105
161
|
};
|
|
106
|
-
|
|
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,24 +374,106 @@ function inferDirectionFromDelta(dx, dy) {
|
|
|
199
374
|
}
|
|
200
375
|
return dy > 0 ? 'down' : 'up';
|
|
201
376
|
}
|
|
202
|
-
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 =
|
|
212
|
-
|
|
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
|
+
};
|
|
213
414
|
}
|
|
214
|
-
if ((children == null
|
|
215
|
-
|
|
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);
|
|
216
426
|
}
|
|
217
|
-
return
|
|
427
|
+
return {
|
|
428
|
+
masked: false
|
|
429
|
+
};
|
|
218
430
|
}
|
|
219
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);
|
|
468
|
+
}
|
|
469
|
+
error(message, ...args) {
|
|
470
|
+
if (this._shouldLog) {
|
|
471
|
+
console.error(`${this._prefix} ${message}`, ...args);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const logger = new Logger();
|
|
476
|
+
|
|
220
477
|
let isEnabled = false;
|
|
221
478
|
// No-op default so reportInteraction never needs a null-check.
|
|
222
479
|
// Set to the real callback by the constructor and cleared on disable().
|
|
@@ -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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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();
|
|
@@ -1008,6 +1537,79 @@ function isSessionReplayOptionsValid(options) {
|
|
|
1008
1537
|
return scaleValid && sampleRateValid && qualityValid;
|
|
1009
1538
|
}
|
|
1010
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
|
+
|
|
1011
1613
|
let lastRouteName;
|
|
1012
1614
|
function getLastNavigationRouteDetected() {
|
|
1013
1615
|
return lastRouteName;
|
|
@@ -1049,22 +1651,90 @@ let CoralogixDomain = /*#__PURE__*/function (CoralogixDomain) {
|
|
|
1049
1651
|
return CoralogixDomain;
|
|
1050
1652
|
}({});
|
|
1051
1653
|
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
+
|
|
1056
1731
|
let beforeSendCallback;
|
|
1057
1732
|
let tracesExporterSubscription;
|
|
1058
1733
|
let _deregisterInstrumentations;
|
|
1059
|
-
const CxSdk = NativeModules.CxSdk ? NativeModules.CxSdk : createErrorProxy(LINKING_ERROR);
|
|
1060
|
-
function createErrorProxy(errorMessage) {
|
|
1061
|
-
return new Proxy({}, {
|
|
1062
|
-
get() {
|
|
1063
|
-
throw new Error(errorMessage);
|
|
1064
|
-
}
|
|
1065
|
-
});
|
|
1066
|
-
}
|
|
1067
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;
|
|
1068
1738
|
// Whether native, rather than JS, rolled the sampling for this init. Only in that mode can
|
|
1069
1739
|
// `CoralogixRum.isInited` be true while native has no RUM behind it.
|
|
1070
1740
|
let nativeOwnsSamplingRoll = false;
|
|
@@ -1079,79 +1749,149 @@ function toSafeRecord(name, value) {
|
|
|
1079
1749
|
}
|
|
1080
1750
|
return value;
|
|
1081
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
|
+
}
|
|
1082
1849
|
const CoralogixRum = {
|
|
1083
1850
|
get isInited() {
|
|
1084
1851
|
return isInited;
|
|
1085
1852
|
},
|
|
1086
|
-
init:
|
|
1087
|
-
var _options$debug, _resolvedOptions$excl, _resolvedOptions$excl2;
|
|
1853
|
+
init: function (options) {
|
|
1088
1854
|
if (isInited) {
|
|
1089
|
-
|
|
1090
|
-
return;
|
|
1091
|
-
}
|
|
1092
|
-
if (typeof (options == null ? void 0 : options.public_key) !== 'string' || options.public_key.length === 0) {
|
|
1093
|
-
console.warn('[Coralogix React Native SDK] - `public_key` is required and must be a non-empty string; initialization aborted.');
|
|
1094
|
-
return;
|
|
1855
|
+
logger.warn('already initialized');
|
|
1856
|
+
return Promise.resolve();
|
|
1095
1857
|
}
|
|
1096
1858
|
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
// only native can honour "sampled out, but still emit these categories", so JS does not
|
|
1110
|
-
// roll and forwards the real rate — pinning to 100 there would sample every session in.
|
|
1111
|
-
const hasExcludeOverrides = ((_resolvedOptions$excl = (_resolvedOptions$excl2 = resolvedOptions.excludeFromSampling) == null ? void 0 : _resolvedOptions$excl2.length) != null ? _resolvedOptions$excl : 0) > 0;
|
|
1112
|
-
if (!hasExcludeOverrides && !isSamplingOn(resolvedOptions.sessionSampleRate)) {
|
|
1113
|
-
logger.debug('CoralogixRum: Session tracking is disabled');
|
|
1114
|
-
return;
|
|
1115
|
-
}
|
|
1116
|
-
nativeOwnsSamplingRoll = hasExcludeOverrides;
|
|
1117
|
-
const nativeSessionSampleRate = hasExcludeOverrides ? resolvedOptions.sessionSampleRate : 100;
|
|
1118
|
-
await registerCoralogixInstrumentations(resolvedOptions);
|
|
1119
|
-
let finalOptions;
|
|
1120
|
-
const lastNavRoute = getLastNavigationRouteDetected();
|
|
1121
|
-
if (lastNavRoute) {
|
|
1122
|
-
finalOptions = _extends({}, resolvedOptions, {
|
|
1123
|
-
view_context: {
|
|
1124
|
-
view: lastNavRoute
|
|
1125
|
-
}
|
|
1126
|
-
});
|
|
1127
|
-
} else {
|
|
1128
|
-
finalOptions = resolvedOptions;
|
|
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;
|
|
1129
1871
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
} catch (e) {
|
|
1143
|
-
logger.warn('tracesExporter callback threw:', e);
|
|
1144
|
-
}
|
|
1145
|
-
});
|
|
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;
|
|
1146
1884
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
}))
|
|
1154
|
-
|
|
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;
|
|
1155
1895
|
},
|
|
1156
1896
|
shutdown: () => {
|
|
1157
1897
|
if (!isInited) {
|
|
@@ -1371,13 +2111,7 @@ const SessionReplay = {
|
|
|
1371
2111
|
logger.debug("session replay: stopSessionRecording called");
|
|
1372
2112
|
CxSdk.stopSessionRecording();
|
|
1373
2113
|
},
|
|
1374
|
-
maskView
|
|
1375
|
-
logger.debug("session replay: maskViewByTag called");
|
|
1376
|
-
const viewTag = findNodeHandle(event.target);
|
|
1377
|
-
if (viewTag) {
|
|
1378
|
-
CxSdk.maskViewByTag(viewTag);
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
2114
|
+
maskView
|
|
1381
2115
|
};
|
|
1382
2116
|
function trackMobileVitals(options) {
|
|
1383
2117
|
var _options$mobileVitals;
|
|
@@ -1465,6 +2199,13 @@ async function registerCoralogixInstrumentations(options) {
|
|
|
1465
2199
|
CxSdk.reportUserInteraction(ctx);
|
|
1466
2200
|
}
|
|
1467
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();
|
|
1468
2209
|
}
|
|
1469
2210
|
|
|
1470
2211
|
// Register Instrumentations
|
|
@@ -1537,6 +2278,12 @@ const EDITABLE_SESSION_FIELDS = ['user_id', 'user_name', 'user_email', 'user_met
|
|
|
1537
2278
|
* Mirrors the native SDKs' read-only set. `view_number` and `isNavigationEvent`
|
|
1538
2279
|
* are included for the same reason they are natively: they record what the SDK
|
|
1539
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.
|
|
1540
2287
|
*/
|
|
1541
2288
|
const READ_ONLY_CX_RUM_KEYS = ['snapshot_context', 'isSnapshotEvent', 'mobile_sdk', 'timestamp', 'traceId', 'spanId', 'fingerPrint', 'prev_session', 'platform', 'view_number', 'isNavigationEvent'];
|
|
1542
2289
|
const isPlainObject = value => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -1590,6 +2337,62 @@ const rebuildSessionContext = (merged, original) => {
|
|
|
1590
2337
|
}
|
|
1591
2338
|
return rebuilt;
|
|
1592
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
|
+
};
|
|
1593
2396
|
|
|
1594
2397
|
/** Puts the SDK's values back on the read-only top-level keys, in place. */
|
|
1595
2398
|
const restoreReadOnlyCxRumKeys = (merged, protectedValues) => {
|
|
@@ -1638,6 +2441,7 @@ const subscription = eventEmitter.addListener('onBeforeSend', events => {
|
|
|
1638
2441
|
}
|
|
1639
2442
|
}
|
|
1640
2443
|
const protectedSessionContext = cloneBridgeValue(event == null ? void 0 : event.session_context);
|
|
2444
|
+
const protectedInteractionContext = cloneBridgeValue(event == null ? void 0 : event.interaction_context);
|
|
1641
2445
|
const res = beforeSendCallback(event);
|
|
1642
2446
|
if (!res) {
|
|
1643
2447
|
continue;
|
|
@@ -1658,6 +2462,7 @@ const subscription = eventEmitter.addListener('onBeforeSend', events => {
|
|
|
1658
2462
|
const mergedCxRum = _extends({}, fullEvent.text.cx_rum, edits);
|
|
1659
2463
|
mergedCxRum.session_context = rebuildSessionContext(mergedCxRum.session_context, protectedSessionContext);
|
|
1660
2464
|
restoreReadOnlyCxRumKeys(mergedCxRum, protectedValues);
|
|
2465
|
+
restoreInteractionMaskFlag(mergedCxRum, protectedInteractionContext);
|
|
1661
2466
|
const mergeEvent = _extends({}, fullEvent, {
|
|
1662
2467
|
text: _extends({}, fullEvent.text, {
|
|
1663
2468
|
cx_rum: mergedCxRum
|
|
@@ -1677,4 +2482,4 @@ const subscription = eventEmitter.addListener('onBeforeSend', events => {
|
|
|
1677
2482
|
}
|
|
1678
2483
|
});
|
|
1679
2484
|
|
|
1680
|
-
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 };
|