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..5cb93f4c3f3 --- /dev/null +++ b/dd-java-agent/agent-otel/otel-shim/src/main/java/datadog/opentelemetry/shim/metrics/JvmOtlpRuntimeMetrics.java @@ -0,0 +1,430 @@ +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.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryPoolMXBean; +import java.lang.management.MemoryUsage; +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. + * + *

Only includes metrics where we can match the exact OTel spec type. Metrics requiring Histogram + * type (jvm.gc.duration) are excluded because JMX cannot produce distribution data. + * + *

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); + registerThreadMetrics(meter); + registerClassLoadingMetrics(meter); + registerCpuMetrics(meter); + registerFileDescriptorMetrics(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); + } + } + + // Note: jvm.gc.duration is excluded — OTel spec requires Histogram type but JMX only provides + // cumulative milliseconds via GarbageCollectorMXBean.getCollectionTime(), not individual + // GC event durations needed to build a distribution. + + /** + * jvm.memory.used, jvm.memory.committed, jvm.memory.limit, jvm.memory.init, + * jvm.memory.used_after_last_gc — all UpDownCounter per spec. + */ + private static void registerMemoryMetrics(Meter meter) { + MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + List pools = ManagementFactory.getMemoryPoolMXBeans(); + + // jvm.memory.used (UpDownCounter, Stable) + meter + .upDownCounterBuilder("jvm.memory.used") + .setDescription("Measure of memory used.") + .setUnit("By") + .buildWithCallback( + measurement -> { + measurement.record( + memoryBean.getHeapMemoryUsage().getUsed(), + Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "heap")); + measurement.record( + memoryBean.getNonHeapMemoryUsage().getUsed(), + Attributes.of(AttributeKey.stringKey("jvm.memory.type"), "non_heap")); + 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 (UpDownCounter, Stable) + 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 (UpDownCounter, Stable) + 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 (UpDownCounter, Development) + 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.memory.used_after_last_gc (UpDownCounter, Stable) + meter + .upDownCounterBuilder("jvm.memory.used_after_last_gc") + .setDescription("Measure of memory used after the most recent garbage collection event.") + .setUnit("By") + .buildWithCallback( + measurement -> { + for (MemoryPoolMXBean pool : pools) { + MemoryUsage collectionUsage = pool.getCollectionUsage(); + if (collectionUsage != null) { + long used = collectionUsage.getUsed(); + if (used >= 0) { + measurement.record( + used, + Attributes.of( + AttributeKey.stringKey("jvm.memory.type"), + pool.getType().name().toLowerCase(), + AttributeKey.stringKey("jvm.memory.pool.name"), + pool.getName())); + } + } + } + }); + } + + /** jvm.buffer.* (UpDownCounter, Development) — JVM buffer pool metrics (direct, mapped). */ + private static void registerBufferMetrics(Meter meter) { + List bufferPools = + ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class); + + 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())); + } + } + }); + + 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())); + } + } + }); + + 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.thread.count (UpDownCounter, Stable) */ + 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.loaded (Counter, Stable) — cumulative total loaded since JVM start. + * jvm.class.unloaded (Counter, Stable) — cumulative total unloaded since JVM start. + * jvm.class.count (UpDownCounter, Stable) — currently loaded count. + */ + private static void registerClassLoadingMetrics(Meter meter) { + // jvm.class.loaded — Counter per spec (cumulative total, only goes up) + meter + .counterBuilder("jvm.class.loaded") + .setDescription("Number of classes loaded since JVM start.") + .setUnit("{class}") + .buildWithCallback( + measurement -> + measurement.record( + ManagementFactory.getClassLoadingMXBean().getTotalLoadedClassCount())); + + // jvm.class.count — UpDownCounter per spec (current count, can decrease) + meter + .upDownCounterBuilder("jvm.class.count") + .setDescription("Number of classes currently loaded.") + .setUnit("{class}") + .buildWithCallback( + measurement -> + measurement.record( + ManagementFactory.getClassLoadingMXBean().getLoadedClassCount())); + + // jvm.class.unloaded — Counter per spec + meter + .counterBuilder("jvm.class.unloaded") + .setDescription("Number of classes unloaded since JVM start.") + .setUnit("{class}") + .buildWithCallback( + measurement -> + measurement.record( + ManagementFactory.getClassLoadingMXBean().getUnloadedClassCount())); + } + + /** + * jvm.cpu.time (Counter, Stable), jvm.cpu.count (UpDownCounter, Stable), + * jvm.cpu.recent_utilization (Gauge, Stable), jvm.system.cpu.utilization (Gauge, Development). + */ + private static void registerCpuMetrics(Meter meter) { + // jvm.cpu.time — Counter per spec (cumulative CPU time in seconds) + meter + .counterBuilder("jvm.cpu.time") + .ofDoubles() + .setDescription("CPU time used by the process as reported by the JVM.") + .setUnit("s") + .buildWithCallback( + measurement -> { + try { + java.lang.management.OperatingSystemMXBean osBean = + ManagementFactory.getOperatingSystemMXBean(); + if (osBean instanceof com.sun.management.OperatingSystemMXBean) { + long nanos = + ((com.sun.management.OperatingSystemMXBean) osBean).getProcessCpuTime(); + if (nanos >= 0) { + measurement.record(nanos / 1e9); + } + } + } catch (Exception e) { + // com.sun.management may not be available + } + }); + + // jvm.cpu.count — UpDownCounter per spec + meter + .upDownCounterBuilder("jvm.cpu.count") + .setDescription("Number of processors available to the JVM.") + .setUnit("{cpu}") + .buildWithCallback( + measurement -> measurement.record(Runtime.getRuntime().availableProcessors())); + + // jvm.cpu.recent_utilization — Gauge per spec + 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 + } + }); + + // jvm.system.cpu.utilization — Gauge, Development + 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 + } + }); + } + + /** jvm.file_descriptor.count and jvm.file_descriptor.limit (UpDownCounter, Development). */ + private static void registerFileDescriptorMetrics(Meter meter) { + meter + .upDownCounterBuilder("jvm.file_descriptor.count") + .setDescription("Number of open file descriptors.") + .setUnit("{file_descriptor}") + .buildWithCallback( + measurement -> { + try { + java.lang.management.OperatingSystemMXBean osBean = + ManagementFactory.getOperatingSystemMXBean(); + if (osBean instanceof com.sun.management.UnixOperatingSystemMXBean) { + long count = + ((com.sun.management.UnixOperatingSystemMXBean) osBean) + .getOpenFileDescriptorCount(); + if (count >= 0) { + measurement.record(count); + } + } + } catch (Exception e) { + // UnixOperatingSystemMXBean not available on Windows + } + }); + + meter + .upDownCounterBuilder("jvm.file_descriptor.limit") + .setDescription("Maximum number of open file descriptors allowed.") + .setUnit("{file_descriptor}") + .buildWithCallback( + measurement -> { + try { + java.lang.management.OperatingSystemMXBean osBean = + ManagementFactory.getOperatingSystemMXBean(); + if (osBean instanceof com.sun.management.UnixOperatingSystemMXBean) { + long limit = + ((com.sun.management.UnixOperatingSystemMXBean) osBean) + .getMaxFileDescriptorCount(); + if (limit >= 0) { + measurement.record(limit); + } + } + } catch (Exception e) { + // UnixOperatingSystemMXBean not available on Windows + } + }); + } + + private JvmOtlpRuntimeMetrics() {} +} diff --git a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/main/java/datadog/trace/instrumentation/opentelemetry147/OpenTelemetryMetricsInstrumentation.java b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/main/java/datadog/trace/instrumentation/opentelemetry147/OpenTelemetryMetricsInstrumentation.java index 305dd9f0f66..5baddb1efe8 100644 --- a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/main/java/datadog/trace/instrumentation/opentelemetry147/OpenTelemetryMetricsInstrumentation.java +++ b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/main/java/datadog/trace/instrumentation/opentelemetry147/OpenTelemetryMetricsInstrumentation.java @@ -7,6 +7,7 @@ import static net.bytebuddy.matcher.ElementMatchers.takesNoArguments; import com.google.auto.service.AutoService; +import datadog.opentelemetry.shim.metrics.JvmOtlpRuntimeMetrics; import datadog.opentelemetry.shim.metrics.OtelMeterProvider; import datadog.trace.agent.tooling.Instrumenter; import datadog.trace.agent.tooling.InstrumenterModule; @@ -100,6 +101,12 @@ public static class MeterProviderAdvice { @Advice.OnMethodExit(suppress = Throwable.class) public static void returnProvider(@Advice.Return(readOnly = false) MeterProvider result) { result = OtelMeterProvider.INSTANCE; + // Start JVM runtime metrics when both DD_METRICS_OTEL_ENABLED and + // DD_RUNTIME_METRICS_ENABLED are true, matching the .NET/Go/NodeJS pattern. + // JvmOtlpRuntimeMetrics.start() is idempotent (checks a started flag internally). + if (datadog.trace.api.Config.get().isRuntimeMetricsEnabled()) { + JvmOtlpRuntimeMetrics.start(); + } } } } 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..d12112d2c70 --- /dev/null +++ b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/test/groovy/opentelemetry147/metrics/JvmOtlpRuntimeMetricsTest.groovy @@ -0,0 +1,153 @@ +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.OtelMetricRegistry +import datadog.trace.bootstrap.otlp.metrics.OtlpDataPoint +import datadog.trace.bootstrap.otlp.metrics.OtlpDoublePoint +import datadog.trace.bootstrap.otlp.metrics.OtlpLongPoint +import datadog.trace.bootstrap.otlp.metrics.OtlpMetricVisitor +import datadog.trace.bootstrap.otlp.metrics.OtlpMetricsVisitor +import datadog.trace.bootstrap.otlp.metrics.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 (5 metrics, all UpDownCounter per spec) + "jvm.memory.used" in names + "jvm.memory.committed" in names + "jvm.memory.limit" in names + "jvm.memory.init" in names + "jvm.memory.used_after_last_gc" in names + // Buffers (3 metrics, UpDownCounter per spec) + "jvm.buffer.memory.used" in names + "jvm.buffer.memory.limit" in names + "jvm.buffer.count" in names + // Threads (1 metric, UpDownCounter per spec) + "jvm.thread.count" in names + // Classes (3 metrics: loaded/unloaded are Counter, count is UpDownCounter per spec) + "jvm.class.loaded" in names + "jvm.class.count" in names + "jvm.class.unloaded" in names + // CPU (4 metrics per spec) + "jvm.cpu.time" in names + "jvm.cpu.count" in names + "jvm.cpu.recent_utilization" in names + "jvm.system.cpu.utilization" in names + // NOT included: jvm.gc.duration (spec requires Histogram, JMX can't produce it) + // NOT included: jvm.gc.count (not in OTel spec) + } + + 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() + } + } +}