Skip to content

Unify runtime-telemetry modules#16087

Merged
trask merged 19 commits intoopen-telemetry:mainfrom
trask:unified-runtime-telemetry
Mar 9, 2026
Merged

Unify runtime-telemetry modules#16087
trask merged 19 commits intoopen-telemetry:mainfrom
trask:unified-runtime-telemetry

Conversation

@trask
Copy link
Copy Markdown
Member

@trask trask commented Feb 2, 2026

Currently built on top of

First of all, I apologize for such a large PR. A good bit of the diff is because of copied files (I've annotated these) and imports, but there is still a lot.

This unifies runtime-telemetry-java8 and runtime-telemetry-java17 modules into a single runtime-telemetry module that supports both Java 8 and Java 17 via multijar.

The motivation was that I was having a lot of trouble understanding the configuration story for the existing runtime-telemetry modules which ended up blocking progress on #15822.

A second motivation is stabilizing the runtime-telemetry instrumentation:

Full backcompat is provided for existing users of both javaagent and library instrumentation, but several stable options have been deprecated and will be removed in 3.0 (see below).

Note: do not merge this PR until after the next release, because a prior deprecation PR needs to be released first:

Settings for the unified Runtime Telemetry instrumentation

System property Type Default Description
otel.instrumentation.runtime-telemetry.emit-experimental-metrics Boolean false Enable the capture of experimental metrics.
otel.instrumentation.runtime-telemetry.experimental.prefer-jfr Boolean false Prefer JFR over JMX for metrics where both collection methods are available (Java 17+).
otel.instrumentation.runtime-telemetry.experimental.package-emitter.enabled Boolean false Enable creating events for JAR libraries used by the application.
otel.instrumentation.runtime-telemetry.experimental.package-emitter.jars-per-second Integer 10 The number of JAR files processed per second.

Deprecated Properties (to be removed in 3.0)

System property Type Default Description
otel.instrumentation.runtime-telemetry.capture-gc-cause Boolean false Enable the capture of the jvm.gc.cause attribute. Will always be captured in 3.0.
otel.instrumentation.runtime-telemetry.emit-experimental-telemetry Boolean false Use emit-experimental-metrics instead.
otel.instrumentation.runtime-telemetry.package-emitter.enabled Boolean false Use experimental.package-emitter.enabled instead.
otel.instrumentation.runtime-telemetry.package-emitter.jars-per-second Integer 10 Use experimental.package-emitter.jars-per-second instead.
otel.instrumentation.runtime-telemetry-java17.enabled Boolean false Deprecated. Use emit-experimental-metrics for experimental JFR features.
otel.instrumentation.runtime-telemetry-java17.enable-all Boolean false Deprecated. Use emit-experimental-metrics and experimental.prefer-jfr.

@github-actions github-actions Bot added the test native This label can be applied to PRs to trigger them to run native tests label Feb 2, 2026
@trask trask force-pushed the unified-runtime-telemetry branch 14 times, most recently from f45a4f0 to bd6ea19 Compare February 4, 2026 22:42
@trask trask changed the title Unified runtime-telemetry module [DO NOT MERGE UNTIL AFTER NEXT RELEASE] Unify runtime-telemetry modules Feb 4, 2026
@trask trask force-pushed the unified-runtime-telemetry branch from 9ea8fa0 to 951da56 Compare February 4, 2026 23:31
@trask trask force-pushed the unified-runtime-telemetry branch from 951da56 to 74b4b47 Compare February 5, 2026 01:17
@trask trask marked this pull request as ready for review February 5, 2026 03:03
@trask trask requested a review from a team as a code owner February 5, 2026 03:03
private static final String METRIC_NAME = "jvm.thread.count";
private static final String EVENT_NAME = "jdk.JavaThreadStatistics";
private static final String METRIC_DESCRIPTION = "Number of executing threads";
private static final String METRIC_DESCRIPTION = "Number of executing platform threads.";
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updating metric description is not a breaking change

assertThat(
path.matches(
"opentelemetry-javaagent-runtime-telemetry-java8-[0-9a-zA-Z-\\.]+\\.jar"))
"opentelemetry-javaagent-runtime-telemetry-[0-9a-zA-Z-\\.]+\\.jar"))
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just a path (not an instrumentation name)

.loggerBuilder(instrumentationName)
.setInstrumentationVersion(instrumentationVersion)
.build();
Worker worker = new Worker(logger, toProcess, jarsPerSecond);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated improvement: Using Logger instead LogRecordBuilder (since it's used to emit multiple logs)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from runtime-telemetry-java8/library/.../JmxRuntimeMetricsFactory.java

diff --git a/instrumentation/runtime-telemetry/runtime-telemetry-java8/library/src/main/java/io/opentelemetry/instrumentation/runtimemetrics/java8/internal/JmxRuntimeMetricsFactory.java b/instrumentation/runtime-telemetry/library/src/main/java/io/opentelemetry/instrumentation/runtimetelemetry/internal/JmxRuntimeMetricsFactory.java
index de9464b4f8..10f16c4cee 100644
--- a/instrumentation/runtime-telemetry/runtime-telemetry-java8/library/src/main/java/io/opentelemetry/instrumentation/runtimemetrics/java8/internal/JmxRuntimeMetricsFactory.java
+++ b/instrumentation/runtime-telemetry/library/src/main/java/io/opentelemetry/instrumentation/runtimetelemetry/internal/JmxRuntimeMetricsFactory.java
@@ -3,9 +3,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-package io.opentelemetry.instrumentation.runtimemetrics.java8.internal;
+package io.opentelemetry.instrumentation.runtimetelemetry.internal;
 
-import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.api.metrics.Meter;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -14,21 +14,30 @@ import java.util.List;
  * any time.
  */
 public class JmxRuntimeMetricsFactory {
-  @SuppressWarnings({"CatchingUnchecked", "deprecation"}) // ExperimentalXxx classes are deprecated
+  @SuppressWarnings("deprecation") // ExperimentalXxx classes are deprecated
   public static List<AutoCloseable> buildObservables(
-      OpenTelemetry openTelemetry, boolean emitExperimentalTelemetry, boolean captureGcCause) {
+      boolean emitExperimentalTelemetry,
+      boolean captureGcCause,
+      boolean preferJfrMetrics,
+      Meter meter) {
     // Set up metrics gathered by JMX
+    // When preferJfrMetrics is true, skip JMX metrics that have JFR equivalents
     List<AutoCloseable> observables = new ArrayList<>();
-    observables.addAll(Classes.registerObservers(openTelemetry));
-    observables.addAll(Cpu.registerObservers(openTelemetry));
-    observables.addAll(GarbageCollector.registerObservers(openTelemetry, captureGcCause));
-    observables.addAll(MemoryPools.registerObservers(openTelemetry));
-    observables.addAll(Threads.registerObservers(openTelemetry));
+    if (!preferJfrMetrics) {
+      observables.addAll(Classes.registerObservers(meter));
+      observables.addAll(Cpu.registerObservers(meter));
+      observables.addAll(GarbageCollector.registerObservers(meter, captureGcCause));
+      observables.addAll(MemoryPools.registerObservers(meter));
+      observables.addAll(Threads.registerObservers(meter));
+    }
     if (emitExperimentalTelemetry) {
-      observables.addAll(ExperimentalBufferPools.registerObservers(openTelemetry));
-      observables.addAll(ExperimentalCpu.registerObservers(openTelemetry));
-      observables.addAll(ExperimentalMemoryPools.registerObservers(openTelemetry));
-      observables.addAll(ExperimentalFileDescriptor.registerObservers(openTelemetry));
+      if (!preferJfrMetrics) {
+        observables.addAll(ExperimentalBufferPools.registerObservers(meter));
+        observables.addAll(ExperimentalCpu.registerObservers(meter));
+        observables.addAll(ExperimentalMemoryPools.registerObservers(meter));
+      }
+      // ExperimentalFileDescriptor has no JFR equivalent, always register
+      observables.addAll(ExperimentalFileDescriptor.registerObservers(meter));
     }
     return observables;
   }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from runtime-telemetry-java8/library/.../RuntimeMetrics.java

diff --git a/instrumentation/runtime-telemetry/runtime-telemetry-java8/library/src/main/java/io/opentelemetry/instrumentation/runtimemetrics/java8/RuntimeMetrics.java b/instrumentation/runtime-telemetry/library/src/main/java/io/opentelemetry/instrumentation/runtimetelemetry/RuntimeTelemetry.java
index 62e711ebd9..2cd832b8f4 100644
--- a/instrumentation/runtime-telemetry/runtime-telemetry-java8/library/src/main/java/io/opentelemetry/instrumentation/runtimemetrics/java8/RuntimeMetrics.java
+++ b/instrumentation/runtime-telemetry/library/src/main/java/io/opentelemetry/instrumentation/runtimetelemetry/RuntimeTelemetry.java
@@ -3,57 +3,79 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-package io.opentelemetry.instrumentation.runtimemetrics.java8;
+package io.opentelemetry.instrumentation.runtimetelemetry;
 
 import io.opentelemetry.api.OpenTelemetry;
-import io.opentelemetry.instrumentation.runtimemetrics.java8.internal.JmxRuntimeMetricsUtil;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.logging.Level;
 import java.util.logging.Logger;
+import javax.annotation.Nullable;
 
-/** The entry point class for runtime metrics support using JMX. */
-public final class RuntimeMetrics implements AutoCloseable {
+/** The entry point class for runtime telemetry support using JMX (Java 8+) and JFR (Java 17+). */
+public final class RuntimeTelemetry implements AutoCloseable {
 
-  private static final Logger logger = Logger.getLogger(RuntimeMetrics.class.getName());
+  private static final Logger logger = Logger.getLogger(RuntimeTelemetry.class.getName());
 
   private final AtomicBoolean isClosed = new AtomicBoolean();
   private final List<AutoCloseable> observables;
+  @Nullable private final AutoCloseable jfrTelemetry;
 
-  RuntimeMetrics(List<AutoCloseable> observables) {
+  RuntimeTelemetry(List<AutoCloseable> observables, @Nullable AutoCloseable jfrTelemetry) {
     this.observables = Collections.unmodifiableList(observables);
+    this.jfrTelemetry = jfrTelemetry;
   }
 
   /**
-   * Create and start {@link RuntimeMetrics}.
+   * Create and start {@link RuntimeTelemetry}.
    *
-   * <p>Listens for select JMX beans, extracts data, and records to various metrics. Recording will
-   * continue until {@link #close()} is called.
+   * <p>Listens for select JMX beans (and JFR events on Java 17+), extracts data, and records to
+   * various metrics. Recording will continue until {@link #close()} is called.
    *
    * @param openTelemetry the {@link OpenTelemetry} instance used to record telemetry
    */
-  public static RuntimeMetrics create(OpenTelemetry openTelemetry) {
-    return new RuntimeMetricsBuilder(openTelemetry).build();
+  public static RuntimeTelemetry create(OpenTelemetry openTelemetry) {
+    return new RuntimeTelemetryBuilder(openTelemetry).build();
   }
 
   /**
-   * Create a builder for configuring {@link RuntimeMetrics}.
+   * Create a builder for configuring {@link RuntimeTelemetry}.
    *
    * @param openTelemetry the {@link OpenTelemetry} instance used to record telemetry
    */
-  public static RuntimeMetricsBuilder builder(OpenTelemetry openTelemetry) {
-    return new RuntimeMetricsBuilder(openTelemetry);
+  public static RuntimeTelemetryBuilder builder(OpenTelemetry openTelemetry) {
+    return new RuntimeTelemetryBuilder(openTelemetry);
   }
 
-  /** Stop recording JMX metrics. */
+  // Only used by tests
+  @Nullable
+  AutoCloseable getJfrTelemetry() {
+    return jfrTelemetry;
+  }
+
+  /** Stop recording metrics. */
   @Override
   public void close() {
     if (!isClosed.compareAndSet(false, true)) {
-      logger.log(Level.WARNING, "RuntimeMetrics is already closed");
+      logger.log(Level.WARNING, "RuntimeTelemetry is already closed");
       return;
     }
 
-    JmxRuntimeMetricsUtil.closeObservers(observables);
+    if (jfrTelemetry != null) {
+      try {
+        jfrTelemetry.close();
+      } catch (Exception e) {
+        logger.log(Level.WARNING, "Error closing JFR telemetry", e);
+      }
+    }
+
+    for (AutoCloseable observable : observables) {
+      try {
+        observable.close();
+      } catch (Exception e) {
+        // Ignore
+      }
+    }
   }
 }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from runtime-telemetry-java17/library/.../JfrFeature.java

diff --git a/instrumentation/runtime-telemetry/runtime-telemetry-java17/library/src/main/java/io/opentelemetry/instrumentation/runtimemetrics/java17/JfrFeature.java b/instrumentation/runtime-telemetry/library/src/main/java17/io/opentelemetry/instrumentation/runtimetelemetry/internal/JfrFeature.java
index aacaaf8fa6..598462e9bc 100644
--- a/instrumentation/runtime-telemetry/runtime-telemetry-java17/library/src/main/java/io/opentelemetry/instrumentation/runtimemetrics/java17/JfrFeature.java
+++ b/instrumentation/runtime-telemetry/library/src/main/java17/io/opentelemetry/instrumentation/runtimetelemetry/internal/JfrFeature.java
@@ -3,36 +3,47 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-package io.opentelemetry.instrumentation.runtimemetrics.java17;
+package io.opentelemetry.instrumentation.runtimetelemetry.internal;
 
 /**
- * Enumeration of JFR features, which can be toggled on or off via {@link RuntimeMetricsBuilder}.
+ * Enumeration of JFR features, used internally to control which JFR events are registered.
  *
- * <p>Features are disabled by default if they are already available through {@code
- * io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry-java8} JMX based
- * instrumentation.
+ * <p>Features that overlap with stable JMX-based instrumentation are disabled by default to avoid
+ * duplicate metrics. Experimental features (those not marked stable in the semantic conventions)
+ * are also disabled by default and require {@code emit_experimental_metrics=true} to enable.
+ *
+ * <p>This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
  */
 public enum JfrFeature {
-  BUFFER_METRICS(/* defaultEnabled= */ false),
-  CLASS_LOAD_METRICS(/* defaultEnabled= */ false),
-  CONTEXT_SWITCH_METRICS(/* defaultEnabled= */ true),
-  CPU_COUNT_METRICS(/* defaultEnabled= */ true),
-  CPU_UTILIZATION_METRICS(/* defaultEnabled= */ false),
-  GC_DURATION_METRICS(/* defaultEnabled= */ false),
-  LOCK_METRICS(/* defaultEnabled= */ true),
-  MEMORY_ALLOCATION_METRICS(/* defaultEnabled= */ true),
-  MEMORY_POOL_METRICS(/* defaultEnabled= */ false),
-  NETWORK_IO_METRICS(/* defaultEnabled= */ true),
-  THREAD_METRICS(/* defaultEnabled= */ false),
+  BUFFER_METRICS(/* overlapsWithJmx= */ true, /* experimental= */ true),
+  CLASS_LOAD_METRICS(/* overlapsWithJmx= */ true, /* experimental= */ false),
+  CONTEXT_SWITCH_METRICS(/* overlapsWithJmx= */ false, /* experimental= */ true),
+  CPU_COUNT_METRICS(/* overlapsWithJmx= */ true, /* experimental= */ false),
+  CPU_UTILIZATION_METRICS(/* overlapsWithJmx= */ true, /* experimental= */ false),
+  GC_DURATION_METRICS(/* overlapsWithJmx= */ true, /* experimental= */ false),
+  LOCK_METRICS(/* overlapsWithJmx= */ false, /* experimental= */ true),
+  MEMORY_ALLOCATION_METRICS(/* overlapsWithJmx= */ false, /* experimental= */ true),
+  MEMORY_POOL_METRICS(/* overlapsWithJmx= */ true, /* experimental= */ false),
+  NETWORK_IO_METRICS(/* overlapsWithJmx= */ false, /* experimental= */ true),
+  THREAD_METRICS(/* overlapsWithJmx= */ true, /* experimental= */ false),
   ;
 
-  private final boolean defaultEnabled;
+  private final boolean overlapsWithJmx;
+  private final boolean experimental;
+
+  JfrFeature(boolean overlapsWithJmx, boolean experimental) {
+    this.overlapsWithJmx = overlapsWithJmx;
+    this.experimental = experimental;
+  }
 
-  JfrFeature(boolean defaultEnabled) {
-    this.defaultEnabled = defaultEnabled;
+  /** Returns true if this JFR feature overlaps with JMX-based metrics. */
+  public boolean overlapsWithJmx() {
+    return overlapsWithJmx;
   }
 
-  boolean isDefaultEnabled() {
-    return defaultEnabled;
+  /** Returns true if this JFR feature produces experimental (non-stable) metrics. */
+  public boolean isExperimental() {
+    return experimental;
   }
 }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact copy from runtime-telemetry-java8/library/.../reflect-config.json

git diff main:instrumentation/runtime-telemetry/runtime-telemetry-java8/library/src/main/resources/META-INF/native-image/reflect-config.json HEAD:instrumentation/runtime-telemetry/library/src/main/resources/META-INF/native-image/reflect-config.json

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from runtime-telemetry-java17/library.../RuntimeMetricsBuilderTest.java

diff --git a/instrumentation/runtime-telemetry/runtime-telemetry-java17/library/src/test/java/io/opentelemetry/instrumentation/runtimemetrics/java17/RuntimeMetricsBuilderTest.java b/instrumentation/runtime-telemetry/library/src/testJava17/java/io/opentelemetry/instrumentation/runtimetelemetry/RuntimeTelemetryBuilderTest.java
index 78f88af3f4..be16d5f587 100644
--- a/instrumentation/runtime-telemetry/runtime-telemetry-java17/library/src/test/java/io/opentelemetry/instrumentation/runtimemetrics/java17/RuntimeMetricsBuilderTest.java
+++ b/instrumentation/runtime-telemetry/library/src/testJava17/java/io/opentelemetry/instrumentation/runtimetelemetry/RuntimeTelemetryBuilderTest.java
@@ -3,11 +3,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-package io.opentelemetry.instrumentation.runtimemetrics.java17;
+package io.opentelemetry.instrumentation.runtimetelemetry;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
 import io.opentelemetry.api.OpenTelemetry;
+import io.opentelemetry.instrumentation.runtimetelemetry.internal.JfrConfig;
+import io.opentelemetry.instrumentation.runtimetelemetry.internal.JfrFeature;
 import java.util.Arrays;
 import java.util.HashMap;
 import jdk.jfr.FlightRecorder;
@@ -15,7 +17,7 @@ import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
-class RuntimeMetricsBuilderTest {
+class RuntimeTelemetryBuilderTest {
 
   @BeforeAll
   static void setup() {
@@ -29,48 +31,68 @@ class RuntimeMetricsBuilderTest {
 
   @Test
   void defaultFeatures() {
+    // By default, features that don't overlap with JMX AND are not experimental are enabled
     var defaultFeatures = new HashMap<JfrFeature, Boolean>();
     Arrays.stream(JfrFeature.values())
-        .forEach(jfrFeature -> defaultFeatures.put(jfrFeature, jfrFeature.isDefaultEnabled()));
+        .forEach(
+            jfrFeature ->
+                defaultFeatures.put(
+                    jfrFeature, !jfrFeature.overlapsWithJmx() && !jfrFeature.isExperimental()));
 
-    assertThat(new RuntimeMetricsBuilder(OpenTelemetry.noop()).enabledFeatureMap)
-        .isEqualTo(defaultFeatures);
+    assertThat(newBuilder().getJfrConfig().enabledFeatureMap).isEqualTo(defaultFeatures);
   }
 
   @Test
   void enableAllFeatures() {
-    assertThat(
-            new RuntimeMetricsBuilder(OpenTelemetry.noop()).enableAllFeatures().enabledFeatureMap)
+    assertThat(newBuilder().getJfrConfig().enableAllFeatures().enabledFeatureMap)
         .allSatisfy((unused, enabled) -> assertThat(enabled).isTrue());
   }
 
   @Test
   void disableAllFeatures() {
-    assertThat(
-            new RuntimeMetricsBuilder(OpenTelemetry.noop()).disableAllFeatures().enabledFeatureMap)
+    assertThat(newBuilder().getJfrConfig().disableAllFeatures().enabledFeatureMap)
         .allSatisfy((unused, enabled) -> assertThat(enabled).isFalse());
   }
 
   @Test
   void enableDisableFeature() {
-    var builder = new RuntimeMetricsBuilder(OpenTelemetry.noop());
+    var builder = RuntimeTelemetry.builder(OpenTelemetry.noop());
 
-    assertThat(builder.enabledFeatureMap.get(JfrFeature.BUFFER_METRICS)).isFalse();
+    // BUFFER_METRICS overlaps with JMX and is experimental, so it's disabled by default
+    assertThat(builder.getJfrConfig().enabledFeatureMap.get(JfrFeature.BUFFER_METRICS)).isFalse();
 
-    builder.enableFeature(JfrFeature.BUFFER_METRICS);
-    assertThat(builder.enabledFeatureMap.get(JfrFeature.BUFFER_METRICS)).isTrue();
-    builder.disableFeature(JfrFeature.BUFFER_METRICS);
-    assertThat(builder.enabledFeatureMap.get(JfrFeature.BUFFER_METRICS)).isFalse();
+    builder.getJfrConfig().enableFeature(JfrFeature.BUFFER_METRICS);
+    assertThat(builder.getJfrConfig().enabledFeatureMap.get(JfrFeature.BUFFER_METRICS)).isTrue();
+    builder.getJfrConfig().disableFeature(JfrFeature.BUFFER_METRICS);
+    assertThat(builder.getJfrConfig().enabledFeatureMap.get(JfrFeature.BUFFER_METRICS)).isFalse();
   }
 
   @Test
-  void build() {
+  void build_DefaultNoJfr() {
+    // By default, no JFR features are enabled because all features either overlap
+    // with JMX or are experimental
     var openTelemetry = OpenTelemetry.noop();
-    try (var jfrTelemetry = new RuntimeMetricsBuilder(openTelemetry).build()) {
-      assertThat(jfrTelemetry.getOpenTelemetry()).isSameAs(openTelemetry);
-      assertThat(jfrTelemetry.getJfrRuntimeMetrics().getRecordedEventHandlers())
+    try (var runtimeTelemetry = RuntimeTelemetry.builder(openTelemetry).build()) {
+      assertThat(runtimeTelemetry.getJfrTelemetry()).isNull();
+    }
+  }
+
+  @Test
+  void build_WithFeatureEnabled() {
+    var openTelemetry = OpenTelemetry.noop();
+    var builder = RuntimeTelemetry.builder(openTelemetry);
+    builder.getJfrConfig().enableFeature(JfrFeature.LOCK_METRICS);
+    try (var runtimeTelemetry = builder.build()) {
+      var jfrRuntimeMetrics = (JfrConfig.JfrRuntimeMetrics) runtimeTelemetry.getJfrTelemetry();
+      assertThat(jfrRuntimeMetrics).isNotNull();
+      assertThat(jfrRuntimeMetrics.getRecordedEventHandlers())
           .hasSizeGreaterThan(0)
-          .allSatisfy(handler -> assertThat(handler.getFeature().isDefaultEnabled()).isTrue());
+          .allSatisfy(
+              handler -> assertThat(handler.getFeature()).isEqualTo(JfrFeature.LOCK_METRICS));
     }
   }
+
+  private static RuntimeTelemetryBuilder newBuilder() {
+    return RuntimeTelemetry.builder(OpenTelemetry.noop());
+  }
 }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from runtime-telemetry-java17/library/.../RuntimeMetricsTest.java

diff --git a/instrumentation/runtime-telemetry/runtime-telemetry-java17/library/src/test/java/io/opentelemetry/instrumentation/runtimemetrics/java17/RuntimeMetricsTest.java b/instrumentation/runtime-telemetry/library/src/testJava17/java/io/opentelemetry/instrumentation/runtimetelemetry/RuntimeTelemetryTest.java
index 15b178abf8..142e23bcf1 100644
--- a/instrumentation/runtime-telemetry/runtime-telemetry-java17/library/src/test/java/io/opentelemetry/instrumentation/runtimemetrics/java17/RuntimeMetricsTest.java
+++ b/instrumentation/runtime-telemetry/library/src/testJava17/java/io/opentelemetry/instrumentation/runtimetelemetry/RuntimeTelemetryTest.java
@@ -3,11 +3,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-package io.opentelemetry.instrumentation.runtimemetrics.java17;
+package io.opentelemetry.instrumentation.runtimetelemetry;
 
 import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat;
 
 import io.github.netmikey.logunit.api.LogCapturer;
+import io.opentelemetry.instrumentation.runtimetelemetry.internal.JfrConfig;
+import io.opentelemetry.instrumentation.runtimetelemetry.internal.JfrFeature;
 import io.opentelemetry.sdk.OpenTelemetrySdk;
 import io.opentelemetry.sdk.metrics.SdkMeterProvider;
 import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader;
@@ -19,9 +21,9 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.RegisterExtension;
 
-class RuntimeMetricsTest {
+class RuntimeTelemetryTest {
 
-  @RegisterExtension LogCapturer logs = LogCapturer.create().captureForType(RuntimeMetrics.class);
+  @RegisterExtension LogCapturer logs = LogCapturer.create().captureForType(RuntimeTelemetry.class);
 
   private InMemoryMetricReader reader;
   private OpenTelemetrySdk sdk;
@@ -51,48 +53,60 @@ class RuntimeMetricsTest {
 
   @Test
   void create_Default() {
-    try (RuntimeMetrics unused = RuntimeMetrics.create(sdk)) {
+    try (RuntimeTelemetry unused = RuntimeTelemetry.create(sdk)) {
       assertThat(reader.collectAllMetrics())
           .isNotEmpty()
           .allSatisfy(
               metric -> {
                 assertThat(metric.getInstrumentationScopeInfo().getName())
-                    .contains("io.opentelemetry.runtime-telemetry-java");
+                    .isEqualTo("io.opentelemetry.runtime-telemetry");
               });
     }
   }
 
   @Test
-  void create_AllDisabled() {
-    try (RuntimeMetrics unused = RuntimeMetrics.builder(sdk).disableAllMetrics().build()) {
-      assertThat(reader.collectAllMetrics()).isEmpty();
+  void builder_DefaultNoJfr() {
+    // By default, no JFR features are enabled because all features either overlap
+    // with JMX or are experimental
+    try (var runtimeTelemetry = RuntimeTelemetry.builder(sdk).build()) {
+      assertThat(runtimeTelemetry.getJfrTelemetry()).isNull();
     }
   }
 
   @Test
-  void builder() {
-    try (var jfrTelemetry = RuntimeMetrics.builder(sdk).build()) {
-      assertThat(jfrTelemetry.getOpenTelemetry()).isSameAs(sdk);
-      assertThat(jfrTelemetry.getJfrRuntimeMetrics().getRecordedEventHandlers())
+  void builder_WithFeatureEnabled() {
+    RuntimeTelemetryBuilder builder = RuntimeTelemetry.builder(sdk);
+    builder.getJfrConfig().enableFeature(JfrFeature.LOCK_METRICS);
+    try (var runtimeTelemetry = builder.build()) {
+      JfrConfig.JfrRuntimeMetrics jfrRuntimeMetrics =
+          (JfrConfig.JfrRuntimeMetrics) runtimeTelemetry.getJfrTelemetry();
+      assertThat(jfrRuntimeMetrics).isNotNull();
+      assertThat(jfrRuntimeMetrics.getRecordedEventHandlers())
           .hasSizeGreaterThan(0)
-          .allSatisfy(handler -> assertThat(handler.getFeature().isDefaultEnabled()).isTrue());
+          .allSatisfy(
+              handler -> {
+                assertThat(handler.getFeature()).isEqualTo(JfrFeature.LOCK_METRICS);
+              });
     }
   }
 
   @Test
   void close() throws InterruptedException {
-    try (RuntimeMetrics jfrTelemetry = RuntimeMetrics.builder(sdk).build()) {
+    RuntimeTelemetryBuilder builder = RuntimeTelemetry.builder(sdk);
+    // Enable a feature to test close behavior with JFR
+    builder.getJfrConfig().enableFeature(JfrFeature.LOCK_METRICS);
+    try (RuntimeTelemetry jfrTelemetry = builder.build()) {
+      JfrConfig.JfrRuntimeMetrics jfrRuntimeMetrics =
+          (JfrConfig.JfrRuntimeMetrics) jfrTelemetry.getJfrTelemetry();
+
       // Track whether RecordingStream has been closed
       AtomicBoolean recordingStreamClosed = new AtomicBoolean(false);
-      jfrTelemetry
-          .getJfrRuntimeMetrics()
-          .getRecordingStream()
-          .onClose(() -> recordingStreamClosed.set(true));
+      jfrRuntimeMetrics.getRecordingStream().onClose(() -> recordingStreamClosed.set(true));
 
       assertThat(reader.collectAllMetrics()).isNotEmpty();
 
       jfrTelemetry.close();
-      logs.assertDoesNotContain("RuntimeMetrics is already closed");
+      logs.assertDoesNotContain("RuntimeTelemetry is already closed");
       assertThat(recordingStreamClosed.get()).isTrue();
 
       // clear all metrics that might have arrived after close
@@ -103,7 +117,7 @@ class RuntimeMetricsTest {
       assertThat(reader.collectAllMetrics()).isEmpty();
 
       jfrTelemetry.close();
-      logs.assertContains("RuntimeMetrics is already closed");
+      logs.assertContains("RuntimeTelemetry is already closed");
     }
   }
 }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from runtime-telemetry-java8/library/README.md

diff --git a/instrumentation/runtime-telemetry/runtime-telemetry-java8/library/README.md b/instrumentation/runtime-telemetry/library/README.md
index 7ddde09e1f..29339b2138 100644
--- a/instrumentation/runtime-telemetry/runtime-telemetry-java8/library/README.md
+++ b/instrumentation/runtime-telemetry/library/README.md
@@ -2,12 +2,16 @@
 
 This module provides JVM runtime metrics as documented in the [semantic conventions](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/runtime/jvm-metrics.md).
 
+This is the unified runtime telemetry module that works on all Java versions. On Java 8-16, it uses
+JMX for metrics collection. On Java 17+, it can additionally use JFR (Java Flight Recorder) for
+metrics that are not available via JMX.
+
 ## Quickstart
 
 ### Add these dependencies to your project
 
 Replace `OPENTELEMETRY_VERSION` with the [latest
-release]( https://central.sonatype.com/artifact/io.opentelemetry.instrumentation/opentelemetry-runtime-telemetry-java8).
+release](https://central.sonatype.com/artifact/io.opentelemetry.instrumentation/opentelemetry-runtime-telemetry).
 
 For Maven, add to your `pom.xml` dependencies:
 
@@ -15,7 +19,7 @@ For Maven, add to your `pom.xml` dependencies:
 <dependencies>
   <dependency>
     <groupId>io.opentelemetry.instrumentation</groupId>
-    <artifactId>opentelemetry-runtime-telemetry-java8</artifactId>
+    <artifactId>opentelemetry-runtime-telemetry</artifactId>
     <version>OPENTELEMETRY_VERSION</version>
   </dependency>
 </dependencies>
@@ -24,7 +28,7 @@ For Maven, add to your `pom.xml` dependencies:
 For Gradle, add to your dependencies:
 
 ```groovy
-runtimeOnly("io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry-java8:OPENTELEMETRY_VERSION")
+runtimeOnly("io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry:OPENTELEMETRY_VERSION")
 ```
 
 ### Usage
@@ -34,10 +38,10 @@ Register JVM runtime metrics:
 ```java
 OpenTelemetry openTelemetry = // OpenTelemetry instance configured elsewhere
 
-RuntimeMetrics runtimeMetrics = RuntimeMetrics.create(openTelemetry);
+RuntimeTelemetry runtimeTelemetry = RuntimeTelemetry.create(openTelemetry);
 
 // When done, close to stop metric collection
-runtimeMetrics.close();
+runtimeTelemetry.close();
 ```
 
 To select specific metrics, configure [metric views](https://opentelemetry.io/docs/languages/java/sdk/#views)
@@ -70,17 +74,61 @@ meter_provider:
   views:
     # Drop all metrics from this instrumentation scope
     - selector:
-        meter_name: io.opentelemetry.runtime-telemetry-java8
+        meter_name: io.opentelemetry.runtime-telemetry
       stream:
         aggregation:
           drop:
     # Keep jvm.memory.used (views are additive, this creates a second stream)
     - selector:
-        meter_name: io.opentelemetry.runtime-telemetry-java8
+        meter_name: io.opentelemetry.runtime-telemetry
         instrument_name: jvm.memory.used
       stream: {}
 ```
 
+## Metrics
+
+### Stable Metrics (enabled by default)
+
+These metrics are collected via JMX on all Java versions:
+
+| Metric | Description |
+| -------- | ----------- |
+| `jvm.class.count` | Number of classes currently loaded |
+| `jvm.class.loaded` | Number of classes loaded since JVM start |
+| `jvm.class.unloaded` | Number of classes unloaded since JVM start |
+| `jvm.cpu.recent_utilization` | Recent CPU utilization for the process |
+| `jvm.cpu.time` | CPU time used by the process |
+| `jvm.gc.duration` | Duration of JVM garbage collection actions |
+| `jvm.memory.committed` | Measure of memory committed |
+| `jvm.memory.limit` | Measure of max obtainable memory |
+| `jvm.memory.used` | Measure of memory used |
+| `jvm.memory.used_after_last_gc` | Measure of memory used, as measured after the most recent garbage collection event on this pool |
+| `jvm.thread.count` | Number of executing platform threads |
+
+### Experimental Metrics
+
+These metrics are enabled with `emitExperimentalMetrics()`:
+
+**JMX-based (all Java versions):**
+
+| Metric | Description |
+| -------- | ----------- |
+| `jvm.buffer.count` | Number of buffers in the pool |
+| `jvm.buffer.memory.limit` | Measure of total memory capacity of buffers |
+| `jvm.buffer.memory.used` | Measure of memory used by buffers |
+| `jvm.memory.init` | Measure of initial memory requested |
+| `jvm.system.cpu.utilization` | System-wide CPU utilization |
+
+**JFR-based (Java 17+ only):**
+
+| Metric | Description |
+| -------- | ----------- |
+| `jvm.cpu.context_switch` | Context switch rate |
+| `jvm.cpu.longlock` | Long lock contention |
+| `jvm.memory.allocation` | Memory allocation rate |
+| `jvm.network.io` | Network I/O bytes |
+| `jvm.network.time` | Network I/O time |
+
 ## Garbage Collector Dependent Metrics
 
 The attributes reported on the memory metrics (`jvm.memory.*`) and gc metrics (`jvm.gc.*`) are dependent on the garbage collector used by the application, since each garbage collector organizes memory pools differently and has different strategies for reclaiming memory during garbage collection.

@jack-berg jack-berg mentioned this pull request Feb 5, 2026
@zeitlinger
Copy link
Copy Markdown
Member

Enable the capture of the jvm.gc.cause attribute. Will always be captured in 3.0.

It's opt-in according to sem conv - is it going to be stable then?

@trask
Copy link
Copy Markdown
Member Author

trask commented Feb 6, 2026

It's opt-in according to sem conv - is it going to be stable then?

Good point. The only reason this particular attribute is opt-in is because it was added after stabilizing the metric, and it's a breaking change in semconv to add a recommended attribute to a stable metric (in most cases). But I think instrumentation that takes a major version bump should add it. Need to confirm / codify in semconv though. I'll raise in Monday's semconv SIG and figure out if / how to do this properly.

Copy link
Copy Markdown
Member

@zeitlinger zeitlinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initial review - will continue once I understand how we want to get this aligned with #15822

final class JarAnalyzer implements ClassFileTransformer {

private static final Logger logger = Logger.getLogger(JarAnalyzer.class.getName());
private static final java.util.logging.Logger logger =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use import

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't because also using io.opentelemetry.api.logs.Logger in this class

.logRecordBuilder();
Worker worker = new Worker(logRecordBuilder, toProcess, jarsPerSecond);
private JarAnalyzer(OpenTelemetry openTelemetry, String instrumentationName, int jarsPerSecond) {
String instrumentationVersion =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

version is nullable - add requireNonNull

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

@Override
public void beforeAgent(AutoConfiguredOpenTelemetrySdk autoConfiguredOpenTelemetrySdk) {
OpenTelemetry openTelemetry = GlobalOpenTelemetry.get();
DeclarativeConfigProperties config =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do I get it right that we're no longer checking if a module is active?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's still being checked, a few lines down


// Determine which configuration is being used
boolean baseEnabled = config.getBoolean("enabled", instrumentationMode.equals("default"));
boolean java17Enabled = java17Config.getBoolean("enabled", false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're still checking if a module is enabled as before - so I don't see how this helps with #15822

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good question, the way I'm thinking about it is that this Internal.configure() method is just to provide backcompat, and so we wouldn't update this method at all in #15822.

And in #15822, we would check the new distro property, and if the unified runtime-telemetry modules isn't enabled, we wouldn't even call Internal.configure().

Then in 3.0 we can remove the whole Internal class and move reading the configuration (other than distro node) into the RuntimeTelemetryBuilder class itself (so library users can also benefit), and javaagent and spring starter can use RuntimeTelemetryBuilder directly without relying on any internal classes (other than maybe the Experimental class which is a different story).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole config class is in an internal package right now - we don't need to create something for backcompat.

Having a class Internal doesn't make it more internal, IMO.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just named it Internal as it kind of pairs with Experimental class name, can rename if you have preference

Comment on lines +249 to +250
public static RuntimeTelemetry configure(
OpenTelemetry openTelemetry, String instrumentationMode) {
Copy link
Copy Markdown
Member Author

@trask trask Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in Distro Node PR, we can look up the single unified enabled property in declarative config distro node, and not call this entirely

@trask trask force-pushed the unified-runtime-telemetry branch from 7acc658 to 4cf6b55 Compare February 21, 2026 16:58
trask added 10 commits February 22, 2026 16:48
…emetry

# Conflicts:
#	instrumentation/runtime-telemetry/runtime-telemetry-java17/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/runtimemetrics/java17/Java17RuntimeMetricsInstaller.java
#	instrumentation/runtime-telemetry/runtime-telemetry-java17/library/src/main/java/io/opentelemetry/instrumentation/runtimemetrics/java17/internal/RuntimeMetricsConfigUtil.java
#	instrumentation/runtime-telemetry/runtime-telemetry-java8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/runtimemetrics/java8/Java8RuntimeMetricsInstaller.java
#	instrumentation/runtime-telemetry/runtime-telemetry-java8/library/src/main/java/io/opentelemetry/instrumentation/runtimemetrics/java8/internal/RuntimeMetricsConfigUtil.java
#	instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/internal/instrumentation/runtimemetrics/Java8RuntimeMetricsAutoConfiguration.java
#	instrumentation/spring/spring-boot-autoconfigure/src/main/java/io/opentelemetry/instrumentation/spring/autoconfigure/internal/instrumentation/runtimetelemetry/RuntimeMetricsAutoConfiguration.java
AgentConfig was removed in upstream commit 3c573ea and replaced with
AgentDistributionConfig. Update Internal.configure() to accept boolean
defaultEnabled instead of String instrumentationMode, and update all
callers accordingly.
Comment thread instrumentation/runtime-telemetry/library/README.md
@trask trask force-pushed the unified-runtime-telemetry branch from d81deb3 to 2238d83 Compare March 9, 2026 19:18
@trask trask force-pushed the unified-runtime-telemetry branch from 2238d83 to 3ae6d19 Compare March 9, 2026 19:29
Comment on lines -91 to -110
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyLambdas" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<property name="ignoreEnhancedForColon" value="false"/>
<property name="tokens"
value="ASSIGN, BAND, BAND_ASSIGN, BOR, BOR_ASSIGN, BSR, BSR_ASSIGN, BXOR,
BXOR_ASSIGN, COLON, DIV, DIV_ASSIGN, DO_WHILE, EQUAL, GE, GT, LAMBDA, LAND,
LCURLY, LE, LITERAL_CATCH, LITERAL_DO, LITERAL_ELSE, LITERAL_FINALLY,
LITERAL_FOR, LITERAL_IF, LITERAL_RETURN, LITERAL_SWITCH, LITERAL_SYNCHRONIZED,
LITERAL_TRY, LITERAL_WHILE, LOR, LT, MINUS, MINUS_ASSIGN, MOD, MOD_ASSIGN,
NOT_EQUAL, PLUS, PLUS_ASSIGN, QUESTION, RCURLY, SL, SLIST, SL_ASSIGN, SR,
SR_ASSIGN, STAR, STAR_ASSIGN, LITERAL_ASSERT, TYPE_EXTENSION_AND"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
Copy link
Copy Markdown
Member Author

@trask trask Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this rule conflicted with spotless handling of empty default expression in Java 17 switch statement

we could probably get rid of a lot of checkstyle checks that are already covered by spotless

@trask trask merged commit 45509ab into open-telemetry:main Mar 9, 2026
95 checks passed
@trask trask deleted the unified-runtime-telemetry branch March 9, 2026 21:17
@trask
Copy link
Copy Markdown
Member Author

trask commented Mar 9, 2026

Thanks @laurit for reviewing!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test native This label can be applied to PRs to trigger them to run native tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants