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
4 changes: 4 additions & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,10 @@ public final class io/sentry/android/core/anr/AnrCulpritIdentifier {
public static fun isSystemFrame (Ljava/lang/String;)Z
}

public final class io/sentry/android/core/anr/AnrFlameGraphAttachment {
public static fun fromAnrProfile (Lio/sentry/android/core/anr/AnrProfile;Ljava/lang/String;)Lio/sentry/Attachment;
}

public final class io/sentry/android/core/anr/AnrProfile {
public final field endTimeMs J
public final field stacks Ljava/util/List;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package io.sentry.android.core.anr;

import io.sentry.Attachment;
import io.sentry.JsonObjectWriter;
import io.sentry.NoOpLogger;
import io.sentry.protocol.AnrFlameGraph;
import io.sentry.protocol.AnrFlameGraphNode;
import io.sentry.protocol.AnrFlameGraphTree;
import io.sentry.protocol.DebugImage;
import io.sentry.protocol.DebugMeta;
import io.sentry.protocol.SentryStackFrame;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Creates a flame graph attachment from an {@link AnrProfile}.
*
* @see <a href="https://develop.sentry.dev/sdk/telemetry/attachments/#flame-graph">Flame Graph
* attachment spec</a>
*/
@ApiStatus.Internal
public final class AnrFlameGraphAttachment {

static final @NotNull String FILENAME = "flamegraph.json";
static final @NotNull String CONTENT_TYPE = "application/json";
static final @NotNull String ATTACHMENT_TYPE = "event.flamegraph";
static final @NotNull String VERSION = "1";
static final @NotNull String PLATFORM = "java";
// The flame graph spec limit, see
// https://develop.sentry.dev/sdk/telemetry/attachments/#flame-graph
// The envelope only enforces the type-agnostic maxAttachmentSize.
static final int MAX_SIZE_BYTES = 10 * 1024 * 1024;

// Safety net only: JsonObjectWriter applies the depth limit to objects it serializes by
// reflection, and an AnrFlameGraph holds only JsonSerializables, strings, numbers and lists. The
// flame graph JSON is at most 4 levels deep.
private static final int MAX_JSON_DEPTH = 10;
private static final int NO_PARENT = -1;

private AnrFlameGraphAttachment() {}

/**
* Aggregates the main thread samples of {@code anrProfile} into a single call tree.
*
* @param proguardUuid the ProGuard UUID of the build that recorded the profile, added as the only
* debug image so the frames can be deobfuscated, or {@code null} to omit the debug meta
* @return the attachment, or {@code null} if the profile has no frames or the serialized flame
* graph exceeds {@link #MAX_SIZE_BYTES}
*/
public static @Nullable Attachment fromAnrProfile(
final @NotNull AnrProfile anrProfile, final @Nullable String proguardUuid) {
final @Nullable AnrFlameGraph flameGraph = toFlameGraph(anrProfile, proguardUuid);
if (flameGraph == null) {
return null;
}

final @NotNull byte[] bytes;
try {
bytes = serialize(flameGraph);
} catch (IOException e) {
// only an in-memory stream is written to, which does not throw
return null;
}
if (bytes.length > MAX_SIZE_BYTES) {
return null;
}
return new Attachment(bytes, FILENAME, CONTENT_TYPE, ATTACHMENT_TYPE, false);
}

private static @Nullable AnrFlameGraph toFlameGraph(
final @NotNull AnrProfile anrProfile, final @Nullable String proguardUuid) {
final @NotNull List<SentryStackFrame> frames = new ArrayList<>();
final @NotNull Map<String, Integer> frameSignatureToIndex = new HashMap<>();
final @NotNull List<AnrFlameGraphNode> nodes = new ArrayList<>();
final @NotNull List<Integer> roots = new ArrayList<>();
final @NotNull Map<Long, Integer> parentAndFrameToNode = new HashMap<>();

for (final @NotNull AnrStackTrace anrStackTrace : anrProfile.stacks) {
final @NotNull StackTraceElement[] stack = anrStackTrace.stack;
int parentId = NO_PARENT;
for (int i = stack.length - 1; i >= 0; i--) {
final @NotNull StackTraceElement element = stack[i];
final @NotNull String frameSignature = StackTraceConverter.createFrameSignature(element);
@Nullable Integer frameId = frameSignatureToIndex.get(frameSignature);
if (frameId == null) {
frameId = frames.size();
frames.add(StackTraceConverter.createSentryStackFrame(element));
frameSignatureToIndex.put(frameSignature, frameId);
}

final long key = ((long) parentId << 32) | frameId;
@Nullable Integer nodeId = parentAndFrameToNode.get(key);
if (nodeId == null) {
nodeId = nodes.size();
nodes.add(new AnrFlameGraphNode(frameId, 0, new ArrayList<>()));
parentAndFrameToNode.put(key, nodeId);
if (parentId == NO_PARENT) {
roots.add(nodeId);
} else {
nodes.get(parentId).getChildren().add(nodeId);
}
}
final @NotNull AnrFlameGraphNode node = nodes.get(nodeId);
node.setSampleCount(node.getSampleCount() + 1);
parentId = nodeId;
}
}

if (nodes.isEmpty()) {
return null;
}

// the ANR profile only samples the main thread, which is the thread that caused the ANR
final @NotNull AnrFlameGraphTree tree = new AnrFlameGraphTree(roots);
tree.setThreadAttributed(true);

final @NotNull AnrFlameGraph flameGraph =
new AnrFlameGraph(VERSION, PLATFORM, frames, nodes, Collections.singletonList(tree));
if (proguardUuid != null) {
flameGraph.setDebugMeta(createDebugMeta(proguardUuid));
}
return flameGraph;
}

private static @NotNull byte[] serialize(final @NotNull AnrFlameGraph flameGraph)
throws IOException {
final @NotNull ByteArrayOutputStream stream = new ByteArrayOutputStream();
try (final @NotNull Writer out = new OutputStreamWriter(stream, StandardCharsets.UTF_8)) {
flameGraph.serialize(new JsonObjectWriter(out, MAX_JSON_DEPTH), NoOpLogger.getInstance());
}
return stream.toByteArray();
}

private static @NotNull DebugMeta createDebugMeta(final @NotNull String proguardUuid) {
final @NotNull DebugImage debugImage = new DebugImage();
debugImage.setType(DebugImage.PROGUARD);
debugImage.setUuid(proguardUuid);
final @NotNull DebugMeta debugMeta = new DebugMeta();
debugMeta.setImages(Collections.singletonList(debugImage));
return debugMeta;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public static SentryProfile convert(final @NotNull AnrProfile anrProfile) {
* @return a signature string representing this frame
*/
@NotNull
private static String createFrameSignature(@NotNull StackTraceElement element) {
static String createFrameSignature(@NotNull StackTraceElement element) {
return element.getClassName()
+ "#"
+ element.getMethodName()
Expand Down Expand Up @@ -153,7 +153,7 @@ private static String createStackSignature(@NotNull List<Integer> frameIndices)
* @return a SentryStackFrame populated with available information
*/
@NotNull
private static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElement element) {
static SentryStackFrame createSentryStackFrame(@NotNull StackTraceElement element) {
final @NotNull SentryStackFrame frame = new SentryStackFrame();
frame.setFilename(element.getFileName());
frame.setFunction(element.getMethodName());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package io.sentry.android.core.anr

import com.google.common.truth.Truth.assertThat
import io.sentry.JsonObjectReader
import java.io.StringReader
import kotlin.test.Test

class AnrFlameGraphAttachmentTest {

private val main = StackTraceElement("com.example.App", "main", "App.java", 10)
private val work = StackTraceElement("com.example.App", "work", "App.java", 20)
private val other = StackTraceElement("com.example.Other", "other", "Other.java", 30)

@Test
fun `aggregates samples into a caller-first call tree`() {
val profile =
AnrProfile(
listOf(
AnrStackTrace(1000, arrayOf(work, main)),
AnrStackTrace(1066, arrayOf(work, main)),
AnrStackTrace(1132, arrayOf(main)),
AnrStackTrace(1198, arrayOf(main, main)),
AnrStackTrace(1264, arrayOf(other)),
)
)

val attachment = AnrFlameGraphAttachment.fromAnrProfile(profile, PROGUARD_UUID)!!

assertThat(parse(String(attachment.bytes!!, Charsets.UTF_8)))
.isEqualTo(
parse(
"""
{
"version": "1",
"platform": "java",
"frames": [
{"filename": "App.java", "function": "main", "module": "com.example.App", "lineno": 10},
{"filename": "App.java", "function": "work", "module": "com.example.App", "lineno": 20},
{"filename": "Other.java", "function": "other", "module": "com.example.Other", "lineno": 30}
],
"nodes": [
{"frame_id": 0, "sample_count": 4, "children": [1, 2]},
{"frame_id": 1, "sample_count": 2},
{"frame_id": 0, "sample_count": 1},
{"frame_id": 2, "sample_count": 1}
],
"trees": [
{"roots": [0, 3], "thread_attributed": true}
],
"debug_meta": {
"images": [{"type": "proguard", "uuid": "$PROGUARD_UUID"}]
}
}
"""
)
)
}

@Test
fun `creates a flame graph attachment`() {
val profile = AnrProfile(listOf(AnrStackTrace(1000, arrayOf(main))))

val attachment = AnrFlameGraphAttachment.fromAnrProfile(profile, PROGUARD_UUID)!!

assertThat(attachment.filename).isEqualTo("flamegraph.json")
assertThat(attachment.contentType).isEqualTo("application/json")
assertThat(attachment.attachmentType).isEqualTo("event.flamegraph")
}

@Test
fun `omits debug meta without a proguard uuid`() {
val profile = AnrProfile(listOf(AnrStackTrace(1000, arrayOf(main))))

val attachment = AnrFlameGraphAttachment.fromAnrProfile(profile, null)!!

val flamegraph = parse(String(attachment.bytes!!, Charsets.UTF_8)) as Map<*, *>
assertThat(flamegraph).doesNotContainKey("debug_meta")
}

@Test
fun `returns null when the profile has no samples`() {
assertThat(AnrFlameGraphAttachment.fromAnrProfile(AnrProfile(emptyList()), PROGUARD_UUID))
.isNull()
}

@Test
fun `returns null when the profile has only empty stacks`() {
val profile = AnrProfile(listOf(AnrStackTrace(1000, emptyArray())))

assertThat(AnrFlameGraphAttachment.fromAnrProfile(profile, PROGUARD_UUID)).isNull()
}

@Test
fun `returns null when the flame graph exceeds the size limit`() {
val longClassName = "com.example." + "a".repeat(200)
val stacks =
(0 until 60).map { sample ->
AnrStackTrace(
1000L + sample,
Array(1000) { depth ->
StackTraceElement(longClassName, "method$sample-$depth", "A.java", depth + 1)
},
)
}

assertThat(AnrFlameGraphAttachment.fromAnrProfile(AnrProfile(stacks), PROGUARD_UUID)).isNull()
}

private companion object {
const val PROGUARD_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

private fun parse(json: String): Any? = JsonObjectReader(StringReader(json)).nextObjectOrNull()
}
79 changes: 79 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -5833,6 +5833,85 @@ public final class io/sentry/profiling/ProfilingServiceLoader {
public static fun loadProfileConverter ()Lio/sentry/IProfileConverter;
}

public final class io/sentry/protocol/AnrFlameGraph : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V
public fun getDebugMeta ()Lio/sentry/protocol/DebugMeta;
public fun getFrames ()Ljava/util/List;
public fun getNodes ()Ljava/util/List;
public fun getPlatform ()Ljava/lang/String;
public fun getTrees ()Ljava/util/List;
public fun getUnknown ()Ljava/util/Map;
public fun getVersion ()Ljava/lang/String;
public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V
public fun setDebugMeta (Lio/sentry/protocol/DebugMeta;)V
public fun setUnknown (Ljava/util/Map;)V
}

public final class io/sentry/protocol/AnrFlameGraph$Deserializer : io/sentry/JsonDeserializer {
public fun <init> ()V
public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/AnrFlameGraph;
public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object;
}

public final class io/sentry/protocol/AnrFlameGraph$JsonKeys {
public static final field DEBUG_META Ljava/lang/String;
public static final field FRAMES Ljava/lang/String;
public static final field NODES Ljava/lang/String;
public static final field PLATFORM Ljava/lang/String;
public static final field TREES Ljava/lang/String;
public static final field VERSION Ljava/lang/String;
public fun <init> ()V
}

public final class io/sentry/protocol/AnrFlameGraphNode : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
public fun <init> (IJLjava/util/List;)V
public fun getChildren ()Ljava/util/List;
public fun getFrameId ()I
public fun getSampleCount ()J
public fun getUnknown ()Ljava/util/Map;
public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V
public fun setSampleCount (J)V
public fun setUnknown (Ljava/util/Map;)V
}

public final class io/sentry/protocol/AnrFlameGraphNode$Deserializer : io/sentry/JsonDeserializer {
public fun <init> ()V
public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/AnrFlameGraphNode;
public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object;
}

public final class io/sentry/protocol/AnrFlameGraphNode$JsonKeys {
public static final field CHILDREN Ljava/lang/String;
public static final field FRAME_ID Ljava/lang/String;
public static final field SAMPLE_COUNT Ljava/lang/String;
public fun <init> ()V
}

public final class io/sentry/protocol/AnrFlameGraphTree : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
public fun <init> (Ljava/util/List;)V
public fun getRoots ()Ljava/util/List;
public fun getThreadAttributed ()Ljava/lang/Boolean;
public fun getThreadId ()Ljava/lang/String;
public fun getUnknown ()Ljava/util/Map;
public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V
public fun setThreadAttributed (Ljava/lang/Boolean;)V
public fun setThreadId (Ljava/lang/String;)V
public fun setUnknown (Ljava/util/Map;)V
}

public final class io/sentry/protocol/AnrFlameGraphTree$Deserializer : io/sentry/JsonDeserializer {
public fun <init> ()V
public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/AnrFlameGraphTree;
public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object;
}

public final class io/sentry/protocol/AnrFlameGraphTree$JsonKeys {
public static final field ROOTS Ljava/lang/String;
public static final field THREAD_ATTRIBUTED Ljava/lang/String;
public static final field THREAD_ID Ljava/lang/String;
public fun <init> ()V
}

public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
public static final field TYPE Ljava/lang/String;
public fun <init> ()V
Expand Down
Loading
Loading