jfr-connection: fixes for using diagnostic command to start a recording (#1352)

Co-authored-by: Jean Bisutti <jean.bisutti@gmail.com>
This commit is contained in:
David Grieve 2024-07-11 11:52:57 -04:00 committed by GitHub
parent 6e7673b11f
commit 63361ccbe8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 156 additions and 34 deletions

View File

@ -9,4 +9,5 @@ otelJava.moduleName.set("io.opentelemetry.contrib.jfr.connection")
dependencies {
testImplementation("org.openjdk.jmc:common:8.3.1")
testImplementation("org.openjdk.jmc:flightrecorder:8.3.1")
testImplementation("org.mockito:mockito-inline")
}

View File

@ -7,6 +7,8 @@ package io.opentelemetry.contrib.jfr.connection;
import java.io.IOException;
import java.io.InputStream;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
@ -33,7 +35,7 @@ import javax.management.ReflectionException;
final class FlightRecorderDiagnosticCommandConnection implements FlightRecorderConnection {
private static final String DIAGNOSTIC_COMMAND_OBJECT_NAME =
"com.sun.management:type=DiagnosticCommand";
private static final String JFR_START_REGEX = "Started recording (.+?)\\. .*";
private static final String JFR_START_REGEX = "Started recording (\\d+?)\\.";
private static final Pattern JFR_START_PATTERN = Pattern.compile(JFR_START_REGEX, Pattern.DOTALL);
// All JFR commands take String[] parameters
@ -56,7 +58,11 @@ final class FlightRecorderDiagnosticCommandConnection implements FlightRecorderC
ObjectInstance objectInstance =
mBeanServerConnection.getObjectInstance(new ObjectName(DIAGNOSTIC_COMMAND_OBJECT_NAME));
ObjectName objectName = objectInstance.getObjectName();
assertCommercialFeaturesUnlocked(mBeanServerConnection, objectName);
if (jdkHasUnlockCommercialFeatures(mBeanServerConnection)) {
assertCommercialFeaturesUnlocked(mBeanServerConnection, objectName);
}
return new FlightRecorderDiagnosticCommandConnection(
mBeanServerConnection, objectInstance.getObjectName());
} catch (MalformedObjectNameException e) {
@ -179,7 +185,7 @@ final class FlightRecorderDiagnosticCommandConnection implements FlightRecorderC
@Override
public void dumpRecording(long id, String outputFile) throws IOException, JfrConnectionException {
try {
Object[] params = mkParams("filename=" + outputFile, "recording=" + id, "compress=true");
Object[] params = mkParams("filename=" + outputFile, "name=" + id);
mBeanServerConnection.invoke(objectName, "jfrDump", params, signature);
} catch (InstanceNotFoundException | MBeanException | ReflectionException e) {
throw JfrConnectionException.canonicalJfrConnectionException(getClass(), "dumpRecording", e);
@ -210,26 +216,41 @@ final class FlightRecorderDiagnosticCommandConnection implements FlightRecorderC
throw new UnsupportedOperationException("closeRecording not supported on Java 8");
}
// Do this check separate from assertCommercialFeatures because reliance
// on System properties makes it difficult to test.
static boolean jdkHasUnlockCommercialFeatures(MBeanServerConnection mBeanServerConnection) {
try {
RuntimeMXBean runtimeMxBean =
ManagementFactory.getPlatformMXBean(mBeanServerConnection, RuntimeMXBean.class);
String javaVmVendor = runtimeMxBean.getVmVendor();
String javaVersion = runtimeMxBean.getVmVersion();
return javaVmVendor.contains("Oracle Corporation")
&& javaVersion.matches("(?:^1\\.8|9|10).*");
} catch (IOException e) {
return false;
}
}
// visible for testing
static void assertCommercialFeaturesUnlocked(
MBeanServerConnection mBeanServerConnection, ObjectName objectName)
throws IOException, JfrConnectionException {
try {
Object unlockedMessage =
mBeanServerConnection.invoke(objectName, "vmCheckCommercialFeatures", null, null);
if (unlockedMessage instanceof String) {
boolean unlocked = ((String) unlockedMessage).contains("unlocked");
if (!unlocked) {
throw new UnsupportedOperationException(
"Unlocking commercial features may be required. This must be explicitly enabled by adding -XX:+UnlockCommercialFeatures");
throw JfrConnectionException.canonicalJfrConnectionException(
FlightRecorderDiagnosticCommandConnection.class,
"assertCommercialFeaturesUnlocked",
new UnsupportedOperationException(
"Unlocking commercial features may be required. This must be explicitly enabled by adding -XX:+UnlockCommercialFeatures"));
}
}
} catch (InstanceNotFoundException
| MBeanException
| ReflectionException
| UnsupportedOperationException e) {
throw JfrConnectionException.canonicalJfrConnectionException(
FlightRecorderDiagnosticCommandConnection.class, "assertCommercialFeaturesUnlocked", e);
} catch (InstanceNotFoundException | MBeanException | ReflectionException ignored) {
// If the MBean doesn't have the vmCheckCommercialFeatures method, then we can't check it.
}
}

View File

@ -207,7 +207,7 @@ public class RecordingOptions {
* <li>"d" (days)
* </ul>
*
* For example, {@code "2 h"}, {@code "3 m"}.
* For example, {@code "2h"}, {@code "3m"}.
*
* <p>If the value is {@code null}, {@code duration} will be set to the default value, which is
* "no limit".
@ -388,7 +388,7 @@ public class RecordingOptions {
case "m":
case "h":
case "d":
return Long.toString(value) + " " + units;
return value + units;
default:
// fall through
}

View File

@ -10,14 +10,67 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import com.google.errorprone.annotations.Keep;
import java.lang.management.ManagementFactory;
import java.lang.management.RuntimeMXBean;
import java.util.stream.Stream;
import javax.management.MBeanServerConnection;
import javax.management.ObjectName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
class FlightRecorderDiagnosticCommandConnectionTest {
@Keep
static Stream<Arguments> assertJdkHasUnlockCommercialFeatures() {
return Stream.of(
Arguments.of("Oracle Corporation", "1.8.0_401", true),
Arguments.of("AdoptOpenJDK", "1.8.0_282", false),
Arguments.of("Oracle Corporation", "10.0.2", true),
Arguments.of("Oracle Corporation", "9.0.4", true),
Arguments.of("Oracle Corporation", "11.0.22", false),
Arguments.of("Microsoft", "11.0.13", false),
Arguments.of("Microsoft", "17.0.3", false),
Arguments.of("Oracle Corporation", "21.0.3", false));
}
@ParameterizedTest
@MethodSource
void assertJdkHasUnlockCommercialFeatures(String vmVendor, String vmVersion, boolean expected)
throws Exception {
MBeanServerConnection mBeanServerConnection = mock(MBeanServerConnection.class);
try (MockedStatic<ManagementFactory> mockedStatic = mockStatic(ManagementFactory.class)) {
mockedStatic
.when(
() -> ManagementFactory.getPlatformMXBean(mBeanServerConnection, RuntimeMXBean.class))
.thenAnswer(
new Answer<RuntimeMXBean>() {
@Override
public RuntimeMXBean answer(InvocationOnMock invocation) {
RuntimeMXBean mockedRuntimeMxBean = mock(RuntimeMXBean.class);
when(mockedRuntimeMxBean.getVmVendor()).thenReturn(vmVendor);
when(mockedRuntimeMxBean.getVmVersion()).thenReturn(vmVersion);
return mockedRuntimeMxBean;
}
});
boolean actual =
FlightRecorderDiagnosticCommandConnection.jdkHasUnlockCommercialFeatures(
mBeanServerConnection);
assertEquals(expected, actual, "Expected " + expected + " for " + vmVendor + " " + vmVersion);
}
}
@Test
void assertCommercialFeaturesUnlocked() throws Exception {
ObjectName objectName = mock(ObjectName.class);

View File

@ -46,18 +46,18 @@ class RecordingOptionsTest {
@Keep
static Stream<Arguments> testGetMaxAge() {
return Stream.of(
Arguments.of("3 ns", "3 ns"),
Arguments.of("3 us", "3 us"),
Arguments.of("3 ms", "3 ms"),
Arguments.of("3 s", "3 s"),
Arguments.of("3 m", "3 m"),
Arguments.of("3 h", "3 h"),
Arguments.of("3 h", "3 h"),
Arguments.of("+3 d", "3 d"),
Arguments.of("3ms", "3 ms"),
Arguments.of("3 ns", "3ns"),
Arguments.of("3 us", "3us"),
Arguments.of("3 ms", "3ms"),
Arguments.of("3 s", "3s"),
Arguments.of("3 m", "3m"),
Arguments.of("3 h", "3h"),
Arguments.of("3 h", "3h"),
Arguments.of("+3 d", "3d"),
Arguments.of("3ms", "3ms"),
Arguments.of("0", "0"),
Arguments.of("", "0"),
Arguments.of((String) null, "0"));
Arguments.of(null, "0"));
}
@ParameterizedTest
@ -209,15 +209,15 @@ class RecordingOptionsTest {
@Keep
private static Stream<Arguments> testGetDuration() {
return Stream.of(
Arguments.of("3 ns", "3 ns"),
Arguments.of("3 us", "3 us"),
Arguments.of("3 ms", "3 ms"),
Arguments.of("3 s", "3 s"),
Arguments.of("3 m", "3 m"),
Arguments.of("3 h", "3 h"),
Arguments.of("3 h", "3 h"),
Arguments.of("+3 d", "3 d"),
Arguments.of("3ms", "3 ms"),
Arguments.of("3 ns", "3ns"),
Arguments.of("3 us", "3us"),
Arguments.of("3 ms", "3ms"),
Arguments.of("3 s", "3s"),
Arguments.of("3 m", "3m"),
Arguments.of("3 h", "3h"),
Arguments.of("3 h", "3h"),
Arguments.of("+3 d", "3d"),
Arguments.of("3ms", "3ms"),
Arguments.of("0", "0"),
Arguments.of("", "0"),
Arguments.of(null, "0"));
@ -263,12 +263,12 @@ class RecordingOptionsTest {
void testGetRecordingOptions() {
Map<String, String> expected = new HashMap<>();
expected.put("name", "test");
expected.put("maxAge", "3 m");
expected.put("maxAge", "3m");
expected.put("maxSize", "1048576");
expected.put("dumpOnExit", "true");
expected.put("destination", "test.jfr");
expected.put("disk", "true");
expected.put("duration", "120 s");
expected.put("duration", "120s");
RecordingOptions opts =
new RecordingOptions.Builder()
.name("test")

View File

@ -48,6 +48,7 @@ import org.junit.jupiter.params.aggregator.ArgumentsAccessor;
import org.junit.jupiter.params.aggregator.ArgumentsAggregator;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.platform.commons.logging.Logger;
import org.junit.platform.commons.logging.LoggerFactory;
@ -329,6 +330,13 @@ class RecordingTest {
String getter =
"get" + key.substring(0, 1).toUpperCase(Locale.ROOT) + key.substring(1);
String expected = (String) compositeData.get("value");
// Special case for duration values. The FlightRecorderMXBean wants "<number><unit>"
// but returns "<number> <unit>", so we need to normalize the expected value.
if (expected != null && expected.matches("([-+]?\\d+)\\s*(\\w*)")) {
expected = expected.replaceAll("\\s", "");
}
try {
Method method = RecordingOptions.class.getMethod(getter);
String actual = (String) method.invoke(recordingOptions);
@ -505,4 +513,43 @@ class RecordingTest {
fail("Error thrown by MBean server or FlightRecorderMXBean: ", badBean);
}
}
@ParameterizedTest
@ValueSource(strings = {"5 s", "5m"})
void assertDiagnosticCommandCanRecordWithDuration(String duration) {
// Issue #1338
MBeanServerConnection mBeanServer = ManagementFactory.getPlatformMBeanServer();
try {
FlightRecorderConnection connection =
FlightRecorderDiagnosticCommandConnection.connect(mBeanServer);
assertCanRecord(connection, duration);
} catch (IOException | JfrConnectionException e) {
fail(e);
}
}
@ParameterizedTest
@ValueSource(strings = {"5 s", "5m"})
void assertMbeanCanRecordWithDuration(String duration) {
// Ensure fix for issue #1338 doesn't regress MBean recording
MBeanServerConnection mBeanServer = ManagementFactory.getPlatformMBeanServer();
try {
FlightRecorderConnection connection = FlightRecorderConnection.connect(mBeanServer);
assertCanRecord(connection, duration);
} catch (IOException | JfrConnectionException e) {
fail(e);
}
}
private static void assertCanRecord(FlightRecorderConnection connection, String duration)
throws JfrConnectionException {
RecordingOptions recordingOptions = new RecordingOptions.Builder().duration(duration).build();
RecordingConfiguration recordingConfiguration = RecordingConfiguration.PROFILE_CONFIGURATION;
try (Recording recording = connection.newRecording(recordingOptions, recordingConfiguration)) {
recording.start();
recording.stop();
} catch (IOException e) {
// Recording is autoclose
}
}
}