Skip to content
Closed
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 @@ -7,6 +7,8 @@
- Prevent duplicated breadcrumbs on tombstone-merged native crash events ([#5888](https://github.com/getsentry/sentry-java/pull/5888))
- Prevent a class of Session Replay deadlocks by confining lifecycle state changes to Android's main thread ([#5965](https://github.com/getsentry/sentry-java/pull/5965))
- Symbolicate tombstone native frames for libraries loaded directly from APKs ([#5992](https://github.com/getsentry/sentry-java/pull/5992))
- Drop the `profiler_id` from in-flight transactions when Android's `ProfilingManager` rejects profiling requests ([#5993](https://github.com/getsentry/sentry-java/pull/5993))
- Previously a rate-limited or failed Perfetto profiling request still left a `profiler_id` on transactions, pointing to non-existent profiles

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: Should we warn folks that we've added non-default methods to IContinuousProfiler, given that it can be vended by an SPI implemented in a different JAR (link)? (I get that the interface is @Internal, but it's a bit unusual given the runtime discovery.)

### Features

Expand Down
5 changes: 5 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IConti
public fun isRunning ()Z
public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V
public fun reevaluateSampling ()V
public fun registerProfilingCanceledCallback (Lio/sentry/IProfilingCanceledCallback;)V
public fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V
public fun stopProfiler (Lio/sentry/ProfileLifecycle;)V
public fun unregisterProfilingCanceledCallback (Lio/sentry/IProfilingCanceledCallback;)V
}

public final class io/sentry/android/core/AndroidCpuCollector : io/sentry/IPerformanceSnapshotCollector {
Expand Down Expand Up @@ -373,13 +375,16 @@ public class io/sentry/android/core/PerfettoContinuousProfiler : io/sentry/ICont
public fun isRunning ()Z
public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V
public fun reevaluateSampling ()V
public fun registerProfilingCanceledCallback (Lio/sentry/IProfilingCanceledCallback;)V
public fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V
public fun stopProfiler (Lio/sentry/ProfileLifecycle;)V
public fun unregisterProfilingCanceledCallback (Lio/sentry/IProfilingCanceledCallback;)V
}

public class io/sentry/android/core/PerfettoProfiler {
public fun <init> (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/ISentryExecutorService;)V
public fun endAndCollect (Ljava/util/function/Consumer;)V
public fun setOnCanceledCallback (Ljava/lang/Runnable;)V
public fun start (J)Z
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import io.sentry.DataCategory;
import io.sentry.IContinuousProfiler;
import io.sentry.ILogger;
import io.sentry.IProfilingCanceledCallback;
import io.sentry.IScopes;
import io.sentry.ISentryExecutorService;
import io.sentry.ISentryLifecycleToken;
Expand Down Expand Up @@ -263,6 +264,12 @@ public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) {
}
}

@Override
public void registerProfilingCanceledCallback(@NotNull IProfilingCanceledCallback callback) {}

@Override
public void unregisterProfilingCanceledCallback(@NotNull IProfilingCanceledCallback callback) {}

private void stop(final boolean restartProfiler) {
initScopes();
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.sentry.DataCategory;
import io.sentry.IContinuousProfiler;
import io.sentry.ILogger;
import io.sentry.IProfilingCanceledCallback;
import io.sentry.IScopes;
import io.sentry.ISentryExecutorService;
import io.sentry.ISentryLifecycleToken;
Expand All @@ -29,14 +30,17 @@
import io.sentry.protocol.SentryId;
import io.sentry.transport.RateLimiter;
import io.sentry.util.AutoClosableReentrantLock;
import io.sentry.util.ExceptionUtils;
import io.sentry.util.LazyEvaluator;
import io.sentry.util.SentryRandom;
import java.io.File;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
Expand All @@ -60,10 +64,9 @@
* created during {@code Sentry.init()}.
*
* <p>Thread safety: all mutable state is guarded by a single {@link
* io.sentry.util.AutoClosableReentrantLock}. Public entry points ({@link #startProfiler}, {@link
* #stopProfiler}, {@link #close}, {@link #onRateLimitChanged}, {@link #reevaluateSampling}, and the
* getters) acquire the lock themselves and are thread-safe. Private methods {@code startInternal}
* and {@code stopInternal} require the caller to hold the lock.
* io.sentry.util.AutoClosableReentrantLock}. Every public entry point acquires the lock itself and
* is thread-safe. Private methods tagged {@code Caller must hold the lock} do not, and must only be
* reached from a frame that already holds it.
*/
@ApiStatus.Internal
@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM)
Expand Down Expand Up @@ -95,6 +98,8 @@ public class PerfettoContinuousProfiler
private int activeTraceCount = 0;

private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
private final @NotNull Set<IProfilingCanceledCallback> profilingCanceledCallbacks =
new HashSet<>();

public PerfettoContinuousProfiler(
final @NotNull ILogger logger,
Expand Down Expand Up @@ -162,6 +167,60 @@ public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) {
}
}

