From b5227eefbd28145b16fc7c887bfe7405dc8c3b59 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Thu, 12 Nov 2026 12:54:00 +0100 Subject: [PATCH] feat(anr): Add flame graph attachment helper for ANR profiles --- .../api/sentry-android-core.api | 4 + .../core/anr/AnrFlameGraphAttachment.java | 153 ++++++++++++++ .../android/core/anr/StackTraceConverter.java | 4 +- .../core/anr/AnrFlameGraphAttachmentTest.kt | 114 +++++++++++ sentry/api/sentry.api | 79 ++++++++ .../main/java/io/sentry/JsonSerializer.java | 6 + .../io/sentry/protocol/AnrFlameGraph.java | 191 ++++++++++++++++++ .../io/sentry/protocol/AnrFlameGraphNode.java | 157 ++++++++++++++ .../io/sentry/protocol/AnrFlameGraphTree.java | 134 ++++++++++++ .../AnrFlameGraphSerializationTest.kt | 114 +++++++++++ .../test/resources/json/anr_flame_graph.json | 44 ++++ 11 files changed, 998 insertions(+), 2 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrFlameGraphAttachment.java create mode 100644 sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrFlameGraphAttachmentTest.kt create mode 100644 sentry/src/main/java/io/sentry/protocol/AnrFlameGraph.java create mode 100644 sentry/src/main/java/io/sentry/protocol/AnrFlameGraphNode.java create mode 100644 sentry/src/main/java/io/sentry/protocol/AnrFlameGraphTree.java create mode 100644 sentry/src/test/java/io/sentry/protocol/AnrFlameGraphSerializationTest.kt create mode 100644 sentry/src/test/resources/json/anr_flame_graph.json 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" + } + ] + } +}