From 5576e8e95ed11b87f955bace2705fbd2af0e86da Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 25 Aug 2026 14:04:48 +0200 Subject: [PATCH 1/2] feat(profiling): drop profiler context from transactions when profiler gets rate-limited --- CHANGELOG.md | 5 + .../api/sentry-android-core.api | 5 + .../core/AndroidContinuousProfiler.java | 7 + .../core/PerfettoContinuousProfiler.java | 101 +++++++++-- .../sentry/android/core/PerfettoProfiler.java | 85 +++++++++- .../core/PerfettoContinuousProfilerTest.kt | 157 ++++++++++++++++++ .../android/core/PerfettoProfilerTest.kt | 93 +++++++++++ .../api/sentry-async-profiler.api | 2 + .../profiling/JavaContinuousProfiler.java | 7 + sentry/api/sentry.api | 11 +- .../java/io/sentry/IContinuousProfiler.java | 4 + .../io/sentry/IProfilingCanceledCallback.java | 18 ++ .../io/sentry/NoOpContinuousProfiler.java | 6 + sentry/src/main/java/io/sentry/Scopes.java | 3 - .../src/main/java/io/sentry/SentryTracer.java | 41 ++++- .../java/io/sentry/DefaultSpanFactoryTest.kt | 69 ++++++++ .../test/java/io/sentry/SentryTracerTest.kt | 122 ++++++++++++++ .../profiling/ProfilingServiceLoaderTest.kt | 9 + 18 files changed, 721 insertions(+), 24 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/IProfilingCanceledCallback.java create mode 100644 sentry/src/test/java/io/sentry/DefaultSpanFactoryTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d914b1081c4..698b8422d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Fixes + +- Drop the `profiler_id` from in-flight transactions when Android's `ProfilingManager` reports that no profile will be produced ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)) + - Previously a rate-limited or failed Perfetto profiling request still left a `profiler_id` on transactions, pointing at a profile that never arrived + ### Performance - Use manifest metadata resolved at build time to reduce Android SDK initialization overhead ([#5976](https://github.com/getsentry/sentry-java/pull/5976)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 65bf072f0a0..1f6f0029dae 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -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 { @@ -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 (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 } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index a1c0c097cb9..4e80bdf62b9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -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; @@ -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()) { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java index 731be774339..488dc8da95f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java @@ -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; @@ -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; @@ -60,10 +64,9 @@ * created during {@code Sentry.init()}. * *

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) @@ -95,6 +98,8 @@ public class PerfettoContinuousProfiler private int activeTraceCount = 0; private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private final @NotNull Set profilingCanceledCallbacks = + new HashSet<>(); public PerfettoContinuousProfiler( final @NotNull ILogger logger, @@ -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 + * that anything already tagged with it can drop the reference before being sent. + * + *

The id outlives a single chunk, so this may fire more than once for the same id, and it also + * 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 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. + if (isRunning) { + stopInternal(false); + } + // Iterated from a copy, as a listener may unregister itself while being notified. + callbacks = new ArrayList<>(profilingCanceledCallbacks); + } + for (final @NotNull IProfilingCanceledCallback callback : callbacks) { + try { + callback.onProfilingCanceled(canceledProfilerId); + } catch (Throwable t) { + // 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. * @@ -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(); } } } @@ -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. * - *

Caller must hold {@link #lock}. + *

Caller must hold the lock. */ private @NotNull IScopes resolveScopes() { if (scopes != null && scopes != NoOpScopes.getInstance()) { @@ -240,16 +303,21 @@ 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; } @@ -257,27 +325,38 @@ private void startInternal() { 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); return; } isRunning = true; - if (profilerId.equals(SentryId.EMPTY_ID)) { - profilerId = new SentryId(); - } - if (chunkId.equals(SentryId.EMPTY_ID)) { chunkId = new SentryId(); } @@ -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; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java index d09c7252694..ad167c01ca9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoProfiler.java @@ -11,8 +11,10 @@ import io.sentry.ILogger; import io.sentry.ISentryExecutorService; import io.sentry.SentryLevel; +import io.sentry.util.ExceptionUtils; import java.io.File; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -49,7 +51,10 @@ public class PerfettoProfiler { private final @NotNull Object profilingResultLock = new Object(); private volatile @Nullable ProfilingResult profilingResult = null; - private @Nullable Consumer<@Nullable File> resultListener = null; + private volatile @Nullable Consumer<@Nullable File> resultListener = null; + private volatile @Nullable Runnable onCanceledCallback; + private final @NotNull AtomicBoolean canceledCallbackInvoked = new AtomicBoolean(false); + private volatile boolean started = false; @SuppressLint("WrongConstant") @@ -104,6 +109,41 @@ public boolean start(final long durationMs) { return true; } + /** + * Sets the callback invoked once it is known that this session will not produce a trace file, + * either because the OS reported an error or because the result never arrived. Must be set before + * {@link #start}, so that an immediate failure (e.g. rate limiting) is not missed. + * + *

The callback runs at most once per instance, on whichever thread learns about the failure — + * an OS binder thread, the executor thread running the result timeout, or the caller of {@link + * #endAndCollect}. + */ + public void setOnCanceledCallback(final @NotNull Runnable onCanceledCallback) { + this.onCanceledCallback = onCanceledCallback; + } + + /** + * Invoked at most once per instance, and never while {@link #profilingResultLock} is held, as the + * callback runs listener code that takes locks of its own. + */ + private void notifyCanceled() { + if (!canceledCallbackInvoked.compareAndSet(false, true)) { + return; + } + final @Nullable Runnable callback = onCanceledCallback; + if (callback == null) { + return; + } + try { + callback.run(); + } catch (Throwable t) { + // The OS delivers results on a binder thread, where an escaping exception ends the process, + // and the callback runs listener code we do not control. + ExceptionUtils.rethrowIfFatal(t); + logger.log(SentryLevel.ERROR, "Failed to notify profiling canceled callback.", t); + } + } + /** * Cancels the current profiling session. The listener is called with the trace file (or null on * error) once the OS delivers the result. The listener may be called synchronously if the result @@ -112,32 +152,50 @@ public boolean start(final long durationMs) { public void endAndCollect(final @NotNull Consumer<@Nullable File> listener) { if (!started) { logger.log(SentryLevel.WARNING, "PerfettoProfiler was never started"); + notifyCanceled(); listener.accept(null); return; } cancellationSignal.cancel(); + final boolean resultAlreadyAvailable; + boolean noTraceFile = false; synchronized (profilingResultLock) { final @Nullable ProfilingResult result = profilingResult; + resultAlreadyAvailable = result != null; if (result != null) { - listener.accept(processResult(result)); - return; + final @Nullable File traceFile = processResult(result); + noTraceFile = traceFile == null; + listener.accept(traceFile); + } else { + resultListener = listener; } - resultListener = listener; + } + + if (resultAlreadyAvailable) { + if (noTraceFile) { + notifyCanceled(); + } + return; } try { executorService.schedule( () -> { + boolean timedOut = false; synchronized (profilingResultLock) { if (resultListener != null) { logger.log(SentryLevel.WARNING, "Timed out waiting for Perfetto profiling result."); resultListener.accept(null); // Nobody consumes a late result anymore, so delete the trace file instead resultListener = this::deleteTraceFile; + timedOut = true; } } + if (timedOut) { + notifyCanceled(); + } }, RESULT_TIMEOUT_MS); } catch (RejectedExecutionException e) { @@ -146,19 +204,34 @@ public void endAndCollect(final @NotNull Consumer<@Nullable File> listener) { } private void onProfilingResult(final @NotNull ProfilingResult result) { + final int errorCode = result.getErrorCode(); + logger.log( SentryLevel.DEBUG, "Perfetto ProfilingResult received: errorCode=%d, filePath=%s", - result.getErrorCode(), + errorCode, result.getResultFilePath()); + // Notify as early as possible: on a rate limit the OS reports back within a few ms, long before + // the chunk would have ended, so transactions still in flight can drop the profiler id. + if (errorCode != ProfilingResult.ERROR_NONE) { + notifyCanceled(); + } + + boolean noTraceFile = false; synchronized (profilingResultLock) { profilingResult = result; if (resultListener != null) { - resultListener.accept(processResult(result)); + final @Nullable File traceFile = processResult(result); + noTraceFile = traceFile == null; + resultListener.accept(traceFile); resultListener = null; } } + + if (noTraceFile) { + notifyCanceled(); + } } /** diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt index 2f76e73108f..95e488d914e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt @@ -3,14 +3,17 @@ package io.sentry.android.core import android.content.Context import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.IConnectionStatusProvider import io.sentry.ILogger +import io.sentry.IProfilingCanceledCallback import io.sentry.IScopes import io.sentry.ProfileLifecycle import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.TracesSampler import io.sentry.android.core.internal.util.SentryFrameMetricsCollector +import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -220,4 +223,158 @@ class PerfettoContinuousProfilerTest { eq("Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."), ) } + + private fun captureCanceledCallback(): () -> Runnable? { + var captured: Runnable? = null + doAnswer { invocation -> + captured = invocation.getArgument(0) + null + } + .whenever(fixture.mockPerfettoProfiler) + .setOnCanceledCallback(any()) + return { captured } + } + + @Test + fun `registered callback is notified with the profiler id of the running chunk`() { + val canceledCallback = captureCanceledCallback() + val profiler = fixture.getSut() + val notified = mutableListOf() + profiler.registerProfilingCanceledCallback(IProfilingCanceledCallback { notified.add(it) }) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + val runningProfilerId = profiler.profilerId + assertThat(runningProfilerId).isNotEqualTo(SentryId.EMPTY_ID) + + canceledCallback()!!.run() + + assertThat(notified).containsExactly(runningProfilerId) + } + + @Test + fun `unregistered callback is not notified`() { + val canceledCallback = captureCanceledCallback() + val profiler = fixture.getSut() + val notified = mutableListOf() + val callback = IProfilingCanceledCallback { notified.add(it) } + profiler.registerProfilingCanceledCallback(callback) + profiler.unregisterProfilingCanceledCallback(callback) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + canceledCallback()!!.run() + + assertThat(notified).isEmpty() + } + + @Test + fun `profiler id is assigned before the canceled callback is installed`() { + // Guards against the OS reporting a failure while start() is still on the stack, which would + // otherwise notify with an id nobody has been given yet. + var idAtInstallTime: SentryId? = null + val profiler = fixture.getSut() + val notified = mutableListOf() + profiler.registerProfilingCanceledCallback(IProfilingCanceledCallback { notified.add(it) }) + doAnswer { invocation -> + idAtInstallTime = profiler.profilerId + invocation.getArgument(0).run() + null + } + .whenever(fixture.mockPerfettoProfiler) + .setOnCanceledCallback(any()) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + + assertThat(idAtInstallTime).isNotEqualTo(SentryId.EMPTY_ID) + assertThat(notified).containsExactly(idAtInstallTime) + } + + @Test + fun `profiler id is restored and callbacks notified when the profiler fails to start`() { + val profiler = fixture.getSut() + val notified = mutableListOf() + profiler.registerProfilingCanceledCallback(IProfilingCanceledCallback { notified.add(it) }) + whenever(fixture.mockPerfettoProfiler.start(any())).thenReturn(false) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + + assertFalse(profiler.isRunning) + assertThat(profiler.profilerId).isEqualTo(SentryId.EMPTY_ID) + assertThat(notified).hasSize(1) + assertThat(notified.first()).isNotEqualTo(SentryId.EMPTY_ID) + } + + @Test + fun `close while terminating notifies callbacks once and then drops them`() { + val canceledCallback = captureCanceledCallback() + val profiler = fixture.getSut() + val notified = mutableListOf() + profiler.registerProfilingCanceledCallback(IProfilingCanceledCallback { notified.add(it) }) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + val runningProfilerId = profiler.profilerId + val runnable = canceledCallback()!! + + // The pending chunk is dropped by sendChunk once closed, so callbacks have to hear about it + profiler.close(true) + assertThat(notified).containsExactly(runningProfilerId) + + runnable.run() + + assertThat(notified).containsExactly(runningProfilerId) + } + + @Test + fun `a throwing callback neither escapes nor stops the remaining callbacks`() { + val canceledCallback = captureCanceledCallback() + val profiler = fixture.getSut() + val notified = mutableListOf() + profiler.registerProfilingCanceledCallback { throw RuntimeException("listener blew up") } + profiler.registerProfilingCanceledCallback { notified.add(it) } + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + canceledCallback()!!.run() + + assertThat(notified).hasSize(1) + } + + @Test + fun `a throwing callback does not escape the start failure path`() { + val profiler = fixture.getSut() + profiler.registerProfilingCanceledCallback { throw RuntimeException("listener blew up") } + whenever(fixture.mockPerfettoProfiler.start(any())).thenReturn(false) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + + assertFalse(profiler.isRunning) + } + + @Test + fun `cancellation tears the running chunk down so later transactions cannot reuse the id`() { + val canceledCallback = captureCanceledCallback() + val profiler = fixture.getSut() + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + canceledCallback()!!.run() + + assertFalse(profiler.isRunning) + assertThat(profiler.profilerId).isEqualTo(SentryId.EMPTY_ID) + } + + @Test + fun `close without terminating keeps registered callbacks`() { + val canceledCallback = captureCanceledCallback() + val profiler = fixture.getSut() + val notified = mutableListOf() + profiler.registerProfilingCanceledCallback { notified.add(it) } + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + val runnable = canceledCallback()!! + profiler.close(false) + + runnable.run() + + assertThat(notified).hasSize(1) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt index 0746d36dfff..ecd9ecff629 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoProfilerTest.kt @@ -5,6 +5,7 @@ import android.os.ProfilingManager import android.os.ProfilingResult import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.ILogger import io.sentry.test.DeferredExecutorService import java.io.File @@ -265,4 +266,96 @@ class PerfettoProfilerTest { assertNull(result.get()) } + + @Test + fun `canceled callback is invoked as soon as the OS reports an error`() { + val profiler = getSut() + val canceledCount = AtomicInteger(0) + profiler.setOnCanceledCallback { canceledCount.incrementAndGet() } + profiler.start(60000) + + capturedCallback.accept(mockResult(errorCode = ProfilingResult.ERROR_FAILED_RATE_LIMIT_PROCESS)) + + // Fired without waiting for endAndCollect, which is what lets in-flight transactions react + assertThat(canceledCount.get()).isEqualTo(1) + } + + @Test + fun `canceled callback is invoked only once when endAndCollect follows an error`() { + val profiler = getSut() + val canceledCount = AtomicInteger(0) + profiler.setOnCanceledCallback { canceledCount.incrementAndGet() } + profiler.start(60000) + + capturedCallback.accept(mockResult(errorCode = ProfilingResult.ERROR_UNKNOWN)) + profiler.endAndCollect {} + + assertThat(canceledCount.get()).isEqualTo(1) + } + + @Test + fun `canceled callback is not invoked when a trace file is produced`() { + val traceFile = createTraceFile() + val profiler = getSut() + val canceledCount = AtomicInteger(0) + profiler.setOnCanceledCallback { canceledCount.incrementAndGet() } + profiler.start(60000) + + capturedCallback.accept(mockResult(filePath = traceFile.absolutePath)) + profiler.endAndCollect {} + + assertThat(canceledCount.get()).isEqualTo(0) + } + + @Test + fun `canceled callback is invoked when result file path is null`() { + val profiler = getSut() + val canceledCount = AtomicInteger(0) + profiler.setOnCanceledCallback { canceledCount.incrementAndGet() } + profiler.start(60000) + + capturedCallback.accept(mockResult(filePath = null)) + profiler.endAndCollect {} + + assertThat(canceledCount.get()).isEqualTo(1) + } + + @Test + fun `canceled callback is invoked when the trace file does not exist`() { + val profiler = getSut() + val canceledCount = AtomicInteger(0) + profiler.setOnCanceledCallback { canceledCount.incrementAndGet() } + profiler.start(60000) + + capturedCallback.accept(mockResult(filePath = "/non/existent/path.pftrace")) + profiler.endAndCollect {} + + assertThat(canceledCount.get()).isEqualTo(1) + } + + @Test + fun `canceled callback is invoked when the result times out`() { + val profiler = getSut() + val canceledCount = AtomicInteger(0) + profiler.setOnCanceledCallback { canceledCount.incrementAndGet() } + profiler.start(60000) + + profiler.endAndCollect {} + assertThat(canceledCount.get()).isEqualTo(0) + + executor.runAll() + + assertThat(canceledCount.get()).isEqualTo(1) + } + + @Test + fun `canceled callback is invoked when endAndCollect is called without start`() { + val profiler = getSut() + val canceledCount = AtomicInteger(0) + profiler.setOnCanceledCallback { canceledCount.incrementAndGet() } + + profiler.endAndCollect {} + + assertThat(canceledCount.get()).isEqualTo(1) + } } diff --git a/sentry-async-profiler/api/sentry-async-profiler.api b/sentry-async-profiler/api/sentry-async-profiler.api index 045465349c2..b4d8c1bfc53 100644 --- a/sentry-async-profiler/api/sentry-async-profiler.api +++ b/sentry-async-profiler/api/sentry-async-profiler.api @@ -26,8 +26,10 @@ public final class io/sentry/asyncprofiler/profiling/JavaContinuousProfiler : io 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/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider : io/sentry/profiling/JavaContinuousProfilerProvider { diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index f5c314bf96c..2a2e25db9f9 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -6,6 +6,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; @@ -286,6 +287,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) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (stopFuture != null) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 334b617fb7d..4abe806cba6 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -831,8 +831,10 @@ public abstract interface class io/sentry/IContinuousProfiler { public abstract fun getProfilerId ()Lio/sentry/protocol/SentryId; public abstract fun isRunning ()Z public abstract fun reevaluateSampling ()V + public abstract fun registerProfilingCanceledCallback (Lio/sentry/IProfilingCanceledCallback;)V public abstract fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V public abstract fun stopProfiler (Lio/sentry/ProfileLifecycle;)V + public abstract fun unregisterProfilingCanceledCallback (Lio/sentry/IProfilingCanceledCallback;)V } public abstract interface class io/sentry/IDistributionApi { @@ -900,6 +902,10 @@ public abstract interface class io/sentry/IProfileConverter { public abstract fun convertFromFile (Ljava/lang/String;)Lio/sentry/protocol/profiling/SentryProfile; } +public abstract interface class io/sentry/IProfilingCanceledCallback { + public abstract fun onProfilingCanceled (Lio/sentry/protocol/SentryId;)V +} + public abstract interface class io/sentry/IReplayApi { public abstract fun disableDebugMaskingOverlay ()V public abstract fun enableDebugMaskingOverlay ()V @@ -1587,8 +1593,10 @@ public final class io/sentry/NoOpContinuousProfiler : io/sentry/IContinuousProfi public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun isRunning ()Z 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/NoOpDistributionApi : io/sentry/IDistributionApi { @@ -4211,7 +4219,7 @@ public final class io/sentry/SentryTraceHeader { public fun isSampled ()Ljava/lang/Boolean; } -public final class io/sentry/SentryTracer : io/sentry/ITransaction { +public final class io/sentry/SentryTracer : io/sentry/IProfilingCanceledCallback, io/sentry/ITransaction { public fun (Lio/sentry/TransactionContext;Lio/sentry/IScopes;)V public fun (Lio/sentry/TransactionContext;Lio/sentry/IScopes;Lio/sentry/TransactionOptions;)V public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V @@ -4243,6 +4251,7 @@ public final class io/sentry/SentryTracer : io/sentry/ITransaction { public fun isProfileSampled ()Ljava/lang/Boolean; public fun isSampled ()Ljava/lang/Boolean; public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun onProfilingCanceled (Lio/sentry/protocol/SentryId;)V public fun scheduleFinish ()V public fun setContext (Ljava/lang/String;Ljava/lang/Object;)V public fun setData (Ljava/lang/String;Ljava/lang/Object;)V diff --git a/sentry/src/main/java/io/sentry/IContinuousProfiler.java b/sentry/src/main/java/io/sentry/IContinuousProfiler.java index f7e59362273..b25c427f3c5 100644 --- a/sentry/src/main/java/io/sentry/IContinuousProfiler.java +++ b/sentry/src/main/java/io/sentry/IContinuousProfiler.java @@ -14,6 +14,10 @@ void startProfiler( void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle); + void registerProfilingCanceledCallback(final @NotNull IProfilingCanceledCallback callback); + + void unregisterProfilingCanceledCallback(final @NotNull IProfilingCanceledCallback callback); + /** * Cancel the profiler and stops it. * diff --git a/sentry/src/main/java/io/sentry/IProfilingCanceledCallback.java b/sentry/src/main/java/io/sentry/IProfilingCanceledCallback.java new file mode 100644 index 00000000000..9137b382327 --- /dev/null +++ b/sentry/src/main/java/io/sentry/IProfilingCanceledCallback.java @@ -0,0 +1,18 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Notified when the profiler learns that no profile will ever exist for a profiler id, so that + * anything already tagged with that id can drop the reference before being sent. + * + *

Implementations are invoked on whichever thread learns about the failure: an OS binder thread, + * the executor thread running the profiler's result timeout, or the caller that started or stopped + * the profiler, which may be the main thread. They must not block. + */ +@ApiStatus.Internal +public interface IProfilingCanceledCallback { + void onProfilingCanceled(final @NotNull SentryId profilerId); +} diff --git a/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java b/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java index 4cda59e7c33..9a22758ed9d 100644 --- a/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java +++ b/sentry/src/main/java/io/sentry/NoOpContinuousProfiler.java @@ -16,6 +16,12 @@ public static NoOpContinuousProfiler getInstance() { @Override public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) {} + @Override + public void registerProfilingCanceledCallback(@NotNull IProfilingCanceledCallback callback) {} + + @Override + public void unregisterProfilingCanceledCallback(@NotNull IProfilingCanceledCallback callback) {} + @Override public boolean isRunning() { return false; diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 936a331e3d4..776c8364ccb 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -992,9 +992,6 @@ && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE transaction = spanFactory.createTransaction( transactionContext, this, transactionOptions, compositePerformanceCollector); - // new SentryTracer( - // transactionContext, this, transactionOptions, - // compositePerformanceCollector); // The listener is called only if the transaction exists, as the transaction is needed to // stop it diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 723538b9924..fac1045552e 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -22,7 +22,7 @@ import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal -public final class SentryTracer implements ITransaction { +public final class SentryTracer implements ITransaction, IProfilingCanceledCallback { private final @NotNull SentryId eventId = new SentryId(); private final @NotNull Span root; private final @NotNull List children = new CopyOnWriteArrayList<>(); @@ -54,6 +54,11 @@ public final class SentryTracer implements ITransaction { private final @Nullable CompositePerformanceCollector compositePerformanceCollector; private final @NotNull TransactionOptions transactionOptions; + /** + * Set once the profiler reports that no profile will exist for the id this transaction carries. + */ + private volatile boolean profilingCanceled = false; + public SentryTracer(final @NotNull TransactionContext context, final @NotNull IScopes scopes) { this(context, scopes, new TransactionOptions(), null); } @@ -89,7 +94,8 @@ public SentryTracer( final @NotNull SentryId continuousProfilerId = getProfilerId(); if (!continuousProfilerId.equals(SentryId.EMPTY_ID) && Boolean.TRUE.equals(isSampled())) { - this.contexts.setProfile(new ProfileContext(continuousProfilerId)); + contexts.setProfile(new ProfileContext(continuousProfilerId)); + scopes.getOptions().getContinuousProfiler().registerProfilingCanceledCallback(this); } // We are currently sending the performance data only in profiles, but we are always sending @@ -261,6 +267,8 @@ public void finish( } }); }); + scopes.getOptions().getContinuousProfiler().unregisterProfilingCanceledCallback(this); + final SentryTransaction transaction = new SentryTransaction(this); if (timersEnabled) { @@ -541,7 +549,9 @@ private ISpan createChild( private void setDefaultSpanData(final @NotNull ISpan span) { final @NotNull IThreadChecker threadChecker = scopes.getOptions().getThreadChecker(); final @NotNull SentryId profilerId = getProfilerId(); - if (!profilerId.equals(SentryId.EMPTY_ID) && Boolean.TRUE.equals(span.isSampled())) { + if (!profilingCanceled + && !profilerId.equals(SentryId.EMPTY_ID) + && Boolean.TRUE.equals(span.isSampled())) { span.setData(SpanDataConvention.PROFILER_ID, profilerId.toString()); } span.setData( @@ -1021,6 +1031,31 @@ public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean this.root.addFeatureFlag(flag, result); } + @Override + public void onProfilingCanceled(final @NotNull SentryId profilerId) { + final @Nullable ProfileContext context = contexts.getProfile(); + if (context == null || !context.getProfilerId().equals(profilerId)) { + return; + } + + // Stops setDefaultSpanData from tagging spans started after this point + profilingCanceled = true; + contexts.remove(ProfileContext.TYPE); + root.setData(SpanDataConvention.PROFILER_ID, null); + for (final @NotNull Span child : children) { + child.setData(SpanDataConvention.PROFILER_ID, null); + } + + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Profiling was canceled for profiler id %s, dropping it from transaction %s.", + profilerId, + eventId); + } + private static final class FinishStatus { static final FinishStatus NOT_FINISHED = FinishStatus.notFinished(); diff --git a/sentry/src/test/java/io/sentry/DefaultSpanFactoryTest.kt b/sentry/src/test/java/io/sentry/DefaultSpanFactoryTest.kt new file mode 100644 index 00000000000..8ad81fb7550 --- /dev/null +++ b/sentry/src/test/java/io/sentry/DefaultSpanFactoryTest.kt @@ -0,0 +1,69 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryId +import io.sentry.test.createTestScopes +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class DefaultSpanFactoryTest { + private val continuousProfiler = mock() + private val profilerId = SentryId() + + private fun createTransaction(sampled: Boolean): ITransaction { + val options = SentryOptions().apply { dsn = "https://key@sentry.io/proj" } + // createTestScopes runs init, which resets the profiler, so it has to be set afterwards + val scopes = createTestScopes(options) + options.setContinuousProfiler(continuousProfiler) + + return DefaultSpanFactory() + .createTransaction( + TransactionContext("name", "op", TracesSamplingDecision(sampled)), + scopes, + TransactionOptions(), + null, + ) + } + + @Test + fun `registers the created transaction for profiling cancellation`() { + whenever(continuousProfiler.profilerId).thenReturn(profilerId) + + val transaction = createTransaction(sampled = true) + + verify(continuousProfiler).registerProfilingCanceledCallback(transaction as SentryTracer) + } + + @Test + fun `the registered transaction drops its profiler id when profiling is canceled`() { + whenever(continuousProfiler.profilerId).thenReturn(profilerId) + val transaction = createTransaction(sampled = true) + assertThat(transaction.contexts.profile).isNotNull() + + (transaction as IProfilingCanceledCallback).onProfilingCanceled(profilerId) + + assertThat(transaction.contexts.profile).isNull() + } + + @Test + fun `does not register an unsampled transaction`() { + whenever(continuousProfiler.profilerId).thenReturn(profilerId) + + createTransaction(sampled = false) + + verify(continuousProfiler, never()).registerProfilingCanceledCallback(any()) + } + + @Test + fun `does not register when the profiler is not running`() { + whenever(continuousProfiler.profilerId).thenReturn(SentryId.EMPTY_ID) + + createTransaction(sampled = true) + + verify(continuousProfiler, never()).registerProfilingCanceledCallback(any()) + } +} diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 20eeafffe92..5d096497678 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -295,6 +295,128 @@ class SentryTracerTest { verify(continuousProfiler, never()).stopProfiler(any()) } + @Test + fun `when profiling is canceled, profile context and profiler id span data are removed`() { + val continuousProfiler = mock() + val profilerId = SentryId() + whenever(continuousProfiler.profilerId).thenReturn(profilerId) + val tracer = + fixture.getSut( + optionsConfiguration = { it.setContinuousProfiler(continuousProfiler) }, + samplingDecision = TracesSamplingDecision(true), + ) + val span = tracer.startChild("span.op") + assertEquals(profilerId.toString(), span.getData(SpanDataConvention.PROFILER_ID)) + + tracer.onProfilingCanceled(profilerId) + + assertNull(span.getData(SpanDataConvention.PROFILER_ID)) + assertNull(tracer.root.getData(SpanDataConvention.PROFILER_ID)) + // Spans started after the cancellation must not pick the id up again + assertNull(tracer.startChild("later.op").getData(SpanDataConvention.PROFILER_ID)) + + tracer.finish() + verify(fixture.scopes) + .captureTransaction( + check { assertNull(it.contexts.profile) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when profiling is canceled for another profiler id, nothing is removed`() { + val continuousProfiler = mock() + val profilerId = SentryId() + whenever(continuousProfiler.profilerId).thenReturn(profilerId) + val tracer = + fixture.getSut( + optionsConfiguration = { it.setContinuousProfiler(continuousProfiler) }, + samplingDecision = TracesSamplingDecision(true), + ) + val span = tracer.startChild("span.op") + + tracer.onProfilingCanceled(SentryId()) + + assertEquals(profilerId.toString(), span.getData(SpanDataConvention.PROFILER_ID)) + tracer.finish() + verify(fixture.scopes) + .captureTransaction( + check { assertNotNull(it.contexts.profile) { assertEquals(profilerId, it.profilerId) } }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when continuous profiler is running, tracer registers the canceled callback`() { + val continuousProfiler = mock() + whenever(continuousProfiler.profilerId).thenReturn(SentryId()) + + val tracer = + fixture.getSut( + optionsConfiguration = { it.setContinuousProfiler(continuousProfiler) }, + samplingDecision = TracesSamplingDecision(true), + ) + + verify(continuousProfiler).registerProfilingCanceledCallback(tracer) + } + + @Test + fun `when transaction is not sampled, tracer does not register the canceled callback`() { + val continuousProfiler = mock() + whenever(continuousProfiler.profilerId).thenReturn(SentryId()) + + fixture.getSut( + optionsConfiguration = { it.setContinuousProfiler(continuousProfiler) }, + samplingDecision = TracesSamplingDecision(false), + ) + + verify(continuousProfiler, never()).registerProfilingCanceledCallback(any()) + } + + @Test + fun `finish unregisters the canceled callback`() { + val continuousProfiler = mock() + whenever(continuousProfiler.profilerId).thenReturn(SentryId()) + val tracer = + fixture.getSut( + optionsConfiguration = { it.setContinuousProfiler(continuousProfiler) }, + samplingDecision = TracesSamplingDecision(true), + ) + + tracer.finish() + + verify(continuousProfiler).unregisterProfilingCanceledCallback(tracer) + } + + @Test + fun `finish does not unregister while waiting for unfinished children`() { + val continuousProfiler = mock() + val profilerId = SentryId() + whenever(continuousProfiler.profilerId).thenReturn(profilerId) + val tracer = + fixture.getSut( + optionsConfiguration = { it.setContinuousProfiler(continuousProfiler) }, + samplingDecision = TracesSamplingDecision(true), + waitForChildren = true, + ) + val child = tracer.startChild("span.op") + + tracer.finish() + + // The transaction has not been captured yet, so a cancellation must still be able to reach it + verify(continuousProfiler, never()).unregisterProfilingCanceledCallback(any()) + tracer.onProfilingCanceled(profilerId) + assertNull(child.getData(SpanDataConvention.PROFILER_ID)) + + child.finish() + + verify(continuousProfiler).unregisterProfilingCanceledCallback(tracer) + } + @Test fun `when continuous profiler is not running, profile context is not set`() { val tracer = diff --git a/sentry/src/test/java/io/sentry/profiling/ProfilingServiceLoaderTest.kt b/sentry/src/test/java/io/sentry/profiling/ProfilingServiceLoaderTest.kt index 0fed85995da..6d7e29feb86 100644 --- a/sentry/src/test/java/io/sentry/profiling/ProfilingServiceLoaderTest.kt +++ b/sentry/src/test/java/io/sentry/profiling/ProfilingServiceLoaderTest.kt @@ -3,6 +3,7 @@ package io.sentry.profiling import io.sentry.IContinuousProfiler import io.sentry.ILogger import io.sentry.IProfileConverter +import io.sentry.IProfilingCanceledCallback import io.sentry.ISentryExecutorService import io.sentry.ProfileLifecycle import io.sentry.TracesSampler @@ -63,6 +64,14 @@ class ContinuousProfilerStub() : IContinuousProfiler { TODO("Not yet implemented") } + override fun registerProfilingCanceledCallback(callback: IProfilingCanceledCallback) { + TODO("Not yet implemented") + } + + override fun unregisterProfilingCanceledCallback(callback: IProfilingCanceledCallback) { + TODO("Not yet implemented") + } + override fun close(isTerminating: Boolean) { TODO("Not yet implemented") } From 5fa1d6a4bc3fced829000a1bbb261f8f630ca61d Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 25 Aug 2026 14:06:16 +0200 Subject: [PATCH 2/2] chore: reference PR number in changelog entry Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 698b8422d14..b26b490c332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixes -- Drop the `profiler_id` from in-flight transactions when Android's `ProfilingManager` reports that no profile will be produced ([#XXXX](https://github.com/getsentry/sentry-java/pull/XXXX)) +- Drop the `profiler_id` from in-flight transactions when Android's `ProfilingManager` reports that no profile will be produced ([#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 at a profile that never arrived ### Performance