Skip to content

Commit

Permalink
Add rate limit for Continuous Profiling v8 (p6) (#3926)
Browse files Browse the repository at this point in the history
* continuous profiler now doesn't start if offline or rate limited
* continuous profiler stops when rate limited
* continuous profiler prevents sending chunks after being closed
* added profile_chunk rate limit
* continuous profiler now reset its id when rate limited or offline
  • Loading branch information
stefanosiano authored Dec 6, 2024
1 parent 6c36c4b commit b0e8333
Show file tree
Hide file tree
Showing 9 changed files with 160 additions and 5 deletions.
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 @@ -36,11 +36,12 @@ public final class io/sentry/android/core/ActivityLifecycleIntegration : android
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
}

public class io/sentry/android/core/AndroidContinuousProfiler : io/sentry/IContinuousProfiler {
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 close ()V
public fun getProfilerId ()Lio/sentry/protocol/SentryId;
public fun isRunning ()Z
public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V
public fun start ()V
public fun stop ()V
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package io.sentry.android.core;

import static io.sentry.DataCategory.All;
import static io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED;
import static java.util.concurrent.TimeUnit.SECONDS;

import android.annotation.SuppressLint;
import android.os.Build;
import io.sentry.CompositePerformanceCollector;
import io.sentry.DataCategory;
import io.sentry.IContinuousProfiler;
import io.sentry.ILogger;
import io.sentry.IScopes;
Expand All @@ -17,17 +20,20 @@
import io.sentry.SentryOptions;
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector;
import io.sentry.protocol.SentryId;
import io.sentry.transport.RateLimiter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.VisibleForTesting;

@ApiStatus.Internal
public class AndroidContinuousProfiler implements IContinuousProfiler {
public class AndroidContinuousProfiler
implements IContinuousProfiler, RateLimiter.IRateLimitObserver {
private static final long MAX_CHUNK_DURATION_MILLIS = 10000;

private final @NotNull ILogger logger;
Expand All @@ -45,6 +51,7 @@ public class AndroidContinuousProfiler implements IContinuousProfiler {
private final @NotNull List<ProfileChunk.Builder> payloadBuilders = new ArrayList<>();
private @NotNull SentryId profilerId = SentryId.EMPTY_ID;
private @NotNull SentryId chunkId = SentryId.EMPTY_ID;
private final @NotNull AtomicBoolean isClosed = new AtomicBoolean(false);

public AndroidContinuousProfiler(
final @NotNull BuildInfoProvider buildInfoProvider,
Expand Down Expand Up @@ -91,11 +98,15 @@ private void init() {
}

public synchronized void start() {
if ((scopes == null || scopes != NoOpScopes.getInstance())
if ((scopes == null || scopes == NoOpScopes.getInstance())
&& Sentry.getCurrentScopes() != NoOpScopes.getInstance()) {
this.scopes = Sentry.getCurrentScopes();
this.performanceCollector =
Sentry.getCurrentScopes().getOptions().getCompositePerformanceCollector();
final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter();
if (rateLimiter != null) {
rateLimiter.addRateLimitObserver(this);
}
}

// Debug.startMethodTracingSampling() is only available since Lollipop, but Android Profiler
Expand All @@ -109,6 +120,26 @@ public synchronized void start() {
return;
}

if (scopes != null) {
final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter();
if (rateLimiter != null
&& (rateLimiter.isActiveForCategory(All)
|| rateLimiter.isActiveForCategory(DataCategory.ProfileChunk))) {
logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler.");
// Let's stop and reset profiler id, as the profile is now broken anyway
stop();
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.");
// Let's stop and reset profiler id, as the profile is now broken anyway
stop();
return;
}
}

final AndroidProfiler.ProfileStartData startData = profiler.start();
// check if profiling started
if (startData == null) {
Expand Down Expand Up @@ -150,6 +181,9 @@ private synchronized void stop(final boolean restartProfiler) {
}
// check if profiler was created and it's running
if (profiler == null || !isRunning) {
// When the profiler is stopped due to an error (e.g. offline or rate limited), reset the ids
profilerId = SentryId.EMPTY_ID;
chunkId = SentryId.EMPTY_ID;
return;
}

Expand Down Expand Up @@ -203,6 +237,7 @@ private synchronized void stop(final boolean restartProfiler) {

public synchronized void close() {
stop();
isClosed.set(true);
}

@Override
Expand All @@ -216,6 +251,10 @@ private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOpti
.getExecutorService()
.submit(
() -> {
// SDK is closed, we don't send the chunks
if (isClosed.get()) {
return;
}
final ArrayList<ProfileChunk> payloads = new ArrayList<>(payloadBuilders.size());
synchronized (payloadBuilders) {
for (ProfileChunk.Builder builder : payloadBuilders) {
Expand All @@ -242,4 +281,16 @@ public boolean isRunning() {
Future<?> getStopFuture() {
return stopFuture;
}

@Override
public void onRateLimitChanged(@NotNull RateLimiter rateLimiter) {
// We stop the profiler as soon as we are rate limited, to avoid the performance overhead
if (rateLimiter.isActiveForCategory(All)
|| rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)) {
logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler.");
stop();
}
// If we are not rate limited anymore, we don't do anything: the profile is broken, so it's
// useless to restart it automatically
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.sentry.CompositePerformanceCollector
import io.sentry.CpuCollectionData
import io.sentry.DataCategory
import io.sentry.IConnectionStatusProvider
import io.sentry.ILogger
import io.sentry.IScopes
import io.sentry.ISentryExecutorService
Expand All @@ -18,8 +20,10 @@ import io.sentry.SentryTracer
import io.sentry.TransactionContext
import io.sentry.android.core.internal.util.SentryFrameMetricsCollector
import io.sentry.profilemeasurements.ProfileMeasurement
import io.sentry.protocol.SentryId
import io.sentry.test.DeferredExecutorService
import io.sentry.test.getProperty
import io.sentry.transport.RateLimiter
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.check
Expand All @@ -37,6 +41,7 @@ import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
Expand Down Expand Up @@ -394,4 +399,74 @@ class AndroidContinuousProfilerTest {
executorService.runAll()
verify(fixture.scopes, times(2)).captureProfileChunk(any())
}

@Test
fun `profiler does not send chunks after close`() {
val executorService = DeferredExecutorService()
val profiler = fixture.getSut {
it.executorService = executorService
}
profiler.start()
assertTrue(profiler.isRunning)

// We close the profiler, which should prevent sending additional chunks
profiler.close()

// The executor used to send the chunk doesn't do anything
executorService.runAll()
verify(fixture.scopes, never()).captureProfileChunk(any())
}

@Test
fun `profiler stops when rate limited`() {
val executorService = DeferredExecutorService()
val profiler = fixture.getSut {
it.executorService = executorService
}
val rateLimiter = mock<RateLimiter>()
whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)).thenReturn(true)

profiler.start()
assertTrue(profiler.isRunning)

// If the SDK is rate limited, the profiler should stop
profiler.onRateLimitChanged(rateLimiter)
assertFalse(profiler.isRunning)
assertEquals(SentryId.EMPTY_ID, profiler.profilerId)
verify(fixture.mockLogger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler."))
}

@Test
fun `profiler does not start when rate limited`() {
val executorService = DeferredExecutorService()
val profiler = fixture.getSut {
it.executorService = executorService
}
val rateLimiter = mock<RateLimiter>()
whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)).thenReturn(true)
whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter)

// If the SDK is rate limited, the profiler should never start
profiler.start()
assertFalse(profiler.isRunning)
assertEquals(SentryId.EMPTY_ID, profiler.profilerId)
verify(fixture.mockLogger).log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler."))
}

@Test
fun `profiler does not start when offline`() {
val executorService = DeferredExecutorService()
val profiler = fixture.getSut {
it.executorService = executorService
it.connectionStatusProvider = mock { provider ->
whenever(provider.connectionStatus).thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED)
}
}

// If the device is offline, the profiler should never start
profiler.start()
assertFalse(profiler.isRunning)
assertEquals(SentryId.EMPTY_ID, profiler.profilerId)
verify(fixture.mockLogger).log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler."))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public final class io/sentry/opentelemetry/OtelSpanFactory : io/sentry/ISpanFact
public fun <init> ()V
public fun <init> (Lio/opentelemetry/api/OpenTelemetry;)V
public fun createSpan (Lio/sentry/IScopes;Lio/sentry/SpanOptions;Lio/sentry/SpanContext;Lio/sentry/ISpan;)Lio/sentry/ISpan;
public fun createTransaction (Lio/sentry/TransactionContext;Lio/sentry/IScopes;Lio/sentry/TransactionOptions;Lio/sentry/TransactionPerformanceCollector;)Lio/sentry/ITransaction;
public fun createTransaction (Lio/sentry/TransactionContext;Lio/sentry/IScopes;Lio/sentry/TransactionOptions;Lio/sentry/CompositePerformanceCollector;)Lio/sentry/ITransaction;
}

public final class io/sentry/opentelemetry/OtelTransactionSpanForwarder : io/sentry/ITransaction {
Expand Down
1 change: 1 addition & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ public final class io/sentry/DataCategory : java/lang/Enum {
public static final field Error Lio/sentry/DataCategory;
public static final field Monitor Lio/sentry/DataCategory;
public static final field Profile Lio/sentry/DataCategory;
public static final field ProfileChunk Lio/sentry/DataCategory;
public static final field Replay Lio/sentry/DataCategory;
public static final field Security Lio/sentry/DataCategory;
public static final field Session Lio/sentry/DataCategory;
Expand Down
1 change: 1 addition & 0 deletions sentry/src/main/java/io/sentry/DataCategory.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public enum DataCategory {
Attachment("attachment"),
Monitor("monitor"),
Profile("profile"),
ProfileChunk("profile_chunk"),
Transaction("transaction"),
Replay("replay"),
Span("span"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ private DataCategory categoryFromItemType(SentryItemType itemType) {
if (SentryItemType.Profile.equals(itemType)) {
return DataCategory.Profile;
}
if (SentryItemType.ProfileChunk.equals(itemType)) {
return DataCategory.ProfileChunk;
}
if (SentryItemType.Attachment.equals(itemType)) {
return DataCategory.Attachment;
}
Expand Down
2 changes: 2 additions & 0 deletions sentry/src/main/java/io/sentry/transport/RateLimiter.java
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ private boolean isRetryAfter(final @NotNull String itemType) {
return DataCategory.Attachment;
case "profile":
return DataCategory.Profile;
case "profile_chunk":
return DataCategory.ProfileChunk;
case "transaction":
return DataCategory.Transaction;
case "check_in":
Expand Down
23 changes: 22 additions & 1 deletion sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import io.sentry.ILogger
import io.sentry.IScopes
import io.sentry.ISerializer
import io.sentry.NoOpLogger
import io.sentry.ProfileChunk
import io.sentry.ProfilingTraceData
import io.sentry.ReplayRecording
import io.sentry.SentryEnvelope
Expand Down Expand Up @@ -208,8 +209,9 @@ class RateLimiterTest {
val attachmentItem = SentryEnvelopeItem.fromAttachment(fixture.serializer, NoOpLogger.getInstance(), Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000)
val profileItem = SentryEnvelopeItem.fromProfilingTrace(ProfilingTraceData(File(""), transaction), 1000, fixture.serializer)
val checkInItem = SentryEnvelopeItem.fromCheckIn(fixture.serializer, CheckIn("monitor-slug-1", CheckInStatus.ERROR))
val profileChunkItem = SentryEnvelopeItem.fromProfileChunk(ProfileChunk(), fixture.serializer)

val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem, userFeedbackItem, sessionItem, attachmentItem, profileItem, checkInItem))
val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(eventItem, userFeedbackItem, sessionItem, attachmentItem, profileItem, checkInItem, profileChunkItem))

rateLimiter.updateRetryAfterLimits(null, null, 429)
val result = rateLimiter.filter(envelope, Hint())
Expand All @@ -222,6 +224,7 @@ class RateLimiterTest {
verify(fixture.clientReportRecorder, times(1)).recordLostEnvelopeItem(eq(DiscardReason.RATELIMIT_BACKOFF), same(attachmentItem))
verify(fixture.clientReportRecorder, times(1)).recordLostEnvelopeItem(eq(DiscardReason.RATELIMIT_BACKOFF), same(profileItem))
verify(fixture.clientReportRecorder, times(1)).recordLostEnvelopeItem(eq(DiscardReason.RATELIMIT_BACKOFF), same(checkInItem))
verify(fixture.clientReportRecorder, times(1)).recordLostEnvelopeItem(eq(DiscardReason.RATELIMIT_BACKOFF), same(profileChunkItem))
verifyNoMoreInteractions(fixture.clientReportRecorder)
}

Expand Down Expand Up @@ -331,6 +334,24 @@ class RateLimiterTest {
verifyNoMoreInteractions(fixture.clientReportRecorder)
}

@Test
fun `drop profileChunk items as lost`() {
val rateLimiter = fixture.getSUT()

val profileChunkItem = SentryEnvelopeItem.fromProfileChunk(ProfileChunk(), fixture.serializer)
val attachmentItem = SentryEnvelopeItem.fromAttachment(fixture.serializer, NoOpLogger.getInstance(), Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), 1000)
val envelope = SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(profileChunkItem, attachmentItem))

rateLimiter.updateRetryAfterLimits("60:profile_chunk:key", null, 1)
val result = rateLimiter.filter(envelope, Hint())

assertNotNull(result)
assertEquals(1, result.items.toList().size)

verify(fixture.clientReportRecorder, times(1)).recordLostEnvelopeItem(eq(DiscardReason.RATELIMIT_BACKOFF), same(profileChunkItem))
verifyNoMoreInteractions(fixture.clientReportRecorder)
}

@Test
fun `apply rate limits notifies observers`() {
val rateLimiter = fixture.getSUT()
Expand Down

0 comments on commit b0e8333

Please sign in to comment.