diff --git a/exporters/otlp/profiles/build.gradle.kts b/exporters/otlp/profiles/build.gradle.kts index 00ddf18651d..dd21d50b52f 100644 --- a/exporters/otlp/profiles/build.gradle.kts +++ b/exporters/otlp/profiles/build.gradle.kts @@ -3,13 +3,30 @@ plugins { // TODO (jack-berg): uncomment when ready to publish // id("otel.publish-conventions") - id("otel.animalsniffer-conventions") + // animalsniffer is disabled on this module to allow use of the JFR API. + // id("otel.animalsniffer-conventions") } description = "OpenTelemetry - Profiles Exporter" otelJava.moduleName.set("io.opentelemetry.exporter.otlp.profiles") val versions: Map by project + +tasks { + // this module uses the jdk.jfr.consumer API, which was backported into 1.8 but is '@since 9' + // and therefore a bit of a pain to get gradle to compile against... + compileJava { + sourceCompatibility = "1.8" + targetCompatibility = "1.8" + options.release.set(null as Int?) + } + compileTestJava { + sourceCompatibility = "1.8" + targetCompatibility = "1.8" + options.release.set(null as Int?) + } +} + dependencies { api(project(":sdk:common")) api(project(":exporters:common")) diff --git a/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrExecutionSampleEventConverter.java b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrExecutionSampleEventConverter.java new file mode 100644 index 00000000000..f761d129d78 --- /dev/null +++ b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrExecutionSampleEventConverter.java @@ -0,0 +1,84 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.otlp.profiles.jfr; + +import io.opentelemetry.exporter.otlp.profiles.ProfilesDictionaryCompositor; +import io.opentelemetry.exporter.otlp.profiles.SampleCompositionBuilder; +import io.opentelemetry.exporter.otlp.profiles.SampleCompositionKey; +import io.opentelemetry.exporter.otlp.profiles.SampleData; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import jdk.jfr.consumer.RecordedEvent; + +/** + * Converter for batching a steam of recorded jfr.ExecutionSample events into a format suitable for + * consumption in a ProfileData i.e. for OTLP export. Similar converters, or a more generalized + * converter, are need for each JFR event type. + */ +public class JfrExecutionSampleEventConverter { + + /* + * The profiles signal encoding uses dictionary lookup tables to save space by deduplicating + * repeated object occurrences. The dictionary compositor is used to assemble these tables. + */ + private final ProfilesDictionaryCompositor profilesDictionaryCompositor = + new ProfilesDictionaryCompositor(); + + /* + * stack frames are dictionary encoded in multiple steps. + * first, frames are converted to Locations, each of which is placed in the dictionary. + * Then the stack as a whole is represented as an array of those Locations, + * and the Stack message itself is also placed in the dictionary. + * This assembly is handled by a JfrLocationDataCompositor wrapping the dictionary + */ + private final JfrLocationDataCompositor locationCompositor = + new JfrLocationDataCompositor(profilesDictionaryCompositor); + + /* + * Samples are occurrences of the same observation, with an optional value and timestamp. + * In JFR, for each given event type, a SampleCompositionBuilder is used to split the + * events (observations) by key (stack+metadata) and record the timestamps. + * If processing multiple event types, a Map would be used. + */ + private final SampleCompositionBuilder sampleCompositionBuilder = new SampleCompositionBuilder(); + + /** + * Convert and add a JFR event, if of appropriate type. + * + * @param recordedEvent the event to process. + */ + public void accept(RecordedEvent recordedEvent) { + if (!"jdk.ExecutionSample".equals(recordedEvent.getEventType().getName())) { + return; + } + + int stackIndex = locationCompositor.putIfAbsent(recordedEvent.getStackTrace().getFrames()); + SampleCompositionKey key = new SampleCompositionKey(stackIndex, Collections.emptyList(), 0); + Instant instant = recordedEvent.getStartTime(); + long epochNanos = TimeUnit.SECONDS.toNanos(instant.getEpochSecond()) + instant.getNano(); + sampleCompositionBuilder.add(key, null, epochNanos); + } + + /** + * Gets the underlying dictionary storage. + * + * @return the ProfilesDictionaryCompositor used by this converter. + */ + public ProfilesDictionaryCompositor getProfilesDictionaryCompositor() { + return profilesDictionaryCompositor; + } + + /** + * Gets the samples assembled from the accepted events. + * + * @return the data samples. + */ + public List getSamples() { + return sampleCompositionBuilder.build(); + } +} diff --git a/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrExportTool.java b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrExportTool.java new file mode 100644 index 00000000000..28508f7a014 --- /dev/null +++ b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrExportTool.java @@ -0,0 +1,95 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.otlp.profiles.jfr; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.exporter.otlp.internal.data.ImmutableProfileData; +import io.opentelemetry.exporter.otlp.internal.data.ImmutableValueTypeData; +import io.opentelemetry.exporter.otlp.profiles.OtlpGrpcProfileExporter; +import io.opentelemetry.exporter.otlp.profiles.OtlpGrpcProfilesExporterBuilder; +import io.opentelemetry.exporter.otlp.profiles.ProfileData; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.resources.Resource; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import jdk.jfr.consumer.RecordingFile; + +/** + * Simple example of how to wire up the profile signal OTLP exporter to convert and send the content + * of a JFR recording file. This is not a supported CLI and is not intended to be configurable by + * e.g. command line flags. + */ +public class JfrExportTool { + + private JfrExportTool() {} + + @SuppressWarnings("SystemOut") + public static void main(String[] args) throws IOException { + + Path jfrFilePath = Path.of("/tmp/demo.jfr"); // TODO set the JFR file location here + ProfileData profileData = convertJfrFile(jfrFilePath); + + // for test purposes https://github.com/elastic/devfiler/ provides a handy standalone backend. + // by default devfiler listens on port 11000 + String destination = "127.0.0.1:11000"; // TODO set the location of the backend receiver here + + OtlpGrpcProfilesExporterBuilder exporterBuilder = OtlpGrpcProfileExporter.builder(); + exporterBuilder.setEndpoint("http://" + destination); + OtlpGrpcProfileExporter exporter = exporterBuilder.build(); + + CompletableResultCode completableResultCode = exporter.export(List.of(profileData)); + completableResultCode.join(1, TimeUnit.MINUTES); + System.out.println(completableResultCode.isSuccess() ? "success" : "failure"); + } + + /** + * Read the content of the JFR recording file and convert it to a ProfileData object in + * preparation for OTLP export. + * + * @param jfrFilePath the data source. + * @return a ProfileData object constructed from the JFR recording. + * @throws IOException if the conversion fails. + */ + public static ProfileData convertJfrFile(Path jfrFilePath) throws IOException { + + JfrExecutionSampleEventConverter converter = new JfrExecutionSampleEventConverter(); + + RecordingFile recordingFile = new RecordingFile(jfrFilePath); + while (recordingFile.hasMoreEvents()) { + converter.accept(recordingFile.readEvent()); + } + recordingFile.close(); + + String profileId = "0123456789abcdef0123456789abcdef"; + InstrumentationScopeInfo scopeInfo = + InstrumentationScopeInfo.builder("testLib") + .setVersion("1.0") + .setSchemaUrl("http://url") + .build(); + + return ImmutableProfileData.create( + Resource.create(Attributes.empty()), + scopeInfo, + converter.getProfilesDictionaryCompositor().getProfileDictionaryData(), + ImmutableValueTypeData.create(0, 0), + converter.getSamples(), + 0, + 0, + ImmutableValueTypeData.create(0, 0), + 0, + profileId, + 0, + "format", + ByteBuffer.wrap(Files.readAllBytes(jfrFilePath)), + Collections.emptyList()); + } +} diff --git a/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrLocationDataCompositor.java b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrLocationDataCompositor.java new file mode 100644 index 00000000000..dd4c901d988 --- /dev/null +++ b/exporters/otlp/profiles/src/main/java/io/opentelemetry/exporter/otlp/profiles/jfr/JfrLocationDataCompositor.java @@ -0,0 +1,101 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.exporter.otlp.profiles.jfr; + +import io.opentelemetry.exporter.otlp.internal.data.ImmutableFunctionData; +import io.opentelemetry.exporter.otlp.internal.data.ImmutableLineData; +import io.opentelemetry.exporter.otlp.internal.data.ImmutableLocationData; +import io.opentelemetry.exporter.otlp.internal.data.ImmutableStackData; +import io.opentelemetry.exporter.otlp.profiles.FunctionData; +import io.opentelemetry.exporter.otlp.profiles.LineData; +import io.opentelemetry.exporter.otlp.profiles.LocationData; +import io.opentelemetry.exporter.otlp.profiles.ProfilesDictionaryCompositor; +import io.opentelemetry.exporter.otlp.profiles.StackData; +import java.util.Collections; +import java.util.List; +import jdk.jfr.consumer.RecordedFrame; + +/** + * Allows for the conversion and storage of JFR thread stacks in the dictionary encoding structure + * used by OTLP profile signal exporters. + * + *

The compositor resembles a builder, though without the fluent API. Instead, mutation methods + * return the index of the offered element, this information being required to construct any element + * that references into the tables. + * + *

This class is not threadsafe and must be externally synchronized. + */ +public class JfrLocationDataCompositor { + + private final ProfilesDictionaryCompositor profilesDictionaryCompositor; + + /** + * Wrap the given dictionary with additional JFR-specific stack data handling functionality. + * + * @param profilesDictionaryCompositor the underlying storage. + */ + public JfrLocationDataCompositor(ProfilesDictionaryCompositor profilesDictionaryCompositor) { + this.profilesDictionaryCompositor = profilesDictionaryCompositor; + } + + /** + * Stores the provided list of frames as a StackData element in the dictionary if an equivalent is + * not already present, and returns its index. + * + * @param frameList the JFR stack data. + * @return the index of the added or existing StackData element. + */ + public int putIfAbsent(List frameList) { + + List locationIndices = frameList.stream().map(this::frameToLocation).toList(); + + StackData stackData = ImmutableStackData.create(locationIndices); + int stackIndex = profilesDictionaryCompositor.putIfAbsent(stackData); + return stackIndex; + } + + /** + * Convert a single frame of a stack to a LocationData, store it and its components in the + * dictionary and return its index. + * + * @param frame the source data + * @return the LocationData storage index in the dictionary + */ + protected int frameToLocation(RecordedFrame frame) { + + // the LocationData references several components which need creating and placing in their + // respective dictionary tables + + String name = nameFrom(frame); + int nameStringIndex = profilesDictionaryCompositor.putIfAbsent(name); + + FunctionData functionData = ImmutableFunctionData.create(nameStringIndex, 0, 0, 0); + int functionIndex = profilesDictionaryCompositor.putIfAbsent(functionData); + + int lineNumber = frame.getLineNumber() != -1 ? frame.getLineNumber() : 0; + LineData lineData = ImmutableLineData.create(functionIndex, lineNumber, 0); + + LocationData locationData = + ImmutableLocationData.create(0, 0, List.of(lineData), Collections.emptyList()); + + int locationIndex = profilesDictionaryCompositor.putIfAbsent(locationData); + return locationIndex; + } + + /** + * Construct a name String from the frame. Note that the wire spec and semantic conventions don't + * define a specific string format. Override this method to customize the conversion. + * + * @param frame the JFR frame data. + * @return the name as a String. + */ + protected String nameFrom(RecordedFrame frame) { + String name = frame.getMethod().getType() != null ? frame.getMethod().getType().getName() : ""; + name += "."; + name += frame.getMethod().getName() != null ? frame.getMethod().getName() : ""; + return name; + } +}