diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8f373df14dd..9ffe5ac3117 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -83,6 +83,7 @@ object Config { val SENTRY_OKHTTP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.okhttp" val SENTRY_REACTOR_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.reactor" val SENTRY_KOTLIN_EXTENSIONS_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.kotlin-extensions" + val SENTRY_ASYNC_PROFILER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.async-profiler" val SENTRY_KTOR_CLIENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.ktor-client" val group = "io.sentry" val description = "SDK for sentry.io" 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 a3fb6f6c8db..1515e18b1ab 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 @@ -300,7 +300,8 @@ private void stop(final boolean restartProfiler) { chunkId, endData.measurementsMap, endData.traceFile, - startProfileChunkTimestamp)); + startProfileChunkTimestamp, + "android")); } } diff --git a/sentry-async-profiler/api/sentry-async-profiler.api b/sentry-async-profiler/api/sentry-async-profiler.api new file mode 100644 index 00000000000..3e11247dd7e --- /dev/null +++ b/sentry-async-profiler/api/sentry-async-profiler.api @@ -0,0 +1,326 @@ +public final class io/sentry/asyncprofiler/BuildConfig { + public static final field SENTRY_ASYNC_PROFILER_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +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 static fun convertFromFileStatic (Ljava/nio/file/Path;)Lio/sentry/protocol/profiling/SentryProfile; +} + +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 + public fun getProfilerId ()Lio/sentry/protocol/SentryId; + public fun getRootSpanCounter ()I + public fun isRunning ()Z + public fun onRateLimitChanged (Lio/sentry/transport/RateLimiter;)V + public fun reevaluateSampling ()V + public fun startProfiler (Lio/sentry/ProfileLifecycle;Lio/sentry/TracesSampler;)V + public fun stopProfiler (Lio/sentry/ProfileLifecycle;)V +} + +public final class io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider : io/sentry/profiling/JavaContinuousProfilerProvider { + public fun ()V + public fun getContinuousProfiler (Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)Lio/sentry/IContinuousProfiler; +} + +public final class io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider : io/sentry/profiling/JavaProfileConverterProvider { + public fun ()V + public fun getProfileConverter ()Lio/sentry/IProfileConverter; +} + +public final class io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider$AsyncProfilerProfileConverter : io/sentry/IProfileConverter { + public fun ()V + public fun convertFromFile (Ljava/nio/file/Path;)Lio/sentry/protocol/profiling/SentryProfile; +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments { + public field alloc Z + public field bci Z + public field classify Z + public field cpu Z + public field dot Z + public field exclude Ljava/util/regex/Pattern; + public final field files Ljava/util/List; + public field from J + public field grain D + public field help Z + public field highlight Ljava/lang/String; + public field include Ljava/util/regex/Pattern; + public field inverted Z + public field leak Z + public field lines Z + public field live Z + public field lock Z + public field minwidth D + public field nativemem Z + public field norm Z + public field output Ljava/lang/String; + public field reverse Z + public field simple Z + public field skip I + public field state Ljava/lang/String; + public field threads Z + public field title Ljava/lang/String; + public field to J + public field total Z + public field wall Z + public fun ([Ljava/lang/String;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame : java/util/HashMap { + public static final field TYPE_C1_COMPILED B + public static final field TYPE_CPP B + public static final field TYPE_INLINED B + public static final field TYPE_INTERPRETED B + public static final field TYPE_JIT_COMPILED B + public static final field TYPE_KERNEL B + public static final field TYPE_NATIVE B +} + +public abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter { + protected final field args Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments; + protected final field collector Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector; + protected final field jfr Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader; + protected field methodNames Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;)V + protected fun collectEvents ()V + public fun convert ()V + protected fun convertChunk ()V + protected fun createCollector (Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector; + public synthetic fun getCategory (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier$Category; + public fun getClassName (J)Ljava/lang/String; + public fun getMethodName (JB)Ljava/lang/String; + public fun getPlainThreadName (I)Ljava/lang/String; + public fun getStackTraceElement (JBI)Ljava/lang/StackTraceElement; + public fun getThreadName (I)Ljava/lang/String; + protected fun getThreadStates (Z)Ljava/util/BitSet; + protected fun isNativeFrame (B)Z + protected fun toThreadState (Ljava/lang/String;)I + protected fun toTicks (J)J +} + +protected abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter$AggregatedEventVisitor : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor { + protected fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter;)V + protected abstract fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;J)V + public final fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef { + public final field name J + public fun (J)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary { + public fun ()V + public fun (I)V + public fun clear ()V + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary$Visitor;)V + public fun get (J)Ljava/lang/Object; + public fun preallocate (I)I + public fun put (JLjava/lang/Object;)V + public fun size ()I +} + +public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary$Visitor { + public abstract fun visit (JLjava/lang/Object;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt { + public fun ()V + public fun (I)V + public fun clear ()V + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt$Visitor;)V + public fun get (J)I + public fun get (JI)I + public fun preallocate (I)I + public fun put (JI)V +} + +public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt$Visitor { + public abstract fun visit (JI)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass { + public fun field (Ljava/lang/String;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField; +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField { +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader : java/io/Closeable { + public field chunkEndNanos J + public field chunkStartNanos J + public field chunkStartTicks J + public final field classes Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public field endNanos J + public final field enums Ljava/util/Map; + public final field javaThreads Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field methods Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field settings Ljava/util/Map; + public final field stackTraces Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public field startNanos J + public field startTicks J + public field stopAtNewChunk Z + public final field strings Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field symbols Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field threads Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public field ticksPerSec J + public final field types Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary; + public final field typesByName Ljava/util/Map; + public fun (Ljava/lang/String;)V + public fun (Ljava/nio/ByteBuffer;)V + public fun close ()V + public fun durationNanos ()J + public fun eof ()Z + public fun getBytes ()[B + public fun getDouble ()D + public fun getEnumKey (Ljava/lang/String;Ljava/lang/String;)I + public fun getEnumValue (Ljava/lang/String;I)Ljava/lang/String; + public fun getFloat ()F + public fun getString ()Ljava/lang/String; + public fun getVarint ()I + public fun getVarlong ()J + public fun hasMoreChunks ()Z + public fun incomplete ()Z + public fun readAllEvents ()Ljava/util/List; + public fun readAllEvents (Ljava/lang/Class;)Ljava/util/List; + public fun readEvent ()Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event; + public fun readEvent (Ljava/lang/Class;)Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event; + public fun registerEvent (Ljava/lang/String;Ljava/lang/Class;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef { + public final field cls J + public final field name J + public final field sig J + public fun (JJJ)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace { + public final field locations [I + public final field methods [J + public final field types [B + public fun ([J[B[I)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field allocationSize J + public final field classId I + public final field tlabSize J + public fun (JIIIJJ)V + public fun classId ()J + public fun hashCode ()I + public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field jvmSystem F + public final field jvmUser F + public final field machineTotal F + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field classId I + public final field duration J + public fun (JIIJI)V + public fun classId ()J + public fun hashCode ()I + public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z + public fun value ()J +} + +public abstract class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event : java/lang/Comparable { + public final field stackTraceId I + public final field tid I + public final field time J + protected fun (JII)V + public fun classId ()J + public fun compareTo (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)I + public synthetic fun compareTo (Ljava/lang/Object;)I + public fun hashCode ()I + public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z + public fun samples ()J + public fun toString ()Ljava/lang/String; + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { + public fun (ZD)V + public fun afterChunk ()V + public fun beforeChunk ()V + public fun coarsen (D)V + public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V + public fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V + public fun finish ()Z + public fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V + public fun size ()I +} + +public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { + public abstract fun afterChunk ()V + public abstract fun beforeChunk ()V + public abstract fun collect (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)V + public abstract fun finish ()Z + public abstract fun forEach (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor;)V +} + +public abstract interface class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector$Visitor { + public abstract fun visit (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;JJ)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field samples I + public final field threadState I + public fun (JIIII)V + public fun samples ()J + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field afterGC Z + public final field committed J + public final field gcId I + public final field reserved J + public final field used J + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field allocationSize J + public final field allocationTime J + public final field classId I + public fun (JIIIJJ)V + public fun classId ()J + public fun hashCode ()I + public fun sameGroup (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event;)Z + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field address J + public final field size J + public fun (JIIJJ)V + public fun value ()J +} + +public final class io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector { + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector;)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/vendor/asyncprofiler/jfr/event/ObjectCount : io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event { + public final field classId I + public final field count J + public final field gcId I + public final field totalSize J + public fun (Lio/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader;)V +} + diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts new file mode 100644 index 00000000000..0f4628e3e06 --- /dev/null +++ b/sentry-async-profiler/build.gradle.kts @@ -0,0 +1,90 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id("io.sentry.javadoc") + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() +} + +kotlin { explicitApi() } + +dependencies { + api(projects.sentry) + + implementation("tools.profiler:async-profiler:3.0") + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentryTestSupport) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.asyncprofiler") + buildConfigField( + "String", + "SENTRY_ASYNC_PROFILER_SDK_NAME", + "\"${Config.Sentry.SENTRY_ASYNC_PROFILER_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_ASYNC_PROFILER_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-async-profiler", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} 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 new file mode 100644 index 00000000000..f22eb76f709 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/convert/JfrAsyncProfilerToSentryProfileConverter.java @@ -0,0 +1,169 @@ +package io.sentry.asyncprofiler.convert; + +import io.sentry.Sentry; +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.protocol.SentryStackFrame; +import io.sentry.protocol.profiling.JfrSample; +import io.sentry.protocol.profiling.SentryProfile; +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; +import org.jetbrains.annotations.NotNull; + +public final class JfrAsyncProfilerToSentryProfileConverter extends JfrConverter { + private final @NotNull SentryProfile sentryProfile = new SentryProfile(); + + public JfrAsyncProfilerToSentryProfileConverter(JfrReader jfr, Arguments args) { + super(jfr, args); + } + + @Override + protected void convertChunk() { + final List events = new ArrayList(); + final List> stacks = new ArrayList<>(); + + collector.forEach( + new AggregatedEventVisitor() { + + @Override + public void visit(Event event, long value) { + events.add(event); + System.out.println(event); + StackTrace stackTrace = jfr.stackTraces.get(event.stackTraceId); + + if (stackTrace != null) { + Arguments args = JfrAsyncProfilerToSentryProfileConverter.this.args; + long[] methods = stackTrace.methods; + byte[] types = stackTrace.types; + int[] locations = stackTrace.locations; + + 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 -> { + ThreadMetadata metadata = new ThreadMetadata(); + metadata.name = threadName; + metadata.priority = 0; + return metadata; + }); + } + } + + if (sentryProfile.samples == null) { + sentryProfile.samples = new ArrayList<>(); + } + + if (sentryProfile.frames == null) { + sentryProfile.frames = new ArrayList<>(); + } + + 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( + new SentryStackTraceFactory(Sentry.getGlobalScope().getOptions()) + .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++; + } + + long divisor = jfr.ticksPerSec / 1000_000_000L; + long myTimeStamp = + jfr.chunkStartNanos + ((event.time - jfr.chunkStartTicks) / divisor); + + 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.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); + } + + stacks.add(stack); + } + } + }); + sentryProfile.stacks = stacks; + System.out.println("Samples: " + events.size()); + } + + 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.alloc = false; + args.threads = true; + args.lines = true; + args.dot = true; + + converter = new JfrAsyncProfilerToSentryProfileConverter(jfrReader, args); + converter.convert(); + } + + return converter.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 new file mode 100644 index 00000000000..dd8db6237bc --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/profiling/JavaContinuousProfiler.java @@ -0,0 +1,393 @@ +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; +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryExecutorService; +import io.sentry.ISentryLifecycleToken; +import io.sentry.NoOpScopes; +import io.sentry.ProfileChunk; +import io.sentry.ProfileLifecycle; +import io.sentry.Sentry; +import io.sentry.SentryDate; +import io.sentry.SentryLevel; +import io.sentry.SentryNanotimeDate; +import io.sentry.SentryOptions; +import io.sentry.SentryUUID; +import io.sentry.TracesSampler; +import io.sentry.protocol.SentryId; +import io.sentry.transport.RateLimiter; +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; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import one.profiler.AsyncProfiler; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +@ApiStatus.Internal +public final class JavaContinuousProfiler + implements IContinuousProfiler, RateLimiter.IRateLimitObserver { + private static final long MAX_CHUNK_DURATION_MILLIS = 10000; + + private final @NotNull ILogger logger; + private final @Nullable String profilingTracesDirPath; + private final int profilingTracesHz; + private final @NotNull ISentryExecutorService executorService; + private boolean isInitialized = false; + private boolean isRunning = false; + private @Nullable IScopes scopes; + 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 volatile boolean shouldSample = true; + private boolean shouldStop = false; + private boolean isSampled = false; + private int rootSpanCounter = 0; + + private final AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private final AutoClosableReentrantLock payloadLock = new AutoClosableReentrantLock(); + + public JavaContinuousProfiler( + final @NotNull ILogger logger, + final @Nullable String profilingTracesDirPath, + final int profilingTracesHz, + final @NotNull ISentryExecutorService executorService) { + this.logger = logger; + this.profilingTracesDirPath = profilingTracesDirPath; + this.profilingTracesHz = profilingTracesHz; + this.executorService = executorService; + this.profiler = AsyncProfiler.getInstance(); + } + + 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; + } + if (profilingTracesHz <= 0) { + logger.log( + SentryLevel.WARNING, + "Disabling profiling because trace rate is set to %d", + profilingTracesHz); + return false; + } + return true; + } + + @SuppressWarnings("ReferenceEquality") + @Override + public void startProfiler( + final @NotNull ProfileLifecycle profileLifecycle, + final @NotNull TracesSampler tracesSampler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (shouldSample) { + isSampled = tracesSampler.sampleSessionProfile(SentryRandom.current().nextDouble()); + shouldSample = false; + } + if (!isSampled) { + logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); + return; + } + + switch (profileLifecycle) { + case TRACE: + // rootSpanCounter should never be negative, unless the user changed profile lifecycle + // while + // the profiler is running or close() is called. This is just a safety check. + if (rootSpanCounter < 0) { + rootSpanCounter = 0; + } + rootSpanCounter++; + break; + case MANUAL: + // We check if the profiler is already running and log a message only in manual mode, + // since + // in trace mode we can have multiple concurrent traces + if (isRunning()) { + logger.log(SentryLevel.DEBUG, "Profiler is already running."); + return; + } + break; + } + + if (!isRunning()) { + logger.log(SentryLevel.DEBUG, "Started Profiler."); + start(); + } + } + } + + 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) { + rateLimiter.addRateLimitObserver(this); + } + } + } + + @SuppressWarnings("ReferenceEquality") + private void start() { + initScopes(); + + // Let's initialize trace folder and profiling interval + if (!init()) { + return; + } + + if (scopes != null) { + final @Nullable RateLimiter rateLimiter = scopes.getRateLimiter(); + if (rateLimiter != null + && (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunk))) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + // Let's stop and reset profiler id, as the profile is now broken anyway + stop(false); + 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(); + } + filename = profilingTracesDirPath + File.separator + SentryUUID.generateSentryId() + ".jfr"; + String startData = null; + try { + 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); + System.out.println(command); + startData = profiler.execute(command); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Failed to start profiling: ", e); + } + // check if profiling started + if (startData == null) { + return; + } + + isRunning = true; + + if (profilerId == SentryId.EMPTY_ID) { + profilerId = new SentryId(); + } + + if (chunkId == SentryId.EMPTY_ID) { + chunkId = new SentryId(); + } + + try { + stopFuture = executorService.schedule(() -> stop(true), MAX_CHUNK_DURATION_MILLIS); + } catch (RejectedExecutionException e) { + logger.log( + SentryLevel.ERROR, + "Failed to schedule profiling chunk finish. Did you call Sentry.close()?", + e); + shouldStop = true; + } + } + + @Override + public void stopProfiler(final @NotNull ProfileLifecycle profileLifecycle) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + switch (profileLifecycle) { + case TRACE: + rootSpanCounter--; + // If there are active spans, and profile lifecycle is trace, we don't stop the profiler + if (rootSpanCounter > 0) { + return; + } + // rootSpanCounter should never be negative, unless the user changed profile lifecycle + // while the profiler is running or close() is called. This is just a safety check. + if (rootSpanCounter < 0) { + rootSpanCounter = 0; + } + shouldStop = true; + break; + case MANUAL: + shouldStop = true; + break; + } + } + } + + private void stop(final boolean restartProfiler) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (stopFuture != null) { + stopFuture.cancel(true); + } + // 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 + profilerId = SentryId.EMPTY_ID; + chunkId = SentryId.EMPTY_ID; + return; + } + + String endData = null; + try { + endData = profiler.execute("stop,jfr"); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // 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. + try (final @NotNull ISentryLifecycleToken ignored2 = payloadLock.acquire()) { + payloadBuilders.add( + new ProfileChunk.Builder( + profilerId, + chunkId, + new HashMap<>(), + new File(filename), + startProfileChunkTimestamp, + "java")); + } + } + + 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) { + logger.log(SentryLevel.DEBUG, "Profile chunk finished. Starting a new one."); + start(); + } else { + // When the profiler is stopped manually, we have to reset its id + profilerId = SentryId.EMPTY_ID; + logger.log(SentryLevel.DEBUG, "Profile chunk finished."); + } + } + } + + @Override + public void reevaluateSampling() { + shouldSample = true; + } + + @Override + public void close(final boolean isTerminating) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + rootSpanCounter = 0; + shouldStop = true; + if (isTerminating) { + stop(false); + isClosed.set(true); + } + } + } + + @Override + public @NotNull SentryId getProfilerId() { + return profilerId; + } + + @SuppressWarnings("FutureReturnValueIgnored") + private void sendChunks(final @NotNull IScopes scopes, final @NotNull SentryOptions options) { + try { + options + .getExecutorService() + .submit( + () -> { + // SDK is closed, we don't send the chunks + if (isClosed.get()) { + return; + } + final ArrayList payloads = new ArrayList<>(payloadBuilders.size()); + try (final @NotNull ISentryLifecycleToken ignored = payloadLock.acquire()) { + for (ProfileChunk.Builder builder : payloadBuilders) { + payloads.add(builder.build(options)); + } + payloadBuilders.clear(); + } + for (ProfileChunk payload : payloads) { + scopes.captureProfileChunk(payload); + } + }); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send profile chunks.", e); + } + } + + @Override + public boolean isRunning() { + return isRunning; + } + + @VisibleForTesting + @Nullable + Future getStopFuture() { + return stopFuture; + } + + @VisibleForTesting + public int getRootSpanCounter() { + return rootSpanCounter; + } + + @Override + public void onRateLimitChanged(@NotNull RateLimiter rateLimiter) { + // We stop the profiler as soon as we are rate limited, to avoid the performance overhead + if (rateLimiter.isActiveForCategory(All) + || rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)) { + logger.log(SentryLevel.WARNING, "SDK is rate limited. Stopping profiler."); + stop(false); + } + // If we are not rate limited anymore, we don't do anything: the profile is broken, so it's + // useless to restart it automatically + } +} 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 new file mode 100644 index 00000000000..e721260545b --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerContinuousProfilerProvider.java @@ -0,0 +1,31 @@ +package io.sentry.asyncprofiler.provider; + +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.ISentryExecutorService; +import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler; +import io.sentry.profiling.JavaContinuousProfilerProvider; +import io.sentry.profiling.JavaProfileConverterProvider; +import org.jetbrains.annotations.NotNull; + +/** + * AsyncProfiler implementation of {@link JavaContinuousProfilerProvider} and {@link + * JavaProfileConverterProvider}. This provider integrates AsyncProfiler with Sentry's continuous + * profiling system and provides profile conversion functionality. + */ +public final class AsyncProfilerContinuousProfilerProvider + implements JavaContinuousProfilerProvider { + + @Override + public @NotNull IContinuousProfiler getContinuousProfiler( + ILogger logger, + String profilingTracesDirPath, + int profilingTracesHz, + ISentryExecutorService executorService) { + return new JavaContinuousProfiler( + logger, + profilingTracesDirPath, + 10, // default profilingTracesHz + executorService); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java new file mode 100644 index 00000000000..bb78af134e4 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/provider/AsyncProfilerProfileConverterProvider.java @@ -0,0 +1,32 @@ +package io.sentry.asyncprofiler.provider; + +import io.sentry.IProfileConverter; +import io.sentry.asyncprofiler.convert.JfrAsyncProfilerToSentryProfileConverter; +import io.sentry.profiling.JavaProfileConverterProvider; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * AsyncProfiler implementation of {@link JavaProfileConverterProvider}. This provider integrates + * AsyncProfiler's JFR converter with Sentry's profiling system. + */ +public final class AsyncProfilerProfileConverterProvider implements JavaProfileConverterProvider { + + @Override + public @Nullable IProfileConverter getProfileConverter() { + return new AsyncProfilerProfileConverter(); + } + + /** + * Internal implementation of IProfileConverter that delegates to + * JfrAsyncProfilerToSentryProfileConverter. + */ + public static final class AsyncProfilerProfileConverter implements IProfileConverter { + + @Override + public @NotNull io.sentry.protocol.profiling.SentryProfile convertFromFile( + @NotNull java.nio.file.Path jfrFilePath) throws java.io.IOException { + return JfrAsyncProfilerToSentryProfileConverter.convertFromFileStatic(jfrFilePath); + } + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/LICENSE b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/LICENSE new file mode 100644 index 00000000000..8dada3edaf5 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java new file mode 100644 index 00000000000..d4d81600481 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Arguments.java @@ -0,0 +1,130 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.regex.Pattern; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class Arguments { + public @NotNull String title = "Flame Graph"; + public @Nullable String highlight; + public @Nullable String output; + public @Nullable String state; + public @Nullable Pattern include; + public @Nullable Pattern exclude; + public double minwidth; + public double grain; + public int skip; + public boolean help; + public boolean reverse; + public boolean inverted; + public boolean cpu; + public boolean wall; + public boolean alloc; + public boolean nativemem; + public boolean leak; + public boolean live; + public boolean lock; + public boolean threads; + public boolean classify; + public boolean total; + public boolean lines; + public boolean bci; + public boolean simple; + public boolean norm; + public boolean dot; + public long from; + public long to; + public final List files = new ArrayList<>(); + + public Arguments(String... args) { + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + String fieldName; + if (arg.startsWith("--")) { + fieldName = arg.substring(2); + } else if (arg.startsWith("-") && arg.length() == 2) { + fieldName = alias(arg.charAt(1)); + } else { + files.add(arg); + continue; + } + + try { + Field f = Arguments.class.getDeclaredField(fieldName); + if ((f.getModifiers() & (Modifier.PRIVATE | Modifier.STATIC | Modifier.FINAL)) != 0) { + throw new IllegalArgumentException(arg); + } + + Class type = f.getType(); + if (type == String.class) { + f.set(this, args[++i]); + } else if (type == boolean.class) { + f.setBoolean(this, true); + } else if (type == int.class) { + f.setInt(this, Integer.parseInt(args[++i])); + } else if (type == double.class) { + f.setDouble(this, Double.parseDouble(args[++i])); + } else if (type == long.class) { + f.setLong(this, parseTimestamp(args[++i])); + } else if (type == Pattern.class) { + f.set(this, Pattern.compile(args[++i])); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new IllegalArgumentException(arg); + } + } + } + + private static String alias(char c) { + switch (c) { + case 'h': + return "help"; + case 'o': + return "output"; + case 'r': + return "reverse"; + case 'i': + return "inverted"; + case 'I': + return "include"; + case 'X': + return "exclude"; + case 't': + return "threads"; + case 's': + return "state"; + default: + return String.valueOf(c); + } + } + + // Milliseconds or HH:mm:ss.S or yyyy-MM-dd'T'HH:mm:ss.S + private static long parseTimestamp(String time) { + if (time.indexOf(':') < 0) { + return Long.parseLong(time); + } + + GregorianCalendar cal = new GregorianCalendar(); + StringTokenizer st = new StringTokenizer(time, "-:.T"); + + if (time.indexOf('T') > 0) { + cal.set(Calendar.YEAR, Integer.parseInt(st.nextToken())); + cal.set(Calendar.MONTH, Integer.parseInt(st.nextToken()) - 1); + cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(st.nextToken())); + } + cal.set(Calendar.HOUR_OF_DAY, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); + cal.set(Calendar.MINUTE, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); + cal.set(Calendar.SECOND, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); + cal.set(Calendar.MILLISECOND, st.hasMoreTokens() ? Integer.parseInt(st.nextToken()) : 0); + + return cal.getTimeInMillis(); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java new file mode 100644 index 00000000000..7990e1b3e72 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Classifier.java @@ -0,0 +1,154 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; + +import static io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Frame.*; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.StackTrace; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +abstract class Classifier { + + enum Category { + GC("[gc]", TYPE_CPP), + JIT("[jit]", TYPE_CPP), + VM("[vm]", TYPE_CPP), + VTABLE_STUBS("[vtable_stubs]", TYPE_NATIVE), + NATIVE("[native]", TYPE_NATIVE), + INTERPRETER("[Interpreter]", TYPE_NATIVE), + C1_COMP("[c1_comp]", TYPE_C1_COMPILED), + C2_COMP("[c2_comp]", TYPE_INLINED), + ADAPTER("[c2i_adapter]", TYPE_INLINED), + CLASS_INIT("[class_init]", TYPE_CPP), + CLASS_LOAD("[class_load]", TYPE_CPP), + CLASS_RESOLVE("[class_resolve]", TYPE_CPP), + CLASS_VERIFY("[class_verify]", TYPE_CPP), + LAMBDA_INIT("[lambda_init]", TYPE_CPP); + + final String title; + final byte type; + + Category(String title, byte type) { + this.title = title; + this.type = type; + } + } + + public @Nullable Category getCategory(@NotNull StackTrace stackTrace) { + long[] methods = stackTrace.methods; + byte[] types = stackTrace.types; + + Category category; + if ((category = detectGcJit(methods, types)) == null + && (category = detectClassLoading(methods, types)) == null) { + category = detectOther(methods, types); + } + return category; + } + + private @Nullable Category detectGcJit(long[] methods, byte[] types) { + boolean vmThread = false; + for (int i = types.length; --i >= 0; ) { + if (types[i] == TYPE_CPP) { + switch (getMethodName(methods[i], types[i])) { + case "CompileBroker::compiler_thread_loop": + return Category.JIT; + case "GCTaskThread::run": + case "WorkerThread::run": + return Category.GC; + case "java_start": + case "thread_native_entry": + vmThread = true; + break; + } + } else if (types[i] != TYPE_NATIVE) { + break; + } + } + return vmThread ? Category.VM : null; + } + + private @Nullable Category detectClassLoading(long[] methods, byte[] types) { + for (int i = 0; i < methods.length; i++) { + String methodName = getMethodName(methods[i], types[i]); + if (methodName.equals("Verifier::verify")) { + return Category.CLASS_VERIFY; + } else if (methodName.startsWith("InstanceKlass::initialize")) { + return Category.CLASS_INIT; + } else if (methodName.startsWith("LinkResolver::") + || methodName.startsWith("InterpreterRuntime::resolve") + || methodName.startsWith("SystemDictionary::resolve")) { + return Category.CLASS_RESOLVE; + } else if (methodName.endsWith("ClassLoader.loadClass")) { + return Category.CLASS_LOAD; + } else if (methodName.endsWith("LambdaMetafactory.metafactory") + || methodName.endsWith("LambdaMetafactory.altMetafactory")) { + return Category.LAMBDA_INIT; + } else if (methodName.endsWith("table stub")) { + return Category.VTABLE_STUBS; + } else if (methodName.equals("Interpreter")) { + return Category.INTERPRETER; + } else if (methodName.startsWith("I2C/C2I")) { + return i + 1 < types.length && types[i + 1] == TYPE_INTERPRETED + ? Category.INTERPRETER + : Category.ADAPTER; + } + } + return null; + } + + private @NotNull Category detectOther(long[] methods, byte[] types) { + boolean inJava = true; + for (int i = 0; i < types.length; i++) { + switch (types[i]) { + case TYPE_INTERPRETED: + return inJava ? Category.INTERPRETER : Category.NATIVE; + case TYPE_JIT_COMPILED: + return inJava ? Category.C2_COMP : Category.NATIVE; + case TYPE_INLINED: + inJava = true; + break; + case TYPE_NATIVE: + { + String methodName = getMethodName(methods[i], types[i]); + if (methodName.startsWith("JVM_") + || methodName.startsWith("Unsafe_") + || methodName.startsWith("MHN_") + || methodName.startsWith("jni_")) { + return Category.VM; + } + switch (methodName) { + case "call_stub": + case "deoptimization": + case "unknown_Java": + case "not_walkable_Java": + case "InlineCacheBuffer": + return Category.VM; + } + if (methodName.endsWith("_arraycopy") || methodName.contains("pthread_cond")) { + break; + } + inJava = false; + break; + } + case TYPE_CPP: + { + String methodName = getMethodName(methods[i], types[i]); + if (methodName.startsWith("Runtime1::")) { + return Category.C1_COMP; + } + break; + } + case TYPE_C1_COMPILED: + return inJava ? Category.C1_COMP : Category.NATIVE; + } + } + return Category.NATIVE; + } + + protected abstract @NotNull String getMethodName(long method, byte type); +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java new file mode 100644 index 00000000000..d1426255458 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/Frame.java @@ -0,0 +1,66 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; + +import java.util.HashMap; + +public final class Frame extends HashMap { + private static final long serialVersionUID = 1L; + public static final byte TYPE_INTERPRETED = 0; + public static final byte TYPE_JIT_COMPILED = 1; + public static final byte TYPE_INLINED = 2; + public static final byte TYPE_NATIVE = 3; + public static final byte TYPE_CPP = 4; + public static final byte TYPE_KERNEL = 5; + public static final byte TYPE_C1_COMPILED = 6; + + private static final int TYPE_SHIFT = 28; + + final int key; + long total; + long self; + long inlined, c1, interpreted; + + private Frame(int key) { + this.key = key; + } + + Frame(int titleIndex, byte type) { + this(titleIndex | type << TYPE_SHIFT); + } + + Frame getChild(int titleIndex, byte type) { + return super.computeIfAbsent(titleIndex | type << TYPE_SHIFT, Frame::new); + } + + int getTitleIndex() { + return key & ((1 << TYPE_SHIFT) - 1); + } + + byte getType() { + if (inlined * 3 >= total) { + return TYPE_INLINED; + } else if (c1 * 2 >= total) { + return TYPE_C1_COMPILED; + } else if (interpreted * 2 >= total) { + return TYPE_INTERPRETED; + } else { + return (byte) (key >>> TYPE_SHIFT); + } + } + + int depth(long cutoff) { + int depth = 0; + if (size() > 0) { + for (Frame child : values()) { + if (child.total >= cutoff) { + depth = Math.max(depth, child.depth(cutoff)); + } + } + } + return depth + 1; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java new file mode 100644 index 00000000000..70f1747fac7 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/convert/JfrConverter.java @@ -0,0 +1,298 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.convert; + +import static io.sentry.asyncprofiler.vendor.asyncprofiler.convert.Frame.*; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.ClassRef; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.Dictionary; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.MethodRef; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.AllocationSample; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ContendedLock; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventAggregator; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.EventCollector; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ExecutionSample; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.LiveObject; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocEvent; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocLeakAggregator; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.BitSet; +import java.util.Map; +import org.jetbrains.annotations.NotNull; + +public abstract class JfrConverter extends Classifier { + protected final @NotNull JfrReader jfr; + protected final @NotNull Arguments args; + protected final @NotNull EventCollector collector; + protected @NotNull Dictionary methodNames; + + public JfrConverter(@NotNull JfrReader jfr, @NotNull Arguments args) { + this.jfr = jfr; + this.args = args; + this.methodNames = new Dictionary<>(); + + EventCollector collector = createCollector(args); + this.collector = args.nativemem && args.leak ? new MallocLeakAggregator(collector) : collector; + } + + public void convert() throws IOException { + jfr.stopAtNewChunk = true; + + while (jfr.hasMoreChunks()) { + // Reset method dictionary, since new chunk may have different IDs + methodNames = new Dictionary<>(); + + collector.beforeChunk(); + collectEvents(); + collector.afterChunk(); + + convertChunk(); + } + + if (collector.finish()) { + convertChunk(); + } + } + + protected EventCollector createCollector(Arguments args) { + return new EventAggregator(args.threads, args.grain); + } + + protected void collectEvents() throws IOException { + Class eventClass = + args.nativemem + ? MallocEvent.class + : args.live + ? LiveObject.class + : args.alloc + ? AllocationSample.class + : args.lock ? ContendedLock.class : ExecutionSample.class; + + BitSet threadStates = null; + if (args.state != null) { + threadStates = new BitSet(); + for (String state : args.state.toUpperCase().split(",", -1)) { + threadStates.set(toThreadState(state)); + } + } else if (args.cpu) { + threadStates = getThreadStates(true); + } else if (args.wall) { + threadStates = getThreadStates(false); + } + + long startTicks = args.from != 0 ? toTicks(args.from) : Long.MIN_VALUE; + long endTicks = args.to != 0 ? toTicks(args.to) : Long.MAX_VALUE; + + for (Event event; (event = jfr.readEvent(eventClass)) != null; ) { + if (event.time >= startTicks && event.time <= endTicks) { + if (threadStates == null || threadStates.get(((ExecutionSample) event).threadState)) { + collector.collect(event); + } + } + } + } + + protected void convertChunk() { + // To be overridden in subclasses + } + + protected int toThreadState(String name) { + Map threadStates = jfr.enums.get("jdk.types.ThreadState"); + if (threadStates != null) { + for (Map.Entry entry : threadStates.entrySet()) { + if (entry.getValue().startsWith(name, 6)) { + return entry.getKey(); + } + } + } + throw new IllegalArgumentException("Unknown thread state: " + name); + } + + protected BitSet getThreadStates(boolean cpu) { + BitSet set = new BitSet(); + Map threadStates = jfr.enums.get("jdk.types.ThreadState"); + if (threadStates != null) { + for (Map.Entry entry : threadStates.entrySet()) { + set.set(entry.getKey(), "STATE_DEFAULT".equals(entry.getValue()) == cpu); + } + } + return set; + } + + // millis can be an absolute timestamp or an offset from the beginning/end of the recording + protected long toTicks(long millis) { + long nanos = millis * 1_000_000; + if (millis < 0) { + nanos += jfr.endNanos; + } else if (millis < 1500000000000L) { + nanos += jfr.startNanos; + } + return (long) ((nanos - jfr.chunkStartNanos) * (jfr.ticksPerSec / 1e9)) + jfr.chunkStartTicks; + } + + @Override + public String getMethodName(long methodId, byte methodType) { + String result = methodNames.get(methodId); + if (result == null) { + methodNames.put(methodId, result = resolveMethodName(methodId, methodType)); + } + return result; + } + + private String resolveMethodName(long methodId, byte methodType) { + MethodRef method = jfr.methods.get(methodId); + if (method == null) { + return "unknown"; + } + + ClassRef cls = jfr.classes.get(method.cls); + byte[] className = jfr.symbols.get(cls.name); + byte[] methodName = jfr.symbols.get(method.name); + + if (className == null || className.length == 0 || isNativeFrame(methodType)) { + return new String(methodName, StandardCharsets.UTF_8); + } else { + String classStr = toJavaClassName(className, 0, args.dot); + if (methodName == null || methodName.length == 0) { + return classStr; + } + String methodStr = new String(methodName, StandardCharsets.UTF_8); + return classStr + '.' + methodStr; + } + } + + public String getClassName(long classId) { + ClassRef cls = jfr.classes.get(classId); + if (cls == null) { + return "null"; + } + byte[] className = jfr.symbols.get(cls.name); + + int arrayDepth = 0; + while (className[arrayDepth] == '[') { + arrayDepth++; + } + + String name = toJavaClassName(className, arrayDepth, true); + while (arrayDepth-- > 0) { + name = name.concat("[]"); + } + return name; + } + + private String toJavaClassName(byte[] symbol, int start, boolean dotted) { + int end = symbol.length; + if (start > 0) { + switch (symbol[start]) { + case 'B': + return "byte"; + case 'C': + return "char"; + case 'S': + return "short"; + case 'I': + return "int"; + case 'J': + return "long"; + case 'Z': + return "boolean"; + case 'F': + return "float"; + case 'D': + return "double"; + case 'L': + start++; + end--; + } + } + + if (args.norm) { + for (int i = end - 2; i > start; i--) { + if (symbol[i] == '/' || symbol[i] == '.') { + if (symbol[i + 1] >= '0' && symbol[i + 1] <= '9') { + end = i; + if (i > start + 19 && symbol[i - 19] == '+' && symbol[i - 18] == '0') { + // Original JFR transforms lambda names to something like + // pkg.ClassName$$Lambda+0x00007f8177090218/543846639 + end = i - 19; + } + } + break; + } + } + } + + if (args.simple) { + for (int i = end - 2; i >= start; i--) { + if (symbol[i] == '/' && (symbol[i + 1] < '0' || symbol[i + 1] > '9')) { + start = i + 1; + break; + } + } + } + + String s = new String(symbol, start, end - start, StandardCharsets.UTF_8); + return dotted ? s.replace('/', '.') : s; + } + + public StackTraceElement getStackTraceElement(long methodId, byte methodType, int location) { + MethodRef method = jfr.methods.get(methodId); + if (method == null) { + return new StackTraceElement("", "unknown", null, 0); + } + + ClassRef cls = jfr.classes.get(method.cls); + byte[] className = jfr.symbols.get(cls.name); + byte[] methodName = jfr.symbols.get(method.name); + + String classStr = + className == null || className.length == 0 || isNativeFrame(methodType) + ? "" + : toJavaClassName(className, 0, args.dot); + String methodStr = + methodName == null || methodName.length == 0 + ? "" + : new String(methodName, StandardCharsets.UTF_8); + return new StackTraceElement(classStr, methodStr, null, location >>> 16); + } + + public String getThreadName(int tid) { + String threadName = jfr.threads.get(tid); + return threadName == null + ? "[tid=" + tid + ']' + : threadName.startsWith("[tid=") ? threadName : '[' + threadName + " tid=" + tid + ']'; + } + + public String getPlainThreadName(int tid) { + String threadName = jfr.threads.get(tid); + return threadName == null ? "[tid=" + tid + ']' : threadName; + } + + protected boolean isNativeFrame(byte methodType) { + // In JDK Flight Recorder, TYPE_NATIVE denotes Java native methods, + // while in async-profiler, TYPE_NATIVE is for C methods + return (methodType == TYPE_NATIVE + && jfr.getEnumValue("jdk.types.FrameType", TYPE_KERNEL) != null) + || methodType == TYPE_CPP + || methodType == TYPE_KERNEL; + } + + // Select sum(samples) or sum(value) depending on the --total option. + // For lock events, convert lock duration from ticks to nanoseconds. + protected abstract class AggregatedEventVisitor implements EventCollector.Visitor { + final double factor = !args.total ? 0.0 : args.lock ? 1e9 / jfr.ticksPerSec : 1.0; + + @Override + public final void visit(Event event, long samples, long value) { + visit(event, factor == 0.0 ? samples : factor == 1.0 ? value : (long) (value * factor)); + } + + protected abstract void visit(Event event, long value); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java new file mode 100644 index 00000000000..7c7fc5d8bf0 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/ClassRef.java @@ -0,0 +1,14 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +public final class ClassRef { + public final long name; + + public ClassRef(long name) { + this.name = name; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java new file mode 100644 index 00000000000..9c9ab8a873c --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Dictionary.java @@ -0,0 +1,114 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import java.util.Arrays; + +/** Fast and compact long->Object map. */ +public final class Dictionary { + private static final int INITIAL_CAPACITY = 16; + + private long[] keys; + private Object[] values; + private int size; + + public Dictionary() { + this(INITIAL_CAPACITY); + } + + public Dictionary(int initialCapacity) { + this.keys = new long[initialCapacity]; + this.values = new Object[initialCapacity]; + } + + public void clear() { + Arrays.fill(keys, 0); + Arrays.fill(values, null); + size = 0; + } + + public int size() { + return size; + } + + public void put(long key, T value) { + if (key == 0) { + throw new IllegalArgumentException("Zero key not allowed"); + } + + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != 0) { + if (keys[i] == key) { + values[i] = value; + return; + } + i = (i + 1) & mask; + } + keys[i] = key; + values[i] = value; + + if (++size * 2 > keys.length) { + resize(keys.length * 2); + } + } + + @SuppressWarnings("unchecked") + public T get(long key) { + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != key && keys[i] != 0) { + i = (i + 1) & mask; + } + return (T) values[i]; + } + + @SuppressWarnings("unchecked") + public void forEach(Visitor visitor) { + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) { + visitor.visit(keys[i], (T) values[i]); + } + } + } + + public int preallocate(int count) { + if (count * 2 > keys.length) { + resize(Integer.highestOneBit(count * 4 - 1)); + } + return count; + } + + private void resize(int newCapacity) { + long[] newKeys = new long[newCapacity]; + Object[] newValues = new Object[newCapacity]; + int mask = newKeys.length - 1; + + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) { + for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { + if (newKeys[j] == 0) { + newKeys[j] = keys[i]; + newValues[j] = values[i]; + break; + } + } + } + } + + keys = newKeys; + values = newValues; + } + + private static int hashCode(long key) { + key *= 0xc6a4a7935bd1e995L; + return (int) (key ^ (key >>> 32)); + } + + public interface Visitor { + void visit(long key, T value); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java new file mode 100644 index 00000000000..f552f1fa819 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/DictionaryInt.java @@ -0,0 +1,123 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import java.util.Arrays; + +/** Fast and compact long->int map. */ +public final class DictionaryInt { + private static final int INITIAL_CAPACITY = 16; + + private long[] keys; + private int[] values; + private int size; + + public DictionaryInt() { + this(INITIAL_CAPACITY); + } + + public DictionaryInt(int initialCapacity) { + this.keys = new long[initialCapacity]; + this.values = new int[initialCapacity]; + } + + public void clear() { + Arrays.fill(keys, 0); + Arrays.fill(values, 0); + size = 0; + } + + public void put(long key, int value) { + if (key == 0) { + throw new IllegalArgumentException("Zero key not allowed"); + } + + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != 0) { + if (keys[i] == key) { + values[i] = value; + return; + } + i = (i + 1) & mask; + } + keys[i] = key; + values[i] = value; + + if (++size * 2 > keys.length) { + resize(keys.length * 2); + } + } + + public int get(long key) { + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != key) { + if (keys[i] == 0) { + throw new IllegalArgumentException("No such key: " + key); + } + i = (i + 1) & mask; + } + return values[i]; + } + + public int get(long key, int notFound) { + int mask = keys.length - 1; + int i = hashCode(key) & mask; + while (keys[i] != key) { + if (keys[i] == 0) { + return notFound; + } + i = (i + 1) & mask; + } + return values[i]; + } + + public void forEach(Visitor visitor) { + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) { + visitor.visit(keys[i], values[i]); + } + } + } + + public int preallocate(int count) { + if (count * 2 > keys.length) { + resize(Integer.highestOneBit(count * 4 - 1)); + } + return count; + } + + private void resize(int newCapacity) { + long[] newKeys = new long[newCapacity]; + int[] newValues = new int[newCapacity]; + int mask = newKeys.length - 1; + + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) { + for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { + if (newKeys[j] == 0) { + newKeys[j] = keys[i]; + newValues[j] = values[i]; + break; + } + } + } + } + + keys = newKeys; + values = newValues; + } + + private static int hashCode(long key) { + key *= 0xc6a4a7935bd1e995L; + return (int) (key ^ (key >>> 32)); + } + + public interface Visitor { + void visit(long key, int value); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java new file mode 100644 index 00000000000..127ce9a6262 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/Element.java @@ -0,0 +1,15 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +abstract class Element { + + void addChild(Element e) {} + + static final class NoOpElement extends Element { + // Empty implementation for unhandled element types + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java new file mode 100644 index 00000000000..d5971b4802c --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrClass.java @@ -0,0 +1,42 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class JfrClass extends Element { + final int id; + final boolean simpleType; + final @Nullable String name; + final List fields; + + JfrClass(@NotNull Map attributes) { + this.id = Integer.parseInt(attributes.get("id")); + this.simpleType = "true".equals(attributes.get("simpleType")); + this.name = attributes.get("name"); + this.fields = new ArrayList<>(2); + } + + @Override + void addChild(Element e) { + if (e instanceof JfrField) { + fields.add((JfrField) e); + } + } + + public @Nullable JfrField field(@NotNull String name) { + for (JfrField field : fields) { + if (field.name != null && field.name.equals(name)) { + return field; + } + } + return null; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java new file mode 100644 index 00000000000..c71787f8379 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrField.java @@ -0,0 +1,22 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class JfrField extends Element { + final @Nullable String name; + final int type; + final boolean constantPool; + + JfrField(@NotNull Map attributes) { + this.name = attributes.get("name"); + this.type = Integer.parseInt(attributes.get("class")); + this.constantPool = "true".equals(attributes.get("constantPool")); + } +} 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 new file mode 100644 index 00000000000..abc9a0024b4 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/JfrReader.java @@ -0,0 +1,710 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.AllocationSample; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.CPULoad; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ContendedLock; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.Event; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ExecutionSample; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.GCHeapSummary; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.LiveObject; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.MallocEvent; +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event.ObjectCount; +import java.io.Closeable; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Parses JFR output produced by async-profiler. */ +public final class JfrReader implements Closeable { + private static final int BUFFER_SIZE = 2 * 1024 * 1024; + private static final int CHUNK_HEADER_SIZE = 68; + private static final int CHUNK_SIGNATURE = 0x464c5200; + + private static final byte STATE_NEW_CHUNK = 0; + private static final byte STATE_READING = 1; + private static final byte STATE_EOF = 2; + private static final byte STATE_INCOMPLETE = 3; + + private final @Nullable FileChannel ch; + private @NotNull ByteBuffer buf; + private final long fileSize; + private long filePosition; + private byte state; + + public long startNanos = Long.MAX_VALUE; + public long endNanos = Long.MIN_VALUE; + public long startTicks = Long.MAX_VALUE; + public long chunkStartNanos; + public long chunkEndNanos; + public long chunkStartTicks; + public long ticksPerSec; + public boolean stopAtNewChunk; + + public final Dictionary types = new Dictionary<>(); + public final Map typesByName = new HashMap<>(); + public final Dictionary threads = new Dictionary<>(); + public final Dictionary javaThreads = new Dictionary<>(); + public final Dictionary classes = new Dictionary<>(); + public final Dictionary strings = new Dictionary<>(); + public final Dictionary symbols = new Dictionary<>(); + public final Dictionary methods = new Dictionary<>(); + public final Dictionary stackTraces = new Dictionary<>(); + public final Map settings = new HashMap<>(); + public final Map> enums = new HashMap<>(); + + private final Dictionary> customEvents = new Dictionary<>(); + + private int executionSample; + private int nativeMethodSample; + private int wallClockSample; + private int allocationInNewTLAB; + private int allocationOutsideTLAB; + private int allocationSample; + private int liveObject; + private int monitorEnter; + private int threadPark; + private int activeSetting; + private int malloc; + private int free; + + public JfrReader(String fileName) throws IOException { + this.ch = FileChannel.open(Paths.get(fileName), StandardOpenOption.READ); + this.buf = ByteBuffer.allocateDirect(BUFFER_SIZE); + this.fileSize = ch.size(); + + buf.flip(); + ensureBytes(CHUNK_HEADER_SIZE); + if (!readChunk(0)) { + throw new IOException("Incomplete JFR file"); + } + } + + public JfrReader(@NotNull ByteBuffer buf) throws IOException { + this.ch = null; + this.buf = buf; + this.fileSize = buf.limit(); + + buf.order(ByteOrder.BIG_ENDIAN); + if (!readChunk(0)) { + throw new IOException("Incomplete JFR file"); + } + } + + @Override + public void close() throws IOException { + if (ch != null) { + ch.close(); + } + } + + public boolean eof() { + return state >= STATE_EOF; + } + + public boolean incomplete() { + return state == STATE_INCOMPLETE; + } + + public long durationNanos() { + return endNanos - startNanos; + } + + public void registerEvent(String name, Class eventClass) { + JfrClass type = typesByName.get(name); + if (type != null) { + try { + customEvents.put(type.id, eventClass.getConstructor(JfrReader.class)); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("No suitable constructor found"); + } + } + } + + // Similar to eof(), but parses the next chunk header + public boolean hasMoreChunks() throws IOException { + return state == STATE_NEW_CHUNK ? readChunk(buf.position()) : state == STATE_READING; + } + + public List readAllEvents() throws IOException { + return readAllEvents(null); + } + + public List readAllEvents(@Nullable Class cls) throws IOException { + ArrayList events = new ArrayList<>(); + for (E event; (event = readEvent(cls)) != null; ) { + events.add(event); + } + Collections.sort(events); + return events; + } + + public @Nullable Event readEvent() throws IOException { + return readEvent(null); + } + + @SuppressWarnings("unchecked") + public @Nullable E readEvent(@Nullable Class cls) throws IOException { + while (ensureBytes(CHUNK_HEADER_SIZE)) { + int pos = buf.position(); + int size = getVarint(); + int type = getVarint(); + + if (type == 'L' && buf.getInt(pos) == CHUNK_SIGNATURE) { + if (state != STATE_NEW_CHUNK && stopAtNewChunk) { + buf.position(pos); + state = STATE_NEW_CHUNK; + } else if (readChunk(pos)) { + continue; + } + return null; + } + + if (type == executionSample || type == nativeMethodSample) { + if (cls == null || cls == ExecutionSample.class) return (E) readExecutionSample(false); + } else if (type == wallClockSample) { + if (cls == null || cls == ExecutionSample.class) return (E) readExecutionSample(true); + } else if (type == allocationInNewTLAB) { + if (cls == null || cls == AllocationSample.class) return (E) readAllocationSample(true); + } else if (type == allocationOutsideTLAB || type == allocationSample) { + if (cls == null || cls == AllocationSample.class) return (E) readAllocationSample(false); + } else if (type == malloc) { + if (cls == null || cls == MallocEvent.class) return (E) readMallocEvent(true); + } else if (type == free) { + if (cls == null || cls == MallocEvent.class) return (E) readMallocEvent(false); + } else if (type == liveObject) { + if (cls == null || cls == LiveObject.class) return (E) readLiveObject(); + } else if (type == monitorEnter) { + if (cls == null || cls == ContendedLock.class) return (E) readContendedLock(false); + } else if (type == threadPark) { + if (cls == null || cls == ContendedLock.class) return (E) readContendedLock(true); + } else if (type == activeSetting) { + readActiveSetting(); + } else { + Constructor customEvent = customEvents.get(type); + if (customEvent != null && (cls == null || cls == customEvent.getDeclaringClass())) { + try { + return (E) customEvent.newInstance(this); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } finally { + seek(filePosition + pos + size); + } + } + } + + seek(filePosition + pos + size); + } + + state = STATE_EOF; + return null; + } + + private ExecutionSample readExecutionSample(boolean hasSamples) { + long time = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + int threadState = getVarint(); + int samples = hasSamples ? getVarint() : 1; + return new ExecutionSample(time, tid, stackTraceId, threadState, samples); + } + + private AllocationSample readAllocationSample(boolean tlab) { + long time = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + int classId = getVarint(); + long allocationSize = getVarlong(); + long tlabSize = tlab ? getVarlong() : 0; + return new AllocationSample(time, tid, stackTraceId, classId, allocationSize, tlabSize); + } + + private MallocEvent readMallocEvent(boolean hasSize) { + long time = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + long address = getVarlong(); + long size = hasSize ? getVarlong() : 0; + return new MallocEvent(time, tid, stackTraceId, address, size); + } + + private LiveObject readLiveObject() { + long time = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + int classId = getVarint(); + long allocationSize = getVarlong(); + long allocatimeTime = getVarlong(); + return new LiveObject(time, tid, stackTraceId, classId, allocationSize, allocatimeTime); + } + + private ContendedLock readContendedLock(boolean hasTimeout) { + long time = getVarlong(); + long duration = getVarlong(); + int tid = getVarint(); + int stackTraceId = getVarint(); + int classId = getVarint(); + if (hasTimeout) getVarlong(); + getVarlong(); + getVarlong(); + return new ContendedLock(time, tid, stackTraceId, duration, classId); + } + + private void readActiveSetting() { + JfrClass activeSetting = typesByName.get("jdk.ActiveSetting"); + if (activeSetting == null) return; + for (JfrField field : activeSetting.fields) { + getVarlong(); + if ("id".equals(field.name)) { + break; + } + } + String name = getString(); + String value = getString(); + settings.put(name, value); + } + + private boolean readChunk(int pos) throws IOException { + if (pos + CHUNK_HEADER_SIZE > buf.limit() || buf.getInt(pos) != CHUNK_SIGNATURE) { + throw new IOException("Not a valid JFR file"); + } + + int version = buf.getInt(pos + 4); + if (version < 0x20000 || version > 0x2ffff) { + throw new IOException( + "Unsupported JFR version: " + (version >>> 16) + "." + (version & 0xffff)); + } + + long chunkStart = filePosition + pos; + long chunkSize = buf.getLong(pos + 8); + if (chunkStart + chunkSize > fileSize) { + state = STATE_INCOMPLETE; + return false; + } + + long cpOffset = buf.getLong(pos + 16); + long metaOffset = buf.getLong(pos + 24); + if (cpOffset == 0 || metaOffset == 0) { + state = STATE_INCOMPLETE; + return false; + } + + chunkStartNanos = buf.getLong(pos + 32); + chunkEndNanos = buf.getLong(pos + 32) + buf.getLong(pos + 40); + chunkStartTicks = buf.getLong(pos + 48); + ticksPerSec = buf.getLong(pos + 56); + + startNanos = Math.min(startNanos, chunkStartNanos); + endNanos = Math.max(endNanos, chunkEndNanos); + startTicks = Math.min(startTicks, chunkStartTicks); + + types.clear(); + typesByName.clear(); + + readMeta(chunkStart + metaOffset); + readConstantPool(chunkStart + cpOffset); + cacheEventTypes(); + + seek(chunkStart + CHUNK_HEADER_SIZE); + state = STATE_READING; + return true; + } + + private void readMeta(long metaOffset) throws IOException { + seek(metaOffset); + ensureBytes(5); + + int posBeforeSize = buf.position(); + ensureBytes(getVarint() - (buf.position() - posBeforeSize)); + getVarint(); + getVarlong(); + getVarlong(); + getVarlong(); + + String[] strings = new String[getVarint()]; + for (int i = 0; i < strings.length; i++) { + strings[i] = getString(); + } + readElement(strings); + } + + private Element readElement(String[] strings) { + String name = strings[getVarint()]; + + int attributeCount = getVarint(); + Map attributes = new HashMap<>(attributeCount); + for (int i = 0; i < attributeCount; i++) { + attributes.put(strings[getVarint()], strings[getVarint()]); + } + + Element e = createElement(name, attributes); + int childCount = getVarint(); + for (int i = 0; i < childCount; i++) { + e.addChild(readElement(strings)); + } + return e; + } + + private Element createElement(String name, Map attributes) { + switch (name) { + case "class": + { + JfrClass type = new JfrClass(attributes); + if (!attributes.containsKey("superType")) { + types.put(type.id, type); + } + typesByName.put(type.name, type); + return type; + } + case "field": + return new JfrField(attributes); + default: + return new Element.NoOpElement(); + } + } + + private void readConstantPool(long cpOffset) throws IOException { + long delta; + do { + seek(cpOffset); + ensureBytes(5); + + int posBeforeSize = buf.position(); + ensureBytes(getVarint() - (buf.position() - posBeforeSize)); + getVarint(); + getVarlong(); + getVarlong(); + delta = getVarlong(); + getVarint(); + + int poolCount = getVarint(); + for (int i = 0; i < poolCount; i++) { + int type = getVarint(); + readConstants(types.get(type)); + } + } while (delta != 0 && (cpOffset += delta) > 0); + } + + private void readConstants(JfrClass type) { + String typeName = type.name; + if (typeName == null) { + readOtherConstants(type.fields); + return; + } + switch (typeName) { + case "jdk.types.ChunkHeader": + buf.position(buf.position() + (CHUNK_HEADER_SIZE + 3)); + break; + case "java.lang.Thread": + readThreads(type.fields.size()); + break; + case "java.lang.Class": + readClasses(type.fields.size()); + break; + case "java.lang.String": + readStrings(); + break; + case "jdk.types.Symbol": + readSymbols(); + break; + case "jdk.types.Method": + readMethods(); + break; + case "jdk.types.StackTrace": + readStackTraces(); + break; + default: + if (type.simpleType && type.fields.size() == 1) { + readEnumValues(typeName); + } else { + readOtherConstants(type.fields); + } + } + } + + private void readThreads(int fieldCount) { + int count = threads.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + String osName = getString(); + getVarint(); // osThreadId + String javaName = getString(); + long javaThreadId = getVarlong(); + readFields(fieldCount - 4); + javaThreads.put(id, javaThreadId); + String threadName = javaName != null ? javaName : (osName != null ? osName : "Thread-" + id); + threads.put(id, threadName); + } + } + + private void readClasses(int fieldCount) { + int count = classes.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + getVarlong(); + long name = getVarlong(); + getVarlong(); + getVarint(); + readFields(fieldCount - 4); + classes.put(id, new ClassRef(name)); + } + } + + private void readMethods() { + int count = methods.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + long cls = getVarlong(); + long name = getVarlong(); + long sig = getVarlong(); + getVarint(); + getVarint(); + methods.put(id, new MethodRef(cls, name, sig)); + } + } + + private void readStackTraces() { + int count = stackTraces.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + getVarint(); // int truncated + StackTrace stackTrace = readStackTrace(); + stackTraces.put(id, stackTrace); + } + } + + private StackTrace readStackTrace() { + int depth = getVarint(); + long[] methods = new long[depth]; + byte[] types = new byte[depth]; + int[] locations = new int[depth]; + for (int i = 0; i < depth; i++) { + methods[i] = getVarlong(); + int line = getVarint(); + int bci = getVarint(); + locations[i] = line << 16 | (bci & 0xffff); + types[i] = buf.get(); + } + return new StackTrace(methods, types, locations); + } + + private void readStrings() { + int count = strings.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + String str = getString(); + if (str == null) str = ""; + strings.put(getVarlong(), str); + } + } + + private void readSymbols() { + int count = symbols.preallocate(getVarint()); + for (int i = 0; i < count; i++) { + long id = getVarlong(); + if (buf.get() != 3) { + throw new IllegalArgumentException("Invalid symbol encoding"); + } + symbols.put(id, getBytes()); + } + } + + private void readEnumValues(@NotNull String typeName) { + HashMap map = new HashMap<>(); + int count = getVarint(); + for (int i = 0; i < count; i++) { + map.put((int) getVarlong(), getString()); + } + enums.put(typeName, map); + } + + private void readOtherConstants(List fields) { + int stringType = getTypeId("java.lang.String"); + + boolean[] numeric = new boolean[fields.size()]; + for (int i = 0; i < numeric.length; i++) { + JfrField f = fields.get(i); + numeric[i] = f.constantPool || f.type != stringType; + } + + int count = getVarint(); + for (int i = 0; i < count; i++) { + getVarlong(); + readFields(numeric); + } + } + + private void readFields(boolean[] numeric) { + for (boolean n : numeric) { + if (n) { + getVarlong(); + } else { + getString(); + } + } + } + + private void readFields(int count) { + while (count-- > 0) { + getVarlong(); + } + } + + private void cacheEventTypes() { + executionSample = getTypeId("jdk.ExecutionSample"); + nativeMethodSample = getTypeId("jdk.NativeMethodSample"); + wallClockSample = getTypeId("profiler.WallClockSample"); + allocationInNewTLAB = getTypeId("jdk.ObjectAllocationInNewTLAB"); + allocationOutsideTLAB = getTypeId("jdk.ObjectAllocationOutsideTLAB"); + allocationSample = getTypeId("jdk.ObjectAllocationSample"); + liveObject = getTypeId("profiler.LiveObject"); + monitorEnter = getTypeId("jdk.JavaMonitorEnter"); + threadPark = getTypeId("jdk.ThreadPark"); + activeSetting = getTypeId("jdk.ActiveSetting"); + malloc = getTypeId("profiler.Malloc"); + free = getTypeId("profiler.Free"); + + registerEvent("jdk.CPULoad", CPULoad.class); + registerEvent("jdk.GCHeapSummary", GCHeapSummary.class); + registerEvent("jdk.ObjectCount", ObjectCount.class); + registerEvent("jdk.ObjectCountAfterGC", ObjectCount.class); + } + + private int getTypeId(String typeName) { + JfrClass type = typesByName.get(typeName); + return type != null ? type.id : -1; + } + + public int getEnumKey(String typeName, String value) { + Map enumValues = enums.get(typeName); + if (enumValues != null) { + for (Map.Entry entry : enumValues.entrySet()) { + if (value.equals(entry.getValue())) { + return entry.getKey(); + } + } + } + return -1; + } + + public @Nullable String getEnumValue(String typeName, int key) { + Map enumMap = enums.get(typeName); + return enumMap != null ? enumMap.get(key) : null; + } + + public int getVarint() { + int result = 0; + for (int shift = 0; ; shift += 7) { + byte b = buf.get(); + result |= (b & 0x7f) << shift; + if (b >= 0) { + return result; + } + } + } + + public long getVarlong() { + long result = 0; + for (int shift = 0; shift < 56; shift += 7) { + byte b = buf.get(); + result |= (b & 0x7fL) << shift; + if (b >= 0) { + return result; + } + } + return result | (buf.get() & 0xffL) << 56; + } + + public float getFloat() { + return buf.getFloat(); + } + + public double getDouble() { + return buf.getDouble(); + } + + public @Nullable String getString() { + switch (buf.get()) { + case 0: + return null; + case 1: + return ""; + case 2: + return strings.get(getVarlong()); + case 3: + return new String(getBytes(), StandardCharsets.UTF_8); + case 4: + { + char[] chars = new char[getVarint()]; + for (int i = 0; i < chars.length; i++) { + chars[i] = (char) getVarint(); + } + return new String(chars); + } + case 5: + return new String(getBytes(), StandardCharsets.ISO_8859_1); + default: + throw new IllegalArgumentException("Invalid string encoding"); + } + } + + public byte[] getBytes() { + byte[] bytes = new byte[getVarint()]; + buf.get(bytes); + return bytes; + } + + private void seek(long pos) throws IOException { + long bufPosition = pos - filePosition; + if (bufPosition >= 0 && bufPosition <= buf.limit()) { + buf.position((int) bufPosition); + } else { + filePosition = pos; + if (ch != null) { + ch.position(pos); + } + buf.rewind().flip(); + } + } + + private boolean ensureBytes(int needed) throws IOException { + if (buf.remaining() >= needed) { + return true; + } + + if (ch == null) { + return false; + } + + filePosition += buf.position(); + + if (buf.capacity() < needed) { + ByteBuffer newBuf = ByteBuffer.allocateDirect(needed); + newBuf.put(buf); + buf = newBuf; + } else { + buf.compact(); + } + + while (ch.read(buf) > 0 && buf.position() < needed) { + // keep reading + } + buf.flip(); + return buf.limit() > 0; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java new file mode 100644 index 00000000000..bbba06b8c0e --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/MethodRef.java @@ -0,0 +1,18 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +public final class MethodRef { + public final long cls; + public final long name; + public final long sig; + + public MethodRef(long cls, long name, long sig) { + this.cls = cls; + this.name = name; + this.sig = sig; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java new file mode 100644 index 00000000000..f0d7b9d0905 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/StackTrace.java @@ -0,0 +1,18 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr; + +public final class StackTrace { + public final long[] methods; + public final byte[] types; + public final int[] locations; + + public StackTrace(long[] methods, byte[] types, int[] locations) { + this.methods = methods; + this.types = types; + this.locations = locations; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java new file mode 100644 index 00000000000..337cbeef563 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/AllocationSample.java @@ -0,0 +1,44 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +public final class AllocationSample extends Event { + public final int classId; + public final long allocationSize; + public final long tlabSize; + + public AllocationSample( + long time, int tid, int stackTraceId, int classId, long allocationSize, long tlabSize) { + super(time, tid, stackTraceId); + this.classId = classId; + this.allocationSize = allocationSize; + this.tlabSize = tlabSize; + } + + @Override + public int hashCode() { + return classId * 127 + stackTraceId + (tlabSize == 0 ? 17 : 0); + } + + @Override + public boolean sameGroup(Event o) { + if (o instanceof AllocationSample) { + AllocationSample a = (AllocationSample) o; + return classId == a.classId && (tlabSize == 0) == (a.tlabSize == 0); + } + return false; + } + + @Override + public long classId() { + return classId; + } + + @Override + public long value() { + return tlabSize != 0 ? tlabSize : allocationSize; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java new file mode 100644 index 00000000000..f8632a21bd3 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/CPULoad.java @@ -0,0 +1,21 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; + +public final class CPULoad extends Event { + public final float jvmUser; + public final float jvmSystem; + public final float machineTotal; + + public CPULoad(JfrReader jfr) { + super(jfr.getVarlong(), 0, 0); + this.jvmUser = jfr.getFloat(); + this.jvmSystem = jfr.getFloat(); + this.machineTotal = jfr.getFloat(); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java new file mode 100644 index 00000000000..c0cc52924a4 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ContendedLock.java @@ -0,0 +1,41 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +public final class ContendedLock extends Event { + public final long duration; + public final int classId; + + public ContendedLock(long time, int tid, int stackTraceId, long duration, int classId) { + super(time, tid, stackTraceId); + this.duration = duration; + this.classId = classId; + } + + @Override + public int hashCode() { + return classId * 127 + stackTraceId; + } + + @Override + public boolean sameGroup(Event o) { + if (o instanceof ContendedLock) { + ContendedLock c = (ContendedLock) o; + return classId == c.classId; + } + return false; + } + + @Override + public long classId() { + return classId; + } + + @Override + public long value() { + return duration; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java new file mode 100644 index 00000000000..323ffb327b6 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/Event.java @@ -0,0 +1,66 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import java.lang.reflect.Field; + +public abstract class Event implements Comparable { + public final long time; + public final int tid; + public final int stackTraceId; + + protected Event(long time, int tid, int stackTraceId) { + this.time = time; + this.tid = tid; + this.stackTraceId = stackTraceId; + } + + @Override + public int compareTo(Event o) { + return Long.compare(time, o.time); + } + + @Override + public int hashCode() { + return stackTraceId; + } + + @Override + public String toString() { + StringBuilder sb = + new StringBuilder(getClass().getSimpleName()) + .append("{time=") + .append(time) + .append(",tid=") + .append(tid) + .append(",stackTraceId=") + .append(stackTraceId); + for (Field f : getClass().getDeclaredFields()) { + try { + sb.append(',').append(f.getName()).append('=').append(f.get(this)); + } catch (ReflectiveOperationException e) { + break; + } + } + return sb.append('}').toString(); + } + + public boolean sameGroup(Event o) { + return getClass() == o.getClass(); + } + + public long classId() { + return 0; + } + + public long samples() { + return 1; + } + + public long value() { + return 1; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java new file mode 100644 index 00000000000..23c9f7aa29c --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventAggregator.java @@ -0,0 +1,155 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import org.jetbrains.annotations.NotNull; + +public final class EventAggregator implements EventCollector { + private static final int INITIAL_CAPACITY = 1024; + + private final boolean threads; + private final double grain; + private @NotNull Event[] keys; + private @NotNull long[] samples; + private @NotNull long[] values; + private int size; + private double fraction; + + public EventAggregator(boolean threads, double grain) { + this.threads = threads; + this.grain = grain; + this.keys = new Event[INITIAL_CAPACITY]; + this.samples = new long[INITIAL_CAPACITY]; + this.values = new long[INITIAL_CAPACITY]; + + beforeChunk(); + } + + public int size() { + return size; + } + + @Override + public void collect(Event e) { + collect(e, e.samples(), e.value()); + } + + public void collect(Event e, long samples, long value) { + int mask = keys.length - 1; + int i = hashCode(e) & mask; + while (keys[i] != null) { + if (sameGroup(keys[i], e)) { + this.samples[i] += samples; + this.values[i] += value; + return; + } + i = (i + 1) & mask; + } + + this.keys[i] = e; + this.samples[i] = samples; + this.values[i] = value; + + if (++size * 2 > keys.length) { + resize(keys.length * 2); + } + } + + @Override + public void beforeChunk() { + if (keys == null || size > 0) { + keys = new Event[INITIAL_CAPACITY]; + samples = new long[INITIAL_CAPACITY]; + values = new long[INITIAL_CAPACITY]; + size = 0; + } + } + + @Override + public void afterChunk() { + if (grain > 0) { + coarsen(grain); + } + } + + @Override + public boolean finish() { + // Don't set to null as it would break nullability contract + keys = new Event[0]; + samples = new long[0]; + values = new long[0]; + return false; + } + + @Override + public void forEach(Visitor visitor) { + if (size > 0) { + for (int i = 0; i < keys.length; i++) { + if (keys[i] != null) { + visitor.visit(keys[i], samples[i], values[i]); + } + } + } + } + + public void coarsen(double grain) { + fraction = 0; + + for (int i = 0; i < keys.length; i++) { + if (keys[i] != null) { + long s0 = samples[i]; + long s1 = round(s0 / grain); + if (s1 == 0) { + keys[i] = null; + size--; + } + samples[i] = s1; + values[i] = (long) (values[i] * ((double) s1 / s0)); + } + } + } + + private long round(double d) { + long r = (long) d; + if ((fraction += d - r) >= 1.0) { + fraction -= 1.0; + r++; + } + return r; + } + + private int hashCode(Event e) { + return e.hashCode() + (threads ? e.tid * 31 : 0); + } + + private boolean sameGroup(Event e1, Event e2) { + return e1.stackTraceId == e2.stackTraceId && (!threads || e1.tid == e2.tid) && e1.sameGroup(e2); + } + + private void resize(int newCapacity) { + Event[] newKeys = new Event[newCapacity]; + long[] newSamples = new long[newCapacity]; + long[] newValues = new long[newCapacity]; + int mask = newKeys.length - 1; + + for (int i = 0; i < keys.length; i++) { + if (keys[i] != null) { + for (int j = hashCode(keys[i]) & mask; ; j = (j + 1) & mask) { + if (newKeys[j] == null) { + newKeys[j] = keys[i]; + newSamples[j] = samples[i]; + newValues[j] = values[i]; + break; + } + } + } + } + + keys = newKeys; + samples = newSamples; + values = newValues; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java new file mode 100644 index 00000000000..ac12de630f6 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/EventCollector.java @@ -0,0 +1,24 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +public interface EventCollector { + + void collect(Event e); + + void beforeChunk(); + + void afterChunk(); + + // Returns true if this collector has remaining data to process + boolean finish(); + + void forEach(Visitor visitor); + + interface Visitor { + void visit(Event event, long samples, long value); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java new file mode 100644 index 00000000000..9bbbea38c72 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ExecutionSample.java @@ -0,0 +1,27 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +public final class ExecutionSample extends Event { + public final int threadState; + public final int samples; + + public ExecutionSample(long time, int tid, int stackTraceId, int threadState, int samples) { + super(time, tid, stackTraceId); + this.threadState = threadState; + this.samples = samples; + } + + @Override + public long samples() { + return samples; + } + + @Override + public long value() { + return samples; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java new file mode 100644 index 00000000000..740fbc82245 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/GCHeapSummary.java @@ -0,0 +1,28 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; + +public final class GCHeapSummary extends Event { + public final int gcId; + public final boolean afterGC; + public final long committed; + public final long reserved; + public final long used; + + public GCHeapSummary(JfrReader jfr) { + super(jfr.getVarlong(), 0, 0); + this.gcId = jfr.getVarint(); + this.afterGC = jfr.getVarint() > 0; + jfr.getVarlong(); // long start + jfr.getVarlong(); // long committedEnd + this.committed = jfr.getVarlong(); + jfr.getVarlong(); // long reservedEnd + this.reserved = jfr.getVarlong(); + this.used = jfr.getVarlong(); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java new file mode 100644 index 00000000000..9fcf776ee6b --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/LiveObject.java @@ -0,0 +1,44 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +public final class LiveObject extends Event { + public final int classId; + public final long allocationSize; + public final long allocationTime; + + public LiveObject( + long time, int tid, int stackTraceId, int classId, long allocationSize, long allocationTime) { + super(time, tid, stackTraceId); + this.classId = classId; + this.allocationSize = allocationSize; + this.allocationTime = allocationTime; + } + + @Override + public int hashCode() { + return classId * 127 + stackTraceId; + } + + @Override + public boolean sameGroup(Event o) { + if (o instanceof LiveObject) { + LiveObject a = (LiveObject) o; + return classId == a.classId; + } + return false; + } + + @Override + public long classId() { + return classId; + } + + @Override + public long value() { + return allocationSize; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java new file mode 100644 index 00000000000..eac63a518d0 --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocEvent.java @@ -0,0 +1,22 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +public final class MallocEvent extends Event { + public final long address; + public final long size; + + public MallocEvent(long time, int tid, int stackTraceId, long address, long size) { + super(time, tid, stackTraceId); + this.address = address; + this.size = size; + } + + @Override + public long value() { + return size; + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java new file mode 100644 index 00000000000..cde4919bd3e --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/MallocLeakAggregator.java @@ -0,0 +1,67 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; + +public final class MallocLeakAggregator implements EventCollector { + private final EventCollector wrapped; + private final Map addresses; + private @NotNull List events; + + public MallocLeakAggregator(@NotNull EventCollector wrapped) { + this.wrapped = wrapped; + this.addresses = new HashMap<>(); + this.events = new ArrayList<>(); + } + + @Override + public void collect(Event e) { + events.add((MallocEvent) e); + } + + @Override + public void beforeChunk() { + events = new ArrayList<>(); + } + + @Override + public void afterChunk() { + events.sort(null); + + for (MallocEvent e : events) { + if (e.size > 0) { + addresses.put(e.address, e); + } else { + addresses.remove(e.address); + } + } + + events = new ArrayList<>(); + } + + @Override + public boolean finish() { + wrapped.beforeChunk(); + for (Event e : addresses.values()) { + wrapped.collect(e); + } + wrapped.afterChunk(); + + // Free memory before the final conversion + addresses.clear(); + return true; + } + + @Override + public void forEach(Visitor visitor) { + wrapped.forEach(visitor); + } +} diff --git a/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java new file mode 100644 index 00000000000..dbec70770df --- /dev/null +++ b/sentry-async-profiler/src/main/java/io/sentry/asyncprofiler/vendor/asyncprofiler/jfr/event/ObjectCount.java @@ -0,0 +1,23 @@ +/* + * Copyright The async-profiler authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.event; + +import io.sentry.asyncprofiler.vendor.asyncprofiler.jfr.JfrReader; + +public final class ObjectCount extends Event { + public final int gcId; + public final int classId; + public final long count; + public final long totalSize; + + public ObjectCount(JfrReader jfr) { + super(jfr.getVarlong(), 0, 0); + this.gcId = jfr.getVarint(); + this.classId = jfr.getVarint(); + this.count = jfr.getVarlong(); + this.totalSize = jfr.getVarlong(); + } +} diff --git a/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider b/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider new file mode 100644 index 00000000000..a59cb70f73c --- /dev/null +++ b/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider @@ -0,0 +1 @@ +io.sentry.asyncprofiler.provider.AsyncProfilerContinuousProfilerProvider diff --git a/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider b/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider new file mode 100644 index 00000000000..5f39755545d --- /dev/null +++ b/sentry-async-profiler/src/main/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider @@ -0,0 +1 @@ +io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilerTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilerTest.kt new file mode 100644 index 00000000000..3c895fd7b84 --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilerTest.kt @@ -0,0 +1,433 @@ +package io.sentry.asyncprofiler + +import io.sentry.DataCategory +import io.sentry.IConnectionStatusProvider +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.SentryLevel +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 +import java.io.File +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +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 +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class JavaContinuousProfilerTest { + + private val fixture = Fixture() + + private class Fixture { + private val mockDsn = "http://key@localhost/proj" + val executor = DeferredExecutorService() + val mockedSentry = mockStatic(Sentry::class.java) + val mockLogger = mock() + val mockTracesSampler = mock() + + val scopes: IScopes = mock() + + lateinit var transaction1: SentryTracer + lateinit var transaction2: SentryTracer + lateinit var transaction3: SentryTracer + + 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) = {}): JavaContinuousProfiler { + options.executorService = executor + optionConfig(options) + whenever(scopes.options).thenReturn(options) + transaction1 = SentryTracer(TransactionContext("", ""), scopes) + transaction2 = SentryTracer(TransactionContext("", ""), scopes) + transaction3 = SentryTracer(TransactionContext("", ""), scopes) + return JavaContinuousProfiler( + options.logger, + options.profilingTracesDirPath, + options.profilingTracesHz, + options.executorService, + ) + } + } + + @BeforeTest + fun `set up`() { + // Profiler doesn't start if the folder doesn't exists. + // Usually it's generated when calling Sentry.init, but for tests we can create it manually. + + fixture.options.cacheDirPath = "." + File(fixture.options.profilingTracesDirPath!!).mkdirs() + + Sentry.setCurrentScopes(fixture.scopes) + + fixture.mockedSentry.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + fixture.mockedSentry.`when` { Sentry.close() }.then { fixture.executor.runAll() } + } + + @AfterTest + fun clear() { + fixture.options.profilingTracesDirPath?.let { File(it).deleteRecursively() } + fixture.options.cacheDirPath?.let { File(it).deleteRecursively() } + + Sentry.stopProfiler() + Sentry.close() + fixture.mockedSentry.close() + } + + @Test + fun `isRunning reflects profiler status`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + fixture.executor.runAll() + assertFalse(profiler.isRunning) + } + + @Test + fun `stopProfiler stops the profiler after chunk is finished`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + 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) + } + + @Test + fun `profiler multiple starts are ignored in manual mode`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + verify(fixture.mockLogger, never()) + .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) + assertTrue(profiler.isRunning) + assertEquals(0, profiler.rootSpanCounter) + } + + @Test + fun `profiler multiple starts are accepted in trace mode`() { + val profiler = fixture.getSut() + + // rootSpanCounter is incremented when the profiler starts in trace mode + assertEquals(0, profiler.rootSpanCounter) + profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) + assertEquals(1, profiler.rootSpanCounter) + assertTrue(profiler.isRunning) + profiler.startProfiler(ProfileLifecycle.TRACE, fixture.mockTracesSampler) + verify(fixture.mockLogger, never()) + .log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) + assertTrue(profiler.isRunning) + assertEquals(2, profiler.rootSpanCounter) + + // rootSpanCounter is decremented when the profiler stops in trace mode, and keeps running until + // rootSpanCounter is 0 + profiler.stopProfiler(ProfileLifecycle.TRACE) + fixture.executor.runAll() + assertEquals(1, profiler.rootSpanCounter) + assertTrue(profiler.isRunning) + + // only when rootSpanCounter is 0 the profiler stops + profiler.stopProfiler(ProfileLifecycle.TRACE) + fixture.executor.runAll() + assertEquals(0, profiler.rootSpanCounter) + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler logs a warning on start if not sampled`() { + val profiler = fixture.getSut() + whenever(fixture.mockTracesSampler.sampleSessionProfile(any())).thenReturn(false) + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + verify(fixture.mockLogger) + .log(eq(SentryLevel.DEBUG), eq("Profiler was not started due to sampling decision.")) + } + + @Test + fun `profiler evaluates sessionSampleRate only the first time`() { + val profiler = fixture.getSut() + verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) + // The first time the profiler is started, the sessionSampleRate is evaluated + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) + // Then, the sessionSampleRate is not evaluated again + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) + } + + @Test + fun `when reevaluateSampling, profiler evaluates sessionSampleRate on next start`() { + val profiler = fixture.getSut() + verify(fixture.mockTracesSampler, never()).sampleSessionProfile(any()) + // The first time the profiler is started, the sessionSampleRate is evaluated + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) + // When reevaluateSampling is called, the sessionSampleRate is not evaluated immediately + profiler.reevaluateSampling() + verify(fixture.mockTracesSampler, times(1)).sampleSessionProfile(any()) + // Then, when the profiler starts again, the sessionSampleRate is reevaluated + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(fixture.mockTracesSampler, times(2)).sampleSessionProfile(any()) + } + + @Test + fun `profiler ignores profilesSampleRate`() { + val profiler = fixture.getSut { it.profilesSampleRate = 0.0 } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + profiler.close(true) + } + + @Test + fun `profiler evaluates profilingTracesDirPath options only on first start`() { + // We create the profiler, and nothing goes wrong + val profiler = fixture.getSut { it.cacheDirPath = null } + verify(fixture.mockLogger, never()) + .log( + SentryLevel.WARNING, + "Disabling profiling because no profiling traces dir path is defined in options.", + ) + + // Regardless of how many times the profiler is started, the option is evaluated and logged only + // once + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(fixture.mockLogger, times(1)) + .log( + SentryLevel.WARNING, + "Disabling profiling because no profiling traces dir path is defined in options.", + ) + } + + @Test + fun `profiler evaluates profilingTracesHz options only on first start`() { + // We create the profiler, and nothing goes wrong + val profiler = fixture.getSut { it.profilingTracesHz = 0 } + verify(fixture.mockLogger, never()) + .log(SentryLevel.WARNING, "Disabling profiling because trace rate is set to %d", 0) + + // Regardless of how many times the profiler is started, the option is evaluated and logged only + // once + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + verify(fixture.mockLogger, times(1)) + .log(SentryLevel.WARNING, "Disabling profiling because trace rate is set to %d", 0) + } + + @Test + fun `profiler on tracesDirPath null`() { + val profiler = fixture.getSut { it.cacheDirPath = null } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler on tracesDirPath empty`() { + val profiler = fixture.getSut { it.cacheDirPath = "" } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler on profilingTracesHz 0`() { + val profiler = fixture.getSut { it.profilingTracesHz = 0 } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler does not throw if traces cannot be written to disk`() { + val profiler = fixture.getSut { File(it.profilingTracesDirPath!!).setWritable(false) } + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + 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()) + } + + // @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() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + fixture.executor.runAll() + verify(fixture.mockLogger) + .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(profiler.isRunning) + + fixture.executor.runAll() + verify(fixture.mockLogger, times(2)) + .log(eq(SentryLevel.DEBUG), eq("Profile chunk finished. Starting a new one.")) + assertTrue(profiler.isRunning) + } + + @Test + fun `profiler sends chunk on each restart`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + // We run the executor service to trigger the profiler restart (chunk finish) + fixture.executor.runAll() + verify(fixture.scopes, never()).captureProfileChunk(any()) + // Now the executor is used to send the chunk + fixture.executor.runAll() + verify(fixture.scopes).captureProfileChunk(any()) + } + + @Test + fun `profiler sends another chunk on stop`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + // We run the executor service to trigger the profiler restart (chunk finish) + fixture.executor.runAll() + verify(fixture.scopes, never()).captureProfileChunk(any()) + profiler.stopProfiler(ProfileLifecycle.MANUAL) + // We stop the profiler, which should send a chunk + fixture.executor.runAll() + verify(fixture.scopes).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 + profiler.close(false) + assertTrue(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 + fixture.executor.runAll() + assertFalse(profiler.isRunning) + } + + @Test + fun `profiler does not send chunks after close`() { + val profiler = fixture.getSut() + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + // We close the profiler, which should prevent sending additional chunks + profiler.close(true) + + // The executor used to send the chunk doesn't do anything + fixture.executor.runAll() + verify(fixture.scopes, never()).captureProfileChunk(any()) + } + + @Test + fun `profiler stops when rate limited`() { + val profiler = fixture.getSut() + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)).thenReturn(true) + + profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) + assertTrue(profiler.isRunning) + + // If the SDK is rate limited, the profiler should stop + profiler.onRateLimitChanged(rateLimiter) + assertFalse(profiler.isRunning) + assertEquals(SentryId.EMPTY_ID, profiler.profilerId) + verify(fixture.mockLogger) + .log(eq(SentryLevel.WARNING), eq("SDK is rate limited. Stopping profiler.")) + } + + @Test + fun `profiler does not start when rate limited`() { + val profiler = fixture.getSut() + val rateLimiter = mock() + whenever(rateLimiter.isActiveForCategory(DataCategory.ProfileChunk)).thenReturn(true) + whenever(fixture.scopes.rateLimiter).thenReturn(rateLimiter) + + // If the SDK is rate limited, 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("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) + closure.invoke() + } +} diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilingServiceLoaderTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilingServiceLoaderTest.kt new file mode 100644 index 00000000000..5bac6d3ddbb --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/JavaContinuousProfilingServiceLoaderTest.kt @@ -0,0 +1,24 @@ +package io.sentry.asyncprofiler + +import io.sentry.ILogger +import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler +import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider +import io.sentry.profiling.ProfilingServiceLoader +import kotlin.test.Test +import org.mockito.kotlin.mock + +class JavaContinuousProfilingServiceLoaderTest { + @Test + fun loadsAsyncProfilerProfileConverter() { + val service = ProfilingServiceLoader.loadProfileConverter() + assert(service is AsyncProfilerProfileConverterProvider.AsyncProfilerProfileConverter) + } + + @Test + fun loadsJavaAsyncProfiler() { + val logger = mock() + + val service = ProfilingServiceLoader.loadContinuousProfiler(logger, "", 10, null) + assert(service is JavaContinuousProfiler) + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 9d084ed97e1..65445d85706 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -76,7 +76,7 @@ android:exported="false"/> - + 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 new file mode 100644 index 00000000000..06c46dd9ab5 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/ProfilingInitializer.java @@ -0,0 +1,25 @@ +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 8050cb8e74c..5ece216f281 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,6 +4,7 @@ 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; @@ -50,6 +51,11 @@ 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-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 8e383764a43..3fb2f721186 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://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 +sentry.dsn=https://08c961cc816946f89b4dd69b92e75979@sentry.bloder.dev/3 sentry.send-default-pii=true sentry.max-request-body-size=medium # Sentry Spring Boot integration allows more fine-grained SentryOptions configuration @@ -13,10 +13,11 @@ sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 sentry.debug=true sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR sentry.enable-backpressure-handling=true -sentry.enable-spotlight=true +sentry.enable-spotlight=false sentry.enablePrettySerializationOutput=false in-app-includes="io.sentry.samples" sentry.logs.enabled=true +sentry.profile-session-sample-rate=1.0 # Uncomment and set to true to enable aot compatibility # This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 527461870aa..cac3a6bae7d 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -350,6 +350,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static final field LogItem Lio/sentry/DataCategory; public static final field Monitor Lio/sentry/DataCategory; public static final field Profile Lio/sentry/DataCategory; + public static final field ProfileChunk Lio/sentry/DataCategory; public static final field ProfileChunkUi Lio/sentry/DataCategory; public static final field Replay Lio/sentry/DataCategory; public static final field Security Lio/sentry/DataCategory; @@ -488,7 +489,9 @@ 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 getProfileSessionSampleRate ()Ljava/lang/Double; public fun getProfilesSampleRate ()Ljava/lang/Double; + public fun getProfilingTracesDirPath ()Ljava/lang/String; public fun getProguardUuid ()Ljava/lang/String; public fun getProxy ()Lio/sentry/SentryOptions$Proxy; public fun getRelease ()Ljava/lang/String; @@ -529,7 +532,9 @@ 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 setProfileSessionSampleRate (Ljava/lang/Double;)V public fun setProfilesSampleRate (Ljava/lang/Double;)V + public fun setProfilingTracesDirPath (Ljava/lang/String;)V public fun setProguardUuid (Ljava/lang/String;)V public fun setProxy (Lio/sentry/SentryOptions$Proxy;)V public fun setRelease (Ljava/lang/String;)V @@ -819,6 +824,10 @@ public abstract interface class io/sentry/IPerformanceSnapshotCollector : io/sen public abstract fun setup ()V } +public abstract interface class io/sentry/IProfileConverter { + public abstract fun convertFromFile (Ljava/nio/file/Path;)Lio/sentry/protocol/profiling/SentryProfile; +} + public abstract interface class io/sentry/IReplayApi { public abstract fun disableDebugMaskingOverlay ()V public abstract fun enableDebugMaskingOverlay ()V @@ -1944,7 +1953,7 @@ 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;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; @@ -1955,6 +1964,7 @@ public final class io/sentry/ProfileChunk : io/sentry/JsonSerializable, io/sentr public fun getProfilerId ()Lio/sentry/protocol/SentryId; public fun getRelease ()Ljava/lang/String; public fun getSampledProfile ()Ljava/lang/String; + public fun getSentryProfile ()Lio/sentry/protocol/profiling/SentryProfile; public fun getTimestamp ()D public fun getTraceFile ()Ljava/io/File; public fun getUnknown ()Ljava/util/Map; @@ -1963,11 +1973,12 @@ public final class io/sentry/ProfileChunk : io/sentry/JsonSerializable, io/sentr public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setDebugMeta (Lio/sentry/protocol/DebugMeta;)V public fun setSampledProfile (Ljava/lang/String;)V + public fun setSentryProfile (Lio/sentry/protocol/profiling/SentryProfile;)V public fun setUnknown (Ljava/util/Map;)V } 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;)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,6 +1998,7 @@ public final class io/sentry/ProfileChunk$JsonKeys { public static final field PROFILER_ID Ljava/lang/String; public static final field RELEASE Ljava/lang/String; public static final field SAMPLED_PROFILE Ljava/lang/String; + public static final field SENTRY_PROFILE Ljava/lang/String; public static final field TIMESTAMP Ljava/lang/String; public static final field VERSION Ljava/lang/String; public fun ()V @@ -3513,6 +3525,7 @@ public class io/sentry/SentryOptions { public fun setProfileSessionSampleRate (Ljava/lang/Double;)V public fun setProfilesSampleRate (Ljava/lang/Double;)V public fun setProfilesSampler (Lio/sentry/SentryOptions$ProfilesSamplerCallback;)V + public fun setProfilingTracesDirPath (Ljava/lang/String;)V public fun setProfilingTracesHz (I)V public fun setProguardUuid (Ljava/lang/String;)V public fun setPropagateTraceparent (Z)V @@ -5026,6 +5039,20 @@ public final class io/sentry/profilemeasurements/ProfileMeasurementValue$JsonKey public fun ()V } +public abstract interface class io/sentry/profiling/JavaContinuousProfilerProvider { + public abstract fun getContinuousProfiler (Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)Lio/sentry/IContinuousProfiler; +} + +public abstract interface class io/sentry/profiling/JavaProfileConverterProvider { + public abstract fun getProfileConverter ()Lio/sentry/IProfileConverter; +} + +public final class io/sentry/profiling/ProfilingServiceLoader { + public fun ()V + public static fun loadContinuousProfiler (Lio/sentry/ILogger;Ljava/lang/String;ILio/sentry/ISentryExecutorService;)Lio/sentry/IContinuousProfiler; + public static fun loadProfileConverter ()Lio/sentry/IProfileConverter; +} + public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun ()V @@ -6281,6 +6308,96 @@ 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; + 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/JfrSample$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/profiling/JfrSample; + 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 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 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 fun ()V + 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/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/ThreadMetadata : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public field name Ljava/lang/String; + public field priority I + 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/ThreadMetadata$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/profiling/ThreadMetadata; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/protocol/profiling/ThreadMetadata$JsonKeys { + public static final field NAME Ljava/lang/String; + public static final field PRIORITY Ljava/lang/String; + public fun ()V +} + public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field EVENT_TAG Ljava/lang/String; public fun ()V diff --git a/sentry/src/main/java/io/sentry/DataCategory.java b/sentry/src/main/java/io/sentry/DataCategory.java index 6f741775b71..226deef9a69 100644 --- a/sentry/src/main/java/io/sentry/DataCategory.java +++ b/sentry/src/main/java/io/sentry/DataCategory.java @@ -13,6 +13,7 @@ public enum DataCategory { Monitor("monitor"), Profile("profile"), ProfileChunkUi("profile_chunk_ui"), + ProfileChunk("profile_chunk"), Transaction("transaction"), Replay("replay"), Span("span"), diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 49e5acc48f3..62daabc26de 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -56,6 +56,9 @@ public final class ExternalOptions { private @Nullable Boolean forceInit; private @Nullable Boolean captureOpenTelemetryEvents; + private @Nullable Double profileSessionSampleRate; + private @Nullable String profilingTracesDirPath; + private @Nullable SentryOptions.Cron cron; @SuppressWarnings("unchecked") @@ -202,6 +205,10 @@ public final class ExternalOptions { options.setEnableSpotlight(propertiesProvider.getBooleanProperty("enable-spotlight")); options.setSpotlightConnectionUrl(propertiesProvider.getProperty("spotlight-connection-url")); + options.setProfileSessionSampleRate( + propertiesProvider.getDoubleProperty("profile-session-sample-rate")); + + options.setProfilingTracesDirPath(propertiesProvider.getProperty("profiling-traces-dir-path")); return options; } @@ -525,4 +532,20 @@ public void setEnableLogs(final @Nullable Boolean enableLogs) { public @Nullable Boolean isEnableLogs() { return enableLogs; } + + public @Nullable Double getProfileSessionSampleRate() { + return profileSessionSampleRate; + } + + public void setProfileSessionSampleRate(@Nullable Double profileSessionSampleRate) { + this.profileSessionSampleRate = profileSessionSampleRate; + } + + public @Nullable String getProfilingTracesDirPath() { + return profilingTracesDirPath; + } + + public void setProfilingTracesDirPath(@Nullable String profilingTracesDirPath) { + this.profilingTracesDirPath = profilingTracesDirPath; + } } diff --git a/sentry/src/main/java/io/sentry/IProfileConverter.java b/sentry/src/main/java/io/sentry/IProfileConverter.java new file mode 100644 index 00000000000..9b594c2dbf5 --- /dev/null +++ b/sentry/src/main/java/io/sentry/IProfileConverter.java @@ -0,0 +1,26 @@ +package io.sentry; + +import io.sentry.protocol.profiling.SentryProfile; +import java.io.IOException; +import java.nio.file.Path; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Interface for converting JFR (Java Flight Recorder) files to Sentry profiles. This abstraction + * allows different profiling implementations to be used without direct dependencies between + * modules. + */ +@ApiStatus.Internal +public interface IProfileConverter { + + /** + * Converts a JFR file to a SentryProfile. + * + * @param jfrFilePath The path to the JFR file to convert + * @return The converted SentryProfile + * @throws IOException If an error occurs while reading or converting the file + */ + @NotNull + SentryProfile convertFromFile(@NotNull Path jfrFilePath) throws IOException; +} diff --git a/sentry/src/main/java/io/sentry/ProfileChunk.java b/sentry/src/main/java/io/sentry/ProfileChunk.java index 0bcd555dbd0..40876f4d10b 100644 --- a/sentry/src/main/java/io/sentry/ProfileChunk.java +++ b/sentry/src/main/java/io/sentry/ProfileChunk.java @@ -4,9 +4,12 @@ import io.sentry.protocol.DebugMeta; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; +import io.sentry.protocol.profiling.SentryProfile; import io.sentry.vendor.gson.stream.JsonToken; import java.io.File; import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -33,6 +36,8 @@ public final class ProfileChunk implements JsonUnknown, JsonSerializable { /** Profile trace encoded with Base64. */ private @Nullable String sampledProfile = null; + private @Nullable SentryProfile sentryProfile; + private @Nullable Map unknown; public ProfileChunk() { @@ -42,6 +47,7 @@ public ProfileChunk() { new File("dummy"), new HashMap<>(), 0.0, + "android", SentryOptions.empty()); } @@ -51,6 +57,7 @@ public ProfileChunk( final @NotNull File traceFile, final @NotNull Map measurements, final @NotNull Double timestamp, + final @NotNull String platform, final @NotNull SentryOptions options) { this.profilerId = profilerId; this.chunkId = chunkId; @@ -60,7 +67,7 @@ public ProfileChunk( this.clientSdk = options.getSdkVersion(); this.release = options.getRelease() != null ? options.getRelease() : ""; this.environment = options.getEnvironment(); - this.platform = "android"; + this.platform = platform; this.version = "2"; this.timestamp = timestamp; } @@ -121,6 +128,14 @@ public double getTimestamp() { return version; } + public @Nullable SentryProfile getSentryProfile() { + return sentryProfile; + } + + public void setSentryProfile(@Nullable SentryProfile sentryProfile) { + this.sentryProfile = sentryProfile; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -136,7 +151,8 @@ public boolean equals(Object o) { && Objects.equals(environment, that.environment) && Objects.equals(version, that.version) && Objects.equals(sampledProfile, that.sampledProfile) - && Objects.equals(unknown, that.unknown); + && Objects.equals(unknown, that.unknown) + && Objects.equals(sentryProfile, that.sentryProfile); } @Override @@ -152,6 +168,7 @@ public int hashCode() { environment, version, sampledProfile, + sentryProfile, unknown); } @@ -162,21 +179,26 @@ public static final class Builder { private final @NotNull File traceFile; private final double timestamp; + private final @NotNull String platform; + public Builder( final @NotNull SentryId profilerId, final @NotNull SentryId chunkId, final @NotNull Map measurements, final @NotNull File traceFile, - final @NotNull SentryDate timestamp) { + final @NotNull SentryDate timestamp, + final @NotNull String platform) { this.profilerId = profilerId; this.chunkId = chunkId; this.measurements = new ConcurrentHashMap<>(measurements); this.traceFile = traceFile; this.timestamp = DateUtils.nanosToSeconds(timestamp.nanoTimestamp()); + this.platform = platform; } public ProfileChunk build(SentryOptions options) { - return new ProfileChunk(profilerId, chunkId, traceFile, measurements, timestamp, options); + return new ProfileChunk( + profilerId, chunkId, traceFile, measurements, timestamp, platform, options); } } @@ -194,6 +216,7 @@ public static final class JsonKeys { public static final String VERSION = "version"; public static final String SAMPLED_PROFILE = "sampled_profile"; public static final String TIMESTAMP = "timestamp"; + public static final String SENTRY_PROFILE = "profile"; } @Override @@ -225,7 +248,10 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (sampledProfile != null) { writer.name(JsonKeys.SAMPLED_PROFILE).value(logger, sampledProfile); } - writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); + writer.name(JsonKeys.TIMESTAMP).value(logger, doubleToBigDecimal(timestamp)); + if (sentryProfile != null) { + writer.name(JsonKeys.SENTRY_PROFILE).value(logger, sentryProfile); + } if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -235,6 +261,10 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.endObject(); } + private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { + return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); + } + @Nullable @Override public Map getUnknown() { @@ -325,6 +355,13 @@ public static final class Deserializer implements JsonDeserializer data.timestamp = timestamp; } break; + case JsonKeys.SENTRY_PROFILE: + SentryProfile sentryProfile = + reader.nextOrNull(logger, new SentryProfile.Deserializer()); + if (sentryProfile != null) { + data.sentryProfile = sentryProfile; + } + break; default: if (unknown == null) { unknown = new ConcurrentHashMap<>(); diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index ec61bb7ea3f..b213bbb9798 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -15,6 +15,7 @@ import io.sentry.internal.modules.ResourcesModulesLoader; import io.sentry.logger.ILoggerApi; import io.sentry.opentelemetry.OpenTelemetryUtil; +import io.sentry.profiling.ProfilingServiceLoader; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; @@ -676,6 +677,26 @@ private static void initConfigurations(final @NotNull SentryOptions options) { } options.getBackpressureMonitor().start(); } + + if (options.isContinuousProfilingEnabled() + && profilingTracesDirPath != null + && options.getContinuousProfiler() == NoOpContinuousProfiler.getInstance()) { + final IContinuousProfiler continuousProfiler = + ProfilingServiceLoader.loadContinuousProfiler( + new SystemOutLogger(), + profilingTracesDirPath, + options.getProfilingTracesHz(), + options.getExecutorService()); + + options.setContinuousProfiler(continuousProfiler); + } + + options + .getLogger() + .log( + SentryLevel.INFO, + "Continuous profiler is enabled %s", + options.isContinuousProfilingEnabled()); } /** Close the SDK */ diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 2419813e96c..5d236bfe2c7 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -6,7 +6,9 @@ import io.sentry.clientreport.ClientReport; import io.sentry.exception.SentryEnvelopeException; +import io.sentry.profiling.ProfilingServiceLoader; import io.sentry.protocol.SentryTransaction; +import io.sentry.protocol.profiling.SentryProfile; import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; import io.sentry.util.Objects; @@ -292,16 +294,31 @@ private static void ensureAttachmentSizeLimit( "Dropping profile chunk, because the file '%s' doesn't exists", traceFile.getName())); } - // The payload of the profile item is a json including the trace file encoded with - // base64 - final byte[] traceFileBytes = - readBytesFromFile(traceFile.getPath(), MAX_PROFILE_CHUNK_SIZE); - final @NotNull String base64Trace = - Base64.encodeToString(traceFileBytes, NO_WRAP | NO_PADDING); - if (base64Trace.isEmpty()) { - throw new SentryEnvelopeException("Profiling trace file is empty"); + + if (profileChunk.getPlatform().equals("java")) { + final IProfileConverter profileConverter = + ProfilingServiceLoader.loadProfileConverter(); + if (profileConverter != null) { + try { + final SentryProfile profile = + profileConverter.convertFromFile(traceFile.toPath()); + profileChunk.setSentryProfile(profile); + } catch (IOException e) { + throw new SentryEnvelopeException("Profile conversion failed"); + } + } + } else { + // The payload of the profile item is a json including the trace file encoded with + // base64 + final byte[] traceFileBytes = + readBytesFromFile(traceFile.getPath(), MAX_PROFILE_CHUNK_SIZE); + final @NotNull String base64Trace = + Base64.encodeToString(traceFileBytes, NO_WRAP | NO_PADDING); + if (base64Trace.isEmpty()) { + throw new SentryEnvelopeException("Profiling trace file is empty"); + } + profileChunk.setSampledProfile(base64Trace); } - profileChunk.setSampledProfile(base64Trace); try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index ddb6dda361a..5c35c4e830d 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -597,6 +597,8 @@ public class SentryOptions { private @NotNull ISocketTagger socketTagger = NoOpSocketTagger.getInstance(); + private @Nullable String profilingTracesDirPath; + /** * Configuration options for Sentry Build Distribution. NOTE: Ideally this would be in * SentryAndroidOptions, but there's a circular dependency issue between sentry-android-core and @@ -2104,13 +2106,25 @@ public void setDeadlineTimeout(long deadlineTimeout) { * @return the profiling traces dir. path or null if not set */ public @Nullable String getProfilingTracesDirPath() { + if (profilingTracesDirPath != null && !profilingTracesDirPath.isEmpty()) { + return dsnHash != null + ? new File(profilingTracesDirPath, dsnHash).getAbsolutePath() + : profilingTracesDirPath; + } + final String cacheDirPath = getCacheDirPath(); + if (cacheDirPath == null) { return null; } + return new File(cacheDirPath, "profiling_traces").getAbsolutePath(); } + public void setProfilingTracesDirPath(final @Nullable String profilingTracesDirPath) { + this.profilingTracesDirPath = profilingTracesDirPath; + } + /** * Returns a list of origins to which `sentry-trace` header should be sent in HTTP integrations. * @@ -3344,6 +3358,14 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isEnableLogs() != null) { getLogs().setEnabled(options.isEnableLogs()); } + + if (options.getProfileSessionSampleRate() != null) { + setProfileSessionSampleRate(options.getProfileSessionSampleRate()); + } + + if (options.getProfilingTracesDirPath() != null) { + setProfilingTracesDirPath(options.getProfilingTracesDirPath()); + } } private @NotNull SdkVersion createSdkVersion() { diff --git a/sentry/src/main/java/io/sentry/profiling/JavaContinuousProfilerProvider.java b/sentry/src/main/java/io/sentry/profiling/JavaContinuousProfilerProvider.java new file mode 100644 index 00000000000..ffc779a02dd --- /dev/null +++ b/sentry/src/main/java/io/sentry/profiling/JavaContinuousProfilerProvider.java @@ -0,0 +1,27 @@ +package io.sentry.profiling; + +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.ISentryExecutorService; +import org.jetbrains.annotations.NotNull; + +/** + * Service provider interface for creating continuous profilers. + * + *

This interface allows for pluggable continuous profiler implementations that can be discovered + * at runtime using the ServiceLoader mechanism. + */ +public interface JavaContinuousProfilerProvider { + + /** + * Creates and returns a continuous profiler instance. + * + * @return a continuous profiler instance, or null if the provider cannot create one + */ + @NotNull + IContinuousProfiler getContinuousProfiler( + ILogger logger, + String profilingTracesDirPath, + int profilingTracesHz, + ISentryExecutorService executorService); +} diff --git a/sentry/src/main/java/io/sentry/profiling/JavaProfileConverterProvider.java b/sentry/src/main/java/io/sentry/profiling/JavaProfileConverterProvider.java new file mode 100644 index 00000000000..34ac31c66f0 --- /dev/null +++ b/sentry/src/main/java/io/sentry/profiling/JavaProfileConverterProvider.java @@ -0,0 +1,21 @@ +package io.sentry.profiling; + +import io.sentry.IProfileConverter; +import org.jetbrains.annotations.Nullable; + +/** + * Service provider interface for creating profile converters. + * + *

This interface allows for pluggable profile converter implementations that can be discovered + * at runtime using the ServiceLoader mechanism. + */ +public interface JavaProfileConverterProvider { + + /** + * Creates and returns a profile converter instance. + * + * @return a profile converter instance, or null if the provider cannot create one + */ + @Nullable + IProfileConverter getProfileConverter(); +} diff --git a/sentry/src/main/java/io/sentry/profiling/ProfilingServiceLoader.java b/sentry/src/main/java/io/sentry/profiling/ProfilingServiceLoader.java new file mode 100644 index 00000000000..ec09de6075f --- /dev/null +++ b/sentry/src/main/java/io/sentry/profiling/ProfilingServiceLoader.java @@ -0,0 +1,76 @@ +package io.sentry.profiling; + +import io.sentry.IContinuousProfiler; +import io.sentry.ILogger; +import io.sentry.IProfileConverter; +import io.sentry.ISentryExecutorService; +import io.sentry.NoOpContinuousProfiler; +import io.sentry.SentryLevel; +import java.util.Iterator; +import java.util.ServiceLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ProfilingServiceLoader { + + public static @NotNull IContinuousProfiler loadContinuousProfiler( + ILogger logger, + String profilingTracesDirPath, + int profilingTracesHz, + ISentryExecutorService executorService) { + try { + JavaContinuousProfilerProvider provider = + loadSingleProvider(JavaContinuousProfilerProvider.class); + + if (provider != null) { + logger.log( + SentryLevel.DEBUG, + "Loaded continuous profiler from provider: %s", + provider.getClass().getName()); + return provider.getContinuousProfiler( + logger, profilingTracesDirPath, profilingTracesHz, executorService); + } + + logger.log( + SentryLevel.DEBUG, "No continuous profiler provider found, using NoOpContinuousProfiler"); + return NoOpContinuousProfiler.getInstance(); + } catch (Throwable t) { + logger.log( + SentryLevel.ERROR, + "Failed to load continuous profiler provider, using NoOpContinuousProfiler", + t); + return NoOpContinuousProfiler.getInstance(); + } + } + + /** + * Loads a profile converter using ServiceLoader discovery pattern. + * + * @return an IProfileConverter instance or null if no provider is found + */ + public static @Nullable IProfileConverter loadProfileConverter() { + try { + JavaProfileConverterProvider provider = + loadSingleProvider(JavaProfileConverterProvider.class); + if (provider != null) { + return provider.getProfileConverter(); + } else { + return null; + } + } catch (Throwable t) { + // Log error and return null to skip conversion + return null; + } + } + + private static @Nullable T loadSingleProvider(Class clazz) { + final ServiceLoader serviceLoader = ServiceLoader.load(clazz); + final Iterator iterator = serviceLoader.iterator(); + + if (iterator.hasNext()) { + return iterator.next(); + } else { + return null; + } + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/JfrFrame.java b/sentry/src/main/java/io/sentry/protocol/profiling/JfrFrame.java new file mode 100644 index 00000000000..e013ec594e6 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/profiling/JfrFrame.java @@ -0,0 +1,69 @@ +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/JfrSample.java b/sentry/src/main/java/io/sentry/protocol/profiling/JfrSample.java new file mode 100644 index 00000000000..14a4b96a867 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/profiling/JfrSample.java @@ -0,0 +1,67 @@ +package io.sentry.protocol.profiling; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.HashMap; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class JfrSample implements JsonUnknown, JsonSerializable { + + public double timestamp; // Unix timestamp in seconds with microsecond precision + + public int stackId; + + public @Nullable String threadId; + + public static final class JsonKeys { + public static final String TIMESTAMP = "timestamp"; + public static final String STACK_ID = "stack_id"; + public static final String THREAD_ID = "thread_id"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + + writer.name(JsonKeys.TIMESTAMP).value(logger, doubleToBigDecimal(timestamp)); + writer.name(JsonKeys.STACK_ID).value(logger, stackId); + + if (threadId != null) { + writer.name(JsonKeys.THREAD_ID).value(logger, threadId); + } + + writer.endObject(); + } + + private @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) { + return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN); + } + + @Override + public @Nullable Map getUnknown() { + return new HashMap<>(); + } + + @Override + public void setUnknown(@Nullable Map unknown) {} + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull JfrSample deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { + reader.beginObject(); + JfrSample data = new JfrSample(); + return data; + } + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/JfrToSentryProfileConverter.java b/sentry/src/main/java/io/sentry/protocol/profiling/JfrToSentryProfileConverter.java new file mode 100644 index 00000000000..7c049ce086f --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/profiling/JfrToSentryProfileConverter.java @@ -0,0 +1,356 @@ +// 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); +// } +// } +// } diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java b/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java new file mode 100644 index 00000000000..ec872072702 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/profiling/SentryProfile.java @@ -0,0 +1,124 @@ +package io.sentry.protocol.profiling; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.protocol.SentryStackFrame; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryProfile implements JsonUnknown, JsonSerializable { + 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) + + 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.beginObject(); + // for (String key : threadMetadata.keySet()) { + // ThreadMetadata value = threadMetadata.get(key); + // writer.name(key).value(logger, value); + // } + // writer.endObject(); + } + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class JsonKeys { + public static final String SAMPLES = "samples"; + public static final String STACKS = "stacks"; + public static final String FRAMES = "frames"; + public static final String THREAD_METADATA = "thread_metadata"; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull SentryProfile deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { + reader.beginObject(); + SentryProfile data = new SentryProfile(); + 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; + } + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/profiling/ThreadMetadata.java b/sentry/src/main/java/io/sentry/protocol/profiling/ThreadMetadata.java new file mode 100644 index 00000000000..9c83a686114 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/profiling/ThreadMetadata.java @@ -0,0 +1,53 @@ +package io.sentry.protocol.profiling; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import 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 int priority; // e.g., "com.example" (package name) + + public static final class JsonKeys { + public static final String NAME = "name"; + public static final String PRIORITY = "priority"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + if (name != null) { + writer.name(JsonKeys.NAME).value(logger, name); + } + writer.name(JsonKeys.PRIORITY).value(logger, priority); + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return new HashMap<>(); + } + + @Override + public void setUnknown(@Nullable Map unknown) {} + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ThreadMetadata deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + ThreadMetadata data = new ThreadMetadata(); + return data; + } + } +} diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index ddb8e2cebf3..9f32d8cb8f8 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -376,6 +376,20 @@ class ExternalOptionsTest { withPropertiesFile("logs.enabled=true") { options -> assertTrue(options.isEnableLogs == true) } } + @Test + fun `creates options with profileSessionSampleRate set to 0_8`() { + withPropertiesFile("profile-session-sample-rate=0.8") { options -> + assertTrue(options.profileSessionSampleRate == 0.8) + } + } + + @Test + fun `creates options with profilingTracesDirPath set to profile_traces`() { + withPropertiesFile("profiling-traces-dir-path=profile_traces") { options -> + assertTrue(options.profilingTracesDirPath == "profile_traces") + } + } + private fun withPropertiesFile( textLines: List = emptyList(), logger: ILogger = mock(), diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index d0c3dcc7322..463b6f5000d 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -962,7 +962,15 @@ class JsonSerializerTest { fixture.options.release = "release" fixture.options.environment = "environment" val profileChunk = - ProfileChunk(profilerId, chunkId, fixture.traceFile, HashMap(), 5.3, fixture.options) + ProfileChunk( + profilerId, + chunkId, + fixture.traceFile, + HashMap(), + 5.3, + "android", + fixture.options, + ) val measurementNow = SentryNanotimeDate().nanoTimestamp() val measurementNowSeconds = BigDecimal.valueOf(DateUtils.nanosToSeconds(measurementNow)) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 878eb2d4aa7..c7637c4c7cb 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -107,7 +107,15 @@ class SentryClientTest { ) sentryTracer.startChild("a-span", "span 1").finish() profileChunk = - ProfileChunk(SentryId(), SentryId(), profilingTraceFile, emptyMap(), 1.0, sentryOptions) + ProfileChunk( + SentryId(), + SentryId(), + profilingTraceFile, + emptyMap(), + 1.0, + "android", + sentryOptions, + ) } var attachment = Attachment("hello".toByteArray(), "hello.txt", "text/plain", true) diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index cd58b5ae345..026177dfc44 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -434,7 +434,11 @@ class SentryEnvelopeItemTest { @Test fun `fromProfilingTrace with unreadable file throws`() { val file = File(fixture.pathname) - val profilingTraceData = mock { whenever(it.traceFile).thenReturn(file) } + val profilingTraceData = + mock { + whenever(it.traceFile).thenReturn(file) + whenever(it.platform).thenReturn("android") + } file.writeBytes(fixture.bytes) file.setReadable(false) assertFailsWith( @@ -492,7 +496,11 @@ class SentryEnvelopeItemTest { @Test fun `fromProfileChunk saves file as Base64`() { val file = File(fixture.pathname) - val profileChunk = mock { whenever(it.traceFile).thenReturn(file) } + val profileChunk = + mock { + whenever(it.traceFile).thenReturn(file) + whenever(it.platform).thenReturn("android") + } file.writeBytes(fixture.bytes) val chunk = SentryEnvelopeItem.fromProfileChunk(profileChunk, mock()).data @@ -503,7 +511,11 @@ class SentryEnvelopeItemTest { @Test fun `fromProfileChunk deletes file only after reading data`() { val file = File(fixture.pathname) - val profileChunk = mock { whenever(it.traceFile).thenReturn(file) } + val profileChunk = + mock { + whenever(it.traceFile).thenReturn(file) + whenever(it.platform).thenReturn("android") + } file.writeBytes(fixture.bytes) assert(file.exists()) @@ -516,7 +528,11 @@ class SentryEnvelopeItemTest { @Test fun `fromProfileChunk with invalid file throws`() { val file = File(fixture.pathname) - val profileChunk = mock { whenever(it.traceFile).thenReturn(file) } + val profileChunk = + mock { + whenever(it.traceFile).thenReturn(file) + whenever(it.platform).thenReturn("android") + } assertFailsWith( "Dropping profiling trace data, because the file ${file.path} doesn't exists" @@ -528,7 +544,11 @@ class SentryEnvelopeItemTest { @Test fun `fromProfileChunk with unreadable file throws`() { val file = File(fixture.pathname) - val profileChunk = mock { whenever(it.traceFile).thenReturn(file) } + val profileChunk = + mock { + whenever(it.traceFile).thenReturn(file) + whenever(it.platform).thenReturn("android") + } file.writeBytes(fixture.bytes) file.setReadable(false) assertFailsWith( @@ -542,7 +562,11 @@ class SentryEnvelopeItemTest { fun `fromProfileChunk with empty file throws`() { val file = File(fixture.pathname) file.writeBytes(ByteArray(0)) - val profileChunk = mock { whenever(it.traceFile).thenReturn(file) } + val profileChunk = + mock { + whenever(it.traceFile).thenReturn(file) + whenever(it.platform).thenReturn("android") + } val chunk = SentryEnvelopeItem.fromProfileChunk(profileChunk, mock()) assertFailsWith("Profiling trace file is empty") { chunk.data } @@ -553,7 +577,11 @@ class SentryEnvelopeItemTest { val file = File(fixture.pathname) val maxSize = 50 * 1024 * 1024 // 50MB file.writeBytes(ByteArray((maxSize + 1)) { 0 }) - val profileChunk = mock { whenever(it.traceFile).thenReturn(file) } + val profileChunk = + mock { + whenever(it.traceFile).thenReturn(file) + whenever(it.platform).thenReturn("android") + } val exception = assertFailsWith { diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index d7fef7a5798..0c8f382fc53 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -409,7 +409,10 @@ class SentryOptionsTest { externalOptions.spotlightConnectionUrl = "http://local.sentry.io:1234" externalOptions.isGlobalHubMode = true externalOptions.isEnableLogs = true + externalOptions.profileSessionSampleRate = 0.8 + externalOptions.profilingTracesDirPath = "/profiling-traces" + val hash = StringUtils.calculateStringHash(externalOptions.dsn, mock()) val options = SentryOptions() options.merge(externalOptions) @@ -463,6 +466,8 @@ class SentryOptionsTest { assertEquals("http://local.sentry.io:1234", options.spotlightConnectionUrl) assertTrue(options.isGlobalHubMode!!) assertTrue(options.logs.isEnabled!!) + assertEquals(0.8, options.profileSessionSampleRate) + assertEquals("/profiling-traces${File.separator}${hash}", options.profilingTracesDirPath) } @Test @@ -534,6 +539,23 @@ class SentryOptionsTest { ) } + @Test + fun `when cacheDirPath and profilingTracesDirPath are set, profilingTracesDirPath takes precedence`() { + val dsn = "http://key@localhost/proj" + val hash = StringUtils.calculateStringHash(dsn, mock()) + val options = + SentryOptions().apply { + setDsn(dsn) + cacheDirPath = "${File.separator}test" + profilingTracesDirPath = "${File.separator}test-profiles" + } + + assertEquals( + "${File.separator}test-profiles${File.separator}${hash}", + options.profilingTracesDirPath, + ) + } + @Test fun `getCacheDirPathWithoutDsn does not contain dsn hash`() { val dsn = "http://key@localhost/proj" diff --git a/sentry/src/test/java/io/sentry/profiling/ProfilingServiceLoaderTest.kt b/sentry/src/test/java/io/sentry/profiling/ProfilingServiceLoaderTest.kt new file mode 100644 index 00000000000..a0963aec100 --- /dev/null +++ b/sentry/src/test/java/io/sentry/profiling/ProfilingServiceLoaderTest.kt @@ -0,0 +1,78 @@ +package io.sentry.profiling + +import io.sentry.IContinuousProfiler +import io.sentry.ILogger +import io.sentry.IProfileConverter +import io.sentry.ISentryExecutorService +import io.sentry.ProfileLifecycle +import io.sentry.TracesSampler +import io.sentry.protocol.SentryId +import io.sentry.protocol.profiling.SentryProfile +import java.nio.file.Path +import kotlin.test.Test +import org.mockito.kotlin.mock + +class ProfilingServiceLoaderTest { + @Test + fun loadsProfileConverterStub() { + val service = ProfilingServiceLoader.loadProfileConverter() + assert(service is ProfileConverterStub) + } + + @Test + fun loadsProfilerStub() { + val logger = mock() + + val service = ProfilingServiceLoader.loadContinuousProfiler(logger, "", 10, null) + assert(service is ContinuousProfilerStub) + } +} + +class JavaProfileConverterProviderStub : JavaProfileConverterProvider { + override fun getProfileConverter(): IProfileConverter? { + return ProfileConverterStub() + } +} + +class ProfileConverterStub() : IProfileConverter { + override fun convertFromFile(jfrFilePath: Path): SentryProfile { + TODO("Not yet implemented") + } +} + +class JavaProfilerProviderStub : JavaContinuousProfilerProvider { + override fun getContinuousProfiler( + logger: ILogger?, + profilingTracesDirPath: String?, + profilingTracesHz: Int, + executorService: ISentryExecutorService?, + ): IContinuousProfiler { + return ContinuousProfilerStub() + } +} + +class ContinuousProfilerStub() : IContinuousProfiler { + override fun isRunning(): Boolean { + TODO("Not yet implemented") + } + + override fun startProfiler(profileLifecycle: ProfileLifecycle, tracesSampler: TracesSampler) { + TODO("Not yet implemented") + } + + override fun stopProfiler(profileLifecycle: ProfileLifecycle) { + TODO("Not yet implemented") + } + + override fun close(isTerminating: Boolean) { + TODO("Not yet implemented") + } + + override fun reevaluateSampling() { + TODO("Not yet implemented") + } + + override fun getProfilerId(): SentryId { + TODO("Not yet implemented") + } +} diff --git a/sentry/src/test/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider b/sentry/src/test/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider new file mode 100644 index 00000000000..885cb45e41e --- /dev/null +++ b/sentry/src/test/resources/META-INF/services/io.sentry.profiling.JavaContinuousProfilerProvider @@ -0,0 +1 @@ +io.sentry.profiling.JavaProfilerProviderStub diff --git a/sentry/src/test/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider b/sentry/src/test/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider new file mode 100644 index 00000000000..9f4146aa9a0 --- /dev/null +++ b/sentry/src/test/resources/META-INF/services/io.sentry.profiling.JavaProfileConverterProvider @@ -0,0 +1 @@ +io.sentry.profiling.JavaProfileConverterProviderStub diff --git a/settings.gradle.kts b/settings.gradle.kts index 0022f2290f7..26970de73c8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,6 +66,7 @@ include( "sentry-quartz", "sentry-okhttp", "sentry-reactor", + "sentry-async-profiler", "sentry-ktor-client", "sentry-samples:sentry-samples-android", "sentry-samples:sentry-samples-console",