@coralogix/react-native-plugin 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- let payload = dictionary
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?.coralogixRum else {
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 let bridge = RCTBridge.current() else {
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
- // New Architecture (Fabric): mounted views live in the component-view
381
- // registry, not the legacy RCTUIManager registry. Resolve them via the
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
- reject("MASK_VIEW_ERROR", "view with tag \(viewTag) not found, aborting maskViewByTag", nil)
474
+ view.cxMask = true
475
+ resolve("view with tag \(viewTag) marked for masking")
401
476
  }
402
477
  }
403
478
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coralogix/react-native-plugin",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Official Coralogix React Native plugin",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Coralogix",
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
- interface CxSdkClient {
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): void;
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 declare function extractInnerText(children: any): string | undefined;
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;
@@ -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;
@@ -260,7 +318,17 @@ export interface CoralogixBrowserSdkConfig {
260
318
  fpsSamplingSeconds?: number;
261
319
  /** Sets a value for the `environment` attribute */
262
320
  environment?: string;
263
- /** Percentage of overall sessions being tracked, defaults to 100% */
321
+ /**
322
+ * Percentage of overall sessions being tracked, defaults to 100%.
323
+ *
324
+ * The rate is applied exactly once. Without `excludeFromSampling` the plugin
325
+ * rolls it and the native SDK is told 100; with `excludeFromSampling` set the
326
+ * native SDK rolls it, since only native can emit the excluded categories for a
327
+ * sampled-out session.
328
+ *
329
+ * Expressed as a whole percentage in [0, 100]; a fractional value is rounded
330
+ * (never down to 0) because the native bridges read it as an integer.
331
+ */
264
332
  sessionSampleRate?: number;
265
333
  /**
266
334
  * Event categories that bypass session sampling. Events of these categories are
@@ -309,6 +377,21 @@ export type ExcludableInstrumentation = 'errors' | 'logs' | 'network' | 'userInt
309
377
  export interface TraceExporterData {
310
378
  resource_spans: Record<string, unknown>[];
311
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
+ */
312
395
  export interface InteractionContext {
313
396
  type: 'click' | 'scroll' | 'swipe';
314
397
  /** Required. Element identifier: accessibilityLabel ?? componentName for clicks; 'ScrollView' for scroll/swipe. */
@@ -316,7 +399,43 @@ export interface InteractionContext {
316
399
  direction?: 'up' | 'down' | 'left' | 'right';
317
400
  element_classes?: string;
318
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
+ */
319
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;
320
439
  attributes?: {
321
440
  x?: number;
322
441
  y?: number;
@@ -0,0 +1,2 @@
1
+ import type { CxSdkClient } from './index';
2
+ export declare const CxSdk: CxSdkClient;
package/src/utils.d.ts CHANGED
@@ -1,5 +1,23 @@
1
1
  import { CoralogixBrowserSdkConfig } from './model/Types';
2
2
  export declare const hrTimeToMilliseconds: ([seconds, nanoseconds]: [number, number]) => number;
3
3
  export declare const getUrlFragments: (url: string) => string;
4
+ /**
5
+ * Rolls the session sampling dice.
6
+ *
7
+ * This is the only roll for a config without `excludeFromSampling`: `init` pins the
8
+ * rate it sends to native to 100 in that case, so native does not roll again. See the
9
+ * comment in `CoralogixRum.init` for why the exclude case is handled the other way round.
10
+ */
4
11
  export declare function isSamplingOn(threshold: number): boolean;
12
+ /**
13
+ * Normalizes `sessionSampleRate` into the shape both native SDKs expect: an
14
+ * integer percentage in [0, 100].
15
+ *
16
+ * The bridges read the value as an integer (`NSNumber.intValue` on iOS,
17
+ * `ReadableMap.getInt` on Android), so a fractional rate such as `0.5` would be
18
+ * truncated to `0` on iOS — silently sampling every session out — and can throw
19
+ * on Android. Round instead, and never round a caller-requested non-zero rate
20
+ * down to `0`: `0` is the one value that means "emit nothing".
21
+ */
22
+ export declare function normalizeSessionSampleRate(rate: unknown): number;
5
23
  export declare function resolveCoralogixOtelWebConfig(config: CoralogixBrowserSdkConfig): CoralogixBrowserSdkConfig;