@Override
public void registerProfilingCanceledCallback(@NotNull IProfilingCanceledCallback callback) {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
this.profilingCanceledCallbacks.add(callback);
}
}

@Override
public void unregisterProfilingCanceledCallback(@NotNull IProfilingCanceledCallback callback) {
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
this.profilingCanceledCallbacks.remove(callback);
}
}

/**
* Invoked once it is known that no profile chunk will be produced for the given profiler id, so

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: "no profile chunk" -> "no additional profile chunks"

* that anything already tagged with it can drop the reference before being sent.
*
* <p>The id outlives a single chunk, so this may fire more than once for the same id, and it also

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: Fwiw, seems like most of this comment would be better suited for SentryTracer.onProfilingCanceled() (but up to you).

Also might be worth noting that transactions are bound to profiling sessions via profiler_id, and a single profiling session can produce 0 to N profile chunks, as that's what leads to the "Losing a valid profile link..." tradeoff you (helpfully!) mention.

* invalidates transactions covered by an earlier chunk that was sent successfully. Losing a valid
* profile link is preferred over sending a link that resolves to nothing.
*/
private void notifyProfilingCanceled(final @NotNull SentryId canceledProfilerId) {
if (canceledProfilerId.equals(SentryId.EMPTY_ID)) {
return;
}
logger.log(
SentryLevel.DEBUG,
"No profile chunk will be produced for profiler id %s, dropping it.",
canceledProfilerId);

final @NotNull List<IProfilingCanceledCallback> callbacks;
try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) {
// The OS can report the failure a few ms into a chunk that would otherwise run for another
// minute. Without tearing it down here, transactions started in the meantime would read the
// id that was just invalidated, and register too late to ever be told about it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: My vote would be to implement (and comment) PerfettoContinuousProfiler as if it has a policy that simply cancels the current profiling session whenever we receive a rate-limited response from the ProfilingManager. We could then include discussions about how that policy interacts with transactions higher up our stack, eg, in SentryTracer.

That'd^^ let us be more explicit about the contract in the profiler, while keeping comments about how that contract interacts with transactions in the abstraction that naturally has to care about both.

(Happy to defer to you, of course...)

if (isRunning) {
stopInternal(false);
}
// Iterated from a copy, as a listener may unregister itself while being notified.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

how can they unregister if they require a lock to unregister?

callbacks = new ArrayList<>(profilingCanceledCallbacks);
}
for (final @NotNull IProfilingCanceledCallback callback : callbacks) {
try {
callback.onProfilingCanceled(canceledProfilerId);
} catch (Throwable t) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My main concern here is how would we know if this new feature is working or not working correctly? How would we get alerted in the future if a change causes this to break?

I'm not sure I understand the comment about this being an OS binder thread. My understanding is that an uncaught exception always ends the process. The onProfilingCanceled is our own callback so we should understand and control the code there.

// Reachable from an OS binder thread, where an escaping exception ends the process, and one
// failing listener must not cost the others their notification.
ExceptionUtils.rethrowIfFatal(t);
logger.log(SentryLevel.ERROR, "Profiling canceled callback failed.", t);
}
}
}

