Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixes

- Preserve single-sample ANR profile chunks so profiles remain available on ANR events ([#5872](https://github.com/getsentry/sentry-java/pull/5872))
- Prevent inflated cold app start when the OS spawns the process in the background (e.g. FCM push) on API 35+ ([#5841](https://github.com/getsentry/sentry-java/pull/5841))
- Avoid a CPU busy-loop when recording discarded log or metric envelopes under rate limiting ([#5835](https://github.com/getsentry/sentry-java/pull/5835))
- `ClientReportRecorder` now reads the item count from the envelope item header instead of deserializing the payload, which under sustained rate limiting could pin CPU cores while repeatedly throwing exceptions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ public final class StackTraceConverter {
private static final String MAIN_THREAD_ID = "0";
private static final String MAIN_THREAD_NAME = "main";

/**
* Timestamp offset used with synthetic ANR profile samples. (Currently 33 ms.)
*
* <p>Places the synthetic sample halfway to the next ANR polling tick.
Comment thread
0xadam-brown marked this conversation as resolved.
*/
private static final double SYNTHETIC_SAMPLE_OFFSET_SECONDS =
(AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0d) / 1000.0d;

/**
* Converts a list of {@link AnrStackTrace} objects to a {@link SentryProfile}.
*
Expand Down Expand Up @@ -80,6 +88,15 @@ public static SentryProfile convert(final @NotNull AnrProfile anrProfile) {
profile.getSamples().add(sample);
}

// Relay will reject ANR profiles with only one sample, even though they're still useful.
// (Relay's policy was defined with continuous profiles in mind, before ANR profiles were a
// thing.) If we only have one sample, synthesize another that only differs in its timestamp.
if (profile.getSamples().size() == 1) {
final @NotNull SentrySample originalSample = profile.getSamples().get(0);
final @NotNull SentrySample syntheticSample = createSyntheticSample(originalSample);
profile.getSamples().add(syntheticSample);
}

profile.setFrames(frames);
profile.setStacks(stacks);

Expand Down Expand Up @@ -147,4 +164,18 @@ private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElemen
}
return frame;
}

/**
* Creates a {@link SentrySample} identical to {@code originalSample}, save that its timestamp is
* advanced by {@link #SYNTHETIC_SAMPLE_OFFSET_SECONDS}.
*
* <p>Lets us produce a plausible synthetic sample without misleading the user about the ANR's
* actual duration or cause.
*/
@NotNull
private static SentrySample createSyntheticSample(@NotNull SentrySample originalSample) {
Comment thread
0xadam-brown marked this conversation as resolved.
final @NotNull SentrySample syntheticSample = new SentrySample(originalSample);
syntheticSample.setTimestamp(originalSample.getTimestamp() + SYNTHETIC_SAMPLE_OFFSET_SECONDS);
return syntheticSample;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1012,9 +1012,15 @@ class ApplicationExitInfoEventProcessorTest {
mockedSentry.`when`<Any> { Sentry.getCurrentScopes() }.thenReturn(scopes)

val processed = processor.process(SentryEvent(), hint)
val chunkCaptor = argumentCaptor<ProfileChunk>()
verify(scopes).captureProfileChunk(chunkCaptor.capture())
val sentryProfile = chunkCaptor.firstValue.sentryProfile

assertNotNull(processed?.contexts?.profile)
assertNotNull(processed.contexts.profile?.profilerId)
assertNotNull(sentryProfile)
// Two samples are present b/c the converter adds a synthetic one to keep Relay happy.
assertEquals(2, sentryProfile.samples.size)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class AnrStackTraceConverterTest {
val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))

Assert.assertNotNull(profile)
Assert.assertEquals(1, profile.samples.size)
// Two samples are present b/c the converter adds a synthetic one to keep Relay happy.
Assert.assertEquals(2, profile.samples.size)
Assert.assertEquals(2, profile.frames.size)
Assert.assertEquals(1, profile.stacks.size)

Expand All @@ -46,6 +47,57 @@ class AnrStackTraceConverterTest {
Assert.assertEquals(1.0, sample.timestamp, 0.001) // 1000ms = 1s
}

@Test
fun testAddSyntheticSampleIfOnlyOneSamplePresent() {
val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42))

val anrStackTraces: MutableList<AnrStackTrace?> = ArrayList()
anrStackTraces.add(AnrStackTrace(1000, elements))

val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))

val originalSample = profile.samples[0]
val syntheticSample = profile.samples[1]
val expectedOffsetSeconds = AnrProfilingIntegration.POLLING_INTERVAL_MS / 2.0 / 1000.0

Assert.assertEquals(
originalSample.timestamp + expectedOffsetSeconds,
syntheticSample.timestamp,
0.001,
)

Assert.assertTrue(profile.stacks[syntheticSample.stackId].isNotEmpty())
Assert.assertEquals(2, profile.samples.size)
Assert.assertEquals(1, profile.stacks.size)
Assert.assertEquals(1, profile.frames.size)
}

@Test
fun testDoNotAddSyntheticSampleIfMultipleSamplesPresent() {
val elements = arrayOf(StackTraceElement("com.example.MyClass", "method1", "MyClass.java", 42))

val anrStackTraces: MutableList<AnrStackTrace?> = ArrayList()
anrStackTraces.add(AnrStackTrace(1000, elements))
anrStackTraces.add(AnrStackTrace(2000, elements))

val profile = StackTraceConverter.convert(AnrProfile(anrStackTraces))

Assert.assertEquals(2, profile.samples.size)
Assert.assertEquals(1.0, profile.samples[0].timestamp, 0.001)
Assert.assertEquals(2.0, profile.samples[1].timestamp, 0.001)
Assert.assertEquals(1, profile.stacks.size)
Assert.assertEquals(1, profile.frames.size)
}

@Test
fun testDoNotAddSyntheticSampleIfNoSamplesPresent() {
val profile = StackTraceConverter.convert(AnrProfile(ArrayList()))

Assert.assertEquals(0, profile.samples.size)
Assert.assertEquals(0, profile.stacks.size)
Assert.assertEquals(0, profile.frames.size)
}

@Test
fun testFrameDeduplication() {
// Create two stack traces with duplicate frames
Expand Down
1 change: 1 addition & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -7066,6 +7066,7 @@ public final class io/sentry/protocol/profiling/SentryProfile$JsonKeys {

public final class io/sentry/protocol/profiling/SentrySample : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
public fun <init> ()V
public fun <init> (Lio/sentry/protocol/profiling/SentrySample;)V
public fun getStackId ()I
public fun getThreadId ()Ljava/lang/String;
public fun getTimestamp ()D
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.sentry.JsonUnknown;
import io.sentry.ObjectReader;
import io.sentry.ObjectWriter;
import io.sentry.util.CollectionUtils;
import io.sentry.vendor.gson.stream.JsonToken;
import java.io.IOException;
import java.util.HashMap;
Expand All @@ -27,6 +28,15 @@ public final class SentrySample implements JsonUnknown, JsonSerializable {

private @Nullable Map<String, Object> unknown;

public SentrySample() {}

public SentrySample(final @NotNull SentrySample sample) {
this.timestamp = sample.timestamp;
this.stackId = sample.stackId;
this.threadId = sample.threadId;
this.unknown = CollectionUtils.newConcurrentHashMap(sample.unknown);
}

public double getTimestamp() {
return timestamp;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.sentry.protocol.profiling

import com.google.common.truth.Truth.assertThat
import org.junit.Test

class SentrySampleTest {

@Test
fun `copying sample keeps values and copies unknown`() {
val unknown = mutableMapOf<String, Any>("key" to "value")
val original =
SentrySample().apply {
timestamp = 1.23
stackId = 4
threadId = "main"
setUnknown(unknown)
}

val copy = SentrySample(original)

assertThat(copy.timestamp).isEqualTo(original.timestamp)
assertThat(copy.stackId).isEqualTo(original.stackId)
assertThat(copy.threadId).isEqualTo(original.threadId)
assertThat(copy.unknown).containsExactlyEntriesIn(original.unknown)
assertThat(copy.unknown).isNotSameInstanceAs(original.unknown)

unknown["key"] = "changed"
assertThat(copy.unknown).containsEntry("key", "value")
}
}
Loading