From f223988850e9f3be98105cfed03c84a0d15debe3 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 22 Jul 2025 10:21:41 +0200 Subject: [PATCH 01/24] delete unused JfrFrame and JfrToSentryProfileconverter --- .../sentry/protocol/profiling/JfrFrame.java | 69 ---- .../JfrToSentryProfileConverter.java | 356 ------------------ 2 files changed, 425 deletions(-) delete mode 100644 sentry/src/main/java/io/sentry/protocol/profiling/JfrFrame.java delete mode 100644 sentry/src/main/java/io/sentry/protocol/profiling/JfrToSentryProfileConverter.java diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/JfrFrame.java b/sentry/src/main/java/io/sentry/protocol/profiling/JfrFrame.java deleted file mode 100644 index e013ec594e6..00000000000 --- a/sentry/src/main/java/io/sentry/protocol/profiling/JfrFrame.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.sentry.protocol.profiling; - -import io.sentry.ILogger; -import io.sentry.JsonSerializable; -import io.sentry.JsonUnknown; -import io.sentry.ObjectWriter; -import java.io.IOException; -import java.util.Map; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public final class JfrFrame implements JsonUnknown, JsonSerializable { - // @JsonProperty("function") - public @Nullable String function; // e.g., "com.example.MyClass.myMethod" - - // @JsonProperty("module") - public @Nullable String module; // e.g., "com.example" (package name) - - // @JsonProperty("filename") - public @Nullable String filename; // e.g., "MyClass.java" - - // @JsonProperty("lineno") - public @Nullable Integer lineno; // Line number (nullable) - - // @JsonProperty("abs_path") - public @Nullable String absPath; // Optional: Absolute path if available - - public static final class JsonKeys { - public static final String FUNCTION = "function"; - public static final String MODULE = "module"; - public static final String FILENAME = "filename"; - public static final String LINE_NO = "lineno"; - public static final String RAW_FUNCTION = "raw_function"; - } - - @Override - public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { - writer.beginObject(); - - if (function != null) { - writer.name(JsonKeys.FUNCTION).value(logger, function); - } - - if (module != null) { - writer.name(JsonKeys.MODULE).value(logger, module); - } - - if (filename != null) { - writer.name(JsonKeys.FILENAME).value(logger, filename); - } - if (lineno != null) { - writer.name(JsonKeys.LINE_NO).value(logger, lineno); - } - - writer.endObject(); - } - - @Override - public @Nullable Map getUnknown() { - return Map.of(); - } - - @Override - public void setUnknown(@Nullable Map unknown) {} - - // We need equals and hashCode for deduplication if we use Frame objects directly as map keys - // However, it's safer to deduplicate based on the source ResolvedFrame or its components. - // Let's assume we handle deduplication before creating these final Frame objects. -} diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/JfrToSentryProfileConverter.java b/sentry/src/main/java/io/sentry/protocol/profiling/JfrToSentryProfileConverter.java deleted file mode 100644 index 7c049ce086f..00000000000 --- a/sentry/src/main/java/io/sentry/protocol/profiling/JfrToSentryProfileConverter.java +++ /dev/null @@ -1,356 +0,0 @@ -// package io.sentry.protocol.profiling; -// -// import io.sentry.EnvelopeReader; -// import io.sentry.JsonSerializer; -// import io.sentry.SentryNanotimeDate; -// import io.sentry.SentryOptions; -// import jdk.jfr.consumer.RecordedClass; -// import jdk.jfr.consumer.RecordedEvent; -// import jdk.jfr.consumer.RecordedFrame; -// import jdk.jfr.consumer.RecordedMethod; -// import jdk.jfr.consumer.RecordedStackTrace; -// import jdk.jfr.consumer.RecordedThread; -// import jdk.jfr.consumer.RecordingFile; -// -// import java.io.File; -// import java.io.IOException; -// import java.io.StringWriter; -// import java.nio.file.Path; -// import java.time.Instant; -// import java.util.ArrayList; -// import java.util.Collections; -// import java.util.HashMap; -// import java.util.List; -// import java.util.Map; -// import java.util.Objects; -// import jdk.jfr.consumer.*; -// -// import java.io.IOException; -// import java.nio.file.Files; // For main method example write -// import java.nio.file.Path; -// import java.time.Instant; -// import java.util.ArrayList; -// import java.util.Collections; -// import java.util.HashMap; -// import java.util.List; -// import java.util.Map; -// import java.util.Objects; -// import java.util.concurrent.ConcurrentHashMap; -// -// public final class JfrToSentryProfileConverter { -// -// // FrameSignature now converts to JfrFrame -// private static class FrameSignature { -// String className; -// String methodName; -// String descriptor; -// String sourceFile; -// int lineNumber; -// -// FrameSignature(RecordedFrame rf) { -// RecordedMethod rm = rf.getMethod(); -// if (rm != null) { -// RecordedClass type = rm.getType(); -// this.className = type != null ? type.getName() : "[unknown_class]"; -// this.methodName = rm.getName(); -// this.descriptor = rm.getDescriptor(); -// } else { -// this.className = "[unknown_class]"; -// this.methodName = "[unknown_method]"; -// this.descriptor = "()V"; -// } -// -// String fileNameFromClass = null; -// if (rf.isJavaFrame() && rm != null && rm.getType() != null) { -// try { fileNameFromClass = rm.getType().getString("sourceFileName"); } -// catch (Exception e) { fileNameFromClass = null; } -// } -// -// if (fileNameFromClass != null && !fileNameFromClass.isEmpty()) { -// this.sourceFile = fileNameFromClass; -// } else if (rf.isJavaFrame() && this.className != null && !this.className.startsWith("[")) { -// int lastDot = this.className.lastIndexOf('.'); -// String simpleClassName = lastDot > 0 ? this.className.substring(lastDot + 1) : -// this.className; -// int firstDollar = simpleClassName.indexOf('$'); -// if (firstDollar > 0) simpleClassName = simpleClassName.substring(0, firstDollar); -// this.sourceFile = simpleClassName + ".java"; -// } else { -// this.sourceFile = "[unknown_source]"; -// } -// if (!rf.isJavaFrame()) this.sourceFile = "[native]"; -// -// this.lineNumber = rf.getInt("lineNumber"); -// if (this.lineNumber < 0) this.lineNumber = 0; -// } -// -// @Override -// public boolean equals(Object o) { -// if (this == o) return true; -// if (!(o instanceof FrameSignature)) return false; -// FrameSignature that = (FrameSignature) o; -// return lineNumber == that.lineNumber && -// Objects.equals(className, that.className) && -// Objects.equals(methodName, that.methodName) && -// Objects.equals(descriptor, that.descriptor) && -// Objects.equals(sourceFile, that.sourceFile); -// } -// -// @Override -// public int hashCode() { -// return Objects.hash(className, methodName, descriptor, sourceFile, lineNumber); -// } -// -// // **** Method now returns JfrFrame **** -// JfrFrame toSentryFrame() { -// JfrFrame frame = new JfrFrame(); // Create JfrFrame instance -// frame.function = this.className + "." + this.methodName; -// -// int lastDot = this.className.lastIndexOf('.'); -// if (lastDot > 0) { -// frame.module = this.className.substring(0, lastDot); -// } else if (!this.className.startsWith("[")) { -// frame.module = ""; -// } -// -// frame.filename = this.sourceFile; -// -// if (this.lineNumber > 0) frame.lineno = this.lineNumber; -// else frame.lineno = null; -// -// if ("[native]".equals(this.sourceFile)) { -// frame.function = "[native_code]"; -// frame.module = null; -// frame.filename = null; -// frame.lineno = null; -// } -// return frame; // Return JfrFrame -// } -// } -// // --- End of FrameSignature --- -// -// private final Map threadNamesByOSId = new ConcurrentHashMap<>(); -// -// public JfrProfile convert(Path jfrFilePath) throws IOException { -// -// // **** Use renamed classes for lists **** -// List samples = new ArrayList<>(); -// List> stacks = new ArrayList<>(); -// List frames = new ArrayList<>(); -// Map threadMetadata = new ConcurrentHashMap<>(); -// -// Map, Integer> stackIdMap = new HashMap<>(); -// Map frameIdMap = new HashMap<>(); -// -// long eventCount = 0; -// long sampleCount = 0; -// long threadsFoundDirectly = 0; -// long threadsFoundInMetadata = 0; -// -// // --- Pre-pass for Thread Metadata --- -// System.out.println("Pre-scanning for thread metadata..."); -// try (RecordingFile recordingFile = new RecordingFile(jfrFilePath)) { -// while (recordingFile.hasMoreEvents()) { -// RecordedEvent event = recordingFile.readEvent(); -// String eventName = event.getEventType().getName(); -// if ("jdk.ThreadStart".equals(eventName)) { -// RecordedThread thread = null; -// try { thread = event.getThread("thread"); } catch(Exception e) { -// // Handle exception if needed -// } -// RecordedThread eventThread = null; -// try { eventThread = event.getThread("eventThread"); } catch(Exception e){ -// // Handle exception if needed -// } -// -// if (thread != null) { -// long osId = thread.getOSThreadId(); -// String name = thread.getJavaName() != null ? thread.getJavaName() : -// thread.getOSName(); -// if (osId > 0 && name != null) threadNamesByOSId.put(osId, name); -// } -// if (eventThread != null) { -// long osId = eventThread.getOSThreadId(); -// String name = eventThread.getJavaName() != null ? eventThread.getJavaName() : -// eventThread.getOSName(); -// if (osId > 0 && name != null) threadNamesByOSId.put(osId, name); -// } -// try { -// long osId = event.getLong("osThreadId"); -// String name = event.getString("threadName"); -// if (osId > 0 && name != null) threadNamesByOSId.put(osId, name); -// } catch (Exception e) {/* ignore */} -// -// } else if ("jdk.JavaThreadStatistics".equals(eventName)) { -// try { -// long osId = event.getLong("osThreadId"); -// String name = event.getString("javaThreadName"); -// if (osId > 0 && name != null) threadNamesByOSId.putIfAbsent(osId, name); -// } catch (Exception e) {/* ignore */} -// } -// } -// } -// System.out.println("Found " + threadNamesByOSId.size() + " thread names during pre-scan."); -// -// // --- Main Processing Pass --- -// System.out.println("Processing execution samples..."); -// try (RecordingFile recordingFile = new RecordingFile(jfrFilePath)) { -// while (recordingFile.hasMoreEvents()) { -// RecordedEvent event = recordingFile.readEvent(); -// eventCount++; -// -// if ("jdk.ExecutionSample".equals(event.getEventType().getName())) { -// sampleCount++; -// Instant timestamp = event.getStartTime(); -// RecordedStackTrace stackTrace = event.getStackTrace(); -// -// if (stackTrace == null) { -// System.err.println("Skipping sample due to missing stacktrace at " + timestamp); -// continue; -// } -// -// // --- Get Thread ID --- -// long osThreadId = -1; -// String threadName = null; -// RecordedThread recordedThread = null; -// try { recordedThread = event.getThread(); } catch (Exception e) { -// // Handle exception if needed -// } -// -// if (recordedThread != null) { -// osThreadId = recordedThread.getOSThreadId(); -// threadsFoundDirectly++; -// } else { -// try { -// if (event.hasField("sampledThread")) { -// RecordedThread eventThreadRef = event.getValue("sampledThread"); -// threadName = eventThreadRef.getJavaName() != null ? eventThreadRef.getJavaName() : -// eventThreadRef.getOSName(); -// if (eventThreadRef != null) osThreadId = eventThreadRef.getOSThreadId(); -// } -//// if (osThreadId <= 0 && event.hasField("tid")) osThreadId = event.getLong("tid"); -//// if (osThreadId <= 0 && event.hasField("osThreadId")) osThreadId = -// event.getLong("osThreadId"); -//// if (osThreadId <= 0) { -//// System.err.println("WARN: Could not determine OS Thread ID for sample at " + -// timestamp + ". Skipping."); -//// continue; -//// } -// threadsFoundInMetadata++; -// } catch (Exception e) { -// System.err.println("WARN: Error accessing thread ID field for sample at " + -// timestamp + ". Skipping. Error: " + e.getMessage()); -// continue; -// } -// } -// -// if (osThreadId <= 0) { -// System.err.println("WARN: Invalid OS Thread ID (<= 0) for sample at " + timestamp + ". -// Skipping."); -// continue; -// } -// String threadIdStr = String.valueOf(osThreadId); -//// final long intermediateThreadId = osThreadId; -// final String intermediateThreadName = threadName; -// // --- Thread Metadata --- -// threadMetadata.computeIfAbsent(threadIdStr, tid -> { -// ThreadMetadata meta = new ThreadMetadata(); -// meta.name = -// intermediateThreadName;//threadNamesByOSId.getOrDefault(intermediateThreadId, "Thread " + tid); -// // meta.priority = ...; // Priority logic if needed -// return meta; -// }); -// -// // --- Stack Trace Processing (Frames and Stacks) --- -// List jfrFrames = stackTrace.getFrames(); -// List currentFrameIds = new ArrayList<>(jfrFrames.size()); -// -// for (RecordedFrame jfrFrame : jfrFrames) { -// FrameSignature sig = new FrameSignature(jfrFrame); -// int frameId = frameIdMap.computeIfAbsent(sig, fSig -> { -// // **** Get JfrFrame from signature **** -// JfrFrame newFrame = fSig.toSentryFrame(); -// frames.add(newFrame); // Add to List -// return frames.size() - 1; -// }); -// currentFrameIds.add(frameId); -// } -// -// Collections.reverse(currentFrameIds); -// -// int stackId = stackIdMap.computeIfAbsent(currentFrameIds, frameIds -> { -// stacks.add(new ArrayList<>(frameIds)); -// return stacks.size() - 1; -// }); -// -// // --- Create Sentry Sample --- -// // **** Create instance of JfrSample **** -// JfrSample sample = new JfrSample(); -// sample.timestamp = timestamp.getEpochSecond() + timestamp.getNano() / 1_000_000_000.0; -// sample.stackId = stackId; -// sample.threadId = threadIdStr; -// samples.add(sample); // Add to List -// } -// } -// } -// -// System.out.println("Processed " + eventCount + " JFR events."); -// System.out.println("Created " + sampleCount + " Sentry samples."); -// System.out.println("Threads found via getThread(): " + threadsFoundDirectly); -// System.out.println("Threads found via field fallback: " + threadsFoundInMetadata); -// System.out.println("Discovered " + frames.size() + " unique frames."); -// System.out.println("Discovered " + stacks.size() + " unique stacks."); -// System.out.println("Discovered " + threadMetadata.size() + " unique threads."); -// -// // --- Assemble final structure --- -// // **** Create instance of JfrProfile **** -// JfrProfile profile = new JfrProfile(); -// profile.samples = samples; -// profile.stacks = stacks; -// profile.frames = frames; -// profile.threadMetadata = new HashMap<>(threadMetadata); // Convert map for final object -// -// return profile; -// -// } -// -// // --- Example Usage (main method remains the same) --- -// public static void main(String[] args) { -// if (args.length < 1) { -// System.err.println("Usage: java JfrToSentryProfileConverter "); -// System.exit(1); -// } -// -// Path jfrPath = new File(args[0]).toPath(); -// JfrToSentryProfileConverter converter = new JfrToSentryProfileConverter(); -// -// SentryOptions options = new SentryOptions(); -// JsonSerializer serializer = new JsonSerializer(options); -// options.setSerializer(serializer); -// options.setEnvelopeReader(new EnvelopeReader(serializer)); -// -// try { -// System.out.println("Parsing JFR file: " + jfrPath.toAbsolutePath()); -// JfrProfile jfrProfile = converter.convert(jfrPath); -// StringWriter writer = new StringWriter(); -// serializer.serialize(jfrProfile, writer); -// String sentryJson = writer.toString(); -// System.out.println("\n--- Sentry Profile JSON ---"); -// System.out.println(sentryJson); -// System.out.println("--- End Sentry Profile JSON ---"); -// -// // Optionally write to a file: -// // Files.writeString(Path.of("sentry_profile.json"), sentryJson); -// // System.out.println("Output written to sentry_profile.json"); -// -// } catch (IOException e) { -// System.err.println("Error processing JFR file: " + e.getMessage()); -// e.printStackTrace(); -// System.exit(1); -// } catch (Exception e) { -// System.err.println("An unexpected error occurred: " + e.getMessage()); -// e.printStackTrace(); -// System.exit(1); -// } -// } -// } From 8599796ea22a271151083c1d28787350bd998aaf Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 22 Jul 2025 10:22:14 +0200 Subject: [PATCH 02/24] use passed-in profilingTracesHz parameter instead of hardcoded value --- .../provider/AsyncProfilerContinuousProfilerProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java index e721260545b..2d0812357d4 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java @@ -25,7 +25,7 @@ public final class AsyncProfilerContinuousProfilerProvider return new JavaContinuousProfiler( logger, profilingTracesDirPath, - 10, // default profilingTracesHz + profilingTracesHz, executorService); } } From cf30ed7e7e6bf841f7767790c9079ff0d8f77a1c Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 22 Jul 2025 16:02:58 +0200 Subject: [PATCH 03/24] start profiler before starting the transaction when ProfileLifecycle.TRACE is used to have the profile ID when SentryTracer is created --- sentry/src/main/java/io/sentry/Scopes.java | 22 ++++++++------- sentry/src/test/java/io/sentry/ScopesTest.kt | 28 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index fa1b7c2a81e..491da449ca1 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -938,6 +938,19 @@ public void flush(long timeoutMillis) { final @NotNull ISpanFactory spanFactory = maybeSpanFactory == null ? getOptions().getSpanFactory() : maybeSpanFactory; + // If continuous profiling is enabled in trace mode, let's start it. Profiler will sample on + // its own. + // Profiler is started before the transaction is created, so that the profiler id is available + // when the transaction starts + if (samplingDecision.getSampled()) { + if (getOptions().isContinuousProfilingEnabled() + && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE) { + getOptions() + .getContinuousProfiler() + .startProfiler(ProfileLifecycle.TRACE, getOptions().getInternalTracesSampler()); + } + } + transaction = spanFactory.createTransaction( transactionContext, this, transactionOptions, compositePerformanceCollector); @@ -960,15 +973,6 @@ public void flush(long timeoutMillis) { transactionProfiler.bindTransaction(transaction); } } - - // If continuous profiling is enabled in trace mode, let's start it. Profiler will sample on - // its own. - if (getOptions().isContinuousProfilingEnabled() - && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE) { - getOptions() - .getContinuousProfiler() - .startProfiler(ProfileLifecycle.TRACE, getOptions().getInternalTracesSampler()); - } } } if (transactionOptions.isBindToScope()) { diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 716bda99aea..157740bcf0d 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -2299,6 +2299,34 @@ class ScopesTest { assertEquals("other.span.origin", transaction.spanContext.origin) } + @Test + fun `startTransaction start the continuous profiler before creating SentryTracer in ProfileLifecycle TRACE`() { + val profiler = mock() + val scopes = generateScopes { + it.setContinuousProfiler(profiler) + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE + } + + whenever(profiler.profilerId).thenReturn(SentryId.EMPTY_ID) + + val expectedSentryId = SentryId() + + doAnswer { whenever(profiler.profilerId).thenReturn(expectedSentryId) } + .whenever(profiler) + .startProfiler(eq(ProfileLifecycle.TRACE), any()) + + val transaction = scopes.startTransaction("test", "test") + + val profilerId = transaction.getData("profiler_id") as? String + val profilingContext = transaction.contexts.get("profile") as? ProfileContext + assertNotNull(profilerId) + assertTrue(SentryId(transaction.getData("profiler_id")!! as String) != SentryId.EMPTY_ID) + assertEquals(expectedSentryId, SentryId(profilerId)) + assertEquals(ProfileContext(SentryId(profilerId)), profilingContext) + verify(profiler).startProfiler(eq(ProfileLifecycle.TRACE), any()) + } + // region profileSession @Test From db85236c9555c44293b00cad0c0e6cb2a9ad89b0 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 22 Jul 2025 16:03:28 +0200 Subject: [PATCH 04/24] use improved way to calculate timestamp of sample --- .../JfrAsyncProfilerToSentryProfileConverter.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index f22eb76f709..4ba8c302872 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -1,5 +1,6 @@ package io.sentry.asyncprofiler.convert; +import io.sentry.DateUtils; import io.sentry.Sentry; import io.sentry.SentryStackTraceFactory; import io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Arguments; @@ -13,7 +14,6 @@ import io.sentry.protocol.profiling.ThreadMetadata; import java.io.IOException; import java.nio.file.Path; -import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -121,16 +121,13 @@ public void visit(Event event, long value) { currentFrame++; } - long divisor = jfr.ticksPerSec / 1000_000_000L; - long myTimeStamp = - jfr.chunkStartNanos + ((event.time - jfr.chunkStartTicks) / divisor); + long nsFromStart = + (event.time - jfr.chunkStartTicks) * 1_000_000_000 / jfr.ticksPerSec; + long timeNs = jfr.chunkStartNanos + nsFromStart; JfrSample sample = new JfrSample(); - Instant instant = Instant.ofEpochSecond(0, myTimeStamp); - double timestampDouble = - instant.getEpochSecond() + instant.getNano() / 1_000_000_000.0; - sample.timestamp = timestampDouble; + sample.timestamp = DateUtils.nanosToSeconds(timeNs); sample.threadId = String.valueOf( jfr.threads.get(event.tid) != null From 29aa41ecc8566d895e6674e38c338bf6f634838a Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 22 Jul 2025 16:03:59 +0200 Subject: [PATCH 05/24] api dump --- sentry/api/sentry.api | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 78e0dac4f49..6a3b796fa1a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -6208,27 +6208,6 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { public fun ()V } -public final class io/sentry/protocol/profiling/JfrFrame : io/sentry/JsonSerializable, io/sentry/JsonUnknown { - public field absPath Ljava/lang/String; - public field filename Ljava/lang/String; - public field function Ljava/lang/String; - public field lineno Ljava/lang/Integer; - public field module Ljava/lang/String; - public fun ()V - public fun getUnknown ()Ljava/util/Map; - public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V - public fun setUnknown (Ljava/util/Map;)V -} - -public final class io/sentry/protocol/profiling/JfrFrame$JsonKeys { - public static final field FILENAME Ljava/lang/String; - public static final field FUNCTION Ljava/lang/String; - public static final field LINE_NO Ljava/lang/String; - public static final field MODULE Ljava/lang/String; - public static final field RAW_FUNCTION Ljava/lang/String; - public fun ()V -} - public final class io/sentry/protocol/profiling/JfrSample : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public field stackId I public field threadId Ljava/lang/String; From e64ba323edac628f5449903bfff97c915fda682c Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Fri, 25 Jul 2025 10:11:27 +0200 Subject: [PATCH 06/24] let profile-lifecycle be set from external_options, add tests for SpringBoot autoconfig --- .../AsyncProfilerContinuousProfilerProvider.java | 5 +---- .../boot/jakarta/SentryAutoConfigurationTest.kt | 9 +++++++++ .../spring/boot/SentryAutoConfigurationTest.kt | 9 +++++++++ sentry/api/sentry.api | 2 ++ .../src/main/java/io/sentry/ExternalOptions.java | 14 ++++++++++++++ sentry/src/main/java/io/sentry/Sentry.java | 5 +++-- sentry/src/main/java/io/sentry/SentryOptions.java | 4 ++++ .../src/test/java/io/sentry/ExternalOptionsTest.kt | 7 +++++++ .../src/test/java/io/sentry/SentryOptionsTest.kt | 2 ++ 9 files changed, 51 insertions(+), 6 deletions(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java index 2d0812357d4..49d83cffb3d 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java @@ -23,9 +23,6 @@ public final class AsyncProfilerContinuousProfilerProvider int profilingTracesHz, ISentryExecutorService executorService) { return new JavaContinuousProfiler( - logger, - profilingTracesDirPath, - profilingTracesHz, - executorService); + logger, profilingTracesDirPath, profilingTracesHz, executorService); } } diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 7b290a42880..8878607a002 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -11,6 +11,7 @@ import io.sentry.IScopes import io.sentry.ITransportFactory import io.sentry.Integration import io.sentry.NoOpTransportFactory +import io.sentry.ProfileLifecycle import io.sentry.SamplingContext import io.sentry.Sentry import io.sentry.SentryEvent @@ -38,6 +39,7 @@ import io.sentry.transport.ITransport import io.sentry.transport.ITransportGate import io.sentry.transport.apache.ApacheHttpClientTransportFactory import jakarta.servlet.Filter +import java.io.File import java.lang.RuntimeException import kotlin.test.Test import kotlin.test.assertEquals @@ -200,6 +202,9 @@ class SentryAutoConfigurationTest { "sentry.cron.default-failure-issue-threshold=40", "sentry.cron.default-recovery-threshold=50", "sentry.logs.enabled=true", + "sentry.profile-session-sample-rate=1.0", + "sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces", + "sentry.profile-lifecycle=TRACE", ) .run { val options = it.getBean(SentryProperties::class.java) @@ -253,6 +258,10 @@ class SentryAutoConfigurationTest { assertThat(options.cron!!.defaultFailureIssueThreshold).isEqualTo(40L) assertThat(options.cron!!.defaultRecoveryThreshold).isEqualTo(50L) assertThat(options.logs.isEnabled).isEqualTo(true) + assertThat(options.profileSessionSampleRate).isEqualTo(1.0) + assertThat(options.profilingTracesDirPath) + .startsWith(File("tmp/sentry/profiling-traces").absolutePath) + assertThat(options.profileLifecycle).isEqualTo(ProfileLifecycle.TRACE) } } diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index 1c47df15bb0..3aef042bebe 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -11,6 +11,7 @@ import io.sentry.IScopes import io.sentry.ITransportFactory import io.sentry.Integration import io.sentry.NoOpTransportFactory +import io.sentry.ProfileLifecycle import io.sentry.SamplingContext import io.sentry.Sentry import io.sentry.SentryEvent @@ -37,6 +38,7 @@ import io.sentry.spring.tracing.TransactionNameProvider import io.sentry.transport.ITransport import io.sentry.transport.ITransportGate import io.sentry.transport.apache.ApacheHttpClientTransportFactory +import java.io.File import java.lang.RuntimeException import javax.servlet.Filter import kotlin.test.Test @@ -199,6 +201,9 @@ class SentryAutoConfigurationTest { "sentry.cron.default-failure-issue-threshold=40", "sentry.cron.default-recovery-threshold=50", "sentry.logs.enabled=true", + "sentry.profile-session-sample-rate=1.0", + "sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces", + "sentry.profile-lifecycle=TRACE", ) .run { val options = it.getBean(SentryProperties::class.java) @@ -252,6 +257,10 @@ class SentryAutoConfigurationTest { assertThat(options.cron!!.defaultFailureIssueThreshold).isEqualTo(40L) assertThat(options.cron!!.defaultRecoveryThreshold).isEqualTo(50L) assertThat(options.logs.isEnabled).isEqualTo(true) + assertThat(options.profileSessionSampleRate).isEqualTo(1.0) + assertThat(options.profilingTracesDirPath) + .startsWith(File("tmp/sentry/profiling-traces").absolutePath) + assertThat(options.profileLifecycle).isEqualTo(ProfileLifecycle.TRACE) } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6a3b796fa1a..4accb84f9f2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -489,6 +489,7 @@ public final class io/sentry/ExternalOptions { public fun getInAppIncludes ()Ljava/util/List; public fun getMaxRequestBodySize ()Lio/sentry/SentryOptions$RequestSize; public fun getPrintUncaughtStackTrace ()Ljava/lang/Boolean; + public fun getProfileLifecycle ()Lio/sentry/ProfileLifecycle; public fun getProfileSessionSampleRate ()Ljava/lang/Double; public fun getProfilesSampleRate ()Ljava/lang/Double; public fun getProfilingTracesDirPath ()Ljava/lang/String; @@ -532,6 +533,7 @@ public final class io/sentry/ExternalOptions { public fun setIgnoredTransactions (Ljava/util/List;)V public fun setMaxRequestBodySize (Lio/sentry/SentryOptions$RequestSize;)V public fun setPrintUncaughtStackTrace (Ljava/lang/Boolean;)V + public fun setProfileLifecycle (Lio/sentry/ProfileLifecycle;)V public fun setProfileSessionSampleRate (Ljava/lang/Double;)V public fun setProfilesSampleRate (Ljava/lang/Double;)V public fun setProfilingTracesDirPath (Ljava/lang/String;)V diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index c2ba950c5ef..fbf0be85cf5 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -58,6 +58,7 @@ public final class ExternalOptions { private @Nullable Double profileSessionSampleRate; private @Nullable String profilingTracesDirPath; + private @Nullable ProfileLifecycle profileLifecycle; private @Nullable SentryOptions.Cron cron; @@ -210,6 +211,11 @@ public final class ExternalOptions { options.setProfilingTracesDirPath(propertiesProvider.getProperty("profiling-traces-dir-path")); + String profileLifecycleString = propertiesProvider.getProperty("profile-lifecycle"); + if (profileLifecycleString != null && !profileLifecycleString.isEmpty()) { + options.setProfileLifecycle(ProfileLifecycle.valueOf(profileLifecycleString.toUpperCase())); + } + return options; } @@ -554,4 +560,12 @@ public void setProfileSessionSampleRate(@Nullable Double profileSessionSampleRat public void setProfilingTracesDirPath(@Nullable String profilingTracesDirPath) { this.profilingTracesDirPath = profilingTracesDirPath; } + + public @Nullable ProfileLifecycle getProfileLifecycle() { + return profileLifecycle; + } + + public void setProfileLifecycle(@Nullable ProfileLifecycle profileLifecycle) { + this.profileLifecycle = profileLifecycle; + } } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index ca58bd79487..1dc0761d879 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -683,8 +683,9 @@ private static void initConfigurations(final @NotNull SentryOptions options) { .getLogger() .log( SentryLevel.INFO, - "Continuous profiler is enabled %s", - options.isContinuousProfilingEnabled()); + "Continuous profiler is enabled %s mode: %s", + options.isContinuousProfilingEnabled(), + options.getProfileLifecycle()); } /** Close the SDK */ diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index a1a7bb3da4f..830520cf47e 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3240,6 +3240,10 @@ public void merge(final @NotNull ExternalOptions options) { if (options.getProfilingTracesDirPath() != null) { setProfilingTracesDirPath(options.getProfilingTracesDirPath()); } + + if (options.getProfileLifecycle() != null) { + setProfileLifecycle(options.getProfileLifecycle()); + } } private @NotNull SdkVersion createSdkVersion() { diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 9f32d8cb8f8..9ed3913f715 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -390,6 +390,13 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with profilingLifecycle set to TRACE`() { + withPropertiesFile("profile-lifecycle=TRACE") { options -> + assertTrue(options.profileLifecycle == ProfileLifecycle.TRACE) + } + } + private fun withPropertiesFile( textLines: List = emptyList(), logger: ILogger = mock(), diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 8890af5a320..9fe8996f5b1 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -409,6 +409,7 @@ class SentryOptionsTest { externalOptions.isEnableLogs = true externalOptions.profileSessionSampleRate = 0.8 externalOptions.profilingTracesDirPath = "/profiling-traces" + externalOptions.profileLifecycle = ProfileLifecycle.TRACE val hash = StringUtils.calculateStringHash(externalOptions.dsn, mock()) val options = SentryOptions() @@ -466,6 +467,7 @@ class SentryOptionsTest { assertTrue(options.logs.isEnabled!!) assertEquals(0.8, options.profileSessionSampleRate) assertEquals("/profiling-traces${File.separator}${hash}", options.profilingTracesDirPath) + assertEquals(ProfileLifecycle.TRACE, options.profileLifecycle) } @Test From 50bd861ac166f145458aaf8fc852ca0db2500293 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Fri, 25 Jul 2025 10:25:00 +0200 Subject: [PATCH 07/24] initialize stackTraceFactory only once per chunk --- .../convert/JfrAsyncProfilerToSentryProfileConverter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index 4ba8c302872..03891aa67bc 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -30,6 +30,7 @@ public JfrAsyncProfilerToSentryProfileConverter(JfrReader jfr, Arguments args) { protected void convertChunk() { final List events = new ArrayList(); final List> stacks = new ArrayList<>(); + final SentryStackTraceFactory stackTraceFactory = new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()); collector.forEach( new AggregatedEventVisitor() { @@ -107,7 +108,7 @@ public void visit(Event event, long value) { frame.setInApp(false); } else { frame.setInApp( - new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()) + stackTraceFactory .isInApp(sanitizedClassName)); } From c5fc1f83e6c254e874122c6c46e3a60c21148bf2 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Fri, 25 Jul 2025 15:15:41 +0200 Subject: [PATCH 08/24] rename profile data classes, add deserialization and tests --- ...AsyncProfilerToSentryProfileConverter.java | 15 ++- .../profiling/JavaContinuousProfiler.java | 1 - sentry/api/sentry.api | 52 ++++---- .../protocol/profiling/SentryProfile.java | 110 ++++++++++------- .../{JfrSample.java => SentrySample.java} | 33 ++++- ...etadata.java => SentryThreadMetadata.java} | 34 +++++- .../SentryProfileSerializationTest.kt | 114 ++++++++++++++++++ .../test/resources/json/sentry_profile.json | 63 ++++++++++ 8 files changed, 331 insertions(+), 91 deletions(-) rename sentry/src/main/java/io/sentry/protocol/profiling/{JfrSample.java => SentrySample.java} (62%) rename sentry/src/main/java/io/sentry/protocol/profiling/{ThreadMetadata.java => SentryThreadMetadata.java} (56%) create mode 100644 sentry/src/test/java/io/sentry/protocol/SentryProfileSerializationTest.kt create mode 100644 sentry/src/test/resources/json/sentry_profile.json diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index 03891aa67bc..c5cc83e1c91 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -9,9 +9,9 @@ import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.StackTrace; import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; import io.sentry.protocol.SentryStackFrame; -import io.sentry.protocol.profiling.JfrSample; import io.sentry.protocol.profiling.SentryProfile; -import io.sentry.protocol.profiling.ThreadMetadata; +import io.sentry.protocol.profiling.SentrySample; +import io.sentry.protocol.profiling.SentryThreadMetadata; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; @@ -30,7 +30,8 @@ public JfrAsyncProfilerToSentryProfileConverter(JfrReader jfr, Arguments args) { protected void convertChunk() { final List events = new ArrayList(); final List> stacks = new ArrayList<>(); - final SentryStackTraceFactory stackTraceFactory = new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()); + final SentryStackTraceFactory stackTraceFactory = + new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()); collector.forEach( new AggregatedEventVisitor() { @@ -60,7 +61,7 @@ public void visit(Event event, long value) { sentryProfile.threadMetadata.computeIfAbsent( String.valueOf(threadIdToUse), k -> { - ThreadMetadata metadata = new ThreadMetadata(); + SentryThreadMetadata metadata = new SentryThreadMetadata(); metadata.name = threadName; metadata.priority = 0; return metadata; @@ -107,9 +108,7 @@ public void visit(Event event, long value) { if (element.isNativeMethod() || classNameWithLambdas.isEmpty()) { frame.setInApp(false); } else { - frame.setInApp( - stackTraceFactory - .isInApp(sanitizedClassName)); + frame.setInApp(stackTraceFactory.isInApp(sanitizedClassName)); } frame.setLineno((element.getLineNumber() != 0) ? element.getLineNumber() : null); @@ -126,7 +125,7 @@ public void visit(Event event, long value) { (event.time - jfr.chunkStartTicks) * 1_000_000_000 / jfr.ticksPerSec; long timeNs = jfr.chunkStartNanos + nsFromStart; - JfrSample sample = new JfrSample(); + SentrySample sample = new SentrySample(); sample.timestamp = DateUtils.nanosToSeconds(timeNs); sample.threadId = diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index dd8db6237bc..4c2e7fc7409 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -148,7 +148,6 @@ public void startProfiler( private void initScopes() { if ((scopes == null || scopes == NoOpScopes.getInstance()) && Sentry.getCurrentScopes() != NoOpScopes.getInstance()) { - // TODO: should we fork the scopes here? this.scopes = Sentry.getCurrentScopes(); final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); if (rateLimiter != null) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4accb84f9f2..0163e7495d8 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -6210,55 +6210,55 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { public fun ()V } -public final class io/sentry/protocol/profiling/JfrSample : io/sentry/JsonSerializable, io/sentry/JsonUnknown { - public field stackId I - public field threadId Ljava/lang/String; - public field timestamp D +public final class io/sentry/protocol/profiling/SentryProfile : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public field frames Ljava/util/List; + public field samples Ljava/util/List; + public field stacks Ljava/util/List; + public field threadMetadata Ljava/util/Map; public fun ()V public fun getUnknown ()Ljava/util/Map; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setUnknown (Ljava/util/Map;)V } -public final class io/sentry/protocol/profiling/JfrSample$Deserializer : io/sentry/JsonDeserializer { +public final class io/sentry/protocol/profiling/SentryProfile$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/profiling/JfrSample; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/profiling/SentryProfile; public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } -public final class io/sentry/protocol/profiling/JfrSample$JsonKeys { - public static final field STACK_ID Ljava/lang/String; - public static final field THREAD_ID Ljava/lang/String; - public static final field TIMESTAMP Ljava/lang/String; +public final class io/sentry/protocol/profiling/SentryProfile$JsonKeys { + public static final field FRAMES Ljava/lang/String; + public static final field SAMPLES Ljava/lang/String; + public static final field STACKS Ljava/lang/String; + public static final field THREAD_METADATA Ljava/lang/String; public fun ()V } -public final class io/sentry/protocol/profiling/SentryProfile : io/sentry/JsonSerializable, io/sentry/JsonUnknown { - public field frames Ljava/util/List; - public field samples Ljava/util/List; - public field stacks Ljava/util/List; - public field threadMetadata Ljava/util/Map; +public final class io/sentry/protocol/profiling/SentrySample : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public field stackId I + public field threadId Ljava/lang/String; + public field timestamp D public fun ()V public fun getUnknown ()Ljava/util/Map; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setUnknown (Ljava/util/Map;)V } -public final class io/sentry/protocol/profiling/SentryProfile$Deserializer : io/sentry/JsonDeserializer { +public final class io/sentry/protocol/profiling/SentrySample$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/profiling/SentryProfile; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/profiling/SentrySample; public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } -public final class io/sentry/protocol/profiling/SentryProfile$JsonKeys { - public static final field FRAMES Ljava/lang/String; - public static final field SAMPLES Ljava/lang/String; - public static final field STACKS Ljava/lang/String; - public static final field THREAD_METADATA Ljava/lang/String; +public final class io/sentry/protocol/profiling/SentrySample$JsonKeys { + public static final field STACK_ID Ljava/lang/String; + public static final field THREAD_ID Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; public fun ()V } -public final class io/sentry/protocol/profiling/ThreadMetadata : io/sentry/JsonSerializable, io/sentry/JsonUnknown { +public final class io/sentry/protocol/profiling/SentryThreadMetadata : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public field name Ljava/lang/String; public field priority I public fun ()V @@ -6267,13 +6267,13 @@ public final class io/sentry/protocol/profiling/ThreadMetadata : io/sentry/JsonS public fun setUnknown (Ljava/util/Map;)V } -public final class io/sentry/protocol/profiling/ThreadMetadata$Deserializer : io/sentry/JsonDeserializer { +public final class io/sentry/protocol/profiling/SentryThreadMetadata$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/profiling/ThreadMetadata; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/profiling/SentryThreadMetadata; public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } -public final class io/sentry/protocol/profiling/ThreadMetadata$JsonKeys { +public final class io/sentry/protocol/profiling/SentryThreadMetadata$JsonKeys { public static final field NAME Ljava/lang/String; public static final field PRIORITY Ljava/lang/String; public fun ()V diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java index ec872072702..880a32366e9 100644 --- a/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java @@ -7,20 +7,23 @@ import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.protocol.SentryStackFrame; +import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public final class SentryProfile implements JsonUnknown, JsonSerializable { - public @Nullable List samples; + public @Nullable List samples; public @Nullable List> stacks; // List of frame indices public @Nullable List frames; - public @Nullable Map threadMetadata; // Key is Thread ID (String) + public @Nullable Map threadMetadata; // Key is Thread ID (String) private @Nullable Map unknown; @@ -39,12 +42,6 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) thr if (threadMetadata != null) { writer.name(JsonKeys.THREAD_METADATA).value(logger, threadMetadata); - // writer.beginObject(); - // for (String key : threadMetadata.keySet()) { - // ThreadMetadata value = threadMetadata.get(key); - // writer.name(key).value(logger, value); - // } - // writer.endObject(); } if (unknown != null) { @@ -80,45 +77,66 @@ public static final class Deserializer implements JsonDeserializer unknown = null; + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.FRAMES: + List jfrFrame = + reader.nextListOrNull(logger, new SentryStackFrame.Deserializer()); + if (jfrFrame != null) { + data.frames = jfrFrame; + } + break; + case JsonKeys.SAMPLES: + List sentrySamples = + reader.nextListOrNull(logger, new SentrySample.Deserializer()); + if (sentrySamples != null) { + data.samples = sentrySamples; + } + break; + + case JsonKeys.STACKS: + List> jfrStacks = + reader.nextOrNull(logger, new NestedIntegerListDeserializer()); + if (jfrStacks != null) { + data.stacks = jfrStacks; + } + break; + default: + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + data.setUnknown(unknown); + reader.endObject(); return data; - // Map unknown = null; - // - // while (reader.peek() == JsonToken.NAME) { - // final String nextName = reader.nextName(); - // switch (nextName) { - // case JsonKeys.FRAMES: - // List jfrFrame = reader.nextListOrNull(logger, new - // JfrFrame().Deserializer()); - // if (jfrFrame != null) { - // data.frames = jfrFrame; - // } - // break; - // case JsonKeys.SAMPLES: - // List jfrSamples = reader.nextListOrNull(logger, new - // JfrSample().Deserializer()); - // if (jfrSamples != null) { - // data.samples = jfrSamples; - // } - // break; - // - //// case JsonKeys.STACKS: - //// List> jfrStacks = reader.nextListOrNull(logger); - //// if (jfrSamples != null) { - //// data.samples = jfrSamples; - //// } - //// break; - // - // default: - // if (unknown == null) { - // unknown = new ConcurrentHashMap<>(); - // } - // reader.nextUnknown(logger, unknown, nextName); - // break; - // } - // } - // data.setUnknown(unknown); - // reader.endObject(); - // return data; + } + } + + // Custom Deserializer to handle nested Integer list + private static final class NestedIntegerListDeserializer + implements JsonDeserializer>> { + @Override + public @NotNull List> deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + List> result = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + List innerList = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + innerList.add(reader.nextInt()); + } + reader.endArray(); + result.add(innerList); + } + reader.endArray(); + return result; } } } diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/JfrSample.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java similarity index 62% rename from sentry/src/main/java/io/sentry/protocol/profiling/JfrSample.java rename to sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java index 14a4b96a867..28f9d5fefbc 100644 --- a/sentry/src/main/java/io/sentry/protocol/profiling/JfrSample.java +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java @@ -6,6 +6,7 @@ import io.sentry.JsonUnknown; import io.sentry.ObjectReader; import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; @@ -14,7 +15,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class JfrSample implements JsonUnknown, JsonSerializable { +public final class SentrySample implements JsonUnknown, JsonSerializable { public double timestamp; // Unix timestamp in seconds with microsecond precision @@ -54,13 +55,37 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) thr @Override public void setUnknown(@Nullable Map unknown) {} - public static final class Deserializer implements JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull JfrSample deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + public @NotNull SentrySample deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); - JfrSample data = new JfrSample(); + SentrySample data = new SentrySample(); + Map unknown = null; + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TIMESTAMP: + data.timestamp = reader.nextDouble(); + break; + case JsonKeys.STACK_ID: + data.stackId = reader.nextInt(); + break; + case JsonKeys.THREAD_ID: + data.threadId = reader.nextStringOrNull(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + data.setUnknown(unknown); + reader.endObject(); return data; } } diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/ThreadMetadata.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentryThreadMetadata.java similarity index 56% rename from sentry/src/main/java/io/sentry/protocol/profiling/ThreadMetadata.java rename to sentry/src/main/java/io/sentry/protocol/profiling/SentryThreadMetadata.java index 9c83a686114..6f380337999 100644 --- a/sentry/src/main/java/io/sentry/protocol/profiling/ThreadMetadata.java +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentryThreadMetadata.java @@ -6,16 +6,17 @@ import io.sentry.JsonUnknown; import io.sentry.ObjectReader; import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class ThreadMetadata implements JsonUnknown, JsonSerializable { - public @Nullable String name; // e.g., "com.example.MyClass.myMethod" +public final class SentryThreadMetadata implements JsonUnknown, JsonSerializable { + public @Nullable String name; - public int priority; // e.g., "com.example" (package name) + public int priority; public static final class JsonKeys { public static final String NAME = "name"; @@ -40,13 +41,34 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) thr @Override public void setUnknown(@Nullable Map unknown) {} - public static final class Deserializer implements JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ThreadMetadata deserialize( + public @NotNull SentryThreadMetadata deserialize( @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); - ThreadMetadata data = new ThreadMetadata(); + SentryThreadMetadata data = new SentryThreadMetadata(); + Map unknown = null; + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.NAME: + data.name = reader.nextStringOrNull(); + break; + case JsonKeys.PRIORITY: + data.priority = reader.nextInt(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + data.setUnknown(unknown); + reader.endObject(); return data; } } diff --git a/sentry/src/test/java/io/sentry/protocol/SentryProfileSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryProfileSerializationTest.kt new file mode 100644 index 00000000000..b605a4a53dd --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/SentryProfileSerializationTest.kt @@ -0,0 +1,114 @@ +package io.sentry.protocol + +import io.sentry.FileFromResources +import io.sentry.ILogger +import io.sentry.JsonObjectReader +import io.sentry.JsonObjectWriter +import io.sentry.JsonSerializable +import io.sentry.protocol.profiling.SentryProfile +import io.sentry.protocol.profiling.SentrySample +import io.sentry.protocol.profiling.SentryThreadMetadata +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.assertEquals +import org.junit.Test +import org.mockito.kotlin.mock + +class SentryProfileSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = + SentryProfile().apply { + samples = + listOf( + SentrySample().apply { + timestamp = 1753439655.387274 + threadId = "57" + stackId = 0 + }, + SentrySample().apply { + timestamp = 1753439655.415672 + threadId = "57" + stackId = 1 + }, + ) + stacks = listOf(listOf(0, 1, 2), listOf(3, 4)) + frames = + listOf( + SentryStackFrame().apply { + filename = "sun.nio.ch.Net" + function = "accept" + module = "sun.nio.ch.Net" + }, + SentryStackFrame().apply { + filename = "org.apache.tomcat.util.net.NioEndpoint" + function = "serverSocketAccept" + module = "org.apache.tomcat.util.net.NioEndpoint" + lineno = 519 + }, + SentryStackFrame().apply { + filename = "java.lang.Thread" + function = "run" + module = "java.lang.Thread" + lineno = 840 + }, + SentryStackFrame().apply { + filename = "io.sentry.samples.spring.boot.jakarta.quartz.SampleJob" + function = "execute" + module = "io.sentry.samples.spring.boot.jakarta.quartz.SampleJob" + lineno = 14 + isInApp = true + }, + SentryStackFrame().apply { + filename = "" + function = "Unsafe_Park" + module = "" + isInApp = false + }, + ) + threadMetadata = + mapOf( + "57" to + SentryThreadMetadata().apply { + name = "http-nio-8080-Acceptor" + priority = 0 + } + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/sentry_profile.json") + val actual = serialize(fixture.getSut()) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/sentry_profile.json") + val actual = deserialize(expectedJson) + val actualJson = serialize(actual) + assertEquals(expectedJson, actualJson) + } + + // Helper + + 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): SentryProfile { + val reader = JsonObjectReader(StringReader(json)) + return SentryProfile.Deserializer().deserialize(reader, fixture.logger) + } +} diff --git a/sentry/src/test/resources/json/sentry_profile.json b/sentry/src/test/resources/json/sentry_profile.json new file mode 100644 index 00000000000..503f4c0e4ba --- /dev/null +++ b/sentry/src/test/resources/json/sentry_profile.json @@ -0,0 +1,63 @@ +{ + "samples": [ + { + "timestamp": 1753439655.387274, + "stack_id": 0, + "thread_id": "57" + }, + { + "timestamp": 1753439655.415672, + "stack_id": 1, + "thread_id": "57" + } + ], + "stacks": [ + [ + 0, + 1, + 2 + ], + [ + 3, + 4 + ] + ], + "frames": [ + { + "filename": "sun.nio.ch.Net", + "function": "accept", + "module": "sun.nio.ch.Net" + }, + { + "filename": "org.apache.tomcat.util.net.NioEndpoint", + "function": "serverSocketAccept", + "module": "org.apache.tomcat.util.net.NioEndpoint", + "lineno": 519 + }, + { + "filename": "java.lang.Thread", + "function": "run", + "module": "java.lang.Thread", + "lineno": 840 + }, + { + "filename": "io.sentry.samples.spring.boot.jakarta.quartz.SampleJob", + "function": "execute", + "module": "io.sentry.samples.spring.boot.jakarta.quartz.SampleJob", + "lineno": 14, + "in_app": true + }, + { + "filename": "", + "function": "Unsafe_Park", + "module": "", + "in_app": false + } + ], + "thread_metadata": { + "57": { + "name": "http-nio-8080-Acceptor", + "priority": 0 + } + } +} From 119189a18bd38547bf7f49d4f43160bd37e6b2ba Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 11:32:35 +0200 Subject: [PATCH 09/24] extract methods in ProfileConverter, fix SentryProfile serialization and make fields private --- ...AsyncProfilerToSentryProfileConverter.java | 207 ++++++++++-------- sentry/api/sentry.api | 12 +- .../protocol/profiling/SentryProfile.java | 67 ++++-- 3 files changed, 169 insertions(+), 117 deletions(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index c5cc83e1c91..6ab9ce1c583 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -15,9 +15,9 @@ import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { private final @NotNull SentryProfile sentryProfile = new SentryProfile(); @@ -28,8 +28,6 @@ public JfrAsyncProfilerToSentryProfileConverter(JfrReader jfr, Arguments args) { @Override protected void convertChunk() { - final List events = new ArrayList(); - final List> stacks = new ArrayList<>(); final SentryStackTraceFactory stackTraceFactory = new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()); @@ -38,112 +36,131 @@ protected void convertChunk() { @Override public void visit(Event event, long value) { - events.add(event); - System.out.println(event); StackTrace stackTrace = jfr.stackTraces.get(event.stackTraceId); + long threadId = + jfr.threads.get(event.tid) != null ? jfr.javaThreads.get(event.tid) : event.tid; if (stackTrace != null) { - Arguments args = JfrAsyncProfilerToSentryProfileConverter.this.args; - long[] methods = stackTrace.methods; - byte[] types = stackTrace.types; - int[] locations = stackTrace.locations; - + // Process thread metadata if enabled if (args.threads) { - if (sentryProfile.threadMetadata == null) { - sentryProfile.threadMetadata = new HashMap<>(); - } - - long threadIdToUse = - jfr.threads.get(event.tid) != null ? jfr.javaThreads.get(event.tid) : event.tid; - - if (sentryProfile.threadMetadata != null) { - final String threadName = getPlainThreadName(event.tid); - sentryProfile.threadMetadata.computeIfAbsent( - String.valueOf(threadIdToUse), - k -> { - SentryThreadMetadata metadata = new SentryThreadMetadata(); - metadata.name = threadName; - metadata.priority = 0; - return metadata; - }); - } + processThreadMetadata(event, threadId); } - if (sentryProfile.samples == null) { - sentryProfile.samples = new ArrayList<>(); - } + // Create and add the sample + createSample(event, threadId); - if (sentryProfile.frames == null) { - sentryProfile.frames = new ArrayList<>(); - } + // Build the stack trace from methods + buildStackTraceAndFrames(stackTrace, stackTraceFactory); + } + } - List stack = new ArrayList<>(); - int currentStack = stacks.size(); - int currentFrame = sentryProfile.frames != null ? sentryProfile.frames.size() : 0; - for (int i = 0; i < methods.length; i++) { - // for (int i = methods.length; --i >= 0; ) { - SentryStackFrame frame = new SentryStackFrame(); - StackTraceElement element = - getStackTraceElement(methods[i], types[i], locations[i]); - if (element.isNativeMethod()) { - continue; - } - - final String classNameWithLambdas = element.getClassName().replace("/", "."); - frame.setFunction(element.getMethodName()); - - int firstDollar = classNameWithLambdas.indexOf('$'); - String sanitizedClassName = classNameWithLambdas; - if (firstDollar != -1) { - sanitizedClassName = classNameWithLambdas.substring(0, firstDollar); - } - - int lastDot = sanitizedClassName.lastIndexOf('.'); - if (lastDot > 0) { - frame.setModule(sanitizedClassName); - } else if (!classNameWithLambdas.startsWith("[")) { - frame.setModule(""); - } - - if (element.isNativeMethod() || classNameWithLambdas.isEmpty()) { - frame.setInApp(false); - } else { - frame.setInApp(stackTraceFactory.isInApp(sanitizedClassName)); - } - - frame.setLineno((element.getLineNumber() != 0) ? element.getLineNumber() : null); - frame.setFilename(classNameWithLambdas); - - if (sentryProfile.frames != null) { - sentryProfile.frames.add(frame); - } - stack.add(currentFrame); - currentFrame++; - } + // Extract thread metadata and add to profile + private void processThreadMetadata(Event event, long threadId) { + + final String threadName = getPlainThreadName(event.tid); + sentryProfile + .getThreadMetadata() + .computeIfAbsent( + String.valueOf(threadId), + k -> { + SentryThreadMetadata metadata = new SentryThreadMetadata(); + metadata.name = threadName; + metadata.priority = 0; + return metadata; + }); + } + + // Build stack trace from method array + private void buildStackTraceAndFrames( + StackTrace stackTrace, SentryStackTraceFactory stackTraceFactory) { + List stack = new ArrayList<>(); + int currentFrame = sentryProfile.getFrames().size(); - long nsFromStart = - (event.time - jfr.chunkStartTicks) * 1_000_000_000 / jfr.ticksPerSec; - long timeNs = jfr.chunkStartNanos + nsFromStart; - - SentrySample sample = new SentrySample(); - - sample.timestamp = DateUtils.nanosToSeconds(timeNs); - sample.threadId = - String.valueOf( - jfr.threads.get(event.tid) != null - ? jfr.javaThreads.get(event.tid) - : event.tid); - sample.stackId = currentStack; - if (sentryProfile.samples != null) { - sentryProfile.samples.add(sample); + long[] methods = stackTrace.methods; + byte[] types = stackTrace.types; + int[] locations = stackTrace.locations; + + for (int i = 0; i < methods.length; i++) { + StackTraceElement element = getStackTraceElement(methods[i], types[i], locations[i]); + if (element.isNativeMethod()) { + continue; } - stacks.add(stack); + SentryStackFrame frame = createStackFrame(element, stackTraceFactory); + sentryProfile.getFrames().add(frame); + + stack.add(currentFrame); + currentFrame++; + } + + sentryProfile.getStacks().add(stack); + } + + // Create a single stack frame from a stack trace element + private SentryStackFrame createStackFrame( + StackTraceElement element, SentryStackTraceFactory stackTraceFactory) { + SentryStackFrame frame = new SentryStackFrame(); + final String classNameWithLambdas = element.getClassName().replace("/", "."); + frame.setFunction(element.getMethodName()); + + // Extract class name without lambda suffix + String sanitizedClassName = extractSanitizedClassName(classNameWithLambdas); + + // Set module based on package structure + frame.setModule(extractModuleName(sanitizedClassName, classNameWithLambdas)); + + // Determine if frame should be marked as in_app + if (element.isNativeMethod() || classNameWithLambdas.isEmpty()) { + frame.setInApp(false); + } else { + frame.setInApp(stackTraceFactory.isInApp(sanitizedClassName)); + } + + frame.setLineno((element.getLineNumber() != 0) ? element.getLineNumber() : null); + frame.setFilename(classNameWithLambdas); + + return frame; + } + + // Remove lambda suffix from class name + private String extractSanitizedClassName(String classNameWithLambdas) { + int firstDollar = classNameWithLambdas.indexOf('$'); + if (firstDollar != -1) { + return classNameWithLambdas.substring(0, firstDollar); } + return classNameWithLambdas; + } + + // Set module name based on package structure + private @Nullable String extractModuleName( + String sanitizedClassName, String classNameWithLambdas) { + int lastDot = sanitizedClassName.lastIndexOf('.'); + if (lastDot > 0) { + return sanitizedClassName; + } else if (!classNameWithLambdas.startsWith("[")) { + return ""; + } else { + return null; + } + } + + // Create sample with timestamp and thread info + private void createSample(Event event, long threadId) { + int stackId = sentryProfile.getStacks().size(); + SentrySample sample = new SentrySample(); + + // Calculate timestamp from JFR event time + long nsFromStart = (event.time - jfr.chunkStartTicks) * 1_000_000_000 / jfr.ticksPerSec; + long timeNs = jfr.chunkStartNanos + nsFromStart; + sample.timestamp = DateUtils.nanosToSeconds(timeNs); + + // Set thread ID + sample.threadId = String.valueOf(threadId); + sample.stackId = stackId; + + sentryProfile.getSamples().add(sample); } }); - sentryProfile.stacks = stacks; - System.out.println("Samples: " + events.size()); } public static @NotNull SentryProfile convertFromFileStatic(@NotNull Path jfrFilePath) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0163e7495d8..2c481a9e045 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -6211,13 +6211,17 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { } public final class io/sentry/protocol/profiling/SentryProfile : io/sentry/JsonSerializable, io/sentry/JsonUnknown { - public field frames Ljava/util/List; - public field samples Ljava/util/List; - public field stacks Ljava/util/List; - public field threadMetadata Ljava/util/Map; public fun ()V + public fun getFrames ()Ljava/util/List; + public fun getSamples ()Ljava/util/List; + public fun getStacks ()Ljava/util/List; + public fun getThreadMetadata ()Ljava/util/Map; public fun getUnknown ()Ljava/util/Map; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setFrames (Ljava/util/List;)V + public fun setSamples (Ljava/util/List;)V + public fun setStacks (Ljava/util/List;)V + public fun setThreadMetadata (Ljava/util/Map;)V public fun setUnknown (Ljava/util/Map;)V } diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java index 880a32366e9..ad64b880625 100644 --- a/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java @@ -10,6 +10,7 @@ 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 java.util.concurrent.ConcurrentHashMap; @@ -17,32 +18,24 @@ import org.jetbrains.annotations.Nullable; public final class SentryProfile implements JsonUnknown, JsonSerializable { - public @Nullable List samples; + private @NotNull List samples = new ArrayList<>(); - public @Nullable List> stacks; // List of frame indices + private @NotNull List> stacks = new ArrayList<>(); // List of frame indices - public @Nullable List frames; + private @NotNull List frames = new ArrayList<>(); // List of stack frames - public @Nullable Map threadMetadata; // Key is Thread ID (String) + private @NotNull Map threadMetadata = + new HashMap<>(); // Key is Thread ID (String) private @Nullable Map unknown; @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { writer.beginObject(); - if (samples != null) { - writer.name(JsonKeys.SAMPLES).value(logger, samples); - } - if (stacks != null) { - writer.name(JsonKeys.STACKS).value(logger, stacks); - } - if (frames != null) { - writer.name(JsonKeys.FRAMES).value(logger, frames); - } - - if (threadMetadata != null) { - writer.name(JsonKeys.THREAD_METADATA).value(logger, threadMetadata); - } + writer.name(JsonKeys.SAMPLES).value(logger, samples); + writer.name(JsonKeys.STACKS).value(logger, stacks); + writer.name(JsonKeys.FRAMES).value(logger, frames); + writer.name(JsonKeys.THREAD_METADATA).value(logger, threadMetadata); if (unknown != null) { for (String key : unknown.keySet()) { @@ -53,6 +46,38 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) thr writer.endObject(); } + public @NotNull List getSamples() { + return samples; + } + + public void setSamples(@NotNull List samples) { + this.samples = samples; + } + + public @NotNull List> getStacks() { + return stacks; + } + + public void setStacks(@NotNull List> stacks) { + this.stacks = stacks; + } + + public @NotNull List getFrames() { + return frames; + } + + public void setFrames(@NotNull List frames) { + this.frames = frames; + } + + public @NotNull Map getThreadMetadata() { + return threadMetadata; + } + + public void setThreadMetadata(@NotNull Map threadMetadata) { + this.threadMetadata = threadMetadata; + } + @Override public @Nullable Map getUnknown() { return unknown; @@ -96,7 +121,13 @@ public static final class Deserializer implements JsonDeserializer threadMetadata = + reader.nextMapOrNull(logger, new SentryThreadMetadata.Deserializer()); + if (threadMetadata != null) { + data.threadMetadata = threadMetadata; + } + break; case JsonKeys.STACKS: List> jfrStacks = reader.nextOrNull(logger, new NestedIntegerListDeserializer()); From 126cc6b9d9ff58ac137b02ef07f08e197f1bc48b Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 11:33:42 +0200 Subject: [PATCH 10/24] use wall=[interval] instead of setting the event to wall and setting the interval separately, this seems to work better and create more samples --- .../sentry/asyncprofiler/profiling/JavaContinuousProfiler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index 4c2e7fc7409..04d7b50d8fe 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -194,8 +194,7 @@ private void start() { final String profilingIntervalMicros = String.format("%dus", (int) SECONDS.toMicros(1) / profilingTracesHz); final String command = - String.format( - "start,jfr,event=wall,interval=%s,file=%s", profilingIntervalMicros, filename); + String.format("start,jfr,wall=%s,file=%s", profilingIntervalMicros, filename); System.out.println(command); startData = profiler.execute(command); } catch (Exception e) { From a65ba60eca32e6ad7fe63d96eadcc5f011daeeae Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 11:34:22 +0200 Subject: [PATCH 11/24] start/stop profiler in OtelSentrySpanProcesser in trace mode for root spans --- .../OtelSentrySpanProcessor.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java index ae32ced33e6..9b260cfc46c 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java @@ -14,6 +14,7 @@ import io.sentry.Baggage; import io.sentry.DateUtils; import io.sentry.IScopes; +import io.sentry.ProfileLifecycle; import io.sentry.PropagationContext; import io.sentry.ScopesAdapter; import io.sentry.Sentry; @@ -82,6 +83,17 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri final @Nullable Boolean sampled = isSampled(otelSpan, samplingDecision); + if (Boolean.TRUE.equals(sampled) && isRootSpan(otelSpan.toSpanData())) { + if (scopes.getOptions().isContinuousProfilingEnabled() + && scopes.getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE) { + scopes + .getOptions() + .getContinuousProfiler() + .startProfiler( + ProfileLifecycle.TRACE, scopes.getOptions().getInternalTracesSampler()); + } + } + final @NotNull PropagationContext propagationContext = new PropagationContext( new SentryId(traceId), sentrySpanId, sentryParentSpanId, baggage, sampled); @@ -159,6 +171,13 @@ public boolean isStartRequired() { public void onEnd(final @NotNull ReadableSpan spanBeingEnded) { final @Nullable IOtelSpanWrapper sentrySpan = spanStorage.getSentrySpan(spanBeingEnded.getSpanContext()); + + if (isRootSpan(spanBeingEnded.toSpanData()) + && scopes.getOptions().isContinuousProfilingEnabled() + && scopes.getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE) { + scopes.getOptions().getContinuousProfiler().stopProfiler(ProfileLifecycle.TRACE); + } + if (sentrySpan != null) { final @NotNull SentryDate finishDate = new SentryLongDate(spanBeingEnded.toSpanData().getEndEpochNanos()); From 77c49420cf87a3430a692547da54a493d52e6085 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 11:35:32 +0200 Subject: [PATCH 12/24] add profiler dependency to jakarta-opentelemetry sample, add needed configs --- .../build.gradle.kts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index 307f6e6803f..5afae86bdd3 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(projects.sentryGraphql22) implementation(projects.sentryQuartz) implementation(libs.otel) + implementation(projects.sentryAsyncProfiler) // database query tracing implementation(projects.sentryJdbc) @@ -90,10 +91,17 @@ tasks.register("bootRunWithAgent").configure { val tracesSampleRate = System.getenv("SENTRY_TRACES_SAMPLE_RATE") ?: "1" environment("SENTRY_DSN", dsn) + environment("SENTRY_DEBUG", "true") + environment("SENTRY_PROFILE_SESSION_SAMPLE_RATE", "1.0") + environment("SENTRY_PROFILING_TRACES_DIR_PATH", "tmp/sentry/profiling-traces") + environment("SENTRY_PROFILE_LIFECYCLE", "TRACE") + environment("SENTRY_TRACES_SAMPLE_RATE", tracesSampleRate) environment("OTEL_TRACES_EXPORTER", "none") environment("OTEL_METRICS_EXPORTER", "none") environment("OTEL_LOGS_EXPORTER", "none") + environment("SENTRY_IN_APP_INCLUDES", "io.sentry.samples") + environment("SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT", "false") jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") } From 923a78a5a8ab95028c8a96ef65856fa1600335a0 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 11:37:10 +0200 Subject: [PATCH 13/24] add dependenies and config to spring-boot-jakarta sample --- .../sentry-samples-spring-boot-jakarta/build.gradle.kts | 1 + .../src/main/resources/application.properties | 2 ++ 2 files changed, 3 insertions(+) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 009088ce53c..f7643430ecd 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(projects.sentryLogback) implementation(projects.sentryGraphql22) implementation(projects.sentryQuartz) + implementation(projects.sentryAsyncProfiler) // database query tracing implementation(projects.sentryJdbc) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties index 3fb2f721186..5348d2c5ad7 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties @@ -18,6 +18,8 @@ sentry.enablePrettySerializationOutput=false in-app-includes="io.sentry.samples" sentry.logs.enabled=true sentry.profile-session-sample-rate=1.0 +sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces +sentry.profile-lifecycle=TRACE # Uncomment and set to true to enable aot compatibility # This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) From 38cdd2810e5ce10574eee68f95f8e75e74fd027d Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 12:28:00 +0200 Subject: [PATCH 14/24] remove connection status check --- .../asyncprofiler/profiling/JavaContinuousProfiler.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index 04d7b50d8fe..f801a303e21 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -1,7 +1,6 @@ package io.sentry.asyncprofiler.profiling; import static io.sentry.DataCategory.All; -import static io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED; import static java.util.concurrent.TimeUnit.SECONDS; import io.sentry.DataCategory; @@ -176,14 +175,6 @@ private void start() { return; } - // TODO: Taken from the android profiler, do we need this on the JVM as well? - // If device is offline, we don't start the profiler, to avoid flooding the cache - if (scopes.getOptions().getConnectionStatusProvider().getConnectionStatus() == DISCONNECTED) { - logger.log(SentryLevel.WARNING, "Device is offline. Stopping profiler."); - // Let's stop and reset profiler id, as the profile is now broken anyway - stop(false); - return; - } startProfileChunkTimestamp = scopes.getOptions().getDateProvider().now(); } else { startProfileChunkTimestamp = new SentryNanotimeDate(); From f5d7b39a2f32e83c4395152e3f3d0c2b4b25a4fa Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 14:32:37 +0200 Subject: [PATCH 15/24] extract event visitor --- ...AsyncProfilerToSentryProfileConverter.java | 315 ++++++++++-------- 1 file changed, 180 insertions(+), 135 deletions(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index 6ab9ce1c583..1ac1e6c3438 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -20,147 +20,20 @@ import org.jetbrains.annotations.Nullable; public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { + private static final long NANOS_PER_SECOND = 1_000_000_000L; + private final @NotNull SentryProfile sentryProfile = new SentryProfile(); + private final @NotNull SentryStackTraceFactory stackTraceFactory; - public JfrAsyncProfilerToSentryProfileConverter(JfrReader jfr, Arguments args) { + public JfrAsyncProfilerToSentryProfileConverter( + JfrReader jfr, Arguments args, @NotNull SentryStackTraceFactory stackTraceFactory) { super(jfr, args); + this.stackTraceFactory = stackTraceFactory; } @Override protected void convertChunk() { - final SentryStackTraceFactory stackTraceFactory = - new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()); - - collector.forEach( - new AggregatedEventVisitor() { - - @Override - public void visit(Event event, long value) { - StackTrace stackTrace = jfr.stackTraces.get(event.stackTraceId); - long threadId = - jfr.threads.get(event.tid) != null ? jfr.javaThreads.get(event.tid) : event.tid; - - if (stackTrace != null) { - // Process thread metadata if enabled - if (args.threads) { - processThreadMetadata(event, threadId); - } - - // Create and add the sample - createSample(event, threadId); - - // Build the stack trace from methods - buildStackTraceAndFrames(stackTrace, stackTraceFactory); - } - } - - // Extract thread metadata and add to profile - private void processThreadMetadata(Event event, long threadId) { - - final String threadName = getPlainThreadName(event.tid); - sentryProfile - .getThreadMetadata() - .computeIfAbsent( - String.valueOf(threadId), - k -> { - SentryThreadMetadata metadata = new SentryThreadMetadata(); - metadata.name = threadName; - metadata.priority = 0; - return metadata; - }); - } - - // Build stack trace from method array - private void buildStackTraceAndFrames( - StackTrace stackTrace, SentryStackTraceFactory stackTraceFactory) { - List stack = new ArrayList<>(); - int currentFrame = sentryProfile.getFrames().size(); - - long[] methods = stackTrace.methods; - byte[] types = stackTrace.types; - int[] locations = stackTrace.locations; - - for (int i = 0; i < methods.length; i++) { - StackTraceElement element = getStackTraceElement(methods[i], types[i], locations[i]); - if (element.isNativeMethod()) { - continue; - } - - SentryStackFrame frame = createStackFrame(element, stackTraceFactory); - sentryProfile.getFrames().add(frame); - - stack.add(currentFrame); - currentFrame++; - } - - sentryProfile.getStacks().add(stack); - } - - // Create a single stack frame from a stack trace element - private SentryStackFrame createStackFrame( - StackTraceElement element, SentryStackTraceFactory stackTraceFactory) { - SentryStackFrame frame = new SentryStackFrame(); - final String classNameWithLambdas = element.getClassName().replace("/", "."); - frame.setFunction(element.getMethodName()); - - // Extract class name without lambda suffix - String sanitizedClassName = extractSanitizedClassName(classNameWithLambdas); - - // Set module based on package structure - frame.setModule(extractModuleName(sanitizedClassName, classNameWithLambdas)); - - // Determine if frame should be marked as in_app - if (element.isNativeMethod() || classNameWithLambdas.isEmpty()) { - frame.setInApp(false); - } else { - frame.setInApp(stackTraceFactory.isInApp(sanitizedClassName)); - } - - frame.setLineno((element.getLineNumber() != 0) ? element.getLineNumber() : null); - frame.setFilename(classNameWithLambdas); - - return frame; - } - - // Remove lambda suffix from class name - private String extractSanitizedClassName(String classNameWithLambdas) { - int firstDollar = classNameWithLambdas.indexOf('$'); - if (firstDollar != -1) { - return classNameWithLambdas.substring(0, firstDollar); - } - return classNameWithLambdas; - } - - // Set module name based on package structure - private @Nullable String extractModuleName( - String sanitizedClassName, String classNameWithLambdas) { - int lastDot = sanitizedClassName.lastIndexOf('.'); - if (lastDot > 0) { - return sanitizedClassName; - } else if (!classNameWithLambdas.startsWith("[")) { - return ""; - } else { - return null; - } - } - - // Create sample with timestamp and thread info - private void createSample(Event event, long threadId) { - int stackId = sentryProfile.getStacks().size(); - SentrySample sample = new SentrySample(); - - // Calculate timestamp from JFR event time - long nsFromStart = (event.time - jfr.chunkStartTicks) * 1_000_000_000 / jfr.ticksPerSec; - long timeNs = jfr.chunkStartNanos + nsFromStart; - sample.timestamp = DateUtils.nanosToSeconds(timeNs); - - // Set thread ID - sample.threadId = String.valueOf(threadId); - sample.stackId = stackId; - - sentryProfile.getSamples().add(sample); - } - }); + collector.forEach(new ProfileEventVisitor(sentryProfile, stackTraceFactory, jfr, args)); } public static @NotNull SentryProfile convertFromFileStatic(@NotNull Path jfrFilePath) @@ -174,10 +47,182 @@ private void createSample(Event event, long threadId) { args.lines = true; args.dot = true; - converter = new JfrAsyncProfilerToSentryProfileConverter(jfrReader, args); + SentryStackTraceFactory stackTraceFactory = + new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()); + converter = new JfrAsyncProfilerToSentryProfileConverter(jfrReader, args, stackTraceFactory); converter.convert(); } return converter.sentryProfile; } + + private class ProfileEventVisitor extends AggregatedEventVisitor { + private final @NotNull SentryProfile sentryProfile; + private final @NotNull SentryStackTraceFactory stackTraceFactory; + private final @NotNull JfrReader jfr; + private final @NotNull Arguments args; + + public ProfileEventVisitor( + @NotNull SentryProfile sentryProfile, + @NotNull SentryStackTraceFactory stackTraceFactory, + @NotNull JfrReader jfr, + @NotNull Arguments args) { + this.sentryProfile = sentryProfile; + this.stackTraceFactory = stackTraceFactory; + this.jfr = jfr; + this.args = args; + } + + @Override + public void visit(Event event, long value) { + StackTrace stackTrace = jfr.stackTraces.get(event.stackTraceId); + long threadId = resolveThreadId(event.tid); + + if (stackTrace != null) { + // Process thread metadata if enabled + if (args.threads) { + processThreadMetadata(event, threadId); + } + + // Create and add the sample + createSample(event, threadId); + + // Build the stack trace from methods + buildStackTraceAndFrames(stackTrace); + } + } + + // Extract thread metadata and add to profile + private void processThreadMetadata(Event event, long threadId) { + final String threadName = getPlainThreadName(event.tid); + sentryProfile + .getThreadMetadata() + .computeIfAbsent( + String.valueOf(threadId), + k -> { + SentryThreadMetadata metadata = new SentryThreadMetadata(); + metadata.name = threadName; + metadata.priority = 0; + return metadata; + }); + } + + // Build stack trace from method array + private void buildStackTraceAndFrames(StackTrace stackTrace) { + List stack = new ArrayList<>(); + int currentFrame = sentryProfile.getFrames().size(); + + long[] methods = stackTrace.methods; + byte[] types = stackTrace.types; + int[] locations = stackTrace.locations; + + for (int i = 0; i < methods.length; i++) { + StackTraceElement element = getStackTraceElement(methods[i], types[i], locations[i]); + if (element.isNativeMethod()) { + continue; + } + + SentryStackFrame frame = createStackFrame(element); + sentryProfile.getFrames().add(frame); + + stack.add(currentFrame); + currentFrame++; + } + + sentryProfile.getStacks().add(stack); + } + + // Create a single stack frame from a stack trace element + private SentryStackFrame createStackFrame(StackTraceElement element) { + SentryStackFrame frame = new SentryStackFrame(); + final String classNameWithLambdas = element.getClassName().replace("/", "."); + frame.setFunction(element.getMethodName()); + + // Extract class name without lambda suffix + String sanitizedClassName = extractSanitizedClassName(classNameWithLambdas); + + // Set module based on package structure + frame.setModule(extractModuleName(sanitizedClassName, classNameWithLambdas)); + + // Determine if frame should be marked as in_app + if (shouldMarkAsSystemFrame(element, classNameWithLambdas)) { + frame.setInApp(false); + } else { + frame.setInApp(stackTraceFactory.isInApp(sanitizedClassName)); + } + + frame.setLineno(extractLineNumber(element)); + frame.setFilename(classNameWithLambdas); + + return frame; + } + + // Remove lambda suffix from class name + private String extractSanitizedClassName(String classNameWithLambdas) { + int firstDollar = classNameWithLambdas.indexOf('$'); + if (firstDollar != -1) { + return classNameWithLambdas.substring(0, firstDollar); + } + return classNameWithLambdas; + } + + // Set module name based on package structure + private @Nullable String extractModuleName( + String sanitizedClassName, String classNameWithLambdas) { + if (hasPackageStructure(sanitizedClassName)) { + return sanitizedClassName; + } else if (isRegularClassWithoutPackage(classNameWithLambdas)) { + return ""; + } else { + return null; + } + } + + // Check if the class name has a package structure (contains dots) + private boolean hasPackageStructure(String className) { + return className.lastIndexOf('.') > 0; + } + + // Check if it's a regular class without package (not an array type) + private boolean isRegularClassWithoutPackage(String className) { + return !className.startsWith("["); + } + + // Create sample with timestamp and thread info + private void createSample(Event event, long threadId) { + int stackId = sentryProfile.getStacks().size(); + SentrySample sample = new SentrySample(); + + // Calculate timestamp from JFR event time + long nsFromStart = + (event.time - jfr.chunkStartTicks) + * JfrAsyncProfilerToSentryProfileConverter.NANOS_PER_SECOND + / jfr.ticksPerSec; + long timeNs = jfr.chunkStartNanos + nsFromStart; + sample.timestamp = DateUtils.nanosToSeconds(timeNs); + + // Set thread ID + sample.threadId = String.valueOf(threadId); + sample.stackId = stackId; + + sentryProfile.getSamples().add(sample); + } + + // Check if the stack frame should be marked as a system frame + private boolean shouldMarkAsSystemFrame(StackTraceElement element, String className) { + return element.isNativeMethod() || className.isEmpty(); + } + + // Check if the stack trace element has a valid line number + private @Nullable Integer extractLineNumber(StackTraceElement element) { + return element.getLineNumber() != 0 ? element.getLineNumber() : null; + } + + // Resolve the actual thread ID from the JFR event + private long resolveThreadId(int eventThreadId) { + return jfr.threads.get(eventThreadId) != null + ? jfr.javaThreads.get(eventThreadId) + : eventThreadId; + } + } } From f6d6233c46c32ac33c0a87a45a2e71c41cd2a805 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 16:41:30 +0200 Subject: [PATCH 16/24] Add enum for ProfileChunk platform --- .../core/AndroidContinuousProfiler.java | 2 +- .../api/sentry-async-profiler.api | 2 +- .../profiling/JavaContinuousProfiler.java | 4 +- sentry/api/sentry.api | 21 ++++++++-- .../src/main/java/io/sentry/ProfileChunk.java | 42 +++++++++++++++---- .../java/io/sentry/SentryEnvelopeItem.java | 4 +- .../test/java/io/sentry/JsonSerializerTest.kt | 4 +- .../test/java/io/sentry/SentryClientTest.kt | 2 +- .../java/io/sentry/SentryEnvelopeItemTest.kt | 16 +++---- 9 files changed, 70 insertions(+), 27 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 1515e18b1ab..82ebe2e6a7a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -301,7 +301,7 @@ private void stop(final boolean restartProfiler) { endData.measurementsMap, endData.traceFile, startProfileChunkTimestamp, - "android")); + ProfileChunk.Platform.ANDROID)); } } diff --git a/sentry-async-profiler/api/sentry-async-profiler.api b/sentry-async-profiler/api/sentry-async-profiler.api index 3e11247dd7e..0bf859efb1e 100644 --- a/sentry-async-profiler/api/sentry-async-profiler.api +++ b/sentry-async-profiler/api/sentry-async-profiler.api @@ -4,7 +4,7 @@ public final class io/sentry/asyncprofiler/BuildConfig { } public final class io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter : io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter { - public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;)V + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;Lio/sentry/SentryStackTraceFactory;)V public static fun convertFromFileStatic (Ljava/nio/file/Path;)Lio/sentry/protocol/profiling/SentryProfile; } diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index f801a303e21..a31e2ee891a 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -277,9 +277,9 @@ private void stop(final boolean restartProfiler) { profilerId, chunkId, new HashMap<>(), - new File(filename), + jfrFile, startProfileChunkTimestamp, - "java")); + ProfileChunk.Platform.JAVA)); } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2c481a9e045..fd91ddc3b99 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1936,14 +1936,14 @@ public final class io/sentry/PerformanceCollectionData { public final class io/sentry/ProfileChunk : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun ()V - public fun (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Ljava/io/File;Ljava/util/Map;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/SentryOptions;)V + public fun (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Ljava/io/File;Ljava/util/Map;Ljava/lang/Double;Lio/sentry/ProfileChunk$Platform;Lio/sentry/SentryOptions;)V public fun equals (Ljava/lang/Object;)Z public fun getChunkId ()Lio/sentry/protocol/SentryId; public fun getClientSdk ()Lio/sentry/protocol/SdkVersion; public fun getDebugMeta ()Lio/sentry/protocol/DebugMeta; public fun getEnvironment ()Ljava/lang/String; public fun getMeasurements ()Ljava/util/Map; - public fun getPlatform ()Ljava/lang/String; + public fun getPlatform ()Lio/sentry/ProfileChunk$Platform; public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun getRelease ()Ljava/lang/String; public fun getSampledProfile ()Ljava/lang/String; @@ -1961,7 +1961,7 @@ public final class io/sentry/ProfileChunk : io/sentry/JsonSerializable, io/sentr } public final class io/sentry/ProfileChunk$Builder { - public fun (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Ljava/util/Map;Ljava/io/File;Lio/sentry/SentryDate;Ljava/lang/String;)V + public fun (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Ljava/util/Map;Ljava/io/File;Lio/sentry/SentryDate;Lio/sentry/ProfileChunk$Platform;)V public fun build (Lio/sentry/SentryOptions;)Lio/sentry/ProfileChunk; } @@ -1987,6 +1987,21 @@ public final class io/sentry/ProfileChunk$JsonKeys { public fun ()V } +public final class io/sentry/ProfileChunk$Platform : java/lang/Enum, io/sentry/JsonSerializable { + public static final field ANDROID Lio/sentry/ProfileChunk$Platform; + public static final field JAVA Lio/sentry/ProfileChunk$Platform; + public fun apiName ()Ljava/lang/String; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/ProfileChunk$Platform; + public static fun values ()[Lio/sentry/ProfileChunk$Platform; +} + +public final class io/sentry/ProfileChunk$Platform$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfileChunk$Platform; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + public final class io/sentry/ProfileContext : 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/ProfileChunk.java b/sentry/src/main/java/io/sentry/ProfileChunk.java index 40876f4d10b..91628ea25b7 100644 --- a/sentry/src/main/java/io/sentry/ProfileChunk.java +++ b/sentry/src/main/java/io/sentry/ProfileChunk.java @@ -11,6 +11,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -25,7 +26,7 @@ public final class ProfileChunk implements JsonUnknown, JsonSerializable { private @NotNull SentryId chunkId; private @Nullable SdkVersion clientSdk; private final @NotNull Map measurements; - private @NotNull String platform; + private @NotNull Platform platform; private @NotNull String release; private @Nullable String environment; private @NotNull String version; @@ -47,7 +48,7 @@ public ProfileChunk() { new File("dummy"), new HashMap<>(), 0.0, - "android", + Platform.ANDROID, SentryOptions.empty()); } @@ -57,7 +58,7 @@ public ProfileChunk( final @NotNull File traceFile, final @NotNull Map measurements, final @NotNull Double timestamp, - final @NotNull String platform, + final @NotNull Platform platform, final @NotNull SentryOptions options) { this.profilerId = profilerId; this.chunkId = chunkId; @@ -96,7 +97,7 @@ public void setDebugMeta(final @Nullable DebugMeta debugMeta) { return environment; } - public @NotNull String getPlatform() { + public @NotNull Platform getPlatform() { return platform; } @@ -179,7 +180,7 @@ public static final class Builder { private final @NotNull File traceFile; private final double timestamp; - private final @NotNull String platform; + private final @NotNull Platform platform; public Builder( final @NotNull SentryId profilerId, @@ -187,7 +188,7 @@ public Builder( final @NotNull Map measurements, final @NotNull File traceFile, final @NotNull SentryDate timestamp, - final @NotNull String platform) { + final @NotNull Platform platform) { this.profilerId = profilerId; this.chunkId = chunkId; this.measurements = new ConcurrentHashMap<>(measurements); @@ -202,6 +203,33 @@ public ProfileChunk build(SentryOptions options) { } } + public enum Platform implements JsonSerializable { + ANDROID, + JAVA; + + public String apiName() { + return name().toLowerCase(Locale.ROOT); + } + + // JsonElementSerializer + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.value(apiName()); + } + + // JsonElementDeserializer + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull Platform deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { + return Platform.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); + } + } + } + // JsonSerializable public static final class JsonKeys { @@ -320,7 +348,7 @@ public static final class Deserializer implements JsonDeserializer } break; case JsonKeys.PLATFORM: - String platform = reader.nextStringOrNull(); + Platform platform = reader.nextOrNull(logger, new Platform.Deserializer()); if (platform != null) { data.platform = platform; } diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 5d236bfe2c7..0737e2417f7 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -295,7 +295,7 @@ private static void ensureAttachmentSizeLimit( traceFile.getName())); } - if (profileChunk.getPlatform().equals("java")) { + if (ProfileChunk.Platform.JAVA == profileChunk.getPlatform()) { final IProfileConverter profileConverter = ProfilingServiceLoader.loadProfileConverter(); if (profileConverter != null) { @@ -340,7 +340,7 @@ private static void ensureAttachmentSizeLimit( "application-json", traceFile.getName(), null, - profileChunk.getPlatform(), + profileChunk.getPlatform().apiName(), null); // avoid method refs on Android due to some issues with older AGP setups diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index 463b6f5000d..d2b998aa94b 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -968,7 +968,7 @@ class JsonSerializerTest { fixture.traceFile, HashMap(), 5.3, - "android", + ProfileChunk.Platform.ANDROID, fixture.options, ) val measurementNow = SentryNanotimeDate().nanoTimestamp() @@ -1127,7 +1127,7 @@ class JsonSerializerTest { assertEquals(SdkVersion("test", "1.2.3"), profileChunk.clientSdk) assertEquals(chunkId, profileChunk.chunkId) assertEquals("environment", profileChunk.environment) - assertEquals("android", profileChunk.platform) + assertEquals(ProfileChunk.Platform.ANDROID, profileChunk.platform) assertEquals(profilerId, profileChunk.profilerId) assertEquals("release", profileChunk.release) assertEquals("sampled profile in base 64", profileChunk.sampledProfile) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 0302843fc3c..c3e563f3a52 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -113,7 +113,7 @@ class SentryClientTest { profilingTraceFile, emptyMap(), 1.0, - "android", + ProfileChunk.Platform.ANDROID, sentryOptions, ) } diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 026177dfc44..70b8b18de09 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -486,11 +486,11 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn("chunk platform") + whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) } val chunk = SentryEnvelopeItem.fromProfileChunk(profileChunk, mock()) - assertEquals("chunk platform", chunk.header.platform) + assertEquals("android", chunk.header.platform) } @Test @@ -499,7 +499,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn("android") + whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) } file.writeBytes(fixture.bytes) @@ -514,7 +514,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn("android") + whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) } file.writeBytes(fixture.bytes) @@ -531,7 +531,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn("android") + whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) } assertFailsWith( @@ -547,7 +547,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn("android") + whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) } file.writeBytes(fixture.bytes) file.setReadable(false) @@ -565,7 +565,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn("android") + whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) } val chunk = SentryEnvelopeItem.fromProfileChunk(profileChunk, mock()) @@ -580,7 +580,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn("android") + whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) } val exception = From 6a8feddbdc2e19a7b79db5caf48faddec946bfdf Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 16:43:30 +0200 Subject: [PATCH 17/24] fallback to default temp directory for profiling on jvm if directory is not configured --- sentry/src/main/java/io/sentry/Sentry.java | 35 +++++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 1dc0761d879..ab06cd5d7d9 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -33,11 +33,13 @@ import java.io.BufferedWriter; import java.io.File; import java.io.FileOutputStream; +import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.lang.reflect.InvocationTargetException; import java.nio.charset.Charset; +import java.nio.file.Files; import java.util.Arrays; import java.util.List; import java.util.Properties; @@ -666,9 +668,29 @@ private static void initConfigurations(final @NotNull SentryOptions options) { options.getBackpressureMonitor().start(); } + initJvmContinuousProfiling(options); + + options + .getLogger() + .log( + SentryLevel.INFO, + "Continuous profiler is enabled %s mode: %s", + options.isContinuousProfilingEnabled(), + options.getProfileLifecycle()); + } + + private static void initJvmContinuousProfiling(@NotNull SentryOptions options) { + if (options.isContinuousProfilingEnabled() - && profilingTracesDirPath != null && options.getContinuousProfiler() == NoOpContinuousProfiler.getInstance()) { + try { + String profilingTracesDirPath = options.getProfilingTracesDirPath(); + if (profilingTracesDirPath == null) { + profilingTracesDirPath = + Files.createTempDirectory("profiling_traces").toAbsolutePath().toString(); + options.setProfilingTracesDirPath(profilingTracesDirPath); + } + final IContinuousProfiler continuousProfiler = ProfilingServiceLoader.loadContinuousProfiler( new SystemOutLogger(), @@ -677,15 +699,12 @@ private static void initConfigurations(final @NotNull SentryOptions options) { options.getExecutorService()); options.setContinuousProfiler(continuousProfiler); - } - + } catch (IOException e) { options .getLogger() - .log( - SentryLevel.INFO, - "Continuous profiler is enabled %s mode: %s", - options.isContinuousProfilingEnabled(), - options.getProfileLifecycle()); + .log(SentryLevel.ERROR, "Failed to create default profiling traces directory", e); + } + } } /** Close the SDK */ From f669b4fb7e5097c370273ee1f6ba789cb33196e4 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 21:09:28 +0200 Subject: [PATCH 18/24] cleanup some minor things --- ...AsyncProfilerToSentryProfileConverter.java | 21 +---- ...yncProfilerToSentryProfileConverterTest.kt | 85 +++++++++++++++++++ .../JavaContinuousProfilerTest.kt | 45 +--------- .../src/main/resources/application.properties | 2 +- 4 files changed, 90 insertions(+), 63 deletions(-) create mode 100644 sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt rename sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/{ => profiling}/JavaContinuousProfilerTest.kt (90%) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index 1ac1e6c3438..47ccba4b059 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -79,20 +79,16 @@ public void visit(Event event, long value) { long threadId = resolveThreadId(event.tid); if (stackTrace != null) { - // Process thread metadata if enabled if (args.threads) { processThreadMetadata(event, threadId); } - - // Create and add the sample + createSample(event, threadId); - // Build the stack trace from methods buildStackTraceAndFrames(stackTrace); } } - // Extract thread metadata and add to profile private void processThreadMetadata(Event event, long threadId) { final String threadName = getPlainThreadName(event.tid); sentryProfile @@ -107,7 +103,6 @@ private void processThreadMetadata(Event event, long threadId) { }); } - // Build stack trace from method array private void buildStackTraceAndFrames(StackTrace stackTrace) { List stack = new ArrayList<>(); int currentFrame = sentryProfile.getFrames().size(); @@ -132,19 +127,14 @@ private void buildStackTraceAndFrames(StackTrace stackTrace) { sentryProfile.getStacks().add(stack); } - // Create a single stack frame from a stack trace element private SentryStackFrame createStackFrame(StackTraceElement element) { SentryStackFrame frame = new SentryStackFrame(); final String classNameWithLambdas = element.getClassName().replace("/", "."); frame.setFunction(element.getMethodName()); - // Extract class name without lambda suffix String sanitizedClassName = extractSanitizedClassName(classNameWithLambdas); - - // Set module based on package structure frame.setModule(extractModuleName(sanitizedClassName, classNameWithLambdas)); - // Determine if frame should be marked as in_app if (shouldMarkAsSystemFrame(element, classNameWithLambdas)) { frame.setInApp(false); } else { @@ -166,7 +156,7 @@ private String extractSanitizedClassName(String classNameWithLambdas) { return classNameWithLambdas; } - // Set module name based on package structure + // TODO: test difference between null and empty string for module private @Nullable String extractModuleName( String sanitizedClassName, String classNameWithLambdas) { if (hasPackageStructure(sanitizedClassName)) { @@ -178,17 +168,14 @@ private String extractSanitizedClassName(String classNameWithLambdas) { } } - // Check if the class name has a package structure (contains dots) private boolean hasPackageStructure(String className) { return className.lastIndexOf('.') > 0; } - // Check if it's a regular class without package (not an array type) private boolean isRegularClassWithoutPackage(String className) { return !className.startsWith("["); } - // Create sample with timestamp and thread info private void createSample(Event event, long threadId) { int stackId = sentryProfile.getStacks().size(); SentrySample sample = new SentrySample(); @@ -201,24 +188,20 @@ private void createSample(Event event, long threadId) { long timeNs = jfr.chunkStartNanos + nsFromStart; sample.timestamp = DateUtils.nanosToSeconds(timeNs); - // Set thread ID sample.threadId = String.valueOf(threadId); sample.stackId = stackId; sentryProfile.getSamples().add(sample); } - // Check if the stack frame should be marked as a system frame private boolean shouldMarkAsSystemFrame(StackTraceElement element, String className) { return element.isNativeMethod() || className.isEmpty(); } - // Check if the stack trace element has a valid line number private @Nullable Integer extractLineNumber(StackTraceElement element) { return element.getLineNumber() != 0 ? element.getLineNumber() : null; } - // Resolve the actual thread ID from the JFR event private long resolveThreadId(int eventThreadId) { return jfr.threads.get(eventThreadId) != null ? jfr.javaThreads.get(eventThreadId) diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt new file mode 100644 index 00000000000..59fcfac6794 --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt @@ -0,0 +1,85 @@ +package io.sentry.asyncprofiler.convert + +import io.sentry.ILogger +import io.sentry.IProfileConverter +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.TracesSampler +import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider +import io.sentry.test.DeferredExecutorService +import java.nio.file.Files +import kotlin.io.path.absolutePathString +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import one.profiler.AsyncProfiler +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import kotlin.io.path.deleteIfExists + +class JfrAsyncProfilerToSentryProfileConverterTest { + + private val fixture = Fixture() + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val executor = DeferredExecutorService() + val mockedSentry = Mockito.mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + + val scopes: IScopes = mock() + val scope: IScope = mock() + + val options = + spy(SentryOptions()).apply { + dsn = mockDsn + profilesSampleRate = 1.0 + isDebug = true + setLogger(mockLogger) + } + + init { + whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + } + + fun getSut(optionConfig: ((options: SentryOptions) -> Unit) = {}): IProfileConverter? { + options.executorService = executor + optionConfig(options) + whenever(scopes.options).thenReturn(options) + whenever(scope.options).thenReturn(options) + return AsyncProfilerProfileConverterProvider().profileConverter + } + } + + @BeforeTest + fun `set up`() { + Sentry.setCurrentScopes(fixture.scopes) + + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.getGlobalScope() }.thenReturn(fixture.scope) + } + + @AfterTest + fun clear() { + Sentry.close() + fixture.mockedSentry.close() + } + + @Test + fun `convert async profiler to sentry`() { + val profiler = AsyncProfiler.getInstance() + val file = Files.createTempFile("sentry-async-profiler-test", ".jfr") + val command = String.format("start,jfr,wall=%s,file=%s", "9900us", file.absolutePathString()) + profiler.execute(command) + profiler.execute("stop,jfr") + + fixture.getSut()!!.convertFromFile(file.toAbsolutePath()) + file.deleteIfExists() + } +} diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilerTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt similarity index 90% rename from sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilerTest.kt rename to sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt index 3c895fd7b84..d89a0a2a1aa 100644 --- a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilerTest.kt +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt @@ -1,7 +1,6 @@ -package io.sentry.asyncprofiler +package io.sentry.asyncprofiler.profiling import io.sentry.DataCategory -import io.sentry.IConnectionStatusProvider import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ProfileLifecycle @@ -11,7 +10,6 @@ import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.TracesSampler import io.sentry.TransactionContext -import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler import io.sentry.protocol.SentryId import io.sentry.test.DeferredExecutorService import io.sentry.transport.RateLimiter @@ -22,9 +20,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlin.use import org.mockito.Mockito -import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -41,7 +37,7 @@ class JavaContinuousProfilerTest { private class Fixture { private val mockDsn = "http://key@localhost/proj" val executor = DeferredExecutorService() - val mockedSentry = mockStatic(Sentry::class.java) + val mockedSentry = Mockito.mockStatic(Sentry::class.java) val mockLogger = mock() val mockTracesSampler = mock() @@ -281,25 +277,6 @@ class JavaContinuousProfilerTest { verify(fixture.mockLogger).log(eq(SentryLevel.ERROR), eq("Failed to start profiling: "), any()) } - // @Test - // fun `profiler stops profiling and clear scheduled job on close`() { - // val profiler = fixture.getSut() - // profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - // assertTrue(profiler.isRunning) - // - // profiler.close(true) - // assertFalse(profiler.isRunning) - // - // // The timeout scheduled job should be cleared - // val androidProfiler = profiler.getProperty("profiler") - // val scheduledJob = androidProfiler?.getProperty?>("scheduledFinish") - // assertNull(scheduledJob) - // - // val stopFuture = profiler.getStopFuture() - // assertNotNull(stopFuture) - // assertTrue(stopFuture.isCancelled || stopFuture.isDone) - // } - @Test fun `profiler stops and restart for each chunk`() { val profiler = fixture.getSut() @@ -407,24 +384,6 @@ class JavaContinuousProfilerTest { .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) } - @Test - fun `profiler does not start when offline`() { - val profiler = - fixture.getSut { - it.connectionStatusProvider = mock { provider -> - whenever(provider.connectionStatus) - .thenReturn(IConnectionStatusProvider.ConnectionStatus.DISCONNECTED) - } - } - - // If the device is offline, the profiler should never start - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) - assertFalse(profiler.isRunning) - assertEquals(SentryId.EMPTY_ID, profiler.profilerId) - verify(fixture.mockLogger) - .log(eq(SentryLevel.WARNING), eq("Device is offline. Stopping profiler.")) - } - fun withMockScopes(closure: () -> Unit) = Mockito.mockStatic(Sentry::class.java).use { it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties index 5348d2c5ad7..2de573d81aa 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties @@ -1,5 +1,5 @@ # NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard -sentry.dsn=https://08c961cc816946f89b4dd69b92e75979@sentry.bloder.dev/3 +sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 sentry.send-default-pii=true sentry.max-request-body-size=medium # Sentry Spring Boot integration allows more fine-grained SentryOptions configuration From 7bd2054d51921bd83527d03d2f14075c1418cb6d Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 21:16:50 +0200 Subject: [PATCH 19/24] remove ProfilingInitializer, fix comments --- ...yncProfilerToSentryProfileConverterTest.kt | 2 +- .../boot/jakarta/ProfilingInitializer.java | 25 ------------------- .../boot/jakarta/SentryDemoApplication.java | 6 ----- .../JavaContinuousProfilerProvider.java | 2 +- 4 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProfilingInitializer.java diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt index 59fcfac6794..4442553f66c 100644 --- a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt @@ -11,6 +11,7 @@ import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider import io.sentry.test.DeferredExecutorService import java.nio.file.Files import kotlin.io.path.absolutePathString +import kotlin.io.path.deleteIfExists import kotlin.test.AfterTest import kotlin.test.BeforeTest import one.profiler.AsyncProfiler @@ -20,7 +21,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.spy import org.mockito.kotlin.whenever -import kotlin.io.path.deleteIfExists class JfrAsyncProfilerToSentryProfileConverterTest { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProfilingInitializer.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProfilingInitializer.java deleted file mode 100644 index 06c46dd9ab5..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProfilingInitializer.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.sentry.samples.spring.boot.jakarta; - -import io.sentry.Sentry; -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.ApplicationEvent; -import org.springframework.context.ApplicationListener; -import org.springframework.context.event.ContextRefreshedEvent; - -public class ProfilingInitializer implements ApplicationListener { - private static final Logger LOGGER = LoggerFactory.getLogger(ProfilingInitializer.class); - - // @Override - // public boolean supportsEventType(final @NotNull ResolvableType eventType) { - // return true; - // } - - @Override - public void onApplicationEvent(final @NotNull ApplicationEvent event) { - if (event instanceof ContextRefreshedEvent) { - Sentry.startProfiler(); - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java index 5ece216f281..8050cb8e74c 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -4,7 +4,6 @@ import io.sentry.samples.spring.boot.jakarta.quartz.SampleJob; import java.util.Collections; -import org.jetbrains.annotations.NotNull; import org.quartz.JobDetail; import org.quartz.SimpleTrigger; import org.springframework.boot.SpringApplication; @@ -51,11 +50,6 @@ public JobDetailFactoryBean jobDetail() { return jobDetailFactory; } - @Bean - public @NotNull ProfilingInitializer profilingInitializer() { - return new ProfilingInitializer(); - } - @Bean public SimpleTriggerFactoryBean trigger(JobDetail job) { SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); diff --git a/sentry/src/main/java/io/sentry/profiling/JavaContinuousProfilerProvider.java b/sentry/src/main/java/io/sentry/profiling/JavaContinuousProfilerProvider.java index ffc779a02dd..087d44f61c0 100644 --- a/sentry/src/main/java/io/sentry/profiling/JavaContinuousProfilerProvider.java +++ b/sentry/src/main/java/io/sentry/profiling/JavaContinuousProfilerProvider.java @@ -16,7 +16,7 @@ public interface JavaContinuousProfilerProvider { /** * Creates and returns a continuous profiler instance. * - * @return a continuous profiler instance, or null if the provider cannot create one + * @return a continuous profiler instance */ @NotNull IContinuousProfiler getContinuousProfiler( From e520d1115154d364c25283abfb1cc447246eb907 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 29 Jul 2025 19:21:52 +0000 Subject: [PATCH 20/24] Format code --- ...AsyncProfilerToSentryProfileConverter.java | 2 +- sentry/src/main/java/io/sentry/Sentry.java | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index 47ccba4b059..a2256e90f1f 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -82,7 +82,7 @@ public void visit(Event event, long value) { if (args.threads) { processThreadMetadata(event, threadId); } - + createSample(event, threadId); buildStackTraceAndFrames(stackTrace); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index ab06cd5d7d9..d72c1cf6921 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -691,17 +691,17 @@ private static void initJvmContinuousProfiling(@NotNull SentryOptions options) { options.setProfilingTracesDirPath(profilingTracesDirPath); } - final IContinuousProfiler continuousProfiler = - ProfilingServiceLoader.loadContinuousProfiler( - new SystemOutLogger(), - profilingTracesDirPath, - options.getProfilingTracesHz(), - options.getExecutorService()); - - options.setContinuousProfiler(continuousProfiler); + final IContinuousProfiler continuousProfiler = + ProfilingServiceLoader.loadContinuousProfiler( + new SystemOutLogger(), + profilingTracesDirPath, + options.getProfilingTracesHz(), + options.getExecutorService()); + + options.setContinuousProfiler(continuousProfiler); } catch (IOException e) { - options - .getLogger() + options + .getLogger() .log(SentryLevel.ERROR, "Failed to create default profiling traces directory", e); } } From 7079391f7334d0d8bb3467d9cb199be073edcb03 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 29 Jul 2025 21:37:32 +0200 Subject: [PATCH 21/24] add getter/setter to sample and metadata --- ...AsyncProfilerToSentryProfileConverter.java | 12 ++++---- sentry/api/sentry.api | 15 ++++++---- .../protocol/profiling/SentrySample.java | 30 +++++++++++++++++-- .../profiling/SentryThreadMetadata.java | 20 +++++++++++-- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index 47ccba4b059..2fc7bda4775 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -82,7 +82,7 @@ public void visit(Event event, long value) { if (args.threads) { processThreadMetadata(event, threadId); } - + createSample(event, threadId); buildStackTraceAndFrames(stackTrace); @@ -97,8 +97,8 @@ private void processThreadMetadata(Event event, long threadId) { String.valueOf(threadId), k -> { SentryThreadMetadata metadata = new SentryThreadMetadata(); - metadata.name = threadName; - metadata.priority = 0; + metadata.setName(threadName); + metadata.setPriority(0); // Default priority return metadata; }); } @@ -186,10 +186,10 @@ private void createSample(Event event, long threadId) { * JfrAsyncProfilerToSentryProfileConverter.NANOS_PER_SECOND / jfr.ticksPerSec; long timeNs = jfr.chunkStartNanos + nsFromStart; - sample.timestamp = DateUtils.nanosToSeconds(timeNs); + sample.setTimestamp(DateUtils.nanosToSeconds(timeNs)); - sample.threadId = String.valueOf(threadId); - sample.stackId = stackId; + sample.setThreadId(String.valueOf(threadId)); + sample.setStackId(stackId); sentryProfile.getSamples().add(sample); } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fd91ddc3b99..0b8f428b1df 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -6255,12 +6255,15 @@ public final class io/sentry/protocol/profiling/SentryProfile$JsonKeys { } public final class io/sentry/protocol/profiling/SentrySample : io/sentry/JsonSerializable, io/sentry/JsonUnknown { - public field stackId I - public field threadId Ljava/lang/String; - public field timestamp D public fun ()V + public fun getStackId ()I + public fun getThreadId ()Ljava/lang/String; + public fun getTimestamp ()D public fun getUnknown ()Ljava/util/Map; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setStackId (I)V + public fun setThreadId (Ljava/lang/String;)V + public fun setTimestamp (D)V public fun setUnknown (Ljava/util/Map;)V } @@ -6278,11 +6281,13 @@ public final class io/sentry/protocol/profiling/SentrySample$JsonKeys { } public final class io/sentry/protocol/profiling/SentryThreadMetadata : io/sentry/JsonSerializable, io/sentry/JsonUnknown { - public field name Ljava/lang/String; - public field priority I public fun ()V + public fun getName ()Ljava/lang/String; + public fun getPriority ()I public fun getUnknown ()Ljava/util/Map; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setName (Ljava/lang/String;)V + public fun setPriority (I)V public fun setUnknown (Ljava/util/Map;)V } diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java index 28f9d5fefbc..83e46023e08 100644 --- a/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentrySample.java @@ -17,11 +17,35 @@ public final class SentrySample implements JsonUnknown, JsonSerializable { - public double timestamp; // Unix timestamp in seconds with microsecond precision + private double timestamp; - public int stackId; + private int stackId; - public @Nullable String threadId; + private @Nullable String threadId; + + public double getTimestamp() { + return timestamp; + } + + public void setTimestamp(double timestamp) { + this.timestamp = timestamp; + } + + public int getStackId() { + return stackId; + } + + public void setStackId(int stackId) { + this.stackId = stackId; + } + + public @Nullable String getThreadId() { + return threadId; + } + + public void setThreadId(@Nullable String threadId) { + this.threadId = threadId; + } public static final class JsonKeys { public static final String TIMESTAMP = "timestamp"; diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/SentryThreadMetadata.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentryThreadMetadata.java index 6f380337999..a4c540d3b66 100644 --- a/sentry/src/main/java/io/sentry/protocol/profiling/SentryThreadMetadata.java +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentryThreadMetadata.java @@ -14,9 +14,25 @@ import org.jetbrains.annotations.Nullable; public final class SentryThreadMetadata implements JsonUnknown, JsonSerializable { - public @Nullable String name; + private @Nullable String name; - public int priority; + private int priority; + + public @Nullable String getName() { + return name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + public int getPriority() { + return priority; + } + + public void setPriority(int priority) { + this.priority = priority; + } public static final class JsonKeys { public static final String NAME = "name"; From 536ffb75b2a773c8a8da7b015f771e8408fd1ddf Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Fri, 1 Aug 2025 14:47:01 +0200 Subject: [PATCH 22/24] fix compile error --- .../sentry/asyncprofiler/profiling/JavaContinuousProfiler.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index a31e2ee891a..cb9bf2bb86a 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -272,6 +272,7 @@ private void stop(final boolean restartProfiler) { // start profiling), meaning there's no scopes to send the chunks. In that case, we store // the data in a list and send it when the next chunk is finished. try (final @NotNull ISentryLifecycleToken ignored2 = payloadLock.acquire()) { + File jfrFile = new File(filename); payloadBuilders.add( new ProfileChunk.Builder( profilerId, From 8b7e4896b64a6bef5b58763ecc499e1ffcb78138 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Fri, 1 Aug 2025 14:50:56 +0200 Subject: [PATCH 23/24] add comment/todo for deleteOnExit --- .../asyncprofiler/profiling/JavaContinuousProfiler.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index cb9bf2bb86a..e76adfa769f 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -273,6 +273,12 @@ private void stop(final boolean restartProfiler) { // the data in a list and send it when the next chunk is finished. try (final @NotNull ISentryLifecycleToken ignored2 = payloadLock.acquire()) { File jfrFile = new File(filename); + // TODO: should we add deleteOnExit() here to let the JVM clean up the file? + // as in `Sentry.java` `initJvmContinuousProfiling` each time we start the profiler we + // create a new + // temp directory/file that we can't cleanup on restart. Unless the user sets + // `profiling-traces-dir-path` manually + // jfrFile.deleteOnExit(); payloadBuilders.add( new ProfileChunk.Builder( profilerId, From e43f1a2369d4bd50a43dbcb4da893d5c26f89f2f Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Fri, 26 Sep 2025 11:31:37 +0200 Subject: [PATCH 24/24] Profiling - Deduplication and cleanup (#4681) * add readme and info about commit of the source repository * delete jfr file on jvm exit * further split into smaller methods * deduplicate frames in order to save bandwidth, add converter tests * remove Platform Enum, use string constants instead for compatibility with cross platform frameworks * implement equals and hashcode for SentryStackFrame to make frame deduplication work * bump api * improve error handling, fix start stop start flow * add new testfile * calculate ticksPerNanosecond in constructor * adapt Ratelimiter to check for both ProfileChunk and ProfileChunkUi ratelimiting * update ratelimiter test to check for both profileChunk and profileChunkUi drops * use string constant instead of string * Format code * add non aggregating event collector to send each event individually, deduplicate stacks * adapt converter tests to new non-aggregated converter * Format code * add logging to loadProfileConverter * Format code * fix duplication of events * catch all exception happening when converting from jfr * add exists and writable info to log message * add method to safely delete file * remove setNative call * fix test * fix reference to commit we vendored from * drop event if it cannot be processed to not lose the whole chunk * make format * fix test * Format code * Profiling - OTEL profiling fix, Stabilization, Logging (#4746) * add skipProfiling flag to TransactionOptions to be able to skip profiling and handle cases where profiling has been started by otel * add profilerId to spanContext so that otel span processor can propagate this to the exporter and SentryTracer * immediately end profiling when stopProfiler is called * bump api, fix android api 24 code * catch all exception happening when converting from jfr * simplify JavaContinuous profiler by catching AsyncProfiler instantiation exceptions in provider * add exists and writable info to log message * add method to safely delete file * remove setNative call * fix test * fix reference to commit we vendored from * drop event if it cannot be processed to not lose the whole chunk * Format code * fix test * Format code * fix test * catch exceptions in startProfiler/stopProfiler * fallback to threadId -1 if it cannot be resolved --------- Co-authored-by: Sentry Github Bot --------- Co-authored-by: Sentry Github Bot --- .../core/AndroidContinuousProfiler.java | 2 +- .../api/sentry-async-profiler.api | 11 +- ...AsyncProfilerToSentryProfileConverter.java | 146 +++++++---- .../convert/NonAggregatingEventCollector.java | 37 +++ .../profiling/JavaContinuousProfiler.java | 138 ++++++---- ...yncProfilerContinuousProfilerProvider.java | 14 +- .../vendor/asyncprofiler/README.md | 4 + .../vendor/asyncprofiler/jfr/JfrReader.java | 2 + ...yncProfilerToSentryProfileConverterTest.kt | 243 +++++++++++++++++- .../profiling/JavaContinuousProfilerTest.kt | 42 ++- .../resources/async_profiler_test_sample.jfr | Bin 0 -> 380882 bytes .../OtelSentrySpanProcessor.java | 3 + sentry/api/sentry.api | 27 +- .../src/main/java/io/sentry/ProfileChunk.java | 45 +--- sentry/src/main/java/io/sentry/Scopes.java | 19 +- .../java/io/sentry/SentryEnvelopeItem.java | 6 +- .../src/main/java/io/sentry/SentryTracer.java | 18 +- .../src/main/java/io/sentry/SpanContext.java | 14 + .../profiling/ProfilingServiceLoader.java | 9 +- .../io/sentry/protocol/SentryStackFrame.java | 58 +++++ .../java/io/sentry/transport/RateLimiter.java | 39 +-- .../test/java/io/sentry/JsonSerializerTest.kt | 4 +- .../test/java/io/sentry/SentryClientTest.kt | 2 +- .../java/io/sentry/SentryEnvelopeItemTest.kt | 16 +- .../io/sentry/transport/RateLimiterTest.kt | 28 +- 25 files changed, 702 insertions(+), 225 deletions(-) create mode 100644 sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java create mode 100644 sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/README.md create mode 100644 sentry-async-profiler/src/test/resources/async_profiler_test_sample.jfr diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 82ebe2e6a7a..654cd17fe44 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -301,7 +301,7 @@ private void stop(final boolean restartProfiler) { endData.measurementsMap, endData.traceFile, startProfileChunkTimestamp, - ProfileChunk.Platform.ANDROID)); + ProfileChunk.PLATFORM_ANDROID)); } } diff --git a/sentry-async-profiler/api/sentry-async-profiler.api b/sentry-async-profiler/api/sentry-async-profiler.api index 0bf859efb1e..af2962cbd61 100644 --- a/sentry-async-profiler/api/sentry-async-profiler.api +++ b/sentry-async-profiler/api/sentry-async-profiler.api @@ -4,10 +4,19 @@ public final class io/sentry/asyncprofiler/BuildConfig { } public final class io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter : io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter { - public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;Lio/sentry/SentryStackTraceFactory;)V + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;Lio/sentry/SentryStackTraceFactory;Lio/sentry/ILogger;)V public static fun convertFromFileStatic (Ljava/nio/file/Path;)Lio/sentry/protocol/profiling/SentryProfile; } +public final class io/sentry/asyncprofiler/convert/NonAggregatingEventCollector : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { + public fun ()V + public fun afterChunk ()V + public fun beforeChunk ()V + public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V + public fun finish ()Z + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V +} + public final class io/sentry/asyncprofiler/profiling/JavaContinuousProfiler : io/sentry/IContinuousProfiler, io/sentry/transport/RateLimiter$IRateLimitObserver { public fun (Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)V public fun close (Z)V diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java index 2fc7bda4775..4489497e815 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -1,13 +1,16 @@ package io.sentry.asyncprofiler.convert; import io.sentry.DateUtils; +import io.sentry.ILogger; import io.sentry.Sentry; +import io.sentry.SentryLevel; import io.sentry.SentryStackTraceFactory; import io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Arguments; import io.sentry.asyncprofiler.vendor.asyncprofiler.convert.JfrConverter; import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.StackTrace; import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; import io.sentry.protocol.SentryStackFrame; import io.sentry.protocol.profiling.SentryProfile; import io.sentry.protocol.profiling.SentrySample; @@ -15,20 +18,30 @@ import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { - private static final long NANOS_PER_SECOND = 1_000_000_000L; + private static final double NANOS_PER_SECOND = 1_000_000_000.0; + private static final long UNKNOWN_THREAD_ID = -1; private final @NotNull SentryProfile sentryProfile = new SentryProfile(); private final @NotNull SentryStackTraceFactory stackTraceFactory; + private final @NotNull ILogger logger; + private final @NotNull Map frameDeduplicationMap = new HashMap<>(); + private final @NotNull Map, Integer> stackDeduplicationMap = new HashMap<>(); public JfrAsyncProfilerToSentryProfileConverter( - JfrReader jfr, Arguments args, @NotNull SentryStackTraceFactory stackTraceFactory) { + JfrReader jfr, + Arguments args, + @NotNull SentryStackTraceFactory stackTraceFactory, + @NotNull ILogger logger) { super(jfr, args); this.stackTraceFactory = stackTraceFactory; + this.logger = logger; } @Override @@ -36,12 +49,18 @@ protected void convertChunk() { collector.forEach(new ProfileEventVisitor(sentryProfile, stackTraceFactory, jfr, args)); } + @Override + protected EventCollector createCollector(Arguments args) { + return new NonAggregatingEventCollector(); + } + public static @NotNull SentryProfile convertFromFileStatic(@NotNull Path jfrFilePath) throws IOException { JfrAsyncProfilerToSentryProfileConverter converter; try (JfrReader jfrReader = new JfrReader(jfrFilePath.toString())) { Arguments args = new Arguments(); args.cpu = false; + args.wall = true; args.alloc = false; args.threads = true; args.lines = true; @@ -49,18 +68,21 @@ protected void convertChunk() { SentryStackTraceFactory stackTraceFactory = new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()); - converter = new JfrAsyncProfilerToSentryProfileConverter(jfrReader, args, stackTraceFactory); + ILogger logger = Sentry.getGlobalScope().getOptions().getLogger(); + converter = + new JfrAsyncProfilerToSentryProfileConverter(jfrReader, args, stackTraceFactory, logger); converter.convert(); } return converter.sentryProfile; } - private class ProfileEventVisitor extends AggregatedEventVisitor { + private class ProfileEventVisitor implements EventCollector.Visitor { private final @NotNull SentryProfile sentryProfile; private final @NotNull SentryStackTraceFactory stackTraceFactory; private final @NotNull JfrReader jfr; private final @NotNull Arguments args; + private final double ticksPerNanosecond; public ProfileEventVisitor( @NotNull SentryProfile sentryProfile, @@ -71,25 +93,37 @@ public ProfileEventVisitor( this.stackTraceFactory = stackTraceFactory; this.jfr = jfr; this.args = args; + ticksPerNanosecond = jfr.ticksPerSec / NANOS_PER_SECOND; } @Override - public void visit(Event event, long value) { - StackTrace stackTrace = jfr.stackTraces.get(event.stackTraceId); - long threadId = resolveThreadId(event.tid); - - if (stackTrace != null) { - if (args.threads) { - processThreadMetadata(event, threadId); - } + public void visit(Event event, long samples, long value) { + try { + StackTrace stackTrace = jfr.stackTraces.get(event.stackTraceId); + long threadId = resolveThreadId(event.tid); - createSample(event, threadId); + if (stackTrace != null) { + if (args.threads) { + processThreadMetadata(event, threadId); + } - buildStackTraceAndFrames(stackTrace); + processSampleWithStack(event, threadId, stackTrace); + } + } catch (Exception e) { + logger.log(SentryLevel.WARNING, "Failed to process JFR event " + event, e); } } + private long resolveThreadId(int eventId) { + Long javaThreadId = jfr.javaThreads.get(eventId); + return javaThreadId != null ? javaThreadId : UNKNOWN_THREAD_ID; + } + private void processThreadMetadata(Event event, long threadId) { + if (threadId == UNKNOWN_THREAD_ID) { + return; + } + final String threadName = getPlainThreadName(event.tid); sentryProfile .getThreadMetadata() @@ -103,9 +137,41 @@ private void processThreadMetadata(Event event, long threadId) { }); } - private void buildStackTraceAndFrames(StackTrace stackTrace) { - List stack = new ArrayList<>(); - int currentFrame = sentryProfile.getFrames().size(); + private void processSampleWithStack(Event event, long threadId, StackTrace stackTrace) { + int stackIndex = addStackTrace(stackTrace); + + SentrySample sample = new SentrySample(); + sample.setTimestamp(calculateTimestamp(event)); + sample.setThreadId(String.valueOf(threadId)); + sample.setStackId(stackIndex); + + sentryProfile.getSamples().add(sample); + } + + private double calculateTimestamp(Event event) { + long nanosFromStart = (long) ((event.time - jfr.chunkStartTicks) / ticksPerNanosecond); + + long timeNs = jfr.chunkStartNanos + nanosFromStart; + + return DateUtils.nanosToSeconds(timeNs); + } + + private int addStackTrace(StackTrace stackTrace) { + List callStack = createFramesAndCallStack(stackTrace); + + Integer existingIndex = stackDeduplicationMap.get(callStack); + if (existingIndex != null) { + return existingIndex; + } + + int stackIndex = sentryProfile.getStacks().size(); + sentryProfile.getStacks().add(callStack); + stackDeduplicationMap.put(callStack, stackIndex); + return stackIndex; + } + + private List createFramesAndCallStack(StackTrace stackTrace) { + List callStack = new ArrayList<>(); long[] methods = stackTrace.methods; byte[] types = stackTrace.types; @@ -113,18 +179,30 @@ private void buildStackTraceAndFrames(StackTrace stackTrace) { for (int i = 0; i < methods.length; i++) { StackTraceElement element = getStackTraceElement(methods[i], types[i], locations[i]); - if (element.isNativeMethod()) { + if (element.isNativeMethod() || isNativeFrame(types[i])) { continue; } SentryStackFrame frame = createStackFrame(element); - sentryProfile.getFrames().add(frame); + int frameIndex = getOrAddFrame(frame); + callStack.add(frameIndex); + } + + return callStack; + } + + // Get existing frame index or add new frame and return its index + private int getOrAddFrame(SentryStackFrame frame) { + Integer existingIndex = frameDeduplicationMap.get(frame); - stack.add(currentFrame); - currentFrame++; + if (existingIndex != null) { + return existingIndex; } - sentryProfile.getStacks().add(stack); + int newIndex = sentryProfile.getFrames().size(); + sentryProfile.getFrames().add(frame); + frameDeduplicationMap.put(frame, newIndex); + return newIndex; } private SentryStackFrame createStackFrame(StackTraceElement element) { @@ -176,24 +254,6 @@ private boolean isRegularClassWithoutPackage(String className) { return !className.startsWith("["); } - private void createSample(Event event, long threadId) { - int stackId = sentryProfile.getStacks().size(); - SentrySample sample = new SentrySample(); - - // Calculate timestamp from JFR event time - long nsFromStart = - (event.time - jfr.chunkStartTicks) - * JfrAsyncProfilerToSentryProfileConverter.NANOS_PER_SECOND - / jfr.ticksPerSec; - long timeNs = jfr.chunkStartNanos + nsFromStart; - sample.setTimestamp(DateUtils.nanosToSeconds(timeNs)); - - sample.setThreadId(String.valueOf(threadId)); - sample.setStackId(stackId); - - sentryProfile.getSamples().add(sample); - } - private boolean shouldMarkAsSystemFrame(StackTraceElement element, String className) { return element.isNativeMethod() || className.isEmpty(); } @@ -201,11 +261,5 @@ private boolean shouldMarkAsSystemFrame(StackTraceElement element, String classN private @Nullable Integer extractLineNumber(StackTraceElement element) { return element.getLineNumber() != 0 ? element.getLineNumber() : null; } - - private long resolveThreadId(int eventThreadId) { - return jfr.threads.get(eventThreadId) != null - ? jfr.javaThreads.get(eventThreadId) - : eventThreadId; - } } } diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java new file mode 100644 index 00000000000..ce9dcaebf36 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/NonAggregatingEventCollector.java @@ -0,0 +1,37 @@ +package io.sentry.asyncprofiler.convert; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; +import java.util.ArrayList; +import java.util.List; + +public final class NonAggregatingEventCollector implements EventCollector { + final List events = new ArrayList<>(); + + @Override + public void collect(Event e) { + events.add(e); + } + + @Override + public void beforeChunk() { + // No-op + } + + @Override + public void afterChunk() { + // No-op + } + + @Override + public boolean finish() { + return false; + } + + @Override + public void forEach(Visitor visitor) { + for (Event event : events) { + visitor.visit(event, event.samples(), event.value()); + } + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java index e76adfa769f..6b45d568394 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -24,7 +24,6 @@ import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.SentryRandom; import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -52,15 +51,13 @@ public final class JavaContinuousProfiler private @Nullable Future stopFuture; private final @NotNull List payloadBuilders = new ArrayList<>(); private @NotNull SentryId profilerId = SentryId.EMPTY_ID; - private @NotNull SentryId chunkId = SentryId.EMPTY_ID; private final @NotNull AtomicBoolean isClosed = new AtomicBoolean(false); private @NotNull SentryDate startProfileChunkTimestamp = new SentryNanotimeDate(); private @NotNull String filename = ""; - private final @NotNull AsyncProfiler profiler; + private @NotNull AsyncProfiler profiler; private volatile boolean shouldSample = true; - private boolean shouldStop = false; private boolean isSampled = false; private int rootSpanCounter = 0; @@ -71,26 +68,47 @@ public JavaContinuousProfiler( final @NotNull ILogger logger, final @Nullable String profilingTracesDirPath, final int profilingTracesHz, - final @NotNull ISentryExecutorService executorService) { + final @NotNull ISentryExecutorService executorService) + throws Exception { this.logger = logger; this.profilingTracesDirPath = profilingTracesDirPath; this.profilingTracesHz = profilingTracesHz; this.executorService = executorService; + initializeProfiler(); + } + + private void initializeProfiler() throws Exception { this.profiler = AsyncProfiler.getInstance(); + // Check version to verify profiler is working + String version = profiler.execute("version"); + logger.log(SentryLevel.DEBUG, "AsyncProfiler initialized successfully. Version: " + version); } private boolean init() { - // We initialize it only once if (isInitialized) { return true; } isInitialized = true; + if (profilingTracesDirPath == null) { logger.log( SentryLevel.WARNING, "Disabling profiling because no profiling traces dir path is defined in options."); return false; } + + File profileDir = new File(profilingTracesDirPath); + + if (!profileDir.canWrite() || !profileDir.exists()) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because traces directory is not writable or does not exist: %s (writable=%b, exists=%b)", + profilingTracesDirPath, + profileDir.canWrite(), + profileDir.exists()); + return false; + } + if (profilingTracesHz <= 0) { logger.log( SentryLevel.WARNING, @@ -101,7 +119,6 @@ private boolean init() { return true; } - @SuppressWarnings("ReferenceEquality") @Override public void startProfiler( final @NotNull ProfileLifecycle profileLifecycle, @@ -141,6 +158,8 @@ public void startProfiler( logger.log(SentryLevel.DEBUG, "Started Profiler."); start(); } + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error starting profiler: ", e); } } @@ -159,7 +178,6 @@ private void initScopes() { private void start() { initScopes(); - // Let's initialize trace folder and profiling interval if (!init()) { return; } @@ -179,20 +197,26 @@ private void start() { } else { startProfileChunkTimestamp = new SentryNanotimeDate(); } + filename = profilingTracesDirPath + File.separator + SentryUUID.generateSentryId() + ".jfr"; - String startData = null; + + File jfrFile = new File(filename); + try { final String profilingIntervalMicros = String.format("%dus", (int) SECONDS.toMicros(1) / profilingTracesHz); + // Example command: start,jfr,event=wall,interval=9900us,file=/path/to/trace.jfr final String command = - String.format("start,jfr,wall=%s,file=%s", profilingIntervalMicros, filename); - System.out.println(command); - startData = profiler.execute(command); + String.format( + "start,jfr,event=wall,interval=%s,file=%s", profilingIntervalMicros, filename); + + profiler.execute(command); + } catch (Exception e) { logger.log(SentryLevel.ERROR, "Failed to start profiling: ", e); - } - // check if profiling started - if (startData == null) { + filename = ""; + // Try to clean up the file if it was created + safelyRemoveFile(jfrFile); return; } @@ -202,10 +226,6 @@ private void start() { profilerId = new SentryId(); } - if (chunkId == SentryId.EMPTY_ID) { - chunkId = new SentryId(); - } - try { stopFuture = executorService.schedule(() -> stop(true), MAX_CHUNK_DURATION_MILLIS); } catch (RejectedExecutionException e) { @@ -213,7 +233,8 @@ private void start() { SentryLevel.ERROR, "Failed to schedule profiling chunk finish. Did you call Sentry.close()?", e); - shouldStop = true; + // If we can't schedule the auto-stop, stop immediately without restart + stop(false); } } @@ -232,10 +253,12 @@ public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { if (rootSpanCounter < 0) { rootSpanCounter = 0; } - shouldStop = true; + // Stop immediately without restart + stop(false); break; case MANUAL: - shouldStop = true; + // Stop immediately without restart + stop(false); break; } } @@ -249,57 +272,55 @@ private void stop(final boolean restartProfiler) { // check if profiler was created and it's running if (!isRunning) { // When the profiler is stopped due to an error (e.g. offline or rate limited), reset the - // ids + // id profilerId = SentryId.EMPTY_ID; - chunkId = SentryId.EMPTY_ID; return; } - String endData = null; + File jfrFile = new File(filename); + try { - endData = profiler.execute("stop,jfr"); - } catch (IOException e) { - throw new RuntimeException(e); + profiler.execute("stop,jfr"); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error stopping profiler, attempting cleanup: ", e); + // Clean up file if it exists + safelyRemoveFile(jfrFile); } - // check if profiler end successfully - if (endData == null) { - logger.log( - SentryLevel.ERROR, - "An error occurred while collecting a profile chunk, and it won't be sent."); - } else { - // The scopes can be null if the profiler is started before the SDK is initialized (app - // start profiling), meaning there's no scopes to send the chunks. In that case, we store - // the data in a list and send it when the next chunk is finished. + // The scopes can be null if the profiler is started before the SDK is initialized (app + // start profiling), meaning there's no scopes to send the chunks. In that case, we store + // the data in a list and send it when the next chunk is finished. + if (jfrFile.exists() && jfrFile.canRead() && jfrFile.length() > 0) { try (final @NotNull ISentryLifecycleToken ignored2 = payloadLock.acquire()) { - File jfrFile = new File(filename); - // TODO: should we add deleteOnExit() here to let the JVM clean up the file? - // as in `Sentry.java` `initJvmContinuousProfiling` each time we start the profiler we - // create a new - // temp directory/file that we can't cleanup on restart. Unless the user sets - // `profiling-traces-dir-path` manually - // jfrFile.deleteOnExit(); + jfrFile.deleteOnExit(); payloadBuilders.add( new ProfileChunk.Builder( profilerId, - chunkId, + new SentryId(), new HashMap<>(), jfrFile, startProfileChunkTimestamp, - ProfileChunk.Platform.JAVA)); + ProfileChunk.PLATFORM_JAVA)); } + } else { + logger.log( + SentryLevel.WARNING, + "JFR file is invalid or empty: exists=%b, readable=%b, size=%d", + jfrFile.exists(), + jfrFile.canRead(), + jfrFile.length()); + safelyRemoveFile(jfrFile); } + // Always clean up state, even if stop failed isRunning = false; - // A chunk is finished. Next chunk will have a different id. - chunkId = SentryId.EMPTY_ID; filename = ""; if (scopes != null) { sendChunks(scopes, scopes.getOptions()); } - if (restartProfiler && !shouldStop) { + if (restartProfiler) { logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); start(); } else { @@ -307,6 +328,8 @@ private void stop(final boolean restartProfiler) { profilerId = SentryId.EMPTY_ID; logger.log(SentryLevel.DEBUG, "Profile chunk finished."); } + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error stopping profiler: ", e); } } @@ -319,9 +342,8 @@ public void reevaluateSampling() { public void close(final boolean isTerminating) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { rootSpanCounter = 0; - shouldStop = true; + stop(false); if (isTerminating) { - stop(false); isClosed.set(true); } } @@ -359,9 +381,21 @@ private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOpti } } + private void safelyRemoveFile(File file) { + try { + if (file.exists()) { + file.delete(); + } + } catch (Exception e) { + logger.log(SentryLevel.INFO, "Failed to remove jfr file %s.", file.getAbsolutePath(), e); + } + } + @Override public boolean isRunning() { - return isRunning; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + return isRunning && !filename.isEmpty(); + } } @VisibleForTesting diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java index 49d83cffb3d..226cfc09084 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java @@ -3,6 +3,8 @@ import io.sentry.IContinuousProfiler; import io.sentry.ILogger; import io.sentry.ISentryExecutorService; +import io.sentry.NoOpContinuousProfiler; +import io.sentry.SentryLevel; import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler; import io.sentry.profiling.JavaContinuousProfilerProvider; import io.sentry.profiling.JavaProfileConverterProvider; @@ -22,7 +24,15 @@ public final class AsyncProfilerContinuousProfilerProvider String profilingTracesDirPath, int profilingTracesHz, ISentryExecutorService executorService) { - return new JavaContinuousProfiler( - logger, profilingTracesDirPath, profilingTracesHz, executorService); + try { + return new JavaContinuousProfiler( + logger, profilingTracesDirPath, profilingTracesHz, executorService); + } catch (Exception e) { + logger.log( + SentryLevel.WARNING, + "Failed to initialize AsyncProfiler. Profiling will be disabled.", + e); + return NoOpContinuousProfiler.getInstance(); + } } } diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/README.md b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/README.md new file mode 100644 index 00000000000..733a69f1c3b --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/README.md @@ -0,0 +1,4 @@ +# Vendored AsyncProfiler code for converting JFR Files +- Vendored-in from commit https://github.com/async-profiler/async-profiler/tree/fe1bc66d4b6181413847f6bbe5c0db805f3e9194 +- Only the code related to JFR conversion is included. +- The `AsyncProfiler` itself is included as a dependency in the Maven project. diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java index abc9a0024b4..98c8aa01b22 100644 --- a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java @@ -60,6 +60,8 @@ public final class JfrReader implements Closeable { public final Dictionary types = new Dictionary<>(); public final Map typesByName = new HashMap<>(); public final Dictionary threads = new Dictionary<>(); + // Maps thread IDs to Java thread IDs + // Change compared to original async-profiler JFR reader public final Dictionary javaThreads = new Dictionary<>(); public final Dictionary classes = new Dictionary<>(); public final Dictionary strings = new Dictionary<>(); diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt index 4442553f66c..2b9c8ae1104 100644 --- a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverterTest.kt @@ -1,20 +1,30 @@ package io.sentry.asyncprofiler.convert +import io.sentry.DateUtils import io.sentry.ILogger import io.sentry.IProfileConverter import io.sentry.IScope import io.sentry.IScopes import io.sentry.Sentry import io.sentry.SentryOptions +import io.sentry.SentryStackTraceFactory import io.sentry.TracesSampler import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider +import io.sentry.protocol.profiling.SentryProfile import io.sentry.test.DeferredExecutorService -import java.nio.file.Files -import kotlin.io.path.absolutePathString -import kotlin.io.path.deleteIfExists +import java.io.IOException +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit +import java.util.* +import kotlin.io.path.Path +import kotlin.math.absoluteValue import kotlin.test.AfterTest import kotlin.test.BeforeTest -import one.profiler.AsyncProfiler +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.junit.Test import org.mockito.Mockito import org.mockito.kotlin.any @@ -32,6 +42,7 @@ class JfrAsyncProfilerToSentryProfileConverterTest { val mockedSentry = Mockito.mockStatic(Sentry::class.java) val mockLogger = mock() val mockTracesSampler = mock() + val mockStackTraceFactory = mock() val scopes: IScopes = mock() val scope: IScope = mock() @@ -42,10 +53,18 @@ class JfrAsyncProfilerToSentryProfileConverterTest { profilesSampleRate = 1.0 isDebug = true setLogger(mockLogger) + // Set in-app packages for testing + addInAppInclude("io.sentry") + addInAppInclude("com.example") } init { whenever(mockTracesSampler.sampleSessionProfile(any())).thenReturn(true) + // Setup default in-app behavior for stack trace factory + whenever(mockStackTraceFactory.isInApp(any())).thenAnswer { invocation -> + val className = invocation.getArgument(0) + className.startsWith("io.sentry") || className.startsWith("com.example") + } } fun getSut(optionConfig: ((options: SentryOptions) -> Unit) = {}): IProfileConverter? { @@ -63,6 +82,9 @@ class JfrAsyncProfilerToSentryProfileConverterTest { fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) fixture.mockedSentry.`when` { Sentry.getGlobalScope() }.thenReturn(fixture.scope) + + // Ensure the global scope returns proper options for the static converter method + whenever(fixture.scope.options).thenReturn(fixture.options) } @AfterTest @@ -72,14 +94,209 @@ class JfrAsyncProfilerToSentryProfileConverterTest { } @Test - fun `convert async profiler to sentry`() { - val profiler = AsyncProfiler.getInstance() - val file = Files.createTempFile("sentry-async-profiler-test", ".jfr") - val command = String.format("start,jfr,wall=%s,file=%s", "9900us", file.absolutePathString()) - profiler.execute(command) - profiler.execute("stop,jfr") - - fixture.getSut()!!.convertFromFile(file.toAbsolutePath()) - file.deleteIfExists() + fun `check number of samples for specific frame`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + val tracingFilterFrame = + sentryProfile.frames.filter { + it.function == "slowFunction" && it.module == "io.sentry.samples.console.Main" + } + + val tracingFilterFrameIndexes = tracingFilterFrame.map { sentryProfile.frames.indexOf(it) } + val tracingFilterStacks = + sentryProfile.stacks.filter { it.any { inner -> tracingFilterFrameIndexes.contains(inner) } } + val tracingFilterStackIds = tracingFilterStacks.map { sentryProfile.stacks.indexOf(it) } + val tracingFilterSamples = + sentryProfile.samples.filter { tracingFilterStackIds.contains(it.stackId) } + + // Sample size base on 101 samples/sec and 5 sec of profiling + // So expected around 500 samples (with some margin) + assertTrue( + tracingFilterSamples.count() >= 500 && tracingFilterSamples.count() <= 600, + "Expected sample count between 500 and 600, but was ${tracingFilterSamples.count()}", + ) + } + + @Test + fun `check number of samples for specific thread`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + val mainThread = + sentryProfile.threadMetadata.entries.firstOrNull { it.value.name == "main" }?.key + + val samples = sentryProfile.samples.filter { it.threadId == mainThread } + + // Sample size base on 101 samples/sec and 5 sec of profiling + // So expected around 500 samples (with some margin) + assertTrue( + samples.count() >= 500 && samples.count() <= 600, + "Expected sample count between 500 and 600, but was ${samples.count()}", + ) + } + + @Test + fun `check no duplicate frames`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val frameSet = sentryProfile.frames.toSet() + + assertEquals(frameSet.size, sentryProfile.frames.size) + } + + @Test + fun `convertFromFile with valid JFR returns populated SentryProfile`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + assertNotNull(sentryProfile) + assertValidSentryProfile(sentryProfile) + } + + @Test + fun `convertFromFile parses timestamps correctly`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val samples = sentryProfile.samples + assertTrue(samples.isNotEmpty()) + + val minTimestamp = samples.minOf { it.timestamp } + val maxTimestamp = samples.maxOf { it.timestamp } + val sampleTimeStamp = + DateUtils.nanosToDate((maxTimestamp * 1000 * 1000 * 1000).toLong()).toInstant() + + // The sample was recorded around "2025-09-05T08:14:50" in UTC timezone + val referenceTimestamp = LocalDateTime.parse("2025-09-05T08:14:50").toInstant(ZoneOffset.UTC) + val between = ChronoUnit.MILLIS.between(sampleTimeStamp, referenceTimestamp).absoluteValue + + assertTrue(between < 5000, "Sample timestamp should be within 5s of reference timestamp") + assertTrue(maxTimestamp >= minTimestamp, "Max timestamp should be >= min timestamp") + assertTrue( + maxTimestamp - minTimestamp <= 10, + "There should be a max difference of <10s between min and max timestamp", + ) + } + + @Test + fun `convertFromFile extracts thread metadata correctly`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val threadMetadata = sentryProfile.threadMetadata + val samples = sentryProfile.samples + + assertTrue(threadMetadata.isNotEmpty()) + + // Verify thread IDs in samples match thread metadata keys + val threadIdsFromSamples = samples.map { it.threadId }.toSet() + threadIdsFromSamples.forEach { threadId -> + assertTrue( + threadMetadata.containsKey(threadId), + "Thread metadata should contain thread ID: $threadId", + ) + } + + // Verify thread metadata has proper values + threadMetadata.forEach { (_, metadata) -> + assertNotNull(metadata.name, "Thread name should not be null") + assertEquals(0, metadata.priority, "Thread priority should be default (0)") + } + } + + @Test + fun `converter processes frames with complete information`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val frames = sentryProfile.frames + assertTrue(frames.isNotEmpty()) + + // Find frames with complete information + val completeFrames = + frames.filter { frame -> + frame.function != null && + frame.module != null && + frame.lineno != null && + frame.filename != null + } + + assertTrue(completeFrames.isNotEmpty(), "Should have frames with complete information") + } + + @Test + fun `converter marks in-app frames correctly`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + val frames = sentryProfile.frames + + // Verify system packages are marked as not in-app + val systemFrames = + frames.filter { frame -> + frame.module?.let { + it.startsWith("java.") || it.startsWith("sun.") || it.startsWith("jdk.") + } ?: false + } + + val inappSentryFrames = + frames.filter { frame -> frame.module?.startsWith("io.sentry.") ?: false } + + val emptyModuleFrames = frames.filter { it.module.isNullOrEmpty() } + + // Verify system classes are not marked as in-app + systemFrames.forEach { frame -> + assertFalse(frame.isInApp ?: false, "System classes should not be marked as in app") + } + + // Verify sentry classes are marked as in-app + inappSentryFrames.forEach { frame -> + assertTrue(frame.isInApp ?: false, "Sentry classes should be marked as in app") + } + + // Verify empty class names are marked as not in-app + emptyModuleFrames.forEach { frame -> + assertFalse(frame.isInApp ?: true, "Empty module frame should not be in-app") + } + } + + @Test + fun `converter filters native methods`() { + val file = Path(loadFile("async_profiler_test_sample.jfr")) + + val sentryProfile = fixture.getSut()!!.convertFromFile(file) + + // Native methods should be filtered out during frame creation + // Verify no frames have characteristics of native methods + sentryProfile.frames.forEach { frame -> + // Native methods would have been skipped, so no frame should indicate native + assertTrue( + frame.filename?.isNotEmpty() == true || frame?.module?.isNotEmpty() == true, + "Frame should have some non-native information", + ) + } + } + + @Test(expected = IOException::class) + fun `convertFromFile with non-existent file throws IOException`() { + val nonExistentFile = Path("/non/existent/file.jfr") + + JfrAsyncProfilerToSentryProfileConverter.convertFromFileStatic(nonExistentFile) + } + + private fun loadFile(path: String): String = javaClass.classLoader!!.getResource(path)!!.file + + private fun assertValidSentryProfile(profile: SentryProfile) { + assertNotNull(profile.samples, "Samples should not be null") + assertNotNull(profile.frames, "Frames should not be null") + assertNotNull(profile.stacks, "Stacks should not be null") + assertNotNull(profile.threadMetadata, "Thread metadata should not be null") } } diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt index d89a0a2a1aa..86f5d51fee2 100644 --- a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfilerTest.kt @@ -20,6 +20,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.mockito.ArgumentMatchers.startsWith import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.eq @@ -116,9 +117,6 @@ class JavaContinuousProfilerTest { assertTrue(profiler.isRunning) // We are scheduling the profiler to stop at the end of the chunk, so it should still be running profiler.stopProfiler(ProfileLifecycle.MANUAL) - assertTrue(profiler.isRunning) - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart - fixture.executor.runAll() assertFalse(profiler.isRunning) } @@ -274,7 +272,17 @@ class JavaContinuousProfilerTest { fixture.executor.runAll() // We assert that no trace files are written assertTrue(File(fixture.options.profilingTracesDirPath!!).list()!!.isEmpty()) - verify(fixture.mockLogger).log(eq(SentryLevel.ERROR), eq("Failed to start profiling: "), any()) + val expectedPath = fixture.options.profilingTracesDirPath + verify(fixture.mockLogger) + .log( + eq(SentryLevel.WARNING), + eq( + "Disabling profiling because traces directory is not writable or does not exist: %s (writable=%b, exists=%b)" + ), + eq(expectedPath), + eq(false), + eq(true), + ) } @Test @@ -314,28 +322,42 @@ class JavaContinuousProfilerTest { assertTrue(profiler.isRunning) // We run the executor service to trigger the profiler restart (chunk finish) fixture.executor.runAll() + // At this point the chunk has been submitted to the executor, but yet to be sent verify(fixture.scopes, never()).captureProfileChunk(any()) profiler.stopProfiler(ProfileLifecycle.MANUAL) - // We stop the profiler, which should send a chunk + // We stop the profiler, which should send both the first and last chunk fixture.executor.runAll() - verify(fixture.scopes).captureProfileChunk(any()) + verify(fixture.scopes, times(2)).captureProfileChunk(any()) } @Test fun `close without terminating stops all profiles after chunk is finished`() { val profiler = fixture.getSut() - profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) assertTrue(profiler.isRunning) - // We are scheduling the profiler to stop at the end of the chunk, so it should still be running + // We are closing the profiler, which should stop all profiles after the chunk is finished profiler.close(false) - assertTrue(profiler.isRunning) + assertFalse(profiler.isRunning) // However, close() already resets the rootSpanCounter assertEquals(0, profiler.rootSpanCounter) + } - // We run the executor service to trigger the chunk finish, and the profiler shouldn't restart + @Test + fun `profiler can be stopped and restarted`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + profiler.stopProfiler(ProfileLifecycle.MANUAL) fixture.executor.runAll() assertFalse(profiler.isRunning) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + fixture.executor.runAll() + + assertTrue(profiler.isRunning) + verify(fixture.mockLogger, never()) + .log(eq(SentryLevel.WARNING), startsWith("JFR file is invalid or empty"), any(), any(), any()) } @Test diff --git a/sentry-async-profiler/src/test/resources/async_profiler_test_sample.jfr b/sentry-async-profiler/src/test/resources/async_profiler_test_sample.jfr new file mode 100644 index 0000000000000000000000000000000000000000..35b4c768eac9e2d1abd469a6017ec753af4366ad GIT binary patch literal 380882 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

Mj)7;JQ0BU(!d!rSb(%c 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 G-2Vq8b~(rZ literal 0 HcmV?d00001 diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java index 9b260cfc46c..bb374f4a517 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentrySpanProcessor.java @@ -117,6 +117,9 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri sentryParentSpanId, baggage); sentrySpan.getSpanContext().setOrigin(SentrySpanExporter.TRACE_ORIGIN); + sentrySpan + .getSpanContext() + .setProfilerId(scopes.getOptions().getContinuousProfiler().getProfilerId()); spanStorage.storeSentrySpan(spanContext, sentrySpan); } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0b8f428b1df..6814fc1de76 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1935,15 +1935,17 @@ public final class io/sentry/PerformanceCollectionData { } public final class io/sentry/ProfileChunk : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field PLATFORM_ANDROID Ljava/lang/String; + public static final field PLATFORM_JAVA Ljava/lang/String; public fun ()V - public fun (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Ljava/io/File;Ljava/util/Map;Ljava/lang/Double;Lio/sentry/ProfileChunk$Platform;Lio/sentry/SentryOptions;)V + public fun (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Ljava/io/File;Ljava/util/Map;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/SentryOptions;)V public fun equals (Ljava/lang/Object;)Z public fun getChunkId ()Lio/sentry/protocol/SentryId; public fun getClientSdk ()Lio/sentry/protocol/SdkVersion; public fun getDebugMeta ()Lio/sentry/protocol/DebugMeta; public fun getEnvironment ()Ljava/lang/String; public fun getMeasurements ()Ljava/util/Map; - public fun getPlatform ()Lio/sentry/ProfileChunk$Platform; + public fun getPlatform ()Ljava/lang/String; public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun getRelease ()Ljava/lang/String; public fun getSampledProfile ()Ljava/lang/String; @@ -1961,7 +1963,7 @@ public final class io/sentry/ProfileChunk : io/sentry/JsonSerializable, io/sentr } public final class io/sentry/ProfileChunk$Builder { - public fun (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Ljava/util/Map;Ljava/io/File;Lio/sentry/SentryDate;Lio/sentry/ProfileChunk$Platform;)V + public fun (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Ljava/util/Map;Ljava/io/File;Lio/sentry/SentryDate;Ljava/lang/String;)V public fun build (Lio/sentry/SentryOptions;)Lio/sentry/ProfileChunk; } @@ -1987,21 +1989,6 @@ public final class io/sentry/ProfileChunk$JsonKeys { public fun ()V } -public final class io/sentry/ProfileChunk$Platform : java/lang/Enum, io/sentry/JsonSerializable { - public static final field ANDROID Lio/sentry/ProfileChunk$Platform; - public static final field JAVA Lio/sentry/ProfileChunk$Platform; - public fun apiName ()Ljava/lang/String; - public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V - public static fun valueOf (Ljava/lang/String;)Lio/sentry/ProfileChunk$Platform; - public static fun values ()[Lio/sentry/ProfileChunk$Platform; -} - -public final class io/sentry/ProfileChunk$Platform$Deserializer : io/sentry/JsonDeserializer { - public fun ()V - public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfileChunk$Platform; - public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; -} - public final class io/sentry/ProfileContext : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V @@ -3980,6 +3967,7 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public fun getOrigin ()Ljava/lang/String; public fun getParentSpanId ()Lio/sentry/SpanId; public fun getProfileSampled ()Ljava/lang/Boolean; + public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun getSampled ()Ljava/lang/Boolean; public fun getSamplingDecision ()Lio/sentry/TracesSamplingDecision; public fun getSpanId ()Lio/sentry/SpanId; @@ -3994,6 +3982,7 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public fun setInstrumenter (Lio/sentry/Instrumenter;)V public fun setOperation (Ljava/lang/String;)V public fun setOrigin (Ljava/lang/String;)V + public fun setProfilerId (Lio/sentry/protocol/SentryId;)V public fun setSampled (Ljava/lang/Boolean;)V public fun setSampled (Ljava/lang/Boolean;Ljava/lang/Boolean;)V public fun setSamplingDecision (Lio/sentry/TracesSamplingDecision;)V @@ -5870,6 +5859,7 @@ public final class io/sentry/protocol/SentrySpan$JsonKeys { public final class io/sentry/protocol/SentryStackFrame : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun ()V + public fun equals (Ljava/lang/Object;)Z public fun getAbsPath ()Ljava/lang/String; public fun getAddrMode ()Ljava/lang/String; public fun getColno ()Ljava/lang/Integer; @@ -5891,6 +5881,7 @@ public final class io/sentry/protocol/SentryStackFrame : io/sentry/JsonSerializa public fun getSymbolAddr ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; public fun getVars ()Ljava/util/Map; + public fun hashCode ()I public fun isInApp ()Ljava/lang/Boolean; public fun isNative ()Ljava/lang/Boolean; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V diff --git a/sentry/src/main/java/io/sentry/ProfileChunk.java b/sentry/src/main/java/io/sentry/ProfileChunk.java index 91628ea25b7..0aa6b7e524d 100644 --- a/sentry/src/main/java/io/sentry/ProfileChunk.java +++ b/sentry/src/main/java/io/sentry/ProfileChunk.java @@ -11,7 +11,6 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.HashMap; -import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -21,12 +20,15 @@ @ApiStatus.Internal public final class ProfileChunk implements JsonUnknown, JsonSerializable { + public static final String PLATFORM_ANDROID = "android"; + public static final String PLATFORM_JAVA = "java"; + private @Nullable DebugMeta debugMeta; private @NotNull SentryId profilerId; private @NotNull SentryId chunkId; private @Nullable SdkVersion clientSdk; private final @NotNull Map measurements; - private @NotNull Platform platform; + private @NotNull String platform; private @NotNull String release; private @Nullable String environment; private @NotNull String version; @@ -48,7 +50,7 @@ public ProfileChunk() { new File("dummy"), new HashMap<>(), 0.0, - Platform.ANDROID, + PLATFORM_ANDROID, SentryOptions.empty()); } @@ -58,7 +60,7 @@ public ProfileChunk( final @NotNull File traceFile, final @NotNull Map measurements, final @NotNull Double timestamp, - final @NotNull Platform platform, + final @NotNull String platform, final @NotNull SentryOptions options) { this.profilerId = profilerId; this.chunkId = chunkId; @@ -97,7 +99,7 @@ public void setDebugMeta(final @Nullable DebugMeta debugMeta) { return environment; } - public @NotNull Platform getPlatform() { + public @NotNull String getPlatform() { return platform; } @@ -180,7 +182,7 @@ public static final class Builder { private final @NotNull File traceFile; private final double timestamp; - private final @NotNull Platform platform; + private final @NotNull String platform; public Builder( final @NotNull SentryId profilerId, @@ -188,7 +190,7 @@ public Builder( final @NotNull Map measurements, final @NotNull File traceFile, final @NotNull SentryDate timestamp, - final @NotNull Platform platform) { + final @NotNull String platform) { this.profilerId = profilerId; this.chunkId = chunkId; this.measurements = new ConcurrentHashMap<>(measurements); @@ -203,33 +205,6 @@ public ProfileChunk build(SentryOptions options) { } } - public enum Platform implements JsonSerializable { - ANDROID, - JAVA; - - public String apiName() { - return name().toLowerCase(Locale.ROOT); - } - - // JsonElementSerializer - - @Override - public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { - writer.value(apiName()); - } - - // JsonElementDeserializer - - public static final class Deserializer implements JsonDeserializer { - @Override - public @NotNull Platform deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) - throws Exception { - return Platform.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); - } - } - } - // JsonSerializable public static final class JsonKeys { @@ -348,7 +323,7 @@ public static final class Deserializer implements JsonDeserializer } break; case JsonKeys.PLATFORM: - Platform platform = reader.nextOrNull(logger, new Platform.Deserializer()); + String platform = reader.nextStringOrNull(); if (platform != null) { data.platform = platform; } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 491da449ca1..1b44d3d80f2 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -938,17 +938,18 @@ public void flush(long timeoutMillis) { final @NotNull ISpanFactory spanFactory = maybeSpanFactory == null ? getOptions().getSpanFactory() : maybeSpanFactory; - // If continuous profiling is enabled in trace mode, let's start it. Profiler will sample on - // its own. + // If continuous profiling is enabled in trace mode, let's start it unless skipProfiling is + // true in TransactionOptions. + // Profiler will sample on its own. // Profiler is started before the transaction is created, so that the profiler id is available // when the transaction starts - if (samplingDecision.getSampled()) { - if (getOptions().isContinuousProfilingEnabled() - && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE) { - getOptions() - .getContinuousProfiler() - .startProfiler(ProfileLifecycle.TRACE, getOptions().getInternalTracesSampler()); - } + if (samplingDecision.getSampled() + && getOptions().isContinuousProfilingEnabled() + && getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE + && transactionContext.getProfilerId().equals(SentryId.EMPTY_ID)) { + getOptions() + .getContinuousProfiler() + .startProfiler(ProfileLifecycle.TRACE, getOptions().getInternalTracesSampler()); } transaction = diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 0737e2417f7..0c9b07f7cd0 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -295,7 +295,7 @@ private static void ensureAttachmentSizeLimit( traceFile.getName())); } - if (ProfileChunk.Platform.JAVA == profileChunk.getPlatform()) { + if (ProfileChunk.PLATFORM_JAVA.equals(profileChunk.getPlatform())) { final IProfileConverter profileConverter = ProfilingServiceLoader.loadProfileConverter(); if (profileConverter != null) { @@ -303,7 +303,7 @@ private static void ensureAttachmentSizeLimit( final SentryProfile profile = profileConverter.convertFromFile(traceFile.toPath()); profileChunk.setSentryProfile(profile); - } catch (IOException e) { + } catch (Exception e) { throw new SentryEnvelopeException("Profile conversion failed"); } } @@ -340,7 +340,7 @@ private static void ensureAttachmentSizeLimit( "application-json", traceFile.getName(), null, - profileChunk.getPlatform().apiName(), + profileChunk.getPlatform(), null); // avoid method refs on Android due to some issues with older AGP setups diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 0496f407219..21d5088a181 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -83,8 +83,8 @@ public SentryTracer( setDefaultSpanData(root); - final @NotNull SentryId continuousProfilerId = - scopes.getOptions().getContinuousProfiler().getProfilerId(); + final @NotNull SentryId continuousProfilerId = getProfilerId(); + if (!continuousProfilerId.equals(SentryId.EMPTY_ID) && Boolean.TRUE.equals(isSampled())) { this.contexts.setProfile(new ProfileContext(continuousProfilerId)); } @@ -229,7 +229,7 @@ public void finish( } }); - // any un-finished childs will remain unfinished + // any un-finished children will remain unfinished // as relay takes care of setting the end-timestamp + deadline_exceeded // see // https://github.com/getsentry/relay/blob/40697d0a1c54e5e7ad8d183fc7f9543b94fe3839/relay-general/src/store/transactions/processor.rs#L374-L378 @@ -244,7 +244,8 @@ public void finish( .onTransactionFinish(this, performanceCollectionData.get(), scopes.getOptions()); } if (scopes.getOptions().isContinuousProfilingEnabled() - && scopes.getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE) { + && scopes.getOptions().getProfileLifecycle() == ProfileLifecycle.TRACE + && root.getSpanContext().getProfilerId().equals(SentryId.EMPTY_ID)) { scopes.getOptions().getContinuousProfiler().stopProfiler(ProfileLifecycle.TRACE); } if (performanceCollectionData.get() != null) { @@ -543,8 +544,7 @@ private ISpan createChild( /** Sets the default data in the span, including profiler _id, thread id and thread name */ private void setDefaultSpanData(final @NotNull ISpan span) { final @NotNull IThreadChecker threadChecker = scopes.getOptions().getThreadChecker(); - final @NotNull SentryId profilerId = - scopes.getOptions().getContinuousProfiler().getProfilerId(); + final @NotNull SentryId profilerId = getProfilerId(); if (!profilerId.equals(SentryId.EMPTY_ID) && Boolean.TRUE.equals(span.isSampled())) { span.setData(SpanDataConvention.PROFILER_ID, profilerId.toString()); } @@ -553,6 +553,12 @@ private void setDefaultSpanData(final @NotNull ISpan span) { span.setData(SpanDataConvention.THREAD_NAME, threadChecker.getCurrentThreadName()); } + private @NotNull SentryId getProfilerId() { + return !root.getSpanContext().getProfilerId().equals(SentryId.EMPTY_ID) + ? root.getSpanContext().getProfilerId() + : scopes.getOptions().getContinuousProfiler().getProfilerId(); + } + @Override public @NotNull ISpan startChild(final @NotNull String operation) { return this.startChild(operation, (String) null); diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index 2999ea4a2b8..8bfc83e6458 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -55,6 +55,12 @@ public class SpanContext implements JsonUnknown, JsonSerializable { protected @Nullable Baggage baggage; + /** + * Set the profiler id associated with this transaction. If set to a non-empty id, this value will + * be sent to sentry instead of {@link SentryOptions#getContinuousProfiler} + */ + private @NotNull SentryId profilerId = SentryId.EMPTY_ID; + public SpanContext( final @NotNull String operation, final @Nullable TracesSamplingDecision samplingDecision) { this(new SentryId(), new SpanId(), operation, null, samplingDecision); @@ -304,6 +310,14 @@ public int hashCode() { return Objects.hash(traceId, spanId, parentSpanId, op, description, getStatus()); } + public @NotNull SentryId getProfilerId() { + return profilerId; + } + + public void setProfilerId(@NotNull SentryId profilerId) { + this.profilerId = profilerId; + } + // region JsonSerializable public static final class JsonKeys { diff --git a/sentry/src/main/java/io/sentry/profiling/ProfilingServiceLoader.java b/sentry/src/main/java/io/sentry/profiling/ProfilingServiceLoader.java index ec09de6075f..ef5a9373c42 100644 --- a/sentry/src/main/java/io/sentry/profiling/ProfilingServiceLoader.java +++ b/sentry/src/main/java/io/sentry/profiling/ProfilingServiceLoader.java @@ -5,6 +5,7 @@ import io.sentry.IProfileConverter; import io.sentry.ISentryExecutorService; import io.sentry.NoOpContinuousProfiler; +import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import java.util.Iterator; import java.util.ServiceLoader; @@ -49,16 +50,22 @@ public final class ProfilingServiceLoader { * @return an IProfileConverter instance or null if no provider is found */ public static @Nullable IProfileConverter loadProfileConverter() { + ILogger logger = ScopesAdapter.getInstance().getGlobalScope().getOptions().getLogger(); try { JavaProfileConverterProvider provider = loadSingleProvider(JavaProfileConverterProvider.class); if (provider != null) { + logger.log( + SentryLevel.DEBUG, + "Loaded profile converter from provider: %s", + provider.getClass().getName()); return provider.getProfileConverter(); } else { + logger.log(SentryLevel.DEBUG, "No profile converter provider found, returning null"); return null; } } catch (Throwable t) { - // Log error and return null to skip conversion + logger.log(SentryLevel.ERROR, "Failed to load profile converter provider, returning null", t); return null; } } diff --git a/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java b/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java index 0c2c725df2a..0b6d6571e91 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java @@ -11,6 +11,7 @@ import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -368,6 +369,63 @@ public static final class JsonKeys { public static final String POST_CONTEXT = "post_context"; } + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + SentryStackFrame that = (SentryStackFrame) o; + return Objects.equals(preContext, that.preContext) + && Objects.equals(postContext, that.postContext) + && Objects.equals(vars, that.vars) + && Objects.equals(framesOmitted, that.framesOmitted) + && Objects.equals(filename, that.filename) + && Objects.equals(function, that.function) + && Objects.equals(module, that.module) + && Objects.equals(lineno, that.lineno) + && Objects.equals(colno, that.colno) + && Objects.equals(absPath, that.absPath) + && Objects.equals(contextLine, that.contextLine) + && Objects.equals(inApp, that.inApp) + && Objects.equals(_package, that._package) + && Objects.equals(_native, that._native) + && Objects.equals(platform, that.platform) + && Objects.equals(imageAddr, that.imageAddr) + && Objects.equals(symbolAddr, that.symbolAddr) + && Objects.equals(instructionAddr, that.instructionAddr) + && Objects.equals(addrMode, that.addrMode) + && Objects.equals(symbol, that.symbol) + && Objects.equals(unknown, that.unknown) + && Objects.equals(rawFunction, that.rawFunction) + && Objects.equals(lock, that.lock); + } + + @Override + public int hashCode() { + return Objects.hash( + preContext, + postContext, + vars, + framesOmitted, + filename, + function, + module, + lineno, + colno, + absPath, + contextLine, + inApp, + _package, + _native, + platform, + imageAddr, + symbolAddr, + instructionAddr, + addrMode, + symbol, + unknown, + rawFunction, + lock); + } + @Override public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { diff --git a/sentry/src/main/java/io/sentry/transport/RateLimiter.java b/sentry/src/main/java/io/sentry/transport/RateLimiter.java index 6c3460c8eb0..fc07e59c063 100644 --- a/sentry/src/main/java/io/sentry/transport/RateLimiter.java +++ b/sentry/src/main/java/io/sentry/transport/RateLimiter.java @@ -20,6 +20,8 @@ import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; @@ -171,8 +173,13 @@ private void markHintWhenSendingFailed(final @NotNull Hint hint, final boolean r */ @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) private boolean isRetryAfter(final @NotNull String itemType) { - final DataCategory dataCategory = getCategoryFromItemType(itemType); - return isActiveForCategory(dataCategory); + final List dataCategory = getCategoryFromItemType(itemType); + for (DataCategory category : dataCategory) { + if (isActiveForCategory(category)) { + return true; + } + } + return false; } /** @@ -181,33 +188,33 @@ private boolean isRetryAfter(final @NotNull String itemType) { * @param itemType the item itemType (eg event, session, attachment, ...) * @return the DataCategory eg (DataCategory.Error, DataCategory.Session, DataCategory.Attachment) */ - private @NotNull DataCategory getCategoryFromItemType(final @NotNull String itemType) { + private @NotNull List getCategoryFromItemType(final @NotNull String itemType) { switch (itemType) { case "event": - return DataCategory.Error; + return Collections.singletonList(DataCategory.Error); case "session": - return DataCategory.Session; + return Collections.singletonList(DataCategory.Session); case "attachment": - return DataCategory.Attachment; + return Collections.singletonList(DataCategory.Attachment); case "profile": - return DataCategory.Profile; + return Collections.singletonList(DataCategory.Profile); // When we send a profile chunk, we have to check for profile_chunk_ui rate limiting, - // because that's what relay returns to rate limit Android. When (if) we will implement JVM - // profiling we will have to check both rate limits. + // because that's what relay returns to rate limit Android. + // And ProfileChunk rate limiting for JVM. case "profile_chunk": - return DataCategory.ProfileChunkUi; + return Arrays.asList(DataCategory.ProfileChunkUi, DataCategory.ProfileChunk); case "transaction": - return DataCategory.Transaction; + return Collections.singletonList(DataCategory.Transaction); case "check_in": - return DataCategory.Monitor; + return Collections.singletonList(DataCategory.Monitor); case "replay_video": - return DataCategory.Replay; + return Collections.singletonList(DataCategory.Replay); case "feedback": - return DataCategory.Feedback; + return Collections.singletonList(DataCategory.Feedback); case "log": - return DataCategory.LogItem; + return Collections.singletonList(DataCategory.LogItem); default: - return DataCategory.Unknown; + return Collections.singletonList(DataCategory.Unknown); } } diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index d2b998aa94b..ba8b5507abb 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -968,7 +968,7 @@ class JsonSerializerTest { fixture.traceFile, HashMap(), 5.3, - ProfileChunk.Platform.ANDROID, + ProfileChunk.PLATFORM_ANDROID, fixture.options, ) val measurementNow = SentryNanotimeDate().nanoTimestamp() @@ -1127,7 +1127,7 @@ class JsonSerializerTest { assertEquals(SdkVersion("test", "1.2.3"), profileChunk.clientSdk) assertEquals(chunkId, profileChunk.chunkId) assertEquals("environment", profileChunk.environment) - assertEquals(ProfileChunk.Platform.ANDROID, profileChunk.platform) + assertEquals(ProfileChunk.PLATFORM_ANDROID, profileChunk.platform) assertEquals(profilerId, profileChunk.profilerId) assertEquals("release", profileChunk.release) assertEquals("sampled profile in base 64", profileChunk.sampledProfile) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index c3e563f3a52..981a5d201f4 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -113,7 +113,7 @@ class SentryClientTest { profilingTraceFile, emptyMap(), 1.0, - ProfileChunk.Platform.ANDROID, + ProfileChunk.PLATFORM_ANDROID, sentryOptions, ) } diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 70b8b18de09..858ca43ab11 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -486,11 +486,11 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) + whenever(it.platform).thenReturn("chunk platform") } val chunk = SentryEnvelopeItem.fromProfileChunk(profileChunk, mock()) - assertEquals("android", chunk.header.platform) + assertEquals("chunk platform", chunk.header.platform) } @Test @@ -499,7 +499,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) + whenever(it.platform).thenReturn(ProfileChunk.PLATFORM_ANDROID) } file.writeBytes(fixture.bytes) @@ -514,7 +514,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) + whenever(it.platform).thenReturn(ProfileChunk.PLATFORM_ANDROID) } file.writeBytes(fixture.bytes) @@ -531,7 +531,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) + whenever(it.platform).thenReturn(ProfileChunk.PLATFORM_ANDROID) } assertFailsWith( @@ -547,7 +547,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) + whenever(it.platform).thenReturn(ProfileChunk.PLATFORM_ANDROID) } file.writeBytes(fixture.bytes) file.setReadable(false) @@ -565,7 +565,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) + whenever(it.platform).thenReturn(ProfileChunk.PLATFORM_ANDROID) } val chunk = SentryEnvelopeItem.fromProfileChunk(profileChunk, mock()) @@ -580,7 +580,7 @@ class SentryEnvelopeItemTest { val profileChunk = mock { whenever(it.traceFile).thenReturn(file) - whenever(it.platform).thenReturn(ProfileChunk.Platform.ANDROID) + whenever(it.platform).thenReturn(ProfileChunk.PLATFORM_ANDROID) } val exception = diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt index 5bea60408d3..ab997a26f76 100644 --- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt +++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt @@ -456,7 +456,7 @@ class RateLimiterTest { } @Test - fun `drop profileChunk items as lost`() { + fun `drop profileChunkUi items as lost`() { val rateLimiter = fixture.getSUT() val profileChunkItem = SentryEnvelopeItem.fromProfileChunk(ProfileChunk(), fixture.serializer) @@ -481,6 +481,32 @@ class RateLimiterTest { verifyNoMoreInteractions(fixture.clientReportRecorder) } + @Test + fun `drop profileChunk items as lost`() { + val rateLimiter = fixture.getSUT() + + val profileChunkItem = SentryEnvelopeItem.fromProfileChunk(ProfileChunk(), fixture.serializer) + val attachmentItem = + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + NoOpLogger.getInstance(), + Attachment("{ \"number\": 10 }".toByteArray(), "log.json"), + 1000, + ) + val envelope = + SentryEnvelope(SentryEnvelopeHeader(), arrayListOf(profileChunkItem, attachmentItem)) + + rateLimiter.updateRetryAfterLimits("60:profile_chunk:key", null, 1) + val result = rateLimiter.filter(envelope, Hint()) + + assertNotNull(result) + assertEquals(1, result.items.toList().size) + + verify(fixture.clientReportRecorder, times(1)) + .recordLostEnvelopeItem(eq(DiscardReason.RATELIMIT_BACKOFF), same(profileChunkItem)) + verifyNoMoreInteractions(fixture.clientReportRecorder) + } + @Test fun `drop feedback items as lost`() { val rateLimiter = fixture.getSUT()