/**
* Stop the profiler as soon as we are rate limited, to avoid the performance overhead.
*
Expand All @@ -186,8 +245,12 @@ public void close(final boolean isTerminating) {
activeTraceCount = 0;
shouldStop = true;
if (isTerminating) {
final @NotNull SentryId closingProfilerId = profilerId;
stopInternal(false);
isClosed.set(true);
// sendChunk drops everything once isClosed is set, so the pending chunk is already lost.
notifyProfilingCanceled(closingProfilerId);
profilingCanceledCallbacks.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Callbacks run under profiler lock

Medium Severity

notifyProfilingCanceled copies listeners and is written to invoke them after releasing its own lock acquisition, but close(true) and the failure paths in startInternal call it while they still hold the profiler lock. That means every registered transaction callback can run under that lock, blocking other start/stop/register work and raising deadlock risk if a listener ever needs profiler state from another thread.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b1e0abe. Configure here.

}
}
}
Expand Down Expand Up @@ -218,7 +281,7 @@ public boolean isRunning() {
* and never used for app-start profiling, scopes is guaranteed to be available by the time
* startProfiler is called.
*
* <p>Caller must hold {@link #lock}.
* <p>Caller must hold the lock.
*/
private @NotNull IScopes resolveScopes() {
if (scopes != null && scopes != NoOpScopes.getInstance()) {
Expand All @@ -240,44 +303,60 @@ public boolean isRunning() {
return scopes;
}

/** Caller must hold {@link #lock}. */
/** Caller must hold the lock. */
private void startInternal() {
final @NotNull IScopes scopes = resolveScopes();

// On a restart the id carries over from the previous chunk, so transactions may already be
// tagged with it when any of the bail-outs below hit.
final @NotNull SentryId profilerIdBeforeStart = profilerId;

final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter();
if (rateLimiter != null
&& (rateLimiter.isActiveForCategory(All)
|| rateLimiter.isActiveForCategory(DataCategory.ProfileChunkUi))) {
logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler.");
stopInternal(false);
notifyProfilingCanceled(profilerIdBeforeStart);
return;
}

// If device is offline, we don't start the profiler, to avoid flooding the cache
if (scopes.getOptions().getConnectionStatusProvider().getConnectionStatus() == DISCONNECTED) {
logger.log(SentryLevel.WARNING, "Device is offline. Stopping profiler.");
stopInternal(false);
notifyProfilingCanceled(profilerIdBeforeStart);
return;
}
startProfileChunkTimestamp = scopes.getOptions().getDateProvider().now();

perfettoProfiler = perfettoProfilerFactory.get();
if (perfettoProfiler == null) {
logger.log(SentryLevel.ERROR, "PerfettoProfiler is not available. Stopping profiler.");
profilerId = SentryId.EMPTY_ID;
notifyProfilingCanceled(profilerIdBeforeStart);
return;
}
// The id has to exist before the callback is installed: the OS may report a rate limit while
// start() is still on the stack, and the callback needs to name the id it is invalidating.
if (profilerId.equals(SentryId.EMPTY_ID)) {
profilerId = new SentryId();
}
final @NotNull SentryId chunkProfilerId = profilerId;

perfettoProfiler.setOnCanceledCallback(() -> notifyProfilingCanceled(chunkProfilerId));

if (!perfettoProfiler.start(MAX_CHUNK_DURATION_MILLIS)) {
logger.log(
SentryLevel.ERROR,
"Failed to start Perfetto profiling. PerfettoProfiler.start() returned false.");
profilerId = SentryId.EMPTY_ID;
notifyProfilingCanceled(chunkProfilerId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sync cancel leaves profiler running

High Severity

When cancellation arrives while start() is still on the stack, notifyProfilingCanceled sees isRunning as false and skips teardown, so it never clears profilerId. startInternal then sets isRunning to true and keeps the already-invalidated id. Later transactions can pick up that dead id and register too late to be notified, which recreates the dangling profile references this change is meant to prevent. The chunk can also keep appearing to run until the timer fires.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b1e0abe. Configure here.

return;
}

isRunning = true;

if (profilerId.equals(SentryId.EMPTY_ID)) {
profilerId = new SentryId();
}

if (chunkId.equals(SentryId.EMPTY_ID)) {
chunkId = new SentryId();
}
Expand All @@ -304,7 +383,7 @@ private void startInternal() {
}
}

/** Caller must hold {@link #lock}. */
/** Caller must hold the lock. */
private void stopInternal(final boolean restartProfiler) {
final @Nullable PerfettoProfiler currentProfiler = perfettoProfiler;

Expand Down
Loading
Loading