From e83ad53c2d6673eb77779ec58820ab27f92e643d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:53:04 +0000 Subject: [PATCH 1/3] perf(replay): Use hash set for mask view class lookups maskViewClasses and unmaskViewClasses were backed by a CopyOnWriteArraySet, whose contains() is a linear scan. The view hierarchy traversal checks both sets for every superclass of every view on the main thread, so capture cost grew with the size of the configured class lists. Back both sets with a ConcurrentHashMap key set for O(1) lookups while keeping thread-safe, non-throwing iteration. Co-Authored-By: Roman Zavarnitsyn --- .../replay/capture/SessionCaptureStrategyTest.kt | 15 +++++---------- .../main/java/io/sentry/SentryMaskingOptions.java | 7 ++++--- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 281eb1da3be..4c31090cee9 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -46,7 +46,6 @@ import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.TimeUnit.SECONDS import kotlin.test.Test -import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -696,8 +695,8 @@ class SessionCaptureStrategyTest { assertEquals(true, optionsEvent[0].optionsPayload["maskAllText"]) assertEquals(false, optionsEvent[0].optionsPayload["maskAllImages"]) assertEquals("high", optionsEvent[0].optionsPayload["quality"]) - assertContentEquals( - listOf( + assertThat(optionsEvent[0].optionsPayload["maskedViewClasses"] as Collection<*>) + .containsExactly( "android.widget.TextView", "android.webkit.WebView", "android.widget.VideoView", @@ -706,13 +705,9 @@ class SessionCaptureStrategyTest { "com.google.android.exoplayer2.ui.PlayerView", "com.google.android.exoplayer2.ui.StyledPlayerView", "my.custom.View", - ), - optionsEvent[0].optionsPayload["maskedViewClasses"] as Collection<*>, - ) - assertContentEquals( - listOf("android.widget.ImageView"), - optionsEvent[0].optionsPayload["unmaskedViewClasses"] as Collection<*>, - ) + ) + assertThat(optionsEvent[0].optionsPayload["unmaskedViewClasses"] as Collection<*>) + .containsExactly("android.widget.ImageView") }, ) } diff --git a/sentry/src/main/java/io/sentry/SentryMaskingOptions.java b/sentry/src/main/java/io/sentry/SentryMaskingOptions.java index dd7c5f9a304..c1e89f93948 100644 --- a/sentry/src/main/java/io/sentry/SentryMaskingOptions.java +++ b/sentry/src/main/java/io/sentry/SentryMaskingOptions.java @@ -1,7 +1,8 @@ package io.sentry; +import java.util.Collections; import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -32,7 +33,7 @@ public abstract class SentryMaskingOptions { * *

Default is empty. */ - protected Set maskViewClasses = new CopyOnWriteArraySet<>(); + protected Set maskViewClasses = Collections.newSetFromMap(new ConcurrentHashMap<>()); /** * Ignore all views with the specified class names from masking. The class name is the fully @@ -44,7 +45,7 @@ public abstract class SentryMaskingOptions { * *

Default is empty. */ - protected Set unmaskViewClasses = new CopyOnWriteArraySet<>(); + protected Set unmaskViewClasses = Collections.newSetFromMap(new ConcurrentHashMap<>()); /** The class name of the view container that masks all of its children. */ protected @Nullable String maskViewContainerClass = null; From 0da182e2cbcc51b60fccdbea0aa2d7b74b1768cb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:53:30 +0000 Subject: [PATCH 2/3] changelog Co-Authored-By: Roman Zavarnitsyn --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d16525733..68ac4ae42ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Added `Environment` to the `Scope`, allowing you to overwrite the `Environment` set on the options ([#5772](https://github.com/getsentry/sentry-java/pull/5772)) - The scope `Environment` is also applied to the trace envelope header (DSC), logs, metrics and profiles, so all telemetry of a trace reports the same environment +### Improvements + +- Speed up Session Replay and screenshot masking for large `maskViewClasses`/`unmaskViewClasses` lists by using constant-time class lookups ([#6410](https://github.com/getsentry/sentry-java/pull/6410)) + ### Fixes - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) From 6d6fdb65a4938504df8df524d1a135f2e762e66f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Oct 2026 22:49:56 +0100 Subject: [PATCH 3/3] fix(replay): Ignore null mask view class names ConcurrentHashMap-backed sets reject null keys, while the previous CopyOnWriteArraySet accepted them. Hybrid SDKs (e.g. React Native via ReadableArray.getString) can pass null despite the @NotNull annotation, which would now throw during SDK init. Co-Authored-By: Claude Opus 5.5 --- .../java/io/sentry/SentryMaskingOptions.java | 8 ++++++++ .../io/sentry/SentryMaskingOptionsTest.kt | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/sentry/src/main/java/io/sentry/SentryMaskingOptions.java b/sentry/src/main/java/io/sentry/SentryMaskingOptions.java index c1e89f93948..44edff39a3d 100644 --- a/sentry/src/main/java/io/sentry/SentryMaskingOptions.java +++ b/sentry/src/main/java/io/sentry/SentryMaskingOptions.java @@ -93,6 +93,10 @@ public Set getMaskViewClasses() { } public void addMaskViewClass(final @NotNull String className) { + // Hybrid SDKs may pass null despite the annotation, and the backing set rejects null keys + if (className == null) { + return; + } this.maskViewClasses.add(className); this.unmaskViewClasses.remove(className); } @@ -103,6 +107,10 @@ public Set getUnmaskViewClasses() { } public void addUnmaskViewClass(final @NotNull String className) { + // Hybrid SDKs may pass null despite the annotation, and the backing set rejects null keys + if (className == null) { + return; + } this.unmaskViewClasses.add(className); this.maskViewClasses.remove(className); } diff --git a/sentry/src/test/java/io/sentry/SentryMaskingOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryMaskingOptionsTest.kt index 9f661710dc7..3f2a62361eb 100644 --- a/sentry/src/test/java/io/sentry/SentryMaskingOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryMaskingOptionsTest.kt @@ -40,6 +40,26 @@ class SentryMaskingOptionsTest { assertTrue(options.unmaskViewClasses.contains("com.example.MyView")) } + @Test + fun `addMaskViewClass ignores null`() { + val options = TestMaskingOptions() + SentryMaskingOptions::class + .java + .getMethod("addMaskViewClass", String::class.java) + .invoke(options, null) + assertTrue(options.maskViewClasses.isEmpty()) + } + + @Test + fun `addUnmaskViewClass ignores null`() { + val options = TestMaskingOptions() + SentryMaskingOptions::class + .java + .getMethod("addUnmaskViewClass", String::class.java) + .invoke(options, null) + assertTrue(options.unmaskViewClasses.isEmpty()) + } + @Test fun `setMaskAllText true adds TextView to maskViewClasses`() { val options = TestMaskingOptions()