@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 CHANGED
@@ -1,3 +1,30 @@
1
+ ## 0.11.0 (2026-08-31)
2
+
3
+ ### 🚀 Features
4
+
5
+ - New `Masked` component masks its subtree in session replay and in the interaction metadata of every control inside it
6
+ - Masking now applies to user-interaction events: a tap anywhere in a masked control reports its text as `***`, with both `Masked` and `SessionReplay.maskView`
7
+ - Masked interactions report `interaction_context.is_masked_element`, readable in `beforeSend`
8
+ - Scroll and swipe inside a masked container now report as masked
9
+ - Taps on a `Text` with an `onPress` handler are now reported as user interactions
10
+
11
+ ### 🩹 Fixes
12
+
13
+ - Controls already on screen when the SDK finishes initializing now report their interactions
14
+ - Masking now warns on the console when a mask cannot be registered, instead of failing silently
15
+ - Interaction reporting can no longer break a host app's own `onPress` handler
16
+ - An interaction that carries no coordinates now omits them, instead of reporting them as `NaN`
17
+ - A `<Text>` with no `onPress` renders untouched, keeping the instrumentation off the most numerous component in a typical app
18
+ - Masking registers each view once instead of on every re-measure, so a long list of masked rows no longer repeats the native call per row per layout pass
19
+ - **Behaviour change:** a `beforeSend` callback that deletes `event.interaction_context`, or replaces it with anything that is not an object, no longer gets the SDK's original put back in its place — only the read-only `is_masked_element` flag is kept, on an otherwise empty object. Previously the coordinates and the reported text were restored, which was the opposite of what the callback asked for. If your callback replaced the container and relied on the SDK restoring it, edit the object in place instead — blank the fields you do not want (`interaction_context.target_element_inner_text = '***'`) or delete just those keys, and everything you leave alone is uploaded as it was
20
+
21
+ ## 0.10.3 (2026-08-30)
22
+
23
+ ### 🩹 Fixes
24
+
25
+ - Initialization is now safe to run more than once — no duplicate sessions, duplicate events, or crashes
26
+
27
+
1
28
  ## 0.10.2 (2026-08-25)
2
29
 
3
30
  ### 🩹 Fixes
package/README.md CHANGED
@@ -222,6 +222,10 @@ await CoralogixRum.init({
222
222
  });
223
223
  ```
224
224
 
225
+ > [!NOTE]
226
+ > The network instrumentation also injects the W3C `traceparent` header. Setting `network` to `false`
227
+ > stops trace context propagation as well, even when `traceParentInHeader` is enabled.
228
+
225
229
  ### Mobile Vital Detectors
226
230
 
227
231
  Disable specific mobile vitals detection and collection
@@ -291,6 +295,11 @@ await CoralogixRum.init({
291
295
  });
292
296
  ```
293
297
 
298
+ > [!NOTE]
299
+ > The `traceparent` header is added by the network instrumentation, so this feature depends on it. If
300
+ > you set `instrumentations.network` to `false`, no trace headers are sent even with
301
+ > `enabled: true`.
302
+
294
303
  <!-- /split -->
295
304
  <!-- split title="Custom instrumentation" path="features/custom-instrumentation.md" -->
296
305
  ### Custom Spans
@@ -618,22 +627,49 @@ Shutdown Session Replay to clean up resources:
618
627
  await SessionReplay.shutdown();
619
628
  ```
620
629
 
621
- #### Masking Sensitive Content
630
+ <!-- /split -->
631
+ <!-- split title="Masking sensitive content" path="features/masking.md" -->
632
+ ### Masking Sensitive Content
622
633
 
623
- To mask sensitive content in your app, use the `onLayout` prop with `SessionReplay.maskView` on any View component that should be masked:
634
+ Masking is not gated on Session Replay. It redacts sensitive content from user-interaction events whether or not you record replays, and when Session Replay is enabled the same mask also hides that content in the recording.
624
635
 
625
- ```javascript
636
+ There are two ways to mask, and **`Masked` is recommended**. Mask the whole logical element — the field, the row, the button — rather than an inner fragment of it.
637
+
638
+ #### `Masked` (recommended)
639
+
640
+ `Masked` is exported from the plugin itself and needs no Session Replay setup. Wrap anything sensitive in it — it renders a `View` and accepts the `View` props, so it can wrap a whole control or just the sensitive part of one:
641
+
642
+ ```jsx
643
+ import { Text } from 'react-native';
644
+ import { Masked } from '@coralogix/react-native-plugin';
645
+
646
+ <Masked>
647
+ <Text>Card: {cardNumber}</Text>
648
+ </Masked>
649
+ ```
650
+
651
+ Do not put `Masked` inside a `<Text>` — wrap the outermost `<Text>` instead.
652
+
653
+ #### `SessionReplay.maskView`
654
+
655
+ Alternatively, pass `SessionReplay.maskView` to the `onLayout` prop of any view. It accepts a `LayoutChangeEvent` and works on both the legacy and the New Architecture (Fabric):
656
+
657
+ ```jsx
658
+ import { Text, View } from 'react-native';
626
659
  import { SessionReplay } from '@coralogix/react-native-plugin';
627
660
 
628
661
  <View onLayout={SessionReplay.maskView}>
629
- <Text>This text will be masked in session replay</Text>
630
- <TextInput placeholder="Password" />
662
+ <Text>{cardNumber}</Text>
631
663
  </View>
632
664
  ```
633
665
 
634
- The `SessionReplay.maskView` function accepts a `LayoutChangeEvent` and will mask the view in session replay recordings. It works on both the legacy and the New Architecture (Fabric).
666
+ Attach it to a host component — a custom component that accepts `onLayout` but never forwards it to a real view is silently not masked. Use `Masked` for a `Switch`; `maskView` cannot flag its interactions.
667
+
668
+ #### Masked interactions
635
669
 
670
+ A tap on masked content is still reported, with `interaction_context.target_element_inner_text` redacted to `***` and `interaction_context.is_masked_element: true`. Identity fields and tap coordinates are reported as-is, and a replay recording shows no tap marker. `is_masked_element` is read-only, so use it in `beforeSend` to drop or further redact masked interactions.
636
671
 
672
+ Note that `target_element` falls back to `accessibilityLabel`, which is not redacted — give masked keypad keys a shared label (`accessibilityLabel="pin-key"`) rather than the digit they enter.
637
673
  <!-- /split -->
638
674
  ### Optional - Coralogix Gradle Plugin (Android)
639
675
 
@@ -688,4 +724,3 @@ However, the plugin is the only way to capture network activity from third-party
688
724
  1. `npm install react-native-url-polyfill`
689
725
  2. At the top of your entry-point file (index.js) add:
690
726
  `import "react-native-url-polyfill/auto"`
691
- ---
@@ -84,7 +84,10 @@ dependencies {
84
84
  implementation "com.facebook.react:react-android"
85
85
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
86
86
 
87
- implementation "com.coralogix:android-sdk:2.21.1"
87
+ // 2.22.0 is the first release exposing View.isCoralogixMasked() and
88
+ // View.isInsideCoralogixMaskedSubtree(), which the masked-interaction walk in
89
+ // reportUserInteraction reads instead of reimplementing the SDK's mask rule here.
90
+ implementation "com.coralogix:android-sdk:2.22.0"
88
91
 
89
92
  testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
90
93
  testImplementation "org.mockito:mockito-core:5.12.0"
@@ -4,6 +4,8 @@ import android.app.Application
4
4
  import android.os.Handler
5
5
  import android.os.Looper
6
6
  import android.util.Log
7
+ import android.view.View
8
+ import android.view.ViewGroup
7
9
  import com.coralogix.android.sdk.CoralogixRum
8
10
  import com.coralogix.android.sdk.customspans.CoralogixCustomSpan
9
11
  import com.coralogix.android.sdk.customspans.CoralogixGlobalSpan
@@ -29,6 +31,8 @@ import com.coralogix.android.sdk.model.UserContext
29
31
  import com.coralogix.android.sdk.model.ViewContext
30
32
  import com.coralogix.android.sdk.session_replay.SessionReplay
31
33
  import com.coralogix.android.sdk.session_replay.SessionReplay.maskView
34
+ import com.coralogix.android.sdk.session_replay.isCoralogixMasked
35
+ import com.coralogix.android.sdk.session_replay.isInsideCoralogixMaskedSubtree
32
36
  import com.coralogix.android.sdk.session_replay.model.SessionReplayOptions
33
37
  import com.facebook.react.bridge.Arguments
34
38
  import com.facebook.react.bridge.Promise
@@ -265,8 +269,114 @@ class CxSdkModule(reactContext: ReactApplicationContext) :
265
269
  innerText = if (interaction.hasKey("inner_text") && !interaction.isNull("inner_text")) interaction.getString("inner_text") else null,
266
270
  x = if (attributesMap?.hasKey("x") == true && attributesMap.isNull("x") == false) attributesMap.getDouble("x") else null,
267
271
  y = if (attributesMap?.hasKey("y") == true && attributesMap.isNull("y") == false) attributesMap.getDouble("y") else null,
272
+ // Present only when JS resolved masking from the component tree. The SDK treats a
273
+ // non-null value as authoritative and otherwise resolves the coordinates itself,
274
+ // which is why JS omits the key rather than sending false.
275
+ isMasked = if (interaction.hasKey("is_masked") && !interaction.isNull("is_masked")) interaction.getBoolean("is_masked") else null,
268
276
  )
269
- CoralogixRum.reportUserInteraction(details)
277
+
278
+ // The tapped control's view tag, sent only when JS could not answer for
279
+ // itself. It is what lets the walk below see an imperative maskView: that
280
+ // marks a native view and leaves nothing in the component tree, so JS is
281
+ // blind to it, while the SDK's own coordinate test cannot tell where the
282
+ // control begins and ends.
283
+ val viewTag = if (interaction.hasKey("view_tag") && !interaction.isNull("view_tag"))
284
+ interaction.getInt("view_tag") else null
285
+
286
+ // JS sends one or the other, never both — a verdict it already has needs no
287
+ // native walk. Checked rather than assumed, because the walk is the expensive
288
+ // half and an authoritative isMasked would make it wasted work.
289
+ if (viewTag == null || details.isMasked != null) {
290
+ CoralogixRum.reportUserInteraction(details)
291
+ return
292
+ }
293
+
294
+ // Resolving a view and reading its mask tags both require the UI thread, so
295
+ // reporting becomes asynchronous on this path. This method otherwise runs on
296
+ // the native-modules thread and reports inline, which means an interaction
297
+ // carrying a tag can be reported after one that arrived later without a tag.
298
+ // Interaction spans carry their own timestamps, so ordering is presentational
299
+ // rather than semantic.
300
+ UiThreadUtil.runOnUiThread {
301
+ // Set only when the walk finds a mask; never set to false. A non-null
302
+ // value is authoritative in the SDK, so a false here would suppress its
303
+ // coordinate resolution and lose masks the walk cannot see — a masked
304
+ // overlay that is neither ancestor nor descendant of the control. The SDK
305
+ // redacts the reported inner text off this flag, so nothing else is needed.
306
+ //
307
+ // Descendants count for a click only. A click reports the control's whole
308
+ // concatenated text, so a masked descendant has to mask the click to keep
309
+ // the verdict and the text consistent. A scroll reports no text at all, so
310
+ // the descendant half would buy nothing there and cost plenty: one masked
311
+ // row anywhere in a list would report every scroll of that list as masked.
312
+ val masked = isControlMasked(viewTag, includeDescendants = details.type == "click")
313
+ val resolved = if (masked) details.copy(isMasked = true) else details
314
+
315
+ // Straight back off the UI thread. Only the walk above needs it, while
316
+ // building and handing over the span does not — and every unmasked click
317
+ // and every scroll carries a tag, so leaving the reporting here would move
318
+ // the whole interaction path onto the main thread for the majority case
319
+ // that has nothing to resolve.
320
+ reactApplicationContext.runOnNativeModulesQueueThread {
321
+ CoralogixRum.reportUserInteraction(resolved)
322
+ }
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Whether anything in the tapped control is masked: the view itself, an
328
+ * ancestor, or — when [includeDescendants] is set — anything below it.
329
+ *
330
+ * Ancestors always count, because masking is inherited: a control inside a
331
+ * masked panel is masked. Descendants count for a click, because React Native
332
+ * renders one logical control as several native views, so
333
+ * `<TouchableOpacity><Text/></TouchableOpacity>` puts the mask on a child and
334
+ * leaves the tapped container itself unmarked.
335
+ *
336
+ * An unresolvable tag answers false, which omits the flag and leaves the SDK's
337
+ * coordinate resolution as the backstop — the right outcome for the race this
338
+ * is most exposed to, a control unmounted between the press and this walk.
339
+ */
340
+ private fun isControlMasked(viewTag: Int, includeDescendants: Boolean): Boolean = try {
341
+ val view = UIManagerHelper.getUIManagerForReactTag(reactApplicationContext, viewTag)
342
+ ?.resolveView(viewTag)
343
+ // Self and ancestors through the SDK's own accessor, so the definition of
344
+ // masked lives in one place.
345
+ view != null &&
346
+ (view.isInsideCoralogixMaskedSubtree() ||
347
+ (includeDescendants && containsMaskedDescendant(view)))
348
+ } catch (t: Throwable) {
349
+ Log.d("CxSdkModule", "reportUserInteraction: could not resolve view $viewTag for masking", t)
350
+ false
351
+ }
352
+
353
+ /**
354
+ * Breadth-first descendant search for a mask mark, iterative because recursion
355
+ * on an app-controlled depth is a stack overflow waiting to happen.
356
+ *
357
+ * Bounded by [MASK_WALK_NODE_BUDGET]: this runs on the UI thread for every
358
+ * interaction, so a deep subtree — a long list row, a heavily nested
359
+ * design-system component — must not be able to cause jank. Exhausting the
360
+ * budget answers false, leaving the SDK's coordinate resolution as the backstop
361
+ * rather than claiming an answer the walk never reached.
362
+ */
363
+ private fun containsMaskedDescendant(root: View): Boolean {
364
+ val queue = ArrayDeque<View>()
365
+ root.enqueueChildren(queue)
366
+
367
+ var visited = 0
368
+ while (queue.isNotEmpty()) {
369
+ if (visited++ >= MASK_WALK_NODE_BUDGET) return false
370
+ val current = queue.removeFirst()
371
+ if (current.isCoralogixMasked()) return true
372
+ current.enqueueChildren(queue)
373
+ }
374
+ return false
375
+ }
376
+
377
+ private fun View.enqueueChildren(queue: ArrayDeque<View>) {
378
+ val group = this as? ViewGroup ?: return
379
+ for (i in 0 until group.childCount) queue.addLast(group.getChildAt(i))
270
380
  }
271
381
 
272
382
  @ReactMethod
@@ -327,7 +437,7 @@ class CxSdkModule(reactContext: ReactApplicationContext) :
327
437
  }
328
438
 
329
439
  @ReactMethod
330
- override fun maskViewByTag(viewTag: Int) {
440
+ override fun maskViewByTag(viewTag: Int, promise: Promise) {
331
441
  // Resolve the view through the architecture-agnostic UIManagerHelper so this
332
442
  // works on both the legacy (Paper) and New (Fabric) architectures.
333
443
  // getNativeModule(UIManagerModule) only exists on Paper; under Fabric it
@@ -337,12 +447,23 @@ class CxSdkModule(reactContext: ReactApplicationContext) :
337
447
  val context = reactApplicationContext
338
448
  // FabricUIManager.resolveView must be called on the UI thread.
339
449
  UiThreadUtil.runOnUiThread {
450
+ // Rejections are the only signal the JS side has: masking that does not
451
+ // apply is invisible in the app, so an unresolvable tag has to travel back
452
+ // rather than be swallowed here.
340
453
  try {
341
454
  val uiManager = UIManagerHelper.getUIManagerForReactTag(context, viewTag)
342
455
  val view = uiManager?.resolveView(viewTag)
343
- view?.maskView()
456
+ if (view == null) {
457
+ promise.reject(
458
+ MASK_VIEW_ERROR,
459
+ "view with tag $viewTag could not be resolved, aborting maskViewByTag"
460
+ )
461
+ return@runOnUiThread
462
+ }
463
+ view.maskView()
464
+ promise.resolve("view with tag $viewTag marked for masking")
344
465
  } catch (t: Throwable) {
345
- t.printStackTrace()
466
+ promise.reject(MASK_VIEW_ERROR, t)
346
467
  }
347
468
  }
348
469
  }
@@ -950,6 +1071,14 @@ class CxSdkModule(reactContext: ReactApplicationContext) :
950
1071
  companion object {
951
1072
  const val NAME = "CxSdk"
952
1073
 
1074
+ // Matches the iOS bridge's rejection code for the same failure.
1075
+ private const val MASK_VIEW_ERROR = "MASK_VIEW_ERROR"
1076
+
1077
+ // Node budget for the descendant half of the masked-control walk. Kept in
1078
+ // step with the iOS bridge's maskWalkNodeBudget so the two platforms give up
1079
+ // at the same tree size.
1080
+ private const val MASK_WALK_NODE_BUDGET = 512
1081
+
953
1082
  // TODO: replace reflection with a public accessor once the Coralogix Android SDK exposes one —
954
1083
  // 'getSpan$library_release' is a Kotlin-internal name that may be renamed in a future SDK version.
955
1084
  internal fun extractOtelIds(child: CoralogixCustomSpan): Pair<String, String>? =
@@ -11,5 +11,5 @@ interface SessionReplayClient {
11
11
  fun startSessionRecording()
12
12
  fun stopSessionRecording()
13
13
  fun captureScreenshot()
14
- fun maskViewByTag(viewTag: Int)
14
+ fun maskViewByTag(viewTag: Int, promise: Promise)
15
15
  }