Skip to content

perf(executor): Prewarm SentryExecutorService #4606

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 47 commits into from
Aug 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2a0d6da
perf(connectivity): Cache network capabilities and status to reduce I…
romtsn Jul 16, 2025
0ac3081
Merge branch 'main' into rz/perf/less-ipc
romtsn Jul 16, 2025
14bb2d5
changelog
romtsn Jul 16, 2025
c82586d
Changelog
romtsn Jul 16, 2025
7418781
revert
romtsn Jul 16, 2025
f739d00
fix(breadcrumbs): Deduplicate battery breadcrumbs
romtsn Jul 16, 2025
833b026
ref
romtsn Jul 16, 2025
e4596ff
Changelog
romtsn Jul 16, 2025
0153ab5
Fix test
romtsn Jul 17, 2025
15794c6
Merge branch 'rz/perf/less-ipc' into rz/fix/diff-battery-crumbs
romtsn Jul 17, 2025
c31d648
perf(connectivity): Have only one NetworkCallback active at a time
romtsn Jul 18, 2025
83d80d7
Changelog
romtsn Jul 18, 2025
5b32662
perf(integrations): Use single lifecycle observer
romtsn Jul 23, 2025
5c5238a
Add tests
romtsn Jul 24, 2025
d2263b8
Changelog
romtsn Jul 24, 2025
42ff8e9
Fix tests
romtsn Jul 24, 2025
fde11a2
perf(integrations): Do not register for SystemEvents and NetworkCallb…
romtsn Jul 29, 2025
bba4efe
Do not cache importance
romtsn Jul 30, 2025
5edb0d3
Revert SR
romtsn Jul 30, 2025
d8402b6
Add tests
romtsn Jul 30, 2025
b7a5e0a
Spotless
romtsn Jul 30, 2025
c7cad14
Comment
romtsn Jul 30, 2025
8a6f145
Revert profiling
romtsn Jul 30, 2025
d868d1a
Changelog
romtsn Jul 30, 2025
c890fe3
fix test name
romtsn Jul 30, 2025
900ecb5
Update sentry-samples/sentry-samples-android/sdkperf/README.md
romtsn Jul 30, 2025
ac613d6
Update sentry-samples/sentry-samples-android/sdkperf/README.md
romtsn Jul 30, 2025
85f5bae
Update sentry-samples/sentry-samples-android/sdkperf/README.md
romtsn Jul 30, 2025
3f3a499
Update sentry-samples/sentry-samples-android/sdkperf/screen_flap.sh
romtsn Aug 1, 2025
c231dfc
Update sentry-samples/sentry-samples-android/sdkperf/wifi_flap.sh
romtsn Aug 1, 2025
cda0b1d
Address PR review
romtsn Aug 1, 2025
f7179a5
Formatting
romtsn Aug 1, 2025
0915d81
use LazyEvaluator for empty options and timer
romtsn Aug 4, 2025
297e1f3
Pre-allocate extras dictionary with the actual size
romtsn Aug 4, 2025
9b55d06
Prewarm SentryExecutorService and make it bounded
romtsn Aug 4, 2025
d20ab34
Use HandlerThread for receiving broadcasts
romtsn Aug 5, 2025
a5702e0
Changelog
romtsn Aug 5, 2025
a0e161a
Update registerReceiver method calls to match Android SDK changes
cursoragent Aug 5, 2025
74bc8a6
Format code
getsentry-bot Aug 5, 2025
026d34a
Merge branch 'main' into rz/perf/enhanced-executor-service
romtsn Aug 6, 2025
c2447df
Add a comment
romtsn Aug 6, 2025
36482b9
Do not leak HandlerThread in SystemEventsReceiver
romtsn Aug 6, 2025
194ee54
Null-check handlerThread
romtsn Aug 6, 2025
9021c78
Clear the queue in a different task
romtsn Aug 6, 2025
0e6a1f4
Formatting
romtsn Aug 6, 2025
face071
Cancel tasks and purge them
romtsn Aug 7, 2025
d46bbc0
Merge branch 'main' into rz/perf/enhanced-executor-service
romtsn Aug 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Session Replay: Use main thread looper to schedule replay capture ([#4542](https://github.com/getsentry/sentry-java/pull/4542))
- Use single `LifecycleObserver` and multi-cast it to the integrations interested in lifecycle states ([#4567](https://github.com/getsentry/sentry-java/pull/4567))
- Prewarm `SentryExecutorService` for better performance at runtime ([#4606](https://github.com/getsentry/sentry-java/pull/4606))

### Fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Handler;
import android.util.DisplayMetrics;
import io.sentry.ILogger;
import io.sentry.SentryLevel;
Expand Down Expand Up @@ -455,8 +456,10 @@ public static boolean appIsLibraryForComposePreview(final @NotNull Context conte
final @NotNull Context context,
final @NotNull SentryOptions options,
final @Nullable BroadcastReceiver receiver,
final @NotNull IntentFilter filter) {
return registerReceiver(context, new BuildInfoProvider(options.getLogger()), receiver, filter);
final @NotNull IntentFilter filter,
final @Nullable Handler handler) {
return registerReceiver(
context, new BuildInfoProvider(options.getLogger()), receiver, filter, handler);
}

/** Register an exported BroadcastReceiver, independently from platform version. */
Expand All @@ -465,15 +468,17 @@ public static boolean appIsLibraryForComposePreview(final @NotNull Context conte
final @NotNull Context context,
final @NotNull BuildInfoProvider buildInfoProvider,
final @Nullable BroadcastReceiver receiver,
final @NotNull IntentFilter filter) {
final @NotNull IntentFilter filter,
final @Nullable Handler handler) {
if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) {
// From https://developer.android.com/guide/components/broadcasts#context-registered-receivers
// If this receiver is listening for broadcasts sent from the system or from other apps, even
// other apps that you own—use the RECEIVER_EXPORTED flag. If instead this receiver is
// listening only for broadcasts sent by your app, use the RECEIVER_NOT_EXPORTED flag.
return context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED);
return context.registerReceiver(
receiver, filter, null, handler, Context.RECEIVER_NOT_EXPORTED);
} else {
return context.registerReceiver(receiver, filter);
return context.registerReceiver(receiver, filter, null, handler);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ private Date getBootTime() {
@Nullable
private Intent getBatteryIntent() {
return ContextUtils.registerReceiver(
context, buildInfoProvider, null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
context, buildInfoProvider, null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED), null);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.sentry.transport.CurrentDateProvider;
import io.sentry.transport.ICurrentDateProvider;
import io.sentry.util.AutoClosableReentrantLock;
import io.sentry.util.LazyEvaluator;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.atomic.AtomicLong;
Expand All @@ -22,7 +23,7 @@ final class LifecycleWatcher implements AppState.AppStateListener {
private final long sessionIntervalMillis;

private @Nullable TimerTask timerTask;
private final @NotNull Timer timer = new Timer(true);
private final @NotNull LazyEvaluator<Timer> timer = new LazyEvaluator<>(() -> new Timer(true));
private final @NotNull AutoClosableReentrantLock timerLock = new AutoClosableReentrantLock();
private final @NotNull IScopes scopes;
private final boolean enableSessionTracking;
Expand Down Expand Up @@ -105,21 +106,19 @@ public void onBackground() {
private void scheduleEndSession() {
try (final @NotNull ISentryLifecycleToken ignored = timerLock.acquire()) {
cancelTask();
if (timer != null) {
timerTask =
new TimerTask() {
@Override
public void run() {
if (enableSessionTracking) {
scopes.endSession();
}
scopes.getOptions().getReplayController().stop();
scopes.getOptions().getContinuousProfiler().close(false);
timerTask =
new TimerTask() {
@Override
public void run() {
if (enableSessionTracking) {
scopes.endSession();
}
};
scopes.getOptions().getReplayController().stop();
scopes.getOptions().getContinuousProfiler().close(false);
}
};

timer.schedule(timerTask, sessionIntervalMillis);
}
timer.getValue().schedule(timerTask, sessionIntervalMillis);
}
}

Expand Down Expand Up @@ -152,6 +151,6 @@ TimerTask getTimerTask() {
@TestOnly
@NotNull
Timer getTimer() {
return timer;
return timer.getValue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Process;
import io.sentry.Breadcrumb;
import io.sentry.Hint;
import io.sentry.IScopes;
Expand Down Expand Up @@ -63,6 +66,7 @@ public final class SystemEventsBreadcrumbsIntegration
private volatile boolean isClosed = false;
private volatile boolean isStopped = false;
private volatile IntentFilter filter = null;
private volatile HandlerThread handlerThread = null;
private final @NotNull AtomicBoolean isReceiverRegistered = new AtomicBoolean(false);
private final @NotNull AutoClosableReentrantLock receiverLock = new AutoClosableReentrantLock();
// Track previous battery state to avoid duplicate breadcrumbs when values haven't changed
Expand Down Expand Up @@ -138,10 +142,19 @@ private void registerReceiver(
filter.addAction(item);
}
}
if (handlerThread == null) {
handlerThread =
new HandlerThread(
"SystemEventsReceiver", Process.THREAD_PRIORITY_BACKGROUND);
handlerThread.start();
}
try {
// registerReceiver can throw SecurityException but it's not documented in the
// official docs
ContextUtils.registerReceiver(context, options, receiver, filter);

// onReceive will be called on this handler thread
final @NotNull Handler handler = new Handler(handlerThread.getLooper());
ContextUtils.registerReceiver(context, options, receiver, filter, handler);
if (!isReceiverRegistered.getAndSet(true)) {
options
.getLogger()
Expand Down Expand Up @@ -195,6 +208,10 @@ public void close() throws IOException {
try (final @NotNull ISentryLifecycleToken ignored = receiverLock.acquire()) {
isClosed = true;
filter = null;
if (handlerThread != null) {
handlerThread.quit();
}
handlerThread = null;
}

AppState.getInstance().removeAppStateListener(this);
Expand Down Expand Up @@ -293,25 +310,15 @@ public void onReceive(final Context context, final @NotNull Intent intent) {

final BatteryState state = batteryState;
final long now = System.currentTimeMillis();
try {
options
.getExecutorService()
.submit(
() -> {
final Breadcrumb breadcrumb = createBreadcrumb(now, intent, action, state);
final Hint hint = new Hint();
hint.set(ANDROID_INTENT, intent);
scopes.addBreadcrumb(breadcrumb, hint);
});
} catch (Throwable t) {
// ignored
}
final Breadcrumb breadcrumb = createBreadcrumb(now, intent, action, state);
final Hint hint = new Hint();
hint.set(ANDROID_INTENT, intent);
scopes.addBreadcrumb(breadcrumb, hint);
}

// in theory this should be ThreadLocal, but we won't have more than 1 thread accessing it,
// so we save some memory here and CPU cycles. 64 is because all intent actions we subscribe for
// are less than 64 chars. We also don't care about encoding as those are always UTF.
// TODO: _MULTI_THREADED_EXECUTOR_
private final char[] buf = new char[64];

@TestOnly
Expand Down Expand Up @@ -365,8 +372,8 @@ String getStringAfterDotFast(final @Nullable String str) {
}
} else {
final Bundle extras = intent.getExtras();
final Map<String, String> newExtras = new HashMap<>();
if (extras != null && !extras.isEmpty()) {
final Map<String, String> newExtras = new HashMap<>(extras.size());
for (String item : extras.keySet()) {
try {
@SuppressWarnings("deprecation")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ class AndroidProfilerTest {
override fun close(timeoutMillis: Long) {}

override fun isClosed() = false

override fun prewarm() = Unit
}

val options =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ class AndroidTransactionProfilerTest {
override fun close(timeoutMillis: Long) {}

override fun isClosed() = false

override fun prewarm() = Unit
}

val options =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import kotlin.test.assertTrue
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
Expand Down Expand Up @@ -221,8 +222,8 @@ class ContextUtilsTest {
val filter = mock<IntentFilter>()
val context = mock<Context>()
whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.S)
ContextUtils.registerReceiver(context, buildInfo, receiver, filter)
verify(context).registerReceiver(eq(receiver), eq(filter))
ContextUtils.registerReceiver(context, buildInfo, receiver, filter, null)
verify(context).registerReceiver(eq(receiver), eq(filter), isNull(), isNull())
}

@Test
Expand All @@ -232,8 +233,15 @@ class ContextUtilsTest {
val filter = mock<IntentFilter>()
val context = mock<Context>()
whenever(buildInfo.sdkInfoVersion).thenReturn(Build.VERSION_CODES.TIRAMISU)
ContextUtils.registerReceiver(context, buildInfo, receiver, filter)
verify(context).registerReceiver(eq(receiver), eq(filter), eq(Context.RECEIVER_NOT_EXPORTED))
ContextUtils.registerReceiver(context, buildInfo, receiver, filter, null)
verify(context)
.registerReceiver(
eq(receiver),
eq(filter),
isNull(),
isNull(),
eq(Context.RECEIVER_NOT_EXPORTED),
)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class SystemEventsBreadcrumbsIntegrationTest {

sut.register(fixture.scopes, fixture.options)

verify(fixture.context).registerReceiver(any(), any(), any())
verify(fixture.context).registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any())
assertNotNull(sut.receiver)
}

Expand Down Expand Up @@ -299,7 +299,8 @@ class SystemEventsBreadcrumbsIntegrationTest {
@Test
fun `Do not crash if registerReceiver throws exception`() {
val sut = fixture.getSut()
whenever(fixture.context.registerReceiver(any(), any(), any())).thenThrow(SecurityException())
whenever(fixture.context.registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any()))
.thenThrow(SecurityException())

sut.register(fixture.scopes, fixture.options)

Expand Down Expand Up @@ -448,12 +449,13 @@ class SystemEventsBreadcrumbsIntegrationTest {
val sut = fixture.getSut()

sut.register(fixture.scopes, fixture.options)
verify(fixture.context).registerReceiver(any(), any(), any())
verify(fixture.context).registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any())

sut.onBackground()
sut.onForeground()

verify(fixture.context, times(2)).registerReceiver(any(), any(), any())
verify(fixture.context, times(2))
.registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any())
assertNotNull(sut.receiver)
}

Expand All @@ -462,7 +464,7 @@ class SystemEventsBreadcrumbsIntegrationTest {
val sut = fixture.getSut()

sut.register(fixture.scopes, fixture.options)
verify(fixture.context).registerReceiver(any(), any(), any())
verify(fixture.context).registerReceiver(any(), any(), anyOrNull(), anyOrNull(), any())
val receiver = sut.receiver

sut.onForeground()
Expand Down
2 changes: 2 additions & 0 deletions sentry-test-support/api/sentry-test-support.api
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public final class io/sentry/test/DeferredExecutorService : io/sentry/ISentryExe
public fun close (J)V
public final fun hasScheduledRunnables ()Z
public fun isClosed ()Z
public fun prewarm ()V
public final fun runAll ()V
public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future;
public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future;
Expand All @@ -30,6 +31,7 @@ public final class io/sentry/test/ImmediateExecutorService : io/sentry/ISentryEx
public fun <init> ()V
public fun close (J)V
public fun isClosed ()Z
public fun prewarm ()V
public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future;
public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future;
public fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future;
Expand Down
4 changes: 4 additions & 0 deletions sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class ImmediateExecutorService : ISentryExecutorService {
override fun close(timeoutMillis: Long) {}

override fun isClosed(): Boolean = false

override fun prewarm() = Unit
}

class DeferredExecutorService : ISentryExecutorService {
Expand Down Expand Up @@ -72,6 +74,8 @@ class DeferredExecutorService : ISentryExecutorService {

override fun isClosed(): Boolean = false

override fun prewarm() = Unit

fun hasScheduledRunnables(): Boolean = scheduledRunnables.isNotEmpty()
}

Expand Down
3 changes: 3 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@ public abstract interface class io/sentry/ISentryClient {
public abstract interface class io/sentry/ISentryExecutorService {
public abstract fun close (J)V
public abstract fun isClosed ()Z
public abstract fun prewarm ()V
public abstract fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future;
public abstract fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future;
public abstract fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future;
Expand Down Expand Up @@ -2957,8 +2958,10 @@ public final class io/sentry/SentryExceptionFactory {

public final class io/sentry/SentryExecutorService : io/sentry/ISentryExecutorService {
public fun <init> ()V
public fun <init> (Lio/sentry/SentryOptions;)V
public fun close (J)V
public fun isClosed ()Z
public fun prewarm ()V
public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future;
public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future;
public fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future;
Expand Down
1 change: 1 addition & 0 deletions sentry/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ tasks {
dependsOn(jacocoTestReport)
}
test {
jvmArgs("--add-opens", "java.base/java.util.concurrent=ALL-UNNAMED")
environment["SENTRY_TEST_PROPERTY"] = "\"some-value\""
environment["SENTRY_TEST_MAP_KEY1"] = "\"value1\""
environment["SENTRY_TEST_MAP_KEY2"] = "value2"
Expand Down
6 changes: 6 additions & 0 deletions sentry/src/main/java/io/sentry/ISentryExecutorService.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,10 @@ Future<?> schedule(final @NotNull Runnable runnable, final long delayMillis)
* @return If the executorService was previously closed
*/
boolean isClosed();

/**
* Pre-warms the executor service by increasing the initial queue capacity. SHOULD be called
* directly after instantiating this executor service.
*/
void prewarm();
}
Loading
Loading