Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixes

- Keep dropped tombstone and ANR events dropped, instead of reporting the same app exit again at every app start ([#6002](https://github.com/getsentry/sentry-java/pull/6002))
- Drop the `profiler_id` from transactions and spans when no Perfetto profile covers them, e.g. when Android's `ProfilingManager` rate limits the profiling request ([#6015](https://github.com/getsentry/sentry-java/pull/6015))

## 8.54.0

Expand Down
3 changes: 2 additions & 1 deletion sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IConti
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 getProfileRecordingState (Lio/sentry/protocol/SentryId;Lio/sentry/SentryDate;Lio/sentry/SentryDate;)Lio/sentry/profiling/ProfileRecordingState;
public fun getProfilerId ()Lio/sentry/protocol/SentryId;
public fun getRootSpanCounter ()I
public fun isRunning ()Z
Expand Down Expand Up @@ -369,6 +370,7 @@ public class io/sentry/android/core/PerfettoContinuousProfiler : io/sentry/ICont
public fun <init> (Lio/sentry/ILogger;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;Ljava/util/function/Supplier;)V
public fun close (Z)V
public fun getChunkId ()Lio/sentry/protocol/SentryId;
public fun getProfileRecordingState (Lio/sentry/protocol/SentryId;Lio/sentry/SentryDate;Lio/sentry/SentryDate;)Lio/sentry/profiling/ProfileRecordingState;
public fun getProfilerId ()Lio/sentry/protocol/SentryId;
public fun isRunning ()Z
public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V
Expand All @@ -380,7 +382,6 @@ public class io/sentry/android/core/PerfettoContinuousProfiler : io/sentry/ICont
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 start (J)Z
}

public final class io/sentry/android/core/ScreenshotEventProcessor : io/sentry/EventProcessor {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import io.sentry.SentryOptions;
import io.sentry.TracesSampler;
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
import io.sentry.profiling.ProfileRecordingState;
import io.sentry.protocol.SentryId;
import io.sentry.transport.RateLimiter;
import io.sentry.util.AutoClosableReentrantLock;
Expand Down Expand Up @@ -358,6 +359,18 @@ public void close(final boolean isTerminating) {
return chunkId;
}

/**
* This profiler does not track the outcome of its profiling requests, so the answer is always
* unknown.
*/
@Override
public @NotNull ProfileRecordingState getProfileRecordingState(
final @NotNull SentryId profilerId,
final @NotNull SentryDate startTime,
final @NotNull SentryDate endTime) {
return ProfileRecordingState.UNKNOWN;
}

private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOptions options) {
try {
options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
import io.sentry.SentryNanotimeDate;
import io.sentry.SentryOptions;
import io.sentry.TracesSampler;
import io.sentry.android.core.internal.profiling.ChunkRecord;
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
import io.sentry.profilemeasurements.ProfileMeasurement;
import io.sentry.profilemeasurements.ProfileMeasurementValue;
import io.sentry.profiling.ProfileRecordingState;
import io.sentry.protocol.SentryId;
import io.sentry.transport.RateLimiter;
import io.sentry.util.AutoClosableReentrantLock;
Expand Down Expand Up @@ -59,18 +61,28 @@
* <p>Currently, this class doesn't do app-start profiling {@link SentryPerformanceProvider}. It is
* 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.
* <p>Thread safety: the profiler state is guarded by {@link #lock}. Every public entry point
* acquires it itself and is thread-safe. Private methods that say {@code Caller must hold} a lock
* do not, and must only be reached from a frame that already holds it.
*
* <p>The chunk history is guarded by its own {@link #chunkHistoryLock}, so that {@link
* #getProfileRecordingState} โ€” called for every span of a finishing transaction โ€” never waits for a
* chunk start or a chunk stop. A frame holding {@link #lock} may take {@link #chunkHistoryLock},
* never the other way around. Each {@link ChunkRecord} guards its own state, as the profiler writes
* the outcome of a running chunk into it.
*/
@ApiStatus.Internal
@RequiresApi(api = Build.VERSION_CODES.VANILLA_ICE_CREAM)
public class PerfettoContinuousProfiler
implements IContinuousProfiler, RateLimiter.IRateLimitObserver {
private static final long MAX_CHUNK_DURATION_MILLIS = 60000;

/**
* How many chunks we remember the outcome of. Spans only ask about windows they were running in,
* so a handful of chunks (a minute each) is plenty.
*/
@VisibleForTesting static final int MAX_CHUNK_HISTORY_SIZE = 10;

// Matches the thread name produced by SentryExecutorService's thread factory, used to detect
// when we are already running on the executor thread.
private static final String EXECUTOR_THREAD_NAME_PREFIX = "SentryExecutorServiceThreadFactory";
Expand All @@ -96,6 +108,12 @@ public class PerfettoContinuousProfiler

private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock();

private final @NotNull ArrayDeque<ChunkRecord> chunkHistory =
new ArrayDeque<>(MAX_CHUNK_HISTORY_SIZE);
private @Nullable ChunkRecord currentChunk = null;

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: Can we just use chunkHistory.peekLast() instead?


private final AutoClosableReentrantLock chunkHistoryLock = new AutoClosableReentrantLock();

public PerfettoContinuousProfiler(
final @NotNull ILogger logger,
final @NotNull SentryFrameMetricsCollector frameMetricsCollector,
Expand Down Expand Up @@ -188,6 +206,8 @@ public void close(final boolean isTerminating) {
if (isTerminating) {
stopInternal(false);
isClosed.set(true);
// sendChunk drops everything once isClosed is set, so the chunk that just ended is lost
markLastChunkNotRecordedIfUnknown();
}
}
}
Expand All @@ -213,6 +233,65 @@ public boolean isRunning() {
}
}

@Override
public @NotNull ProfileRecordingState getProfileRecordingState(
final @NotNull SentryId profilerId,
final @NotNull SentryDate startTime,
final @NotNull SentryDate endTime) {
try (final @NotNull ISentryLifecycleToken ignored = chunkHistoryLock.acquire()) {
if (chunkHistory.isEmpty()) {
return ProfileRecordingState.UNKNOWN;
}

for (final @NotNull ChunkRecord chunk : chunkHistory) {
// A chunk that is still running, or that is still being collected, is assumed to be
// recorded in the end
if (chunk.getProfilerId().equals(profilerId)
&& chunk.overlaps(startTime, endTime)
&& chunk.getRecordingState() != ProfileRecordingState.NOT_RECORDED) {
return ProfileRecordingState.RECORDED;

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: Thoughts about updating our approach to match the following policy?

Scenario Returned value
No chunks have ever run UNKNOWN
No recorded chunk overlaps window but one or more overlapping chunk remain undecided UNKNOWN
At least one overlapping chunk was recorded RECORDED
Overlapping chunks exist, but all failed NOT_RECORDED
History exists, but no chunk for that profiler/window overlaps NOT_RECORDED

We could implement it like this:

 @Override                                                                                                                                                                                                                                                                                                                                               
     public @NotNull ProfileRecordingState getProfileRecordingState(                                                                                                                                                                                                                                                                                         
         final @NotNull SentryId profilerId,                                                                                                                                                                                                                                                                                                                 
         final @NotNull SentryDate startTime,                                                                                                                                                                                                                                                                                                                
         final @NotNull SentryDate endTime) {                                                                                                                                                                                                                                                                                                                
       try (final @NotNull ISentryLifecycleToken ignored = chunkHistoryLock.acquire()) {                                                                                                                                                                                                                                                                     
         if (chunkHistory.isEmpty()) {                                                                                                                                                                                                                                                                                                                       
           return ProfileRecordingState.UNKNOWN;                                                                                                                                                                                                                                                                                                             
         }                                                                                                                                                                                                                                                                                                                                                   
                                                                                                                                                                                                                                                                                                                                                             
         boolean hasUnknownOverlappingChunk = false;                                                                                                                                                                                                                                                                                                         
                                                                                                                                                                                                                                                                                                                                                             
         for (final @NotNull ChunkRecord chunk : chunkHistory) {                                                                                                                                                                                                                                                                                             
           if (!chunk.getProfilerId().equals(profilerId) || !chunk.overlaps(startTime, endTime)) {                                                                                                                                                                                                                                                           
             continue;                                                                                                                                                                                                                                                                                                                                       
           }                                                                                                                                                                                                                                                                                                                                                 
                                                                                                                                                                                                                                                                                                                                                             
           final @NotNull ProfileRecordingState state = chunk.getRecordingState();                                                                                                                                                                                                                                                                           
           if (state == ProfileRecordingState.RECORDED) {                                                                                                                                                                                                                                                                                                    
             return ProfileRecordingState.RECORDED;                                                                                                                                                                                                                                                                                                          
           }                                                                                                                                                                                                                                                                                                                                                 
           if (state == ProfileRecordingState.UNKNOWN) {                                                                                                                                                                                                                                                                                                     
             hasUnknownOverlappingChunk = true;                                                                                                                                                                                                                                                                                                              
           }                                                                                                                                                                                                                                                                                                                                                 
         }                  

         if (hasUnknownOverlappingChunk) {                                                                                                                                                                                                                                                                                                                   
           return ProfileRecordingState.UNKNOWN;                                                                                                                                                                                                                                                                                                             
         }                                                                                                                                                                                                                                                                                                                                                   
                                                                                                                                                                                                                                                                                                                                                             
         return ProfileRecordingState.NOT_RECORDED;                                                                                                                                                                                                                                                                                                          
       }                                                                                                                                                                                                                                                                                                                                                     
     }                       

That'd let us avoid returning RECORDED in situations where we don't actually know yet. (Doesn't change current behavior b/c we only act on NOT_RECORDED, but it could matter going forward.)

}
}

// Every chunk overlapping the window failed, or no chunk ran during the window at all
return ProfileRecordingState.NOT_RECORDED;
}
}

/**
* Gives up on the newest chunk, unless its outcome is already known. Only that one can still be
* undecided, as a chunk is decided before the next one starts.
*/
private void markLastChunkNotRecordedIfUnknown() {
try (final @NotNull ISentryLifecycleToken ignored = chunkHistoryLock.acquire()) {
final @Nullable ChunkRecord lastChunk = chunkHistory.peekLast();
if (lastChunk != null && lastChunk.getRecordingState() == ProfileRecordingState.UNKNOWN) {
lastChunk.setRecordingState(ProfileRecordingState.NOT_RECORDED);
}
}
}

private void addChunkRecord(final @NotNull ChunkRecord chunk) {
try (final @NotNull ISentryLifecycleToken ignored = chunkHistoryLock.acquire()) {
if (chunkHistory.size() == MAX_CHUNK_HISTORY_SIZE) {
chunkHistory.removeFirst();
}
currentChunk = chunk;
chunkHistory.addLast(chunk);
}
}

private @Nullable ChunkRecord endChunkRecord(final @NotNull SentryDate endTimestamp) {
try (final @NotNull ISentryLifecycleToken ignored = chunkHistoryLock.acquire()) {
final @Nullable ChunkRecord chunk = currentChunk;
if (chunk != null) {
chunk.setEndTimestamp(endTimestamp);
currentChunk = null;
}
return chunk;
}
}

/**
* Resolves scopes on first call. Since PerfettoContinuousProfiler is created during Sentry.init()
* and never used for app-start profiling, scopes is guaranteed to be available by the time
Expand Down Expand Up @@ -265,23 +344,27 @@ private void startInternal() {
if (perfettoProfiler == null) {
return;
}
if (!perfettoProfiler.start(MAX_CHUNK_DURATION_MILLIS)) {
if (SentryId.EMPTY_ID.equals(profilerId)) {
profilerId = new SentryId();
}

final @Nullable ChunkRecord chunkRecord =
perfettoProfiler.start(startProfileChunkTimestamp, profilerId, MAX_CHUNK_DURATION_MILLIS);
if (chunkRecord == null) {
profilerId = SentryId.EMPTY_ID;

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: What's the thinking behind updating the profilerId?

Fwiw, I would've figured we'd keep the profilerId intact and let the ProfileRecordingState inform the outside world about the success vs failure of recording โ€“ but perhaps I'm missing something?

chunkId = SentryId.EMPTY_ID;
logger.log(
SentryLevel.ERROR,
"Failed to start Perfetto profiling. PerfettoProfiler.start() returned false.");
"Failed to start Perfetto profiling. PerfettoProfiler.start() returned no chunk.");
return;
}

isRunning = true;

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

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

addChunkRecord(chunkRecord);
chunkMeasurements.start(performanceCollector, chunkId.toString());

try {
Expand Down Expand Up @@ -328,6 +411,7 @@ private void stopInternal(final boolean restartProfiler) {
final @NotNull SentryId chunkProfilerId = profilerId;
final @NotNull SentryId chunkChunkId = chunkId;
final @NotNull SentryDate chunkTimestamp = startProfileChunkTimestamp;
final @Nullable ChunkRecord chunkRecord = endChunkRecord(options.getDateProvider().now());

isRunning = false;
perfettoProfiler = null;
Expand All @@ -348,6 +432,7 @@ private void stopInternal(final boolean restartProfiler) {
traceFile,
chunkProfilerId,
chunkChunkId,
chunkRecord,
measurements,
chunkTimestamp,
shouldRestart,
Expand All @@ -359,11 +444,22 @@ private void onChunkCollected(
final @Nullable File traceFile,
final @NotNull SentryId chunkProfilerId,
final @NotNull SentryId chunkChunkId,
final @Nullable ChunkRecord chunkRecord,
final @NotNull Map<String, ProfileMeasurement> measurements,
final @NotNull SentryDate chunkTimestamp,
final boolean shouldRestart,
final @NotNull IScopes scopes,
final @NotNull SentryOptions options) {
// The trace file is the last word on whether the chunk was recorded: the OS may report success
// and still leave no usable file behind
if (chunkRecord != null) {
// Nothing is sent once the profiler is closed, so a collected chunk still covers nothing
chunkRecord.setRecordingState(
traceFile != null && !isClosed.get()
? ProfileRecordingState.RECORDED
: ProfileRecordingState.NOT_RECORDED);
}

if (traceFile == null) {
logger.log(
SentryLevel.ERROR,
Expand Down
Loading
Loading