diff --git a/dd-java-agent/agent-otel/otel-shim/src/main/java/datadog/opentelemetry/shim/metrics/JvmOtlpRuntimeMetrics.java b/dd-java-agent/agent-otel/otel-shim/src/main/java/datadog/opentelemetry/shim/metrics/JvmOtlpRuntimeMetrics.java
new file mode 100644
index 00000000000..72fd6d4a6a1
--- /dev/null
+++ b/dd-java-agent/agent-otel/otel-shim/src/main/java/datadog/opentelemetry/shim/metrics/JvmOtlpRuntimeMetrics.java
@@ -0,0 +1,381 @@
+package datadog.opentelemetry.shim.metrics;
+
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.metrics.Meter;
+import java.lang.management.BufferPoolMXBean;
+import java.lang.management.GarbageCollectorMXBean;
+import java.lang.management.ManagementFactory;
+import java.lang.management.MemoryMXBean;
+import java.lang.management.MemoryPoolMXBean;
+import java.lang.management.ThreadMXBean;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Registers JVM runtime metrics using OTel semantic convention names via the dd-trace-java OTLP
+ * metrics pipeline. These metrics flow via OTLP without requiring a Datadog Agent or DogStatsD.
+ *
+ *
OTel JVM runtime metrics conventions:
+ * https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/
+ *
+ *
Semantic-core equivalence mappings:
+ * https://github.com/DataDog/semantic-core/blob/main/sor/domains/metrics/integrations/java/_equivalence/
+ */
+public final class JvmOtlpRuntimeMetrics {
+
+ private static final Logger log = LoggerFactory.getLogger(JvmOtlpRuntimeMetrics.class);
+ private static final String INSTRUMENTATION_SCOPE = "datadog.jvm.runtime";
+
+ private static volatile boolean started = false;
+
+ /** Registers all JVM runtime metric instruments on the OTel MeterProvider. */
+ public static void start() {
+ if (started) {
+ return;
+ }
+ started = true;
+
+ try {
+ Meter meter = OtelMeterProvider.INSTANCE.get(INSTRUMENTATION_SCOPE);
+ registerMemoryMetrics(meter);
+ registerBufferMetrics(meter);
+ registerGcMetrics(meter);
+ registerThreadMetrics(meter);
+ registerClassLoadingMetrics(meter);
+ registerCpuMetrics(meter);
+ log.debug("Started OTLP runtime metrics with OTel-native naming (jvm.*)");
+ } catch (Exception e) {
+ log.error("Failed to start JVM OTLP runtime metrics", e);
+ }
+ }
+
+ /**
+ * jvm.memory.used - JVM memory used, split by type (heap/non_heap) and pool.
+ *
+ *
Maps to: jvm.heap_memory, jvm.non_heap_memory (via semantic-core, requires Sum By)
+ */
+ private static void registerMemoryMetrics(Meter meter) {
+ MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
+ List pools = ManagementFactory.getMemoryPoolMXBeans();
+
+ // jvm.memory.used - Measure of memory used
+ meter
+ .upDownCounterBuilder("jvm.memory.used")
+ .setDescription("Measure of memory used.")
+ .setUnit("By")
+ .buildWithCallback(
+ measurement -> {
+ // Heap total
+ measurement.record(
+ memoryBean.getHeapMemoryUsage().getUsed(),
+ Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "heap"));
+ // Non-heap total
+ measurement.record(
+ memoryBean.getNonHeapMemoryUsage().getUsed(),
+ Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "non_heap"));
+ // Per-pool breakdown
+ for (MemoryPoolMXBean pool : pools) {
+ measurement.record(
+ pool.getUsage().getUsed(),
+ Attributes.of(
+ AttributeKey.stringKey("jvm.memory.type"),
+ pool.getType().name().toLowerCase(),
+ AttributeKey.stringKey("jvm.memory.pool.name"),
+ pool.getName()));
+ }
+ });
+
+ // jvm.memory.committed - Measure of memory committed
+ meter
+ .upDownCounterBuilder("jvm.memory.committed")
+ .setDescription("Measure of memory committed.")
+ .setUnit("By")
+ .buildWithCallback(
+ measurement -> {
+ measurement.record(
+ memoryBean.getHeapMemoryUsage().getCommitted(),
+ Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "heap"));
+ measurement.record(
+ memoryBean.getNonHeapMemoryUsage().getCommitted(),
+ Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "non_heap"));
+ for (MemoryPoolMXBean pool : pools) {
+ measurement.record(
+ pool.getUsage().getCommitted(),
+ Attributes.of(
+ AttributeKey.stringKey("jvm.memory.type"),
+ pool.getType().name().toLowerCase(),
+ AttributeKey.stringKey("jvm.memory.pool.name"),
+ pool.getName()));
+ }
+ });
+
+ // jvm.memory.limit - Measure of max obtainable memory
+ meter
+ .upDownCounterBuilder("jvm.memory.limit")
+ .setDescription("Measure of max obtainable memory.")
+ .setUnit("By")
+ .buildWithCallback(
+ measurement -> {
+ long heapMax = memoryBean.getHeapMemoryUsage().getMax();
+ if (heapMax > 0) {
+ measurement.record(
+ heapMax,
+ Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "heap"));
+ }
+ long nonHeapMax = memoryBean.getNonHeapMemoryUsage().getMax();
+ if (nonHeapMax > 0) {
+ measurement.record(
+ nonHeapMax,
+ Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "non_heap"));
+ }
+ for (MemoryPoolMXBean pool : pools) {
+ long max = pool.getUsage().getMax();
+ if (max > 0) {
+ measurement.record(
+ max,
+ Attributes.of(
+ AttributeKey.stringKey("jvm.memory.type"),
+ pool.getType().name().toLowerCase(),
+ AttributeKey.stringKey("jvm.memory.pool.name"),
+ pool.getName()));
+ }
+ }
+ });
+
+ // jvm.memory.init - Measure of initial memory requested
+ meter
+ .upDownCounterBuilder("jvm.memory.init")
+ .setDescription("Measure of initial memory requested.")
+ .setUnit("By")
+ .buildWithCallback(
+ measurement -> {
+ long heapInit = memoryBean.getHeapMemoryUsage().getInit();
+ if (heapInit > 0) {
+ measurement.record(
+ heapInit,
+ Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "heap"));
+ }
+ long nonHeapInit = memoryBean.getNonHeapMemoryUsage().getInit();
+ if (nonHeapInit > 0) {
+ measurement.record(
+ nonHeapInit,
+ Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "non_heap"));
+ }
+ });
+ }
+
+ /**
+ * jvm.buffer.* - JVM buffer pool metrics (direct, mapped). Maps to: jvm.buffer_pool.* (via
+ * semantic-core)
+ */
+ private static void registerBufferMetrics(Meter meter) {
+ List bufferPools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
+
+ // jvm.buffer.memory.used
+ meter
+ .upDownCounterBuilder("jvm.buffer.memory.used")
+ .setDescription("Measure of memory used by buffers.")
+ .setUnit("By")
+ .buildWithCallback(
+ measurement -> {
+ for (BufferPoolMXBean pool : bufferPools) {
+ long used = pool.getMemoryUsed();
+ if (used >= 0) {
+ measurement.record(
+ used,
+ Attributes.of(
+ AttributeKey.stringKey("jvm.buffer.pool.name"), pool.getName()));
+ }
+ }
+ });
+
+ // jvm.buffer.memory.limit
+ meter
+ .upDownCounterBuilder("jvm.buffer.memory.limit")
+ .setDescription("Measure of total memory capacity of buffers.")
+ .setUnit("By")
+ .buildWithCallback(
+ measurement -> {
+ for (BufferPoolMXBean pool : bufferPools) {
+ long limit = pool.getTotalCapacity();
+ if (limit >= 0) {
+ measurement.record(
+ limit,
+ Attributes.of(
+ AttributeKey.stringKey("jvm.buffer.pool.name"), pool.getName()));
+ }
+ }
+ });
+
+ // jvm.buffer.count
+ meter
+ .upDownCounterBuilder("jvm.buffer.count")
+ .setDescription("Number of buffers in the pool.")
+ .setUnit("{buffer}")
+ .buildWithCallback(
+ measurement -> {
+ for (BufferPoolMXBean pool : bufferPools) {
+ long count = pool.getCount();
+ if (count >= 0) {
+ measurement.record(
+ count,
+ Attributes.of(
+ AttributeKey.stringKey("jvm.buffer.pool.name"), pool.getName()));
+ }
+ }
+ });
+ }
+
+ /**
+ * jvm.gc.duration - Duration of JVM garbage collection actions. Maps to: jvm.gc.pause_time (via
+ * semantic-core)
+ */
+ private static void registerGcMetrics(Meter meter) {
+ List gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
+
+ // jvm.gc.duration - GC collection time (monotonic counter, in seconds)
+ meter
+ .counterBuilder("jvm.gc.duration")
+ .ofDoubles()
+ .setDescription("Duration of JVM garbage collection actions.")
+ .setUnit("s")
+ .buildWithCallback(
+ measurement -> {
+ for (GarbageCollectorMXBean gc : gcBeans) {
+ long timeMs = gc.getCollectionTime();
+ if (timeMs >= 0) {
+ measurement.record(
+ timeMs / 1000.0,
+ Attributes.of(
+ AttributeKey.stringKey("jvm.gc.name"),
+ gc.getName(),
+ AttributeKey.stringKey("jvm.gc.action"),
+ gc.getName()));
+ }
+ }
+ });
+
+ // jvm.gc.count - Number of GC collections
+ meter
+ .counterBuilder("jvm.gc.count")
+ .setDescription("Number of executions of the garbage collector.")
+ .setUnit("{collection}")
+ .buildWithCallback(
+ measurement -> {
+ for (GarbageCollectorMXBean gc : gcBeans) {
+ long count = gc.getCollectionCount();
+ if (count >= 0) {
+ measurement.record(
+ count,
+ Attributes.of(AttributeKey.stringKey("jvm.gc.name"), gc.getName()));
+ }
+ }
+ });
+ }
+
+ /** jvm.thread.count - Number of executing threads. Maps to: jvm.thread_count (via semantic-core) */
+ private static void registerThreadMetrics(Meter meter) {
+ ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
+
+ meter
+ .upDownCounterBuilder("jvm.thread.count")
+ .setDescription("Number of executing platform threads.")
+ .setUnit("{thread}")
+ .buildWithCallback(
+ measurement -> {
+ measurement.record(threadBean.getThreadCount());
+ });
+ }
+
+ /** jvm.class.* - Class loading metrics. */
+ private static void registerClassLoadingMetrics(Meter meter) {
+ meter
+ .upDownCounterBuilder("jvm.class.loaded")
+ .setDescription("Number of classes loaded since JVM start.")
+ .setUnit("{class}")
+ .buildWithCallback(
+ measurement -> {
+ measurement.record(
+ ManagementFactory.getClassLoadingMXBean().getTotalLoadedClassCount());
+ });
+
+ meter
+ .upDownCounterBuilder("jvm.class.count")
+ .setDescription("Number of classes currently loaded.")
+ .setUnit("{class}")
+ .buildWithCallback(
+ measurement -> {
+ measurement.record(
+ ManagementFactory.getClassLoadingMXBean().getLoadedClassCount());
+ });
+
+ meter
+ .counterBuilder("jvm.class.unloaded")
+ .setDescription("Number of classes unloaded since JVM start.")
+ .setUnit("{class}")
+ .buildWithCallback(
+ measurement -> {
+ measurement.record(
+ ManagementFactory.getClassLoadingMXBean().getUnloadedClassCount());
+ });
+ }
+
+ /** jvm.cpu.recent_utilization - Recent CPU utilization by the JVM process. */
+ private static void registerCpuMetrics(Meter meter) {
+ meter
+ .gaugeBuilder("jvm.cpu.recent_utilization")
+ .setDescription("Recent CPU utilization for the process as reported by the JVM.")
+ .setUnit("1")
+ .buildWithCallback(
+ measurement -> {
+ try {
+ java.lang.management.OperatingSystemMXBean osBean =
+ ManagementFactory.getOperatingSystemMXBean();
+ if (osBean instanceof com.sun.management.OperatingSystemMXBean) {
+ double cpuLoad =
+ ((com.sun.management.OperatingSystemMXBean) osBean).getProcessCpuLoad();
+ if (cpuLoad >= 0) {
+ measurement.record(cpuLoad);
+ }
+ }
+ } catch (Exception e) {
+ // com.sun.management may not be available on all JVMs
+ }
+ });
+
+ meter
+ .upDownCounterBuilder("jvm.cpu.count")
+ .setDescription("Number of processors available to the JVM.")
+ .setUnit("{cpu}")
+ .buildWithCallback(
+ measurement -> {
+ measurement.record(Runtime.getRuntime().availableProcessors());
+ });
+
+ // jvm.system.cpu.utilization - Recent CPU utilization for the whole system
+ meter
+ .gaugeBuilder("jvm.system.cpu.utilization")
+ .setDescription("Recent CPU utilization for the whole system as reported by the JVM.")
+ .setUnit("1")
+ .buildWithCallback(
+ measurement -> {
+ try {
+ java.lang.management.OperatingSystemMXBean osBean =
+ ManagementFactory.getOperatingSystemMXBean();
+ if (osBean instanceof com.sun.management.OperatingSystemMXBean) {
+ double load =
+ ((com.sun.management.OperatingSystemMXBean) osBean).getSystemCpuLoad();
+ if (load >= 0) {
+ measurement.record(load);
+ }
+ }
+ } catch (Exception e) {
+ // com.sun.management may not be available
+ }
+ });
+ }
+
+ private JvmOtlpRuntimeMetrics() {}
+}
diff --git a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/test/groovy/opentelemetry147/metrics/JvmOtlpRuntimeMetricsTest.groovy b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/test/groovy/opentelemetry147/metrics/JvmOtlpRuntimeMetricsTest.groovy
new file mode 100644
index 00000000000..a9d8dfb80ff
--- /dev/null
+++ b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/test/groovy/opentelemetry147/metrics/JvmOtlpRuntimeMetricsTest.groovy
@@ -0,0 +1,152 @@
+package opentelemetry147.metrics
+
+import datadog.opentelemetry.shim.metrics.JvmOtlpRuntimeMetrics
+import datadog.opentelemetry.shim.metrics.OtelMeterProvider
+import datadog.trace.agent.test.InstrumentationSpecification
+import datadog.trace.bootstrap.otel.common.OtelInstrumentationScope
+import datadog.trace.bootstrap.otel.metrics.OtelInstrumentDescriptor
+import datadog.trace.bootstrap.otel.metrics.data.OtlpDoublePoint
+import datadog.trace.bootstrap.otel.metrics.data.OtlpLongPoint
+import datadog.trace.bootstrap.otel.metrics.data.OtelMetricRegistry
+import datadog.trace.bootstrap.otel.metrics.data.OtlpDataPoint
+import datadog.trace.bootstrap.otel.metrics.export.OtlpMetricVisitor
+import datadog.trace.bootstrap.otel.metrics.export.OtlpMetricsVisitor
+import datadog.trace.bootstrap.otel.metrics.export.OtlpScopedMetricsVisitor
+
+/**
+ * Tests that JVM runtime metrics are registered and exported via OTLP
+ * using OTel semantic convention names (jvm.memory.used, jvm.thread.count, etc.).
+ *
+ * Ref: https://opentelemetry.io/docs/specs/semconv/runtime/jvm-metrics/
+ * Ref: https://github.com/DataDog/semantic-core/blob/main/sor/domains/metrics/integrations/java/_equivalence/
+ */
+class JvmOtlpRuntimeMetricsTest extends InstrumentationSpecification {
+
+ @Override
+ void configurePreAgent() {
+ super.configurePreAgent()
+ injectSysConfig("dd.metrics.otel.enabled", "true")
+ }
+
+ def "JVM runtime metrics are registered and produce data points"() {
+ when:
+ JvmOtlpRuntimeMetrics.start()
+ def collector = new MetricCollector()
+ OtelMetricRegistry.INSTANCE.collectMetrics(collector)
+
+ then:
+ // OtelInstrumentDescriptor.name is UTF8BytesString, convert to String for comparison
+ def names = collector.metricNames.collect { it.toString() }
+ // Memory
+ "jvm.memory.used" in names
+ "jvm.memory.committed" in names
+ "jvm.memory.limit" in names
+ "jvm.memory.init" in names
+ // Buffers
+ "jvm.buffer.memory.used" in names
+ "jvm.buffer.memory.limit" in names
+ "jvm.buffer.count" in names
+ // GC
+ "jvm.gc.duration" in names
+ "jvm.gc.count" in names
+ // Threads
+ "jvm.thread.count" in names
+ // Classes
+ "jvm.class.loaded" in names
+ "jvm.class.count" in names
+ "jvm.class.unloaded" in names
+ // CPU
+ "jvm.cpu.count" in names
+ "jvm.cpu.recent_utilization" in names
+ "jvm.system.cpu.utilization" in names
+ }
+
+ def "jvm.memory.used has heap and non_heap type attributes"() {
+ when:
+ JvmOtlpRuntimeMetrics.start()
+ def collector = new MetricCollector()
+ OtelMetricRegistry.INSTANCE.collectMetrics(collector)
+
+ then:
+ def types = collector.attributeValues("jvm.memory.used", "jvm.memory.type")
+ types.contains("heap")
+ types.contains("non_heap")
+ }
+
+ def "jvm.memory.used heap value is positive"() {
+ when:
+ JvmOtlpRuntimeMetrics.start()
+ def collector = new MetricCollector()
+ OtelMetricRegistry.INSTANCE.collectMetrics(collector)
+
+ then:
+ def heapPoints = collector.points["jvm.memory.used"]
+ .findAll { it.attrs["jvm.memory.type"] == "heap" }
+ heapPoints.size() > 0
+ heapPoints[0].value > 0
+ }
+
+ def "jvm.thread.count is positive"() {
+ when:
+ JvmOtlpRuntimeMetrics.start()
+ def collector = new MetricCollector()
+ OtelMetricRegistry.INSTANCE.collectMetrics(collector)
+
+ then:
+ def threadPoints = collector.points["jvm.thread.count"]
+ threadPoints.size() > 0
+ threadPoints[0].value > 0
+ }
+
+ static class DataPointEntry {
+ Map attrs
+ Number value
+ }
+
+ static class MetricCollector implements OtlpMetricsVisitor, OtlpScopedMetricsVisitor, OtlpMetricVisitor {
+ String currentInstrument = ""
+ Map currentAttrs = [:]
+ Set metricNames = new LinkedHashSet<>()
+ Map> points = [:].withDefault { [] }
+
+ @Override
+ OtlpScopedMetricsVisitor visitScopedMetrics(OtelInstrumentationScope scope) {
+ return this
+ }
+
+ @Override
+ OtlpMetricVisitor visitMetric(OtelInstrumentDescriptor descriptor) {
+ currentInstrument = descriptor.name.toString()
+ metricNames.add(descriptor.name.toString())
+ return this
+ }
+
+ @Override
+ void visitAttribute(int type, String key, Object value) {
+ currentAttrs.put(key.toString(), value.toString())
+ }
+
+ @Override
+ void visitDataPoint(OtlpDataPoint point) {
+ def attrs = new HashMap(currentAttrs)
+ currentAttrs.clear()
+ Number value = 0
+ if (point instanceof OtlpLongPoint) {
+ value = ((OtlpLongPoint) point).value
+ } else if (point instanceof OtlpDoublePoint) {
+ value = ((OtlpDoublePoint) point).value
+ }
+ def entry = new DataPointEntry()
+ entry.attrs = attrs
+ entry.value = value
+ points[currentInstrument].add(entry)
+ }
+
+ Set attributeValues(String metricName, String attrKey) {
+ points[metricName]
+ .collect { it.attrs[attrKey] }
+ .findAll { it != null }
+ .toSet()
+ }
+ }
+}