@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/ios/CxSdk.swift
CHANGED
|
@@ -189,21 +189,111 @@ class CxSdk: RCTEventEmitter {
|
|
|
189
189
|
if let targetId = interaction["target_id"] as? String { dictionary["element_id"] = targetId }
|
|
190
190
|
if let innerText = interaction["inner_text"] as? String { dictionary["target_element_inner_text"] = innerText }
|
|
191
191
|
if let direction = interaction["direction"] as? String { dictionary["scroll_direction"] = direction }
|
|
192
|
+
// Present only when JS resolved masking from the component tree. The native SDK
|
|
193
|
+
// treats a present value as authoritative and otherwise resolves the coordinates
|
|
194
|
+
// itself, which is why JS omits the key rather than sending false.
|
|
195
|
+
if let isMasked = interaction["is_masked"] as? Bool { dictionary["is_masked"] = isMasked }
|
|
192
196
|
if let attributes = interaction["attributes"] as? [String: Any] {
|
|
193
197
|
if let x = attributes["x"] as? Double { dictionary["x"] = x }
|
|
194
198
|
if let y = attributes["y"] as? Double { dictionary["y"] = y }
|
|
195
199
|
}
|
|
196
200
|
|
|
197
|
-
|
|
201
|
+
// The tapped control's view tag, present only when JS could not answer for
|
|
202
|
+
// itself. It is what lets the walk below see an imperative maskView: that
|
|
203
|
+
// marks a native view and leaves nothing in the component tree, so JS is
|
|
204
|
+
// blind to it, while the SDK's own coordinate test cannot tell where the
|
|
205
|
+
// control begins and ends.
|
|
206
|
+
let viewTag = interaction["view_tag"] as? NSNumber
|
|
207
|
+
|
|
198
208
|
DispatchQueue.main.async { [weak self] in
|
|
199
|
-
guard let rum = self
|
|
209
|
+
guard let self = self, let rum = self.coralogixRum else {
|
|
200
210
|
print("[CxSdk] reportUserInteraction called before CoralogixRum is initialized, dropping interaction")
|
|
201
211
|
return
|
|
202
212
|
}
|
|
213
|
+
var payload = dictionary
|
|
214
|
+
|
|
215
|
+
// Already on the main thread, which is where resolving a view and reading
|
|
216
|
+
// its masking state has to happen.
|
|
217
|
+
//
|
|
218
|
+
// Set only when the walk finds a mask; never set to false. An explicit
|
|
219
|
+
// value is authoritative in the SDK, so a false here would suppress its
|
|
220
|
+
// coordinate resolution and lose masks the walk cannot see — a masked
|
|
221
|
+
// overlay that is neither ancestor nor descendant of the control. The SDK
|
|
222
|
+
// redacts the reported inner text off this flag, so nothing else is needed.
|
|
223
|
+
//
|
|
224
|
+
// Descendants count for a click only. A click reports the control's whole
|
|
225
|
+
// concatenated text, so a masked descendant has to mask the click to keep
|
|
226
|
+
// the verdict and the text consistent. A scroll reports no text at all, so
|
|
227
|
+
// the descendant half would buy nothing there and cost plenty: one masked
|
|
228
|
+
// row anywhere in a list would report every scroll of that list as masked.
|
|
229
|
+
let isClick = (payload["event_name"] as? String) == "click"
|
|
230
|
+
if let viewTag = viewTag,
|
|
231
|
+
payload["is_masked"] == nil,
|
|
232
|
+
let view = self.resolveReactView(tag: viewTag),
|
|
233
|
+
self.controlIsMasked(view, includeDescendants: isClick) {
|
|
234
|
+
payload["is_masked"] = true
|
|
235
|
+
}
|
|
236
|
+
|
|
203
237
|
rum.setUserInteraction(payload)
|
|
204
238
|
}
|
|
205
239
|
}
|
|
206
240
|
|
|
241
|
+
/// Node budget for the descendant half of ``controlIsMasked(_:)``.
|
|
242
|
+
///
|
|
243
|
+
/// The walk runs on the main thread for every interaction, so a deep subtree —
|
|
244
|
+
/// a long list row, a heavily nested design-system component — must not be able
|
|
245
|
+
/// to cause jank. Exhausting the budget answers "not masked", which omits the
|
|
246
|
+
/// key and leaves the SDK's coordinate resolution as the backstop, rather than
|
|
247
|
+
/// claiming an answer the walk never reached.
|
|
248
|
+
private static let maskWalkNodeBudget = 512
|
|
249
|
+
|
|
250
|
+
/// Whether anything in the tapped control is masked: the view itself, an
|
|
251
|
+
/// ancestor, or any descendant.
|
|
252
|
+
///
|
|
253
|
+
/// Ancestors always count — masking is inherited, so a control inside a masked
|
|
254
|
+
/// panel is masked. Descendants count only when `includeDescendants` is set,
|
|
255
|
+
/// because React Native renders one logical control as several native views:
|
|
256
|
+
/// `<TouchableOpacity><Text/></TouchableOpacity>` puts the mask on a child and
|
|
257
|
+
/// leaves the tapped container itself unmarked.
|
|
258
|
+
private func controlIsMasked(_ view: UIView, includeDescendants: Bool) -> Bool {
|
|
259
|
+
// Self and ancestors, through the SDK's own accessor so the definition of
|
|
260
|
+
// masked lives in one place.
|
|
261
|
+
if view.isInsideMaskedSubtree { return true }
|
|
262
|
+
guard includeDescendants else { return false }
|
|
263
|
+
|
|
264
|
+
// Descendants, breadth-first and iteratively: recursion on an app-controlled
|
|
265
|
+
// depth is a stack overflow waiting to happen.
|
|
266
|
+
var queue = view.subviews
|
|
267
|
+
var index = 0
|
|
268
|
+
while index < queue.count {
|
|
269
|
+
if index >= Self.maskWalkNodeBudget { return false }
|
|
270
|
+
let current = queue[index]
|
|
271
|
+
index += 1
|
|
272
|
+
if current.cxMask { return true }
|
|
273
|
+
queue.append(contentsOf: current.subviews)
|
|
274
|
+
}
|
|
275
|
+
return false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Resolves a React view tag to its native view. Main thread only.
|
|
279
|
+
///
|
|
280
|
+
/// New Architecture (Fabric): mounted views live in the component-view
|
|
281
|
+
/// registry, not the legacy RCTUIManager registry, and are reachable only
|
|
282
|
+
/// through the surface presenter — which is typed non-null but absent on the
|
|
283
|
+
/// legacy architecture, so it is bound through an optional before use.
|
|
284
|
+
private func resolveReactView(tag: NSNumber) -> UIView? {
|
|
285
|
+
guard let bridge = RCTBridge.current() else { return nil }
|
|
286
|
+
|
|
287
|
+
let surfacePresenter: (any RCTSurfacePresenterStub)? = bridge.surfacePresenter()
|
|
288
|
+
if let surfacePresenter = surfacePresenter,
|
|
289
|
+
let view = surfacePresenter.findComponentView(withTag_DO_NOT_USE_DEPRECATED: tag.intValue) {
|
|
290
|
+
return view
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Legacy (Paper) architecture.
|
|
294
|
+
return bridge.uiManager?.view(forReactTag: tag)
|
|
295
|
+
}
|
|
296
|
+
|
|
207
297
|
@objc(shutdown:withRejecter:)
|
|
208
298
|
func shutdown(resolve:RCTPromiseResolveBlock,
|
|
209
299
|
reject:RCTPromiseRejectBlock) -> Void {
|
|
@@ -370,34 +460,19 @@ class CxSdk: RCTEventEmitter {
|
|
|
370
460
|
func maskViewByTag(viewTag: NSNumber,
|
|
371
461
|
resolve:@escaping RCTPromiseResolveBlock,
|
|
372
462
|
reject:@escaping RCTPromiseRejectBlock) -> Void {
|
|
373
|
-
guard
|
|
463
|
+
guard RCTBridge.current() != nil else {
|
|
374
464
|
reject("MASK_VIEW_ERROR", "no bridge found, aborting maskViewByTag", nil)
|
|
375
465
|
return
|
|
376
466
|
}
|
|
377
467
|
|
|
378
468
|
// View resolution and the cxMask mutation must happen on the main thread.
|
|
379
|
-
DispatchQueue.main.async {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
// surface presenter; without this, masking silently no-ops on Fabric.
|
|
383
|
-
// The presenter is typed non-null but is absent on the legacy
|
|
384
|
-
// architecture, so bind it through an optional before use.
|
|
385
|
-
let surfacePresenter: (any RCTSurfacePresenterStub)? = bridge.surfacePresenter()
|
|
386
|
-
if let surfacePresenter = surfacePresenter,
|
|
387
|
-
let view = surfacePresenter.findComponentView(withTag_DO_NOT_USE_DEPRECATED: viewTag.intValue) {
|
|
388
|
-
view.cxMask = true
|
|
389
|
-
resolve("view with tag \(viewTag) marked for masking")
|
|
390
|
-
return
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Legacy (Paper) architecture.
|
|
394
|
-
if let view = bridge.uiManager?.view(forReactTag: viewTag) {
|
|
395
|
-
view.cxMask = true
|
|
396
|
-
resolve("view with tag \(viewTag) marked for masking")
|
|
469
|
+
DispatchQueue.main.async { [weak self] in
|
|
470
|
+
guard let view = self?.resolveReactView(tag: viewTag) else {
|
|
471
|
+
reject("MASK_VIEW_ERROR", "view with tag \(viewTag) not found, aborting maskViewByTag", nil)
|
|
397
472
|
return
|
|
398
473
|
}
|
|
399
|
-
|
|
400
|
-
|
|
474
|
+
view.cxMask = true
|
|
475
|
+
resolve("view with tag \(viewTag) marked for masking")
|
|
401
476
|
}
|
|
402
477
|
}
|
|
403
478
|
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -6,7 +6,13 @@ import { CoralogixLogSeverity } from './model/Types';
|
|
|
6
6
|
import type { NetworkRequestDetails } from './model/NetworkRequestDetails';
|
|
7
7
|
import type { CustomMeasurement } from './model/CustomMeasurement';
|
|
8
8
|
import { SessionReplayOptions, SessionReplayType } from './model/SessionReplayType';
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The bridge contract, and the source of truth for it: every method here must
|
|
11
|
+
* exist in `ios/CxSdk.swift` and in the Android module with a matching
|
|
12
|
+
* signature. Exported as a type so `nativeModule.ts` can hold the instance
|
|
13
|
+
* without the declaration leaving this file.
|
|
14
|
+
*/
|
|
15
|
+
export interface CxSdkClient {
|
|
10
16
|
initialize(options: CoralogixBrowserSdkConfig & {
|
|
11
17
|
frameworkVersion?: string;
|
|
12
18
|
hasBeforeSend?: boolean;
|
|
@@ -48,7 +54,7 @@ interface CxSdkClient {
|
|
|
48
54
|
startSessionRecording(): void;
|
|
49
55
|
stopSessionRecording(): void;
|
|
50
56
|
captureScreenshot(): void;
|
|
51
|
-
maskViewByTag(viewTag: number):
|
|
57
|
+
maskViewByTag(viewTag: number): Promise<string>;
|
|
52
58
|
reportUserInteraction(interaction: InteractionContext): void;
|
|
53
59
|
startGlobalSpan(name: string, labels: Record<string, any> | null, ignoredInstruments: string[]): Promise<{
|
|
54
60
|
spanId: string;
|
|
@@ -60,7 +66,6 @@ interface CxSdkClient {
|
|
|
60
66
|
} | null>;
|
|
61
67
|
endSpan(spanId: string): Promise<void>;
|
|
62
68
|
}
|
|
63
|
-
export declare const CxSdk: CxSdkClient;
|
|
64
69
|
export declare const CoralogixRum: CoralogixOtelWebType;
|
|
65
70
|
export declare const SessionReplay: SessionReplayType;
|
|
66
71
|
export declare const parseErrorStack: (error: Error) => CoralogixStackFrame[];
|
|
@@ -79,7 +84,7 @@ export { type ApplicationContextConfig } from './model/ApplicationContextConfig'
|
|
|
79
84
|
export { type ViewContextConfig } from './model/ViewContextConfig';
|
|
80
85
|
export { CoralogixDomain } from './model/CoralogixDomain';
|
|
81
86
|
export { type UserContextConfig } from './model/UserContextConfig';
|
|
82
|
-
export { type CoralogixBrowserSdkConfig, type CoralogixIgnoredInstrument, type ExcludableInstrumentation, type NetworkCaptureRule, type TraceExporterData, type EditableCxRumEvent, type BeforeSendResult, CoralogixEventType, CoralogixLogSeverity, } from './model/Types';
|
|
87
|
+
export { type CoralogixBrowserSdkConfig, type CoralogixIgnoredInstrument, type ExcludableInstrumentation, type NetworkCaptureRule, type TraceExporterData, type EditableCxRumEvent, type BeforeSendResult, type InteractionEventContext, CoralogixEventType, CoralogixLogSeverity, } from './model/Types';
|
|
83
88
|
export { type CoralogixOtelWebOptionsInstrumentations } from './model/CoralogixOtelWebOptionsInstrumentations';
|
|
84
89
|
export { type CustomMeasurement } from './model/CustomMeasurement';
|
|
85
90
|
export { type NetworkRequestDetails } from './model/NetworkRequestDetails';
|
|
@@ -88,3 +93,5 @@ export { attachReactNavigationObserver } from './instrumentations/navigation/Nav
|
|
|
88
93
|
export { CoralogixCustomTracer } from './custom-spans/CoralogixCustomTracer';
|
|
89
94
|
export { CoralogixGlobalSpan } from './custom-spans/CoralogixGlobalSpan';
|
|
90
95
|
export { CoralogixCustomSpan } from './custom-spans/CoralogixCustomSpan';
|
|
96
|
+
export { CxSdk } from './nativeModule';
|
|
97
|
+
export { Masked, type MaskedProps } from './masking/Masked';
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { InstrumentationBase } from '@opentelemetry/instrumentation';
|
|
2
2
|
import type { InteractionContext } from '../../model/Types';
|
|
3
|
+
/**
|
|
4
|
+
* Puts the original RN components back.
|
|
5
|
+
*
|
|
6
|
+
* For one caller: a customer who opted out with
|
|
7
|
+
* `instrumentations: { user_interaction: false }`. Patching happens at module
|
|
8
|
+
* load, before any options exist to consult, so the opt-out is honored by undoing
|
|
9
|
+
* it once the options are known. `disable()` deliberately does not do this.
|
|
10
|
+
*/
|
|
11
|
+
export declare function restoreUserInteractionComponents(): void;
|
|
3
12
|
export declare class UserInteractionInstrumentation extends InstrumentationBase {
|
|
13
|
+
private readonly report;
|
|
4
14
|
constructor(config: {
|
|
5
15
|
onInteraction: (ctx: InteractionContext) => void;
|
|
6
16
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare const USER_INTERACTION_INSTRUMENTATION_NAME = "user-interaction";
|
|
2
2
|
export declare const USER_INTERACTION_INSTRUMENTATION_VERSION = "1";
|
|
3
3
|
export declare const SWIPE_MOMENTUM_TIMEOUT_MS = 80;
|
|
4
|
+
export declare const MASKED_INNER_TEXT = "***";
|
|
4
5
|
export declare const SWIPE_VELOCITY_THRESHOLD = 0.5;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export type Direction = 'up' | 'down' | 'left' | 'right';
|
|
2
2
|
export declare function inferDirectionFromDelta(dx: number, dy: number): Direction;
|
|
3
|
-
export
|
|
3
|
+
export type InnerTextExtraction = {
|
|
4
|
+
/** Visible text harvested from the subtree, unredacted. */
|
|
5
|
+
text?: string;
|
|
6
|
+
/** Whether a `Masked` wrapper was found anywhere in the walked subtree. */
|
|
7
|
+
masked: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function extractInnerTextWithMasking(children: any): InnerTextExtraction;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { UserInteractionInstrumentation } from './UserInteractionInstrumentation';
|
|
1
|
+
export { restoreUserInteractionComponents, UserInteractionInstrumentation, } from './UserInteractionInstrumentation';
|
package/src/logger.d.ts
CHANGED
|
@@ -10,6 +10,14 @@ declare class Logger {
|
|
|
10
10
|
debug(message: string, ...args: unknown[]): void;
|
|
11
11
|
info(message: string, ...args: unknown[]): void;
|
|
12
12
|
warn(message: string, ...args: unknown[]): void;
|
|
13
|
+
/**
|
|
14
|
+
* Warns even when debug logging is off.
|
|
15
|
+
*
|
|
16
|
+
* For failures the developer has to know about because nothing else in their
|
|
17
|
+
* app reveals them — masking that did not apply, say, where the cost of
|
|
18
|
+
* staying quiet is sensitive data reaching the backend.
|
|
19
|
+
*/
|
|
20
|
+
alwaysWarn(message: string, ...args: unknown[]): void;
|
|
13
21
|
error(message: string, ...args: unknown[]): void;
|
|
14
22
|
}
|
|
15
23
|
export declare const logger: Logger;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, ViewProps } from 'react-native';
|
|
3
|
+
import type { PropsWithChildren } from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* Props of `Masked`. It renders a real `View`, so it accepts everything a
|
|
6
|
+
* `View` does — `style` included. `onLayout` is chained, not replaced: the
|
|
7
|
+
* handler runs after the view is registered for masking.
|
|
8
|
+
*
|
|
9
|
+
* `collapsable` is not among them: masking depends on this view surviving RN's
|
|
10
|
+
* view flattening, so the component pins it to `false` and the prop would be
|
|
11
|
+
* ignored. Omitted rather than accepted-and-dropped, so the type says so.
|
|
12
|
+
*/
|
|
13
|
+
export type MaskedProps = PropsWithChildren<Omit<ViewProps, 'collapsable'>>;
|
|
14
|
+
/**
|
|
15
|
+
* Masks its subtree in session replay, and tells the user-interaction
|
|
16
|
+
* instrumentation that everything inside is masked.
|
|
17
|
+
*
|
|
18
|
+
* The imperative `SessionReplay.maskView` masks pixels just as well, but it can
|
|
19
|
+
* only be resolved by coordinate: native sees `<Pressable><Text>5</Text></Pressable>`
|
|
20
|
+
* as a ReactViewGroup wrapping a ReactTextView, so masking the inner `<Text>`
|
|
21
|
+
* alone leaves a tap on the pressable's padding resolving as unmasked, shipping
|
|
22
|
+
* the real text. This wrapper closes that gap by making the verdict visible in
|
|
23
|
+
* JS, where the component tree is.
|
|
24
|
+
*
|
|
25
|
+
* Renders a real `View` — it participates in layout, and forwards `style` and
|
|
26
|
+
* the rest of its props. `collapsable={false}` keeps RN view flattening from
|
|
27
|
+
* removing the very view being masked.
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <Pressable onPress={pay}>
|
|
31
|
+
* <Masked>
|
|
32
|
+
* <Text>{cardNumber}</Text>
|
|
33
|
+
* </Masked>
|
|
34
|
+
* </Pressable>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export declare const Masked: React.ForwardRefExoticComponent<Omit<ViewProps, "collapsable"> & {
|
|
38
|
+
children?: React.ReactNode | undefined;
|
|
39
|
+
} & React.RefAttributes<View>>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { LayoutChangeEvent } from 'react-native';
|
|
2
|
+
/**
|
|
3
|
+
* Registers the view that fired the layout event for masking.
|
|
4
|
+
*
|
|
5
|
+
* Failures warn rather than pass silently: masking that does not apply leaves
|
|
6
|
+
* sensitive content in the replay, which is not something a caller can notice
|
|
7
|
+
* by looking at their own code.
|
|
8
|
+
*/
|
|
9
|
+
export declare function maskView(event: LayoutChangeEvent): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Brands a component type as the `Masked` wrapper.
|
|
4
|
+
*
|
|
5
|
+
* Keyed on a `Symbol.for` rather than element identity (`type === Masked`) so
|
|
6
|
+
* recognition survives a consuming app resolving two copies of the plugin: the
|
|
7
|
+
* symbol comes from the global registry, so a `Masked` rendered by one copy is
|
|
8
|
+
* still recognized by the instrumentation of another.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MASKED_COMPONENT_MARKER: unique symbol;
|
|
11
|
+
/**
|
|
12
|
+
* True anywhere inside a `Masked` subtree.
|
|
13
|
+
*
|
|
14
|
+
* Read it with `MaskedContext.Consumer`, never `useContext`: the plugin's React
|
|
15
|
+
* and the renderer's React are different instances in a consuming app, and hooks
|
|
16
|
+
* resolve through the instance-specific ReactCurrentDispatcher. Non-hook APIs
|
|
17
|
+
* (`createContext`, `forwardRef`, `createElement`) are safe under that
|
|
18
|
+
* constraint — the same one the user-interaction wrappers already work under.
|
|
19
|
+
*
|
|
20
|
+
* Cached on the global registry rather than created per module, for the same
|
|
21
|
+
* reason {@link MASKED_COMPONENT_MARKER} is a `Symbol.for`: an app that resolves
|
|
22
|
+
* two copies of the plugin would otherwise get two distinct context objects, and
|
|
23
|
+
* a `Consumer` from one copy reads its own default `false` no matter what a
|
|
24
|
+
* `Masked` from the other copy provided. That fails open — masking dropped and
|
|
25
|
+
* the real text reported — so the two copies have to share one object.
|
|
26
|
+
*/
|
|
27
|
+
export declare const MaskedContext: React.Context<boolean>;
|
|
28
|
+
/**
|
|
29
|
+
* A pressable's record of whether masked content rendered anywhere inside it.
|
|
30
|
+
*
|
|
31
|
+
* The children walk in `extractInnerTextWithMasking` reads the *element* tree —
|
|
32
|
+
* what the caller passed — so it cannot see a `Masked` that a composite renders
|
|
33
|
+
* internally. `<Pressable><SensitiveValue>{card}</SensitiveValue></Pressable>`,
|
|
34
|
+
* where `SensitiveValue` renders `<Masked>` around its children, harvests the
|
|
35
|
+
* card number through `props.children` and never meets the `Masked`. That fails
|
|
36
|
+
* open: the real value ships with the flag omitted.
|
|
37
|
+
*
|
|
38
|
+
* This closes it from the other end. Each instrumented pressable owns one scope,
|
|
39
|
+
* and every `Masked` marks the scope it renders inside, whoever rendered it. The
|
|
40
|
+
* press handler reads the mark, so the verdict comes from what actually
|
|
41
|
+
* rendered rather than from what was passed in.
|
|
42
|
+
*
|
|
43
|
+
* `masked` is sticky. React can re-render a pressable without re-rendering
|
|
44
|
+
* children that did not change, so clearing the mark per render would drop a
|
|
45
|
+
* verdict that nothing goes on to re-assert. Over-masking after a `Masked`
|
|
46
|
+
* genuinely goes away is the direction that leaks nothing.
|
|
47
|
+
*/
|
|
48
|
+
export type MaskScope = {
|
|
49
|
+
masked: boolean;
|
|
50
|
+
/** The enclosing pressable's scope, so a nested `Masked` marks both. */
|
|
51
|
+
parent: MaskScope | null;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The nearest enclosing pressable's {@link MaskScope}, or null outside one.
|
|
55
|
+
*
|
|
56
|
+
* Cached on the global registry for the same reason {@link MaskedContext} is.
|
|
57
|
+
*/
|
|
58
|
+
export declare const MaskScopeContext: React.Context<MaskScope | null>;
|
|
59
|
+
/** Marks `scope` and every scope enclosing it as containing masked content. */
|
|
60
|
+
export declare function markScopeMasked(scope: MaskScope | null): void;
|
|
61
|
+
/**
|
|
62
|
+
* Owns one {@link MaskScope} and provides it to its subtree.
|
|
63
|
+
*
|
|
64
|
+
* A class, not a function component: the scope has to be the same object across
|
|
65
|
+
* re-renders for a mark made during one render to still be readable at press
|
|
66
|
+
* time, and `useRef` is unavailable here — the plugin's React and the renderer's
|
|
67
|
+
* React are different instances in a consuming app, and hooks resolve through
|
|
68
|
+
* the instance-specific ReactCurrentDispatcher. A class instance needs no
|
|
69
|
+
* dispatcher.
|
|
70
|
+
*/
|
|
71
|
+
export declare class MaskScopeBoundary extends React.Component<{
|
|
72
|
+
children: (scope: MaskScope) => React.ReactNode;
|
|
73
|
+
/**
|
|
74
|
+
* Called after every commit, with this boundary's scope.
|
|
75
|
+
*
|
|
76
|
+
* The render callback above is the only place a caller can reach the scope,
|
|
77
|
+
* and it runs during render — so anything with a side effect the app can
|
|
78
|
+
* observe, such as writing a node into a ref the app passed, has to be
|
|
79
|
+
* recorded there and performed here instead.
|
|
80
|
+
*/
|
|
81
|
+
onCommit?: (scope: MaskScope) => void;
|
|
82
|
+
}> {
|
|
83
|
+
static contextType: React.Context<MaskScope | null>;
|
|
84
|
+
private readonly scope;
|
|
85
|
+
componentDidMount(): void;
|
|
86
|
+
componentDidUpdate(): void;
|
|
87
|
+
render(): React.ReactNode;
|
|
88
|
+
}
|
|
89
|
+
/** Whether a React element's `type` is the `Masked` wrapper. */
|
|
90
|
+
export declare function isMaskedComponentType(type: unknown): boolean;
|
package/src/model/Types.d.ts
CHANGED
|
@@ -129,6 +129,57 @@ export interface NetworkRequestContext {
|
|
|
129
129
|
response_content_length: string;
|
|
130
130
|
duration: number;
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* `interaction_context` as it appears on a user-interaction RUM event — the
|
|
134
|
+
* shape `beforeSend` receives. Built natively (field names differ from the
|
|
135
|
+
* {@link InteractionContext} payload JS hands to the bridge).
|
|
136
|
+
*
|
|
137
|
+
* Intentionally editable in `beforeSend` (except `is_masked_element`): a
|
|
138
|
+
* callback may blank the coordinates in `attributes`, redact text fields, or
|
|
139
|
+
* drop the whole event to enforce a masking policy stricter than the default.
|
|
140
|
+
*/
|
|
141
|
+
export interface InteractionEventContext {
|
|
142
|
+
event_name: 'click' | 'scroll' | 'swipe';
|
|
143
|
+
target_element: string;
|
|
144
|
+
/**
|
|
145
|
+
* Whether the interaction targeted content masked in session replay.
|
|
146
|
+
*
|
|
147
|
+
* Resolved from whichever source knows, in order: JS, when a `Masked` wrapper
|
|
148
|
+
* makes the answer visible in the component tree; the plugin's native module,
|
|
149
|
+
* walking the tapped control's own view tree for a `maskView` mark on it, an
|
|
150
|
+
* ancestor or a descendant; and finally the SDK, testing the tap coordinates
|
|
151
|
+
* against the frame's mask geometry — the backstop for masks only it can see,
|
|
152
|
+
* such as a native view layered under the RN tree.
|
|
153
|
+
* `false` is the documented default whenever none of them can resolve it — the
|
|
154
|
+
* SDK under-reports rather than guesses. Scroll and swipe carry no coordinates,
|
|
155
|
+
* so they depend entirely on the second source.
|
|
156
|
+
*
|
|
157
|
+
* Optional because a native SDK older than the version this plugin pins does
|
|
158
|
+
* not stamp it, and the SDK never fabricates a value it did not receive — the
|
|
159
|
+
* same reason `session_context.isSessionSampledIn` is optional. Treat
|
|
160
|
+
* `undefined` as `false`, which is what the native SDKs report when they
|
|
161
|
+
* cannot resolve masking either.
|
|
162
|
+
*
|
|
163
|
+
* Read-only: the SDK's value is restored after `beforeSend` returns, so
|
|
164
|
+
* assigning to it has no effect on the sent event.
|
|
165
|
+
*/
|
|
166
|
+
readonly is_masked_element?: boolean;
|
|
167
|
+
element_classes?: string;
|
|
168
|
+
element_id?: string;
|
|
169
|
+
/** Visible text of the tapped element. `'***'` when the element is masked. */
|
|
170
|
+
target_element_inner_text?: string;
|
|
171
|
+
scroll_direction?: 'up' | 'down' | 'left' | 'right';
|
|
172
|
+
/**
|
|
173
|
+
* Tap coordinates (clicks only). Open-shaped on purpose: a `beforeSend`
|
|
174
|
+
* callback may blank the coordinates or add its own keys (e.g. a
|
|
175
|
+
* masking-strategy stamp) — the native wire type is an arbitrary JSON object.
|
|
176
|
+
*/
|
|
177
|
+
attributes?: {
|
|
178
|
+
x?: number;
|
|
179
|
+
y?: number;
|
|
180
|
+
[key: string]: unknown;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
132
183
|
export interface SnapshotContext {
|
|
133
184
|
timestamp: number;
|
|
134
185
|
errorCount: number;
|
|
@@ -164,6 +215,13 @@ export interface CxRumEvent {
|
|
|
164
215
|
error_context?: ErrorContext;
|
|
165
216
|
log_context?: LogContext;
|
|
166
217
|
network_request_context?: NetworkRequestContext;
|
|
218
|
+
/**
|
|
219
|
+
* Present on user-interaction events. Editable in `beforeSend` — the escape
|
|
220
|
+
* hatch for masking policies stricter than the default (blank the
|
|
221
|
+
* coordinates, or return `null` to drop the event) — except for
|
|
222
|
+
* `is_masked_element`, whose SDK value is restored after the callback.
|
|
223
|
+
*/
|
|
224
|
+
interaction_context?: InteractionEventContext;
|
|
167
225
|
readonly snapshot_context?: SnapshotContext;
|
|
168
226
|
labels: CoralogixRumLabels;
|
|
169
227
|
readonly spanId: string;
|
|
@@ -319,6 +377,21 @@ export type ExcludableInstrumentation = 'errors' | 'logs' | 'network' | 'userInt
|
|
|
319
377
|
export interface TraceExporterData {
|
|
320
378
|
resource_spans: Record<string, unknown>[];
|
|
321
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Interaction payload JS hands to the native bridge.
|
|
382
|
+
*
|
|
383
|
+
* Masking is resolved from three sources whose knowledge is unioned:
|
|
384
|
+
* `is_masked` below, when JS can see the answer in the component tree;
|
|
385
|
+
* {@link InteractionContext.view_tag}, which lets the plugin's native module
|
|
386
|
+
* walk the tapped control's own view tree for a `maskView` mark; and finally
|
|
387
|
+
* the tap coordinates in `attributes`, tested by the SDK against the frame's
|
|
388
|
+
* mask geometry.
|
|
389
|
+
*
|
|
390
|
+
* Scroll and swipe carry no coordinates, so the third source cannot answer for
|
|
391
|
+
* them — but they do carry `view_tag`, so a scroll inside a masked container
|
|
392
|
+
* resolves through the second and reports
|
|
393
|
+
* `interaction_context.is_masked_element: true`.
|
|
394
|
+
*/
|
|
322
395
|
export interface InteractionContext {
|
|
323
396
|
type: 'click' | 'scroll' | 'swipe';
|
|
324
397
|
/** Required. Element identifier: accessibilityLabel ?? componentName for clicks; 'ScrollView' for scroll/swipe. */
|
|
@@ -326,7 +399,43 @@ export interface InteractionContext {
|
|
|
326
399
|
direction?: 'up' | 'down' | 'left' | 'right';
|
|
327
400
|
element_classes?: string;
|
|
328
401
|
target_id?: string;
|
|
402
|
+
/**
|
|
403
|
+
* Extracted string content, click only. Already `'***'` when JS resolved the
|
|
404
|
+
* element as masked — the unredacted text never crosses the bridge.
|
|
405
|
+
*/
|
|
329
406
|
inner_text?: string;
|
|
407
|
+
/**
|
|
408
|
+
* JS's masking verdict, set only when a `Masked` wrapper puts it beyond
|
|
409
|
+
* doubt: a `Masked` ancestor or descendant of the tapped element.
|
|
410
|
+
*
|
|
411
|
+
* Typed `true` because the wire contract is true-or-omit, never false. The key
|
|
412
|
+
* is authoritative in both native SDKs, so sending `false` would suppress
|
|
413
|
+
* their coordinate resolution and lose masks only they can see (an imperative
|
|
414
|
+
* `maskView`, or a native view layered under the RN tree). Absence means "JS
|
|
415
|
+
* doesn't know", which is what lets native decide.
|
|
416
|
+
*/
|
|
417
|
+
is_masked?: true;
|
|
418
|
+
/**
|
|
419
|
+
* The tapped control's native view tag, so the plugin's native module can
|
|
420
|
+
* resolve masking from the control's own view tree.
|
|
421
|
+
*
|
|
422
|
+
* `SessionReplay.maskView` marks a view natively and leaves no trace in the
|
|
423
|
+
* component tree, so JS cannot see it — and native, resolving by coordinate,
|
|
424
|
+
* cannot tell where a control begins and ends, because React Native presses
|
|
425
|
+
* go through the JS responder system and leave no native clickable marker.
|
|
426
|
+
* The tag is the one thing that joins the two: JS knows which component is
|
|
427
|
+
* the control, native knows which views are masked.
|
|
428
|
+
*
|
|
429
|
+
* The *control's* tag, from our own ref, not `nativeEvent.target` — the
|
|
430
|
+
* event's target is the deepest hit-tested view, which scopes the native walk
|
|
431
|
+
* to the wrong subtree when the tap lands on a sibling inside the control.
|
|
432
|
+
* Scroll and swipe are the exception: their events are emitted by the scroll
|
|
433
|
+
* view itself, so there the target is the control.
|
|
434
|
+
*
|
|
435
|
+
* Omitted when {@link is_masked} is already set: the `Masked` path answers in
|
|
436
|
+
* JS and needs no native walk.
|
|
437
|
+
*/
|
|
438
|
+
view_tag?: number;
|
|
330
439
|
attributes?: {
|
|
331
440
|
x?: number;
|
|
332
441
|
y?: number;
|