diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api
index 712f981ecd2..17d9c1edd6d 100644
--- a/sentry-android-core/api/sentry-android-core.api
+++ b/sentry-android-core/api/sentry-android-core.api
@@ -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;
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrFlameGraphAttachment.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrFlameGraphAttachment.java
new file mode 100644
index 00000000000..42a9e846b64
--- /dev/null
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrFlameGraphAttachment.java
@@ -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 Flame Graph
+ * attachment spec
+ */
+@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 frames = new ArrayList<>();
+ final @NotNull Map frameSignatureToIndex = new HashMap<>();
+ final @NotNull List nodes = new ArrayList<>();
+ final @NotNull List roots = new ArrayList<>();
+ final @NotNull Map 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;
+ }
+}
diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java
index 8f1254e08ca..2514306f1a8 100644
--- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java
+++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/StackTraceConverter.java
@@ -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()
@@ -153,7 +153,7 @@ private static String createStackSignature(@NotNull List 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());
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrFlameGraphAttachmentTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrFlameGraphAttachmentTest.kt
new file mode 100644
index 00000000000..4ad8f5d9ec6
--- /dev/null
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrFlameGraphAttachmentTest.kt
@@ -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()
+}
diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api
index dfce80a93d8..1a1562e880c 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -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 (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 ()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 ()V
+}
+
+public final class io/sentry/protocol/AnrFlameGraphNode : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
+ public fun (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 ()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 ()V
+}
+
+public final class io/sentry/protocol/AnrFlameGraphTree : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
+ public fun (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 ()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 ()V
+}
+
public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
public static final field TYPE Ljava/lang/String;
public fun ()V
diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java
index 79a1c72bef3..f41a42698e5 100644
--- a/sentry/src/main/java/io/sentry/JsonSerializer.java
+++ b/sentry/src/main/java/io/sentry/JsonSerializer.java
@@ -3,6 +3,9 @@
import io.sentry.clientreport.ClientReport;
import io.sentry.profilemeasurements.ProfileMeasurement;
import io.sentry.profilemeasurements.ProfileMeasurementValue;
+import io.sentry.protocol.AnrFlameGraph;
+import io.sentry.protocol.AnrFlameGraphNode;
+import io.sentry.protocol.AnrFlameGraphTree;
import io.sentry.protocol.App;
import io.sentry.protocol.Browser;
import io.sentry.protocol.Contexts;
@@ -147,6 +150,9 @@ public JsonSerializer(@NotNull SentryOptions options) {
deserializersByClass.put(ClientReport.class, new ClientReport.Deserializer());
deserializersByClass.put(ViewHierarchyNode.class, new ViewHierarchyNode.Deserializer());
deserializersByClass.put(ViewHierarchy.class, new ViewHierarchy.Deserializer());
+ deserializersByClass.put(AnrFlameGraph.class, new AnrFlameGraph.Deserializer());
+ deserializersByClass.put(AnrFlameGraphNode.class, new AnrFlameGraphNode.Deserializer());
+ deserializersByClass.put(AnrFlameGraphTree.class, new AnrFlameGraphTree.Deserializer());
}
// Deserialize
diff --git a/sentry/src/main/java/io/sentry/protocol/AnrFlameGraph.java b/sentry/src/main/java/io/sentry/protocol/AnrFlameGraph.java
new file mode 100644
index 00000000000..d8cb90aa930
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/protocol/AnrFlameGraph.java
@@ -0,0 +1,191 @@
+package io.sentry.protocol;
+
+import io.sentry.ILogger;
+import io.sentry.JsonDeserializer;
+import io.sentry.JsonSerializable;
+import io.sentry.JsonUnknown;
+import io.sentry.ObjectReader;
+import io.sentry.ObjectWriter;
+import io.sentry.SentryLevel;
+import io.sentry.vendor.gson.stream.JsonToken;
+import java.io.IOException;
+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;
+
+/**
+ * The payload of a flame graph attachment, holding the call trees sampled during an ANR.
+ *
+ * @see Flame Graph
+ * attachment spec
+ */
+@ApiStatus.Internal
+public final class AnrFlameGraph implements JsonUnknown, JsonSerializable {
+
+ public static final class JsonKeys {
+ public static final String VERSION = "version";
+ public static final String PLATFORM = "platform";
+ public static final String FRAMES = "frames";
+ public static final String NODES = "nodes";
+ public static final String TREES = "trees";
+ public static final String DEBUG_META = "debug_meta";
+ }
+
+ private final @NotNull String version;
+ private final @NotNull String platform;
+ private final @NotNull List frames;
+ private final @NotNull List nodes;
+ private final @NotNull List trees;
+ private @Nullable DebugMeta debugMeta;
+ private @Nullable Map unknown;
+
+ public AnrFlameGraph(
+ final @NotNull String version,
+ final @NotNull String platform,
+ final @NotNull List frames,
+ final @NotNull List nodes,
+ final @NotNull List trees) {
+ this.version = version;
+ this.platform = platform;
+ this.frames = frames;
+ this.nodes = nodes;
+ this.trees = trees;
+ }
+
+ public @NotNull String getVersion() {
+ return version;
+ }
+
+ public @NotNull String getPlatform() {
+ return platform;
+ }
+
+ public @NotNull List getFrames() {
+ return frames;
+ }
+
+ public @NotNull List getNodes() {
+ return nodes;
+ }
+
+ public @NotNull List getTrees() {
+ return trees;
+ }
+
+ public @Nullable DebugMeta getDebugMeta() {
+ return debugMeta;
+ }
+
+ public void setDebugMeta(final @Nullable DebugMeta debugMeta) {
+ this.debugMeta = debugMeta;
+ }
+
+ @Override
+ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger)
+ throws IOException {
+ writer.beginObject();
+ writer.name(JsonKeys.VERSION).value(version);
+ writer.name(JsonKeys.PLATFORM).value(platform);
+ writer.name(JsonKeys.FRAMES).value(logger, frames);
+ writer.name(JsonKeys.NODES).value(logger, nodes);
+ writer.name(JsonKeys.TREES).value(logger, trees);
+ if (debugMeta != null) {
+ writer.name(JsonKeys.DEBUG_META).value(logger, debugMeta);
+ }
+ if (unknown != null) {
+ for (final String key : unknown.keySet()) {
+ writer.name(key).value(logger, unknown.get(key));
+ }
+ }
+ writer.endObject();
+ }
+
+ @Override
+ public @Nullable Map getUnknown() {
+ return unknown;
+ }
+
+ @Override
+ public void setUnknown(final @Nullable Map unknown) {
+ this.unknown = unknown;
+ }
+
+ public static final class Deserializer implements JsonDeserializer {
+
+ @Override
+ public @NotNull AnrFlameGraph deserialize(
+ final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception {
+ @Nullable String version = null;
+ @Nullable String platform = null;
+ @Nullable List frames = null;
+ @Nullable List nodes = null;
+ @Nullable List trees = null;
+ @Nullable DebugMeta debugMeta = null;
+ @Nullable Map unknown = null;
+
+ reader.beginObject();
+ while (reader.peek() == JsonToken.NAME) {
+ final String nextName = reader.nextName();
+ switch (nextName) {
+ case JsonKeys.VERSION:
+ version = reader.nextStringOrNull();
+ break;
+ case JsonKeys.PLATFORM:
+ platform = reader.nextStringOrNull();
+ break;
+ case JsonKeys.FRAMES:
+ frames = reader.nextListOrNull(logger, new SentryStackFrame.Deserializer());
+ break;
+ case JsonKeys.NODES:
+ nodes = reader.nextListOrNull(logger, new AnrFlameGraphNode.Deserializer());
+ break;
+ case JsonKeys.TREES:
+ trees = reader.nextListOrNull(logger, new AnrFlameGraphTree.Deserializer());
+ break;
+ case JsonKeys.DEBUG_META:
+ debugMeta = reader.nextOrNull(logger, new DebugMeta.Deserializer());
+ break;
+ default:
+ if (unknown == null) {
+ unknown = new HashMap<>();
+ }
+ reader.nextUnknown(logger, unknown, nextName);
+ break;
+ }
+ }
+ reader.endObject();
+
+ if (version == null) {
+ throw missingRequiredFieldException(JsonKeys.VERSION, logger);
+ }
+ if (platform == null) {
+ throw missingRequiredFieldException(JsonKeys.PLATFORM, logger);
+ }
+ if (frames == null) {
+ throw missingRequiredFieldException(JsonKeys.FRAMES, logger);
+ }
+ if (nodes == null) {
+ throw missingRequiredFieldException(JsonKeys.NODES, logger);
+ }
+ if (trees == null) {
+ throw missingRequiredFieldException(JsonKeys.TREES, logger);
+ }
+
+ final AnrFlameGraph flameGraph = new AnrFlameGraph(version, platform, frames, nodes, trees);
+ flameGraph.setDebugMeta(debugMeta);
+ flameGraph.setUnknown(unknown);
+ return flameGraph;
+ }
+
+ private @NotNull Exception missingRequiredFieldException(
+ final @NotNull String field, final @NotNull ILogger logger) {
+ final String message = "Missing required field \"" + field + "\"";
+ final Exception exception = new IllegalStateException(message);
+ logger.log(SentryLevel.ERROR, message, exception);
+ return exception;
+ }
+ }
+}
diff --git a/sentry/src/main/java/io/sentry/protocol/AnrFlameGraphNode.java b/sentry/src/main/java/io/sentry/protocol/AnrFlameGraphNode.java
new file mode 100644
index 00000000000..7a854cde1ce
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/protocol/AnrFlameGraphNode.java
@@ -0,0 +1,157 @@
+package io.sentry.protocol;
+
+import io.sentry.ILogger;
+import io.sentry.JsonDeserializer;
+import io.sentry.JsonSerializable;
+import io.sentry.JsonUnknown;
+import io.sentry.ObjectReader;
+import io.sentry.ObjectWriter;
+import io.sentry.SentryLevel;
+import io.sentry.vendor.gson.stream.JsonToken;
+import java.io.IOException;
+import java.util.ArrayList;
+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;
+
+/**
+ * A node of an {@link AnrFlameGraph} call tree. Its sample count includes the samples of its
+ * descendants.
+ */
+@ApiStatus.Internal
+public final class AnrFlameGraphNode implements JsonUnknown, JsonSerializable {
+
+ public static final class JsonKeys {
+ public static final String FRAME_ID = "frame_id";
+ public static final String SAMPLE_COUNT = "sample_count";
+ public static final String CHILDREN = "children";
+ }
+
+ private final int frameId;
+ private long sampleCount;
+ private final @NotNull List children;
+ private @Nullable Map unknown;
+
+ public AnrFlameGraphNode(
+ final int frameId, final long sampleCount, final @NotNull List children) {
+ this.frameId = frameId;
+ this.sampleCount = sampleCount;
+ this.children = children;
+ }
+
+ public int getFrameId() {
+ return frameId;
+ }
+
+ public long getSampleCount() {
+ return sampleCount;
+ }
+
+ public void setSampleCount(final long sampleCount) {
+ this.sampleCount = sampleCount;
+ }
+
+ public @NotNull List getChildren() {
+ return children;
+ }
+
+ @Override
+ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger)
+ throws IOException {
+ writer.beginObject();
+ writer.name(JsonKeys.FRAME_ID).value(frameId);
+ writer.name(JsonKeys.SAMPLE_COUNT).value(sampleCount);
+ if (!children.isEmpty()) {
+ writer.name(JsonKeys.CHILDREN).value(logger, children);
+ }
+ if (unknown != null) {
+ for (final String key : unknown.keySet()) {
+ writer.name(key).value(logger, unknown.get(key));
+ }
+ }
+ writer.endObject();
+ }
+
+ @Override
+ public @Nullable Map getUnknown() {
+ return unknown;
+ }
+
+ @Override
+ public void setUnknown(final @Nullable Map unknown) {
+ this.unknown = unknown;
+ }
+
+ public static final class Deserializer implements JsonDeserializer {
+
+ @Override
+ public @NotNull AnrFlameGraphNode deserialize(
+ final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception {
+ @Nullable Integer frameId = null;
+ @Nullable Long sampleCount = null;
+ @Nullable List children = null;
+ @Nullable Map unknown = null;
+
+ reader.beginObject();
+ while (reader.peek() == JsonToken.NAME) {
+ final String nextName = reader.nextName();
+ switch (nextName) {
+ case JsonKeys.FRAME_ID:
+ frameId = reader.nextIntegerOrNull();
+ break;
+ case JsonKeys.SAMPLE_COUNT:
+ sampleCount = reader.nextLongOrNull();
+ break;
+ case JsonKeys.CHILDREN:
+ children = reader.nextOrNull(logger, new IntegerListDeserializer());
+ break;
+ default:
+ if (unknown == null) {
+ unknown = new HashMap<>();
+ }
+ reader.nextUnknown(logger, unknown, nextName);
+ break;
+ }
+ }
+ reader.endObject();
+
+ if (frameId == null) {
+ throw missingRequiredFieldException(JsonKeys.FRAME_ID, logger);
+ }
+ if (sampleCount == null) {
+ throw missingRequiredFieldException(JsonKeys.SAMPLE_COUNT, logger);
+ }
+
+ final AnrFlameGraphNode node =
+ new AnrFlameGraphNode(
+ frameId, sampleCount, children != null ? children : new ArrayList<>());
+ node.setUnknown(unknown);
+ return node;
+ }
+
+ private @NotNull Exception missingRequiredFieldException(
+ final @NotNull String field, final @NotNull ILogger logger) {
+ final String message = "Missing required field \"" + field + "\"";
+ final Exception exception = new IllegalStateException(message);
+ logger.log(SentryLevel.ERROR, message, exception);
+ return exception;
+ }
+ }
+
+ static final class IntegerListDeserializer implements JsonDeserializer> {
+ @Override
+ public @NotNull List deserialize(
+ final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws IOException {
+ final @NotNull List result = new ArrayList<>();
+ reader.beginArray();
+ while (reader.hasNext()) {
+ result.add(reader.nextInt());
+ }
+ reader.endArray();
+ return result;
+ }
+ }
+}
diff --git a/sentry/src/main/java/io/sentry/protocol/AnrFlameGraphTree.java b/sentry/src/main/java/io/sentry/protocol/AnrFlameGraphTree.java
new file mode 100644
index 00000000000..c326ad193ed
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/protocol/AnrFlameGraphTree.java
@@ -0,0 +1,134 @@
+package io.sentry.protocol;
+
+import io.sentry.ILogger;
+import io.sentry.JsonDeserializer;
+import io.sentry.JsonSerializable;
+import io.sentry.JsonUnknown;
+import io.sentry.ObjectReader;
+import io.sentry.ObjectWriter;
+import io.sentry.SentryLevel;
+import io.sentry.vendor.gson.stream.JsonToken;
+import java.io.IOException;
+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;
+
+/** An independent call tree of an {@link AnrFlameGraph}, usually one per sampled thread. */
+@ApiStatus.Internal
+public final class AnrFlameGraphTree implements JsonUnknown, JsonSerializable {
+
+ public static final class JsonKeys {
+ public static final String ROOTS = "roots";
+ public static final String THREAD_ID = "thread_id";
+ public static final String THREAD_ATTRIBUTED = "thread_attributed";
+ }
+
+ private final @NotNull List roots;
+ private @Nullable String threadId;
+ private @Nullable Boolean threadAttributed;
+ private @Nullable Map unknown;
+
+ public AnrFlameGraphTree(final @NotNull List roots) {
+ this.roots = roots;
+ }
+
+ public @NotNull List getRoots() {
+ return roots;
+ }
+
+ public @Nullable String getThreadId() {
+ return threadId;
+ }
+
+ public void setThreadId(final @Nullable String threadId) {
+ this.threadId = threadId;
+ }
+
+ public @Nullable Boolean getThreadAttributed() {
+ return threadAttributed;
+ }
+
+ public void setThreadAttributed(final @Nullable Boolean threadAttributed) {
+ this.threadAttributed = threadAttributed;
+ }
+
+ @Override
+ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger)
+ throws IOException {
+ writer.beginObject();
+ writer.name(JsonKeys.ROOTS).value(logger, roots);
+ if (threadId != null) {
+ writer.name(JsonKeys.THREAD_ID).value(threadId);
+ }
+ if (threadAttributed != null) {
+ writer.name(JsonKeys.THREAD_ATTRIBUTED).value(threadAttributed);
+ }
+ if (unknown != null) {
+ for (final String key : unknown.keySet()) {
+ writer.name(key).value(logger, unknown.get(key));
+ }
+ }
+ writer.endObject();
+ }
+
+ @Override
+ public @Nullable Map getUnknown() {
+ return unknown;
+ }
+
+ @Override
+ public void setUnknown(final @Nullable Map unknown) {
+ this.unknown = unknown;
+ }
+
+ public static final class Deserializer implements JsonDeserializer {
+
+ @Override
+ public @NotNull AnrFlameGraphTree deserialize(
+ final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception {
+ @Nullable List roots = null;
+ @Nullable String threadId = null;
+ @Nullable Boolean threadAttributed = null;
+ @Nullable Map unknown = null;
+
+ reader.beginObject();
+ while (reader.peek() == JsonToken.NAME) {
+ final String nextName = reader.nextName();
+ switch (nextName) {
+ case JsonKeys.ROOTS:
+ roots = reader.nextOrNull(logger, new AnrFlameGraphNode.IntegerListDeserializer());
+ break;
+ case JsonKeys.THREAD_ID:
+ threadId = reader.nextStringOrNull();
+ break;
+ case JsonKeys.THREAD_ATTRIBUTED:
+ threadAttributed = reader.nextBooleanOrNull();
+ break;
+ default:
+ if (unknown == null) {
+ unknown = new HashMap<>();
+ }
+ reader.nextUnknown(logger, unknown, nextName);
+ break;
+ }
+ }
+ reader.endObject();
+
+ if (roots == null) {
+ final String message = "Missing required field \"" + JsonKeys.ROOTS + "\"";
+ final Exception exception = new IllegalStateException(message);
+ logger.log(SentryLevel.ERROR, message, exception);
+ throw exception;
+ }
+
+ final AnrFlameGraphTree tree = new AnrFlameGraphTree(roots);
+ tree.setThreadId(threadId);
+ tree.setThreadAttributed(threadAttributed);
+ tree.setUnknown(unknown);
+ return tree;
+ }
+ }
+}
diff --git a/sentry/src/test/java/io/sentry/protocol/AnrFlameGraphSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/AnrFlameGraphSerializationTest.kt
new file mode 100644
index 00000000000..109f55223ea
--- /dev/null
+++ b/sentry/src/test/java/io/sentry/protocol/AnrFlameGraphSerializationTest.kt
@@ -0,0 +1,114 @@
+package io.sentry.protocol
+
+import com.google.common.truth.Truth.assertThat
+import io.sentry.FileFromResources
+import io.sentry.ILogger
+import io.sentry.JsonObjectReader
+import io.sentry.JsonObjectWriter
+import io.sentry.JsonSerializable
+import java.io.StringReader
+import java.io.StringWriter
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+import org.mockito.kotlin.mock
+
+class AnrFlameGraphSerializationTest {
+ private class Fixture {
+ val logger = mock()
+
+ fun getSut() =
+ AnrFlameGraph(
+ "1",
+ "java",
+ listOf(frame("main", 10), frame("work", 20)),
+ listOf(
+ AnrFlameGraphNode(0, 3, mutableListOf(1)),
+ AnrFlameGraphNode(1, 2, mutableListOf()),
+ ),
+ listOf(
+ AnrFlameGraphTree(listOf(0)).apply {
+ threadId = "1"
+ threadAttributed = true
+ }
+ ),
+ )
+ .apply {
+ debugMeta =
+ DebugMeta().apply {
+ images =
+ listOf(
+ DebugImage().apply {
+ type = DebugImage.PROGUARD
+ uuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+ }
+ )
+ }
+ }
+
+ private fun frame(function: String, lineno: Int) =
+ SentryStackFrame().apply {
+ filename = "App.java"
+ this.function = function
+ module = "com.example.App"
+ this.lineno = lineno
+ }
+ }
+
+ private val fixture = Fixture()
+
+ @Test
+ fun serialize() {
+ val expected = sanitizedFile("json/anr_flame_graph.json")
+ val actual = serialize(fixture.getSut())
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun deserialize() {
+ val expectedJson = sanitizedFile("json/anr_flame_graph.json")
+ val actual = deserialize(expectedJson)
+ val actualJson = serialize(actual)
+ assertThat(actualJson).isEqualTo(expectedJson)
+ }
+
+ @Test
+ fun `deserializes a node without children as a node with no children`() {
+ val node =
+ AnrFlameGraphNode.Deserializer()
+ .deserialize(
+ JsonObjectReader(StringReader("""{"frame_id":1,"sample_count":2}""")),
+ fixture.logger,
+ )
+
+ assertThat(node.children).isEmpty()
+ }
+
+ @Test
+ fun `keeps unknown fields`() {
+ val json = """{"version":"1","platform":"java","frames":[],"nodes":[],"trees":[],"extra":"x"}"""
+
+ assertThat(serialize(deserialize(json))).isEqualTo(json)
+ }
+
+ @Test
+ fun `fails to deserialize without a required field`() {
+ assertFailsWith {
+ deserialize("""{"version":"1","platform":"java","frames":[],"nodes":[]}""")
+ }
+ }
+
+ private fun sanitizedFile(path: String): String =
+ FileFromResources.invoke(path).replace(Regex("[\n\r]"), "").replace(" ", "")
+
+ private fun serialize(jsonSerializable: JsonSerializable): String {
+ val wrt = StringWriter()
+ val jsonWrt = JsonObjectWriter(wrt, 100)
+ jsonSerializable.serialize(jsonWrt, fixture.logger)
+ return wrt.toString()
+ }
+
+ private fun deserialize(json: String): AnrFlameGraph {
+ val reader = JsonObjectReader(StringReader(json))
+ return AnrFlameGraph.Deserializer().deserialize(reader, fixture.logger)
+ }
+}
diff --git a/sentry/src/test/resources/json/anr_flame_graph.json b/sentry/src/test/resources/json/anr_flame_graph.json
new file mode 100644
index 00000000000..4b796533830
--- /dev/null
+++ b/sentry/src/test/resources/json/anr_flame_graph.json
@@ -0,0 +1,44 @@
+{
+ "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
+ }
+ ],
+ "nodes": [
+ {
+ "frame_id": 0,
+ "sample_count": 3,
+ "children": [1]
+ },
+ {
+ "frame_id": 1,
+ "sample_count": 2
+ }
+ ],
+ "trees": [
+ {
+ "roots": [0],
+ "thread_id": "1",
+ "thread_attributed": true
+ }
+ ],
+ "debug_meta": {
+ "images": [
+ {
+ "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "type": "proguard"
+ }
+ ]
+ }
+}