Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
### Fixes

- When merging tombstones with Native SDK, use the tombstone message if the Native SDK didn't explicitly provide one. ([#5095](https://github.com/getsentry/sentry-java/pull/5095))
- Fix thread leak caused by eager creation of `SentryExecutorService` in `SentryOptions` ([#5093](https://github.com/getsentry/sentry-java/pull/5093))
- There were cases where we created options that ended up unused but we failed to clean those up.

### Dependencies

Expand Down
4 changes: 2 additions & 2 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android
}

public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver {
public fun <init> (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)V
public fun <init> (Lio/sentry/android/core/BuildInfoProvider;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/util/LazyEvaluator$Evaluator;)V
public fun close (Z)V
public fun getChunkId ()Lio/sentry/protocol/SentryId;
public fun getProfilerId ()Lio/sentry/protocol/SentryId;
Expand Down Expand Up @@ -114,7 +114,7 @@ public final class io/sentry/android/core/AndroidMetricsBatchProcessorFactory :

public class io/sentry/android/core/AndroidProfiler {
protected final field lock Lio/sentry/util/AutoClosableReentrantLock;
public fun <init> (Ljava/lang/String;ILio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/ISentryExecutorService;Lio/sentry/ILogger;)V
public fun <init> (Ljava/lang/String;ILio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;Lio/sentry/ILogger;)V
public fun close ()V
public fun endAndCollect (ZLjava/util/List;)Lio/sentry/android/core/AndroidProfiler$ProfileEndData;
public fun start ()Lio/sentry/android/core/AndroidProfiler$ProfileStartData;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import io.sentry.protocol.SentryId;
import io.sentry.transport.RateLimiter;
import io.sentry.util.AutoClosableReentrantLock;
import io.sentry.util.LazyEvaluator;
import io.sentry.util.SentryRandom;
import java.util.ArrayList;
import java.util.List;
Expand All @@ -45,7 +46,7 @@ public class AndroidContinuousProfiler
private final @NotNull ILogger logger;
private final @Nullable String profilingTracesDirPath;
private final int profilingTracesHz;
private final @NotNull ISentryExecutorService executorService;
private final @NotNull LazyEvaluator.Evaluator<ISentryExecutorService> executorServiceSupplier;
private final @NotNull BuildInfoProvider buildInfoProvider;
private boolean isInitialized = false;
private final @NotNull SentryFrameMetricsCollector frameMetricsCollector;
Expand Down Expand Up @@ -73,13 +74,13 @@ public AndroidContinuousProfiler(
final @NotNull ILogger logger,
final @Nullable String profilingTracesDirPath,
final int profilingTracesHz,
final @NotNull ISentryExecutorService executorService) {
final @NotNull LazyEvaluator.Evaluator<ISentryExecutorService> executorServiceSupplier) {
this.logger = logger;
this.frameMetricsCollector = frameMetricsCollector;
this.buildInfoProvider = buildInfoProvider;
this.profilingTracesDirPath = profilingTracesDirPath;
this.profilingTracesHz = profilingTracesHz;
this.executorService = executorService;
this.executorServiceSupplier = executorServiceSupplier;
}

private void init() {
Expand Down Expand Up @@ -222,7 +223,8 @@ private void start() {
}

try {
stopFuture = executorService.schedule(() -> stop(true), MAX_CHUNK_DURATION_MILLIS);
stopFuture =
executorServiceSupplier.evaluate().schedule(() -> stop(true), MAX_CHUNK_DURATION_MILLIS);
} catch (RejectedExecutionException e) {
logger.log(
SentryLevel.ERROR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ private static void setupProfiler(
options.getLogger(),
options.getProfilingTracesDirPath(),
options.getProfilingTracesHz(),
options.getExecutorService()));
() -> options.getExecutorService()));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import io.sentry.profilemeasurements.ProfileMeasurement;
import io.sentry.profilemeasurements.ProfileMeasurementValue;
import io.sentry.util.AutoClosableReentrantLock;
import io.sentry.util.LazyEvaluator;
import io.sentry.util.Objects;
import java.io.File;
import java.util.ArrayDeque;
Expand Down Expand Up @@ -92,7 +93,8 @@ public ProfileEndData(
private final @NotNull ArrayDeque<ProfileMeasurementValue> frozenFrameRenderMeasurements =
new ArrayDeque<>();
private final @NotNull Map<String, ProfileMeasurement> measurementsMap = new HashMap<>();
private final @Nullable ISentryExecutorService timeoutExecutorService;
private final @Nullable LazyEvaluator.Evaluator<ISentryExecutorService>
timeoutExecutorServiceSupplier;
private final @NotNull ILogger logger;
private volatile boolean isRunning = false;
protected final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
Expand All @@ -101,14 +103,15 @@ public AndroidProfiler(
final @NotNull String tracesFilesDirPath,
final int intervalUs,
final @NotNull SentryFrameMetricsCollector frameMetricsCollector,
final @Nullable ISentryExecutorService timeoutExecutorService,
final @Nullable LazyEvaluator.Evaluator<ISentryExecutorService>
timeoutExecutorServiceSupplier,
final @NotNull ILogger logger) {
this.traceFilesDir =
new File(Objects.requireNonNull(tracesFilesDirPath, "TracesFilesDirPath is required"));
this.intervalUs = intervalUs;
this.logger = Objects.requireNonNull(logger, "Logger is required");
// Timeout executor is nullable, as timeouts will not be there for continuous profiling
this.timeoutExecutorService = timeoutExecutorService;
this.timeoutExecutorServiceSupplier = timeoutExecutorServiceSupplier;
this.frameMetricsCollector =
Objects.requireNonNull(frameMetricsCollector, "SentryFrameMetricsCollector is required");
}
Expand Down Expand Up @@ -185,10 +188,11 @@ public void onFrameMetricCollected(

// We stop profiling after a timeout to avoid huge profiles to be sent
try {
if (timeoutExecutorService != null) {
if (timeoutExecutorServiceSupplier != null) {
scheduledFinish =
timeoutExecutorService.schedule(
() -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS);
timeoutExecutorServiceSupplier
.evaluate()
.schedule(() -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS);
}
} catch (RejectedExecutionException e) {
logger.log(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import io.sentry.android.core.internal.util.CpuInfoUtils;
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
import io.sentry.util.AutoClosableReentrantLock;
import io.sentry.util.LazyEvaluator;
import io.sentry.util.Objects;
import java.util.ArrayList;
import java.util.Date;
Expand All @@ -34,7 +35,7 @@ final class AndroidTransactionProfiler implements ITransactionProfiler {
private final @Nullable String profilingTracesDirPath;
private final boolean isProfilingEnabled;
private final int profilingTracesHz;
private final @NotNull ISentryExecutorService executorService;
private final @NotNull LazyEvaluator.Evaluator<ISentryExecutorService> executorServiceSupplier;
private final @NotNull BuildInfoProvider buildInfoProvider;
private boolean isInitialized = false;
private final @NotNull AtomicBoolean isRunning = new AtomicBoolean(false);
Expand Down Expand Up @@ -65,7 +66,7 @@ public AndroidTransactionProfiler(
sentryAndroidOptions.getProfilingTracesDirPath(),
sentryAndroidOptions.isProfilingEnabled(),
sentryAndroidOptions.getProfilingTracesHz(),
sentryAndroidOptions.getExecutorService());
() -> sentryAndroidOptions.getExecutorService());
}

public AndroidTransactionProfiler(
Expand All @@ -77,6 +78,26 @@ public AndroidTransactionProfiler(
final boolean isProfilingEnabled,
final int profilingTracesHz,
final @NotNull ISentryExecutorService executorService) {
this(
context,
buildInfoProvider,
frameMetricsCollector,
logger,
profilingTracesDirPath,
isProfilingEnabled,
profilingTracesHz,
() -> executorService);
}

public AndroidTransactionProfiler(
final @NotNull Context context,
final @NotNull BuildInfoProvider buildInfoProvider,
final @NotNull SentryFrameMetricsCollector frameMetricsCollector,
final @NotNull ILogger logger,
final @Nullable String profilingTracesDirPath,
final boolean isProfilingEnabled,
final int profilingTracesHz,
final @NotNull LazyEvaluator.Evaluator<ISentryExecutorService> executorServiceSupplier) {
this.context =
Objects.requireNonNull(
ContextUtils.getApplicationContext(context), "The application context is required");
Expand All @@ -88,8 +109,9 @@ public AndroidTransactionProfiler(
this.profilingTracesDirPath = profilingTracesDirPath;
this.isProfilingEnabled = isProfilingEnabled;
this.profilingTracesHz = profilingTracesHz;
this.executorService =
Objects.requireNonNull(executorService, "The ISentryExecutorService is required.");
this.executorServiceSupplier =
Objects.requireNonNull(
executorServiceSupplier, "A supplier for ISentryExecutorService is required.");
this.profileStartTimestamp = DateUtils.getCurrentDateTime();
}

Expand Down Expand Up @@ -123,7 +145,7 @@ private void init() {
profilingTracesDirPath,
(int) SECONDS.toMicros(1) / profilingTracesHz,
frameMetricsCollector,
executorService,
executorServiceSupplier,
logger);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ private void createAndStartContinuousProfiler(
return;
}

final @NotNull SentryExecutorService startupExecutorService = new SentryExecutorService();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a new thread for app startup profiling. My current understanding is that this is later reused and not just used for app startup profiling. This means we're not really leaking it since it ends up being used.

If there's cases where app startup profiler is abandoned, we'll need some sort of shutdown hook to also shut down the SentryExecutorService. LMK if I should implement that as well just to be safe here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine as-is. Plus, afaik we'll need to deprecate/delete the transaction profiler soon (with the next major likely or span-only), so I wouldn't pour much into it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah I see this also affects continuous profiler too.. but I don't think we abandon it anywhere (unless process termination), so we hsould be good.

Btw, speaking of transaction profiler - I guess we should change it to accept a lambda, too (line 216)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah I see this also affects continuous profiler too.. but I don't think we abandon it anywhere (unless process termination), so we hsould be good.

I'd assume once we get rid of transaction profiler, there wouldn't be much reason to abandon it.
I'll go through some things again to check for cases but I assume we can tackle those as a follow up since this isn't new behaviour but already existed previously.

Btw, speaking of transaction profiler - I guess we should change it to accept a lambda, too (line 216)?

Yeah, will change that too.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I created the ctor but didn't use it. It's being used now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We agreed to ignore the edge case of an abandoned app start profiler for now since it only happens when switching between continuous and transaction profiling or when switching from enabled app startup profiling to disabled. And it'll only be leaking a single thread for a single app run. With plans to remove transaction based profiling in the future, this will become even more of an edge case.

final @NotNull IContinuousProfiler appStartContinuousProfiler =
new AndroidContinuousProfiler(
buildInfoProvider,
Comment on lines 165 to 171
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SentryPerformanceProvider eagerly creates SentryExecutorService instances at app startup, spawning threads before Sentry.init() is called and potentially causing thread leaks if profiling is disabled.
Severity: MEDIUM

Suggested Fix

Instead of creating new SentryExecutorService instances with new SentryExecutorService(), the profilers in SentryPerformanceProvider should obtain the executor service from SentryOptions. This can be achieved by passing a supplier that lazily gets the executor from the options once the SDK is initialized, ensuring thread creation is deferred until Sentry.init() and managed within the central SDK lifecycle.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java#L165-L171

Potential issue: In `SentryPerformanceProvider.java`, new `SentryExecutorService`
instances are created directly via `new SentryExecutorService()`. This constructor
immediately spawns a thread. Since `SentryPerformanceProvider` is a `ContentProvider`
that runs at app startup before `Sentry.init()`, these threads are created
unconditionally, bypassing the lazy initialization logic intended for the SDK. If app
start profiling is not enabled, these threads are created but never used, resulting in a
thread leak for the lifetime of the application process. Additionally, these executors
are created with `null` options, which prevents proper logging of rejected tasks.

Did we get this right? 👍 / 👎 to inform future reviews.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We accept this edge case for now.
#5093 (comment)

Expand All @@ -173,7 +174,7 @@ private void createAndStartContinuousProfiler(
logger,
profilingOptions.getProfilingTracesDirPath(),
profilingOptions.getProfilingTracesHz(),
new SentryExecutorService());
() -> startupExecutorService);
appStartMetrics.setAppStartProfiler(null);
appStartMetrics.setAppStartContinuousProfiler(appStartContinuousProfiler);
logger.log(SentryLevel.DEBUG, "App start continuous profiling started.");
Expand Down Expand Up @@ -203,6 +204,7 @@ private void createAndStartTransactionProfiler(
return;
}

final @NotNull SentryExecutorService executorService = new SentryExecutorService();
final @NotNull ITransactionProfiler appStartProfiler =
new AndroidTransactionProfiler(
context,
Expand All @@ -212,7 +214,7 @@ private void createAndStartTransactionProfiler(
profilingOptions.getProfilingTracesDirPath(),
profilingOptions.isProfilingEnabled(),
profilingOptions.getProfilingTracesHz(),
new SentryExecutorService());
() -> executorService);
appStartMetrics.setAppStartContinuousProfiler(null);
appStartMetrics.setAppStartProfiler(appStartProfiler);
logger.log(SentryLevel.DEBUG, "App start profiling started.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ class AndroidContinuousProfilerTest {
options.logger,
options.profilingTracesDirPath,
options.profilingTracesHz,
options.executorService,
{ options.executorService },
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector
import io.sentry.profilemeasurements.ProfileMeasurement
import io.sentry.test.getCtor
import io.sentry.test.getProperty
import io.sentry.util.LazyEvaluator
import java.io.File
import java.util.concurrent.Callable
import java.util.concurrent.Future
Expand Down Expand Up @@ -44,7 +45,7 @@ class AndroidProfilerTest {
String::class.java,
Int::class.java,
SentryFrameMetricsCollector::class.java,
ISentryExecutorService::class.java,
LazyEvaluator.Evaluator::class.java,
ILogger::class.java,
)
private val fixture = Fixture()
Expand Down Expand Up @@ -100,7 +101,7 @@ class AndroidProfilerTest {
options.profilingTracesDirPath!!,
interval,
frameMetricsCollector,
options.executorService,
{ options.executorService },
options.logger,
)
}
Expand Down Expand Up @@ -154,19 +155,19 @@ class AndroidProfilerTest {

assertFailsWith<IllegalArgumentException> {
ctor.newInstance(
arrayOf(null, 0, mock(), mock<SentryExecutorService>(), mock<AndroidLogger>())
arrayOf(null, 0, mock(), { mock<SentryExecutorService>() }, mock<AndroidLogger>())
)
}
assertFailsWith<IllegalArgumentException> {
ctor.newInstance(
arrayOf("mock", 0, null, mock<SentryExecutorService>(), mock<AndroidLogger>())
arrayOf("mock", 0, null, { mock<SentryExecutorService>() }, mock<AndroidLogger>())
)
}
assertFailsWith<IllegalArgumentException> {
ctor.newInstance(arrayOf("mock", 0, mock(), null, mock<AndroidLogger>()))
}
assertFailsWith<IllegalArgumentException> {
ctor.newInstance(arrayOf("mock", 0, mock(), mock<SentryExecutorService>(), null))
ctor.newInstance(arrayOf("mock", 0, mock(), { mock<SentryExecutorService>() }, null))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import io.sentry.IScopes
import io.sentry.ISentryExecutorService
import io.sentry.SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForget
import io.sentry.SendCachedEnvelopeFireAndForgetIntegration.SendFireAndForgetFactory
import io.sentry.SentryExecutorService
import io.sentry.SentryLevel.DEBUG
import io.sentry.SentryOptions
import io.sentry.test.DeferredExecutorService
import io.sentry.test.ImmediateExecutorService
import io.sentry.transport.RateLimiter
Expand Down Expand Up @@ -46,7 +46,7 @@ class SendCachedEnvelopeIntegrationTest {
options.cacheDirPath = cacheDirPath
options.setLogger(logger)
options.isDebug = true
options.executorService = mockExecutorService ?: SentryOptions().executorService
options.executorService = mockExecutorService ?: SentryExecutorService()

whenever(sender.send()).then {
Thread.sleep(delaySend)
Expand Down
1 change: 1 addition & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -3440,6 +3440,7 @@ public class io/sentry/SentryOptions {
public static final field MAX_EVENT_SIZE_BYTES J
protected final field lock Lio/sentry/util/AutoClosableReentrantLock;
public fun <init> ()V
public fun activate ()V
public fun addBundleId (Ljava/lang/String;)V
public fun addContextTag (Ljava/lang/String;)V
public fun addEventProcessor (Lio/sentry/EventProcessor;)V
Expand Down
2 changes: 2 additions & 0 deletions sentry/src/main/java/io/sentry/Sentry.java
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ private static void init(final @NotNull SentryOptions options, final boolean glo
"Sentry has been already initialized. Previous configuration will be overwritten.");
}

options.activate();

final IScopes scopes = getCurrentScopes();
scopes.close(true);

Expand Down
15 changes: 11 additions & 4 deletions sentry/src/main/java/io/sentry/SentryOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,17 @@ public void setProfilerConverter(@NotNull IProfileConverter profilerConverter) {
this.profilerConverter = profilerConverter;
}

/** Starts expensive parts of the options during Sentry.init */
@ApiStatus.Internal
public void activate() {
if (executorService instanceof NoOpSentryExecutorService) {
// SentryExecutorService should be initialized before any
// SendCachedEventFireAndForgetIntegration
executorService = new SentryExecutorService(this);
executorService.prewarm();
}
}

/**
* Configuration options for Sentry Build Distribution. NOTE: Ideally this would be in
* SentryAndroidOptions, but there's a circular dependency issue between sentry-android-core and
Expand Down Expand Up @@ -3322,10 +3333,6 @@ private SentryOptions(final boolean empty) {

if (!empty) {
setSpanFactory(SpanFactoryFactory.create(new LoadClass(), NoOpLogger.getInstance()));
// SentryExecutorService should be initialized before any
// SendCachedEventFireAndForgetIntegration
executorService = new SentryExecutorService(this);
executorService.prewarm();

// UncaughtExceptionHandlerIntegration should be inited before any other Integration.
// if there's an error on the setup, we are able to capture it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class SendCachedEnvelopeFireAndForgetIntegrationTest {
options.setDebug(true)
options.setLogger(logger)
options.sdkVersion = SdkVersion("test", "1.2.3")
options.activate()
}

fun getSut(): SendCachedEnvelopeFireAndForgetIntegration =
Expand Down
5 changes: 5 additions & 0 deletions sentry/src/test/java/io/sentry/SentryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -776,9 +776,12 @@ class SentryTest {
it.profilesSampleRate = 1.0
it.cacheDirPath = getTempPath()
it.setLogger(logger)
it.executorService = SentryExecutorService()
it.executorService.prewarm()
it.executorService.close(0)
it.isDebug = true
}

verify(logger)
.log(
eq(SentryLevel.ERROR),
Expand Down Expand Up @@ -874,6 +877,7 @@ class SentryTest {
it.release = "io.sentry.sample@1.1.0+220"
it.environment = "debug"

it.executorService = SentryExecutorService()
it.executorService.submit {
// here the values should be still old. Sentry.init will submit another runnable
// to notify the options observers, but because the executor is single-threaded, the
Expand Down Expand Up @@ -942,6 +946,7 @@ class SentryTest {
previousSessionFile.bufferedWriter(),
)

it.executorService = SentryExecutorService()
it.executorService.submit {
// here the previous session should still exist. Sentry.init will submit another runnable
// to finalize the previous session, but because the executor is single-threaded, the
Expand Down
Loading