Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion exporters/otlp/profiles/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EventType,SampleCompositionBuilder> 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<SampleData> getSamples() {
return sampleCompositionBuilder.build();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<RecordedFrame> frameList) {

List<Integer> 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;
}
}
Loading