diff --git a/dd-java-agent/agent-jmxfetch/build.gradle b/dd-java-agent/agent-jmxfetch/build.gradle index b65d3a110f5..8eba4b9222e 100644 --- a/dd-java-agent/agent-jmxfetch/build.gradle +++ b/dd-java-agent/agent-jmxfetch/build.gradle @@ -25,6 +25,14 @@ dependencies { api libs.slf4j api project(':internal-api') api project(':dd-java-agent:agent-bootstrap') + + // The opentelemetry-api compileOnly is only for io.opentelemetry.api.common.Attributes / + // AttributeKey used to label the recorded measurements; everything else comes from otel-bootstrap. + compileOnly group: 'io.opentelemetry', name: 'opentelemetry-api', version: '1.47.0' + // JvmOtlpRuntimeMetrics registers JVM runtime instruments directly against the + // bootstrap-level OTel metric registry. otel-bootstrap vendors and repackages the + // OTel API at build time so this won't conflict with anything in the customer app. + compileOnly project(':dd-java-agent:agent-otel:otel-bootstrap') } tasks.named("shadowJar", ShadowJar) { diff --git a/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JMXFetch.java b/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JMXFetch.java index f0a2a5541f8..5375fb8b3e6 100644 --- a/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JMXFetch.java +++ b/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JMXFetch.java @@ -9,6 +9,7 @@ import datadog.metrics.api.statsd.StatsDClientManager; import datadog.trace.api.Config; import datadog.trace.api.GlobalTracer; +import datadog.trace.api.InstrumenterConfig; import datadog.trace.api.flare.TracerFlare; import datadog.trace.api.telemetry.LogCollector; import de.thetaphi.forbiddenapis.SuppressForbidden; @@ -32,6 +33,7 @@ public class JMXFetch { private static final Logger log = LoggerFactory.getLogger(JMXFetch.class); private static final String DEFAULT_CONFIG = "jmxfetch-config.yaml"; + private static final String OTLP_JMX_CONFIG = "jmxfetch-config-no-jvm-defaults.yaml"; private static final String WEBSPHERE_CONFIG = "jmxfetch-websphere-config.yaml"; private static final int DELAY_BETWEEN_RUN_ATTEMPTS = 5000; @@ -93,9 +95,22 @@ private static void run(final StatsDClientManager statsDClientManager, final Con final StatsDClient statsd = statsDClientManager.statsDClient(host, port, namedPipe, null, null); final AgentStatsdReporter reporter = new AgentStatsdReporter(statsd); + final boolean otlpRuntimeMetricsEnabled = + InstrumenterConfig.get().isMetricsOtelEnabled() && config.isMetricsOtlpExporterEnabled(); + TracerFlare.addReporter(reporter); final List defaultConfigs = new ArrayList<>(); - defaultConfigs.add(DEFAULT_CONFIG); + if (otlpRuntimeMetricsEnabled) { + // Register JVM runtime metric callbacks against the OtelMeterProvider so the OTLP + // exporter started by CoreTracer collects them. Started here so it rides the same + // delayed-start path as JMXFetch itself. + JvmOtlpRuntimeMetrics.start(); + // When the OTLP exporter is collecting JVM runtime metrics, skip the default JMXFetch + // JVM config to avoid double-reporting. + defaultConfigs.add(OTLP_JMX_CONFIG); + } else { + defaultConfigs.add(DEFAULT_CONFIG); + } if (config.isJmxFetchIntegrationEnabled(Collections.singletonList("websphere"), false)) { defaultConfigs.add(WEBSPHERE_CONFIG); } diff --git a/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JvmOtlpRuntimeMetrics.java b/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JvmOtlpRuntimeMetrics.java new file mode 100644 index 00000000000..696b2345f93 --- /dev/null +++ b/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JvmOtlpRuntimeMetrics.java @@ -0,0 +1,353 @@ +package datadog.trace.agent.jmxfetch; + +import static datadog.trace.bootstrap.otel.metrics.OtelInstrumentType.COUNTER; +import static datadog.trace.bootstrap.otel.metrics.OtelInstrumentType.GAUGE; +import static datadog.trace.bootstrap.otel.metrics.OtelInstrumentType.UP_DOWN_COUNTER; + +import com.sun.management.OperatingSystemMXBean; +import datadog.trace.bootstrap.otel.common.OtelInstrumentationScope; +import datadog.trace.bootstrap.otel.metrics.OtelInstrumentBuilder; +import datadog.trace.bootstrap.otel.metrics.OtelInstrumentDescriptor; +import datadog.trace.bootstrap.otel.metrics.OtelInstrumentType; +import datadog.trace.bootstrap.otel.metrics.data.OtelMetricRegistry; +import datadog.trace.bootstrap.otel.metrics.data.OtelMetricStorage; +import datadog.trace.bootstrap.otel.metrics.data.OtelRunnableObservable; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import java.lang.management.BufferPoolMXBean; +import java.lang.management.ClassLoadingMXBean; +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 java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.ToLongFunction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Registers JVM runtime metrics with OTel-native names against the agent's bootstrap-level metric + * registry. + */ +public final class JvmOtlpRuntimeMetrics { + + private static final Logger log = LoggerFactory.getLogger(JvmOtlpRuntimeMetrics.class); + private static final OtelInstrumentationScope SCOPE = + new OtelInstrumentationScope("datadog.jvm.runtime", null, null); + private static final AttributeKey MEMORY_TYPE = AttributeKey.stringKey("jvm.memory.type"); + private static final AttributeKey MEMORY_POOL = + AttributeKey.stringKey("jvm.memory.pool.name"); + private static final AttributeKey BUFFER_POOL = + AttributeKey.stringKey("jvm.buffer.pool.name"); + private static final Attributes HEAP_ATTRS = Attributes.of(MEMORY_TYPE, "heap"); + private static final Attributes NON_HEAP_ATTRS = Attributes.of(MEMORY_TYPE, "non_heap"); + + private static final AtomicBoolean started = new AtomicBoolean(false); + + /** Registers all JVM runtime metric instruments on the bootstrap-level metric registry. */ + public static void start() { + if (!started.compareAndSet(false, true)) { + return; + } + + try { + // Ensure OtelMetricStorage can serialize io.opentelemetry.api Attributes recorded below; + // the otel-shim registers an equivalent reader on its own class-loader, but agent-jmxfetch + // does not depend on the shim — so we register one here for our Attributes class-loader. + OtelMetricStorage.registerAttributeReader( + Attributes.class.getClassLoader(), + (attributes, visitor) -> + ((Attributes) attributes) + .forEach((a, v) -> visitor.visitAttribute(a.getType().ordinal(), a.getKey(), v))); + + registerMemoryMetrics(); + registerBufferMetrics(); + registerThreadMetrics(); + registerClassLoadingMetrics(); + registerCpuMetrics(); + 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.committed, jvm.memory.limit, jvm.memory.init, + * jvm.memory.used_after_last_gc — all UpDownCounter per spec. + */ + private static void registerMemoryMetrics() { + MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); + List pools = ManagementFactory.getMemoryPoolMXBeans(); + + registerLongObservable( + "jvm.memory.used", + "Measure of memory used.", + "By", + UP_DOWN_COUNTER, + storage -> { + storage.recordLong(memoryBean.getHeapMemoryUsage().getUsed(), HEAP_ATTRS); + storage.recordLong(memoryBean.getNonHeapMemoryUsage().getUsed(), NON_HEAP_ATTRS); + for (MemoryPoolMXBean pool : pools) { + storage.recordLong(pool.getUsage().getUsed(), poolAttributes(pool)); + } + }); + + registerLongObservable( + "jvm.memory.committed", + "Measure of memory committed.", + "By", + UP_DOWN_COUNTER, + storage -> { + storage.recordLong(memoryBean.getHeapMemoryUsage().getCommitted(), HEAP_ATTRS); + storage.recordLong(memoryBean.getNonHeapMemoryUsage().getCommitted(), NON_HEAP_ATTRS); + for (MemoryPoolMXBean pool : pools) { + storage.recordLong(pool.getUsage().getCommitted(), poolAttributes(pool)); + } + }); + + registerLongObservable( + "jvm.memory.limit", + "Measure of max obtainable memory.", + "By", + UP_DOWN_COUNTER, + storage -> { + long heapMax = memoryBean.getHeapMemoryUsage().getMax(); + if (heapMax > 0) { + storage.recordLong(heapMax, HEAP_ATTRS); + } + long nonHeapMax = memoryBean.getNonHeapMemoryUsage().getMax(); + if (nonHeapMax > 0) { + storage.recordLong(nonHeapMax, NON_HEAP_ATTRS); + } + for (MemoryPoolMXBean pool : pools) { + long max = pool.getUsage().getMax(); + if (max > 0) { + storage.recordLong(max, poolAttributes(pool)); + } + } + }); + + registerLongObservable( + "jvm.memory.init", + "Measure of initial memory requested.", + "By", + UP_DOWN_COUNTER, + storage -> { + long heapInit = memoryBean.getHeapMemoryUsage().getInit(); + if (heapInit > 0) { + storage.recordLong(heapInit, HEAP_ATTRS); + } + long nonHeapInit = memoryBean.getNonHeapMemoryUsage().getInit(); + if (nonHeapInit > 0) { + storage.recordLong(nonHeapInit, NON_HEAP_ATTRS); + } + }); + + registerLongObservable( + "jvm.memory.used_after_last_gc", + "Measure of memory used after the most recent garbage collection event.", + "By", + UP_DOWN_COUNTER, + storage -> { + for (MemoryPoolMXBean pool : pools) { + MemoryUsage collectionUsage = pool.getCollectionUsage(); + if (collectionUsage != null && collectionUsage.getUsed() >= 0) { + storage.recordLong(collectionUsage.getUsed(), poolAttributes(pool)); + } + } + }); + } + + /** jvm.buffer.* (UpDownCounter, Development) — direct + mapped pool metrics. */ + private static void registerBufferMetrics() { + List bufferPools = + ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class); + bufferPoolMetric( + "jvm.buffer.memory.used", + "Measure of memory used by buffers.", + "By", + bufferPools, + BufferPoolMXBean::getMemoryUsed); + bufferPoolMetric( + "jvm.buffer.memory.limit", + "Measure of total memory capacity of buffers.", + "By", + bufferPools, + BufferPoolMXBean::getTotalCapacity); + bufferPoolMetric( + "jvm.buffer.count", + "Number of buffers in the pool.", + "{buffer}", + bufferPools, + BufferPoolMXBean::getCount); + } + + /** jvm.thread.count (UpDownCounter, Stable). */ + private static void registerThreadMetrics() { + ThreadMXBean threadBean = ManagementFactory.getThreadMXBean(); + registerLongObservable( + "jvm.thread.count", + "Number of executing platform threads.", + "{thread}", + UP_DOWN_COUNTER, + storage -> storage.recordLong(threadBean.getThreadCount(), Attributes.empty())); + } + + /** + * jvm.class.loaded (Counter), jvm.class.unloaded (Counter), jvm.class.count (UpDownCounter) — all + * Stable per spec. + */ + private static void registerClassLoadingMetrics() { + ClassLoadingMXBean classLoadingBean = ManagementFactory.getClassLoadingMXBean(); + registerLongObservable( + "jvm.class.loaded", + "Number of classes loaded since JVM start.", + "{class}", + COUNTER, + storage -> + storage.recordLong(classLoadingBean.getTotalLoadedClassCount(), Attributes.empty())); + + registerLongObservable( + "jvm.class.count", + "Number of classes currently loaded.", + "{class}", + UP_DOWN_COUNTER, + storage -> storage.recordLong(classLoadingBean.getLoadedClassCount(), Attributes.empty())); + + registerLongObservable( + "jvm.class.unloaded", + "Number of classes unloaded since JVM start.", + "{class}", + COUNTER, + storage -> + storage.recordLong(classLoadingBean.getUnloadedClassCount(), Attributes.empty())); + } + + /** + * jvm.cpu.time (Counter), jvm.cpu.count (UpDownCounter), jvm.cpu.recent_utilization (Gauge) — all + * Stable per spec. + */ + private static void registerCpuMetrics() { + java.lang.management.OperatingSystemMXBean rawOsBean = + ManagementFactory.getOperatingSystemMXBean(); + OperatingSystemMXBean osBean = + rawOsBean instanceof OperatingSystemMXBean ? (OperatingSystemMXBean) rawOsBean : null; + + if (osBean != null) { + registerDoubleObservable( + "jvm.cpu.time", + "CPU time used by the process as reported by the JVM.", + "s", + COUNTER, + OtelMetricStorage::newDoubleSumStorage, + storage -> { + long nanos = osBean.getProcessCpuTime(); + if (nanos >= 0) { + storage.recordDouble(nanos / 1e9, Attributes.empty()); + } + }); + + registerDoubleObservable( + "jvm.cpu.recent_utilization", + "Recent CPU utilization for the process as reported by the JVM.", + "1", + GAUGE, + OtelMetricStorage::newDoubleValueStorage, + storage -> { + double cpuLoad = osBean.getProcessCpuLoad(); + if (cpuLoad >= 0) { + storage.recordDouble(cpuLoad, Attributes.empty()); + } + }); + } + + registerLongObservable( + "jvm.cpu.count", + "Number of processors available to the JVM.", + "{cpu}", + UP_DOWN_COUNTER, + storage -> + storage.recordLong(Runtime.getRuntime().availableProcessors(), Attributes.empty())); + } + + /** + * Registers an UpDownCounter that iterates each platform buffer pool and records {@code getter} + * with the {@code jvm.buffer.pool.name} attribute. Skips negative readings. + */ + private static void bufferPoolMetric( + String name, + String description, + String unit, + List bufferPools, + ToLongFunction getter) { + registerLongObservable( + name, + description, + unit, + UP_DOWN_COUNTER, + storage -> { + for (BufferPoolMXBean pool : bufferPools) { + long value = getter.applyAsLong(pool); + if (value >= 0) { + storage.recordLong(value, Attributes.of(BUFFER_POOL, pool.getName())); + } + } + }); + } + + /** Registers a long observable instrument and its callback against the bootstrap registry. */ + private static void registerLongObservable( + String name, + String description, + String unit, + OtelInstrumentType type, + Consumer callback) { + registerObservable( + OtelInstrumentBuilder.ofLongs(name, type), + description, + unit, + OtelMetricStorage::newLongSumStorage, + callback); + } + + /** Registers a double observable instrument and its callback against the bootstrap registry. */ + private static void registerDoubleObservable( + String name, + String description, + String unit, + OtelInstrumentType type, + Function storageFactory, + Consumer callback) { + registerObservable( + OtelInstrumentBuilder.ofDoubles(name, type), description, unit, storageFactory, callback); + } + + private static void registerObservable( + OtelInstrumentBuilder builder, + String description, + String unit, + Function storageFactory, + Consumer callback) { + builder.setDescription(description); + builder.setUnit(unit); + OtelMetricStorage storage = + OtelMetricRegistry.INSTANCE.registerStorage( + SCOPE, builder.observableDescriptor(), storageFactory); + OtelMetricRegistry.INSTANCE.registerObservable( + SCOPE, new OtelRunnableObservable(() -> callback.accept(storage))); + } + + /** Returns Attributes carrying jvm.memory.type and jvm.memory.pool.name for the given pool. */ + private static Attributes poolAttributes(MemoryPoolMXBean pool) { + return Attributes.of( + MEMORY_TYPE, pool.getType().name().toLowerCase(Locale.ROOT), + MEMORY_POOL, pool.getName()); + } + + private JvmOtlpRuntimeMetrics() {} +} diff --git a/dd-java-agent/agent-jmxfetch/src/main/resources/jmxfetch-config-no-jvm-defaults.yaml b/dd-java-agent/agent-jmxfetch/src/main/resources/jmxfetch-config-no-jvm-defaults.yaml new file mode 100644 index 00000000000..9cb727267e7 --- /dev/null +++ b/dd-java-agent/agent-jmxfetch/src/main/resources/jmxfetch-config-no-jvm-defaults.yaml @@ -0,0 +1,9 @@ +init_config: + is_jmx: true + new_gc_metrics: true + +instances: + - jvm_direct: true + name: dd-java-agent default + collect_default_jvm_metrics: false + conf: [] # Intentionally left empty for now diff --git a/dd-java-agent/agent-otel/otel-bootstrap/src/main/java/datadog/trace/bootstrap/otel/metrics/data/OtelRunnableObservable.java b/dd-java-agent/agent-otel/otel-bootstrap/src/main/java/datadog/trace/bootstrap/otel/metrics/data/OtelRunnableObservable.java new file mode 100644 index 00000000000..975d59f8826 --- /dev/null +++ b/dd-java-agent/agent-otel/otel-bootstrap/src/main/java/datadog/trace/bootstrap/otel/metrics/data/OtelRunnableObservable.java @@ -0,0 +1,28 @@ +package datadog.trace.bootstrap.otel.metrics.data; + +import datadog.logging.RatelimitedLogger; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** {@link OtelObservable} backed by a {@link Runnable}, for call sites that prefer a lambda. */ +public final class OtelRunnableObservable extends OtelObservable { + private static final Logger LOGGER = LoggerFactory.getLogger(OtelRunnableObservable.class); + private static final RatelimitedLogger RATELIMITED_LOGGER = + new RatelimitedLogger(LOGGER, 5, TimeUnit.MINUTES); + + private final Runnable callback; + + public OtelRunnableObservable(Runnable callback) { + this.callback = callback; + } + + @Override + protected void observeMeasurements() { + try { + callback.run(); + } catch (Throwable e) { + RATELIMITED_LOGGER.warn("An exception occurred invoking observable callback.", e); + } + } +} diff --git a/dd-java-agent/instrumentation/graal/graal-native-image-20.0/src/main/java/datadog/trace/instrumentation/graal/nativeimage/ResourcesFeatureInstrumentation.java b/dd-java-agent/instrumentation/graal/graal-native-image-20.0/src/main/java/datadog/trace/instrumentation/graal/nativeimage/ResourcesFeatureInstrumentation.java index f45917f9e8c..bdc78bed041 100644 --- a/dd-java-agent/instrumentation/graal/graal-native-image-20.0/src/main/java/datadog/trace/instrumentation/graal/nativeimage/ResourcesFeatureInstrumentation.java +++ b/dd-java-agent/instrumentation/graal/graal-native-image-20.0/src/main/java/datadog/trace/instrumentation/graal/nativeimage/ResourcesFeatureInstrumentation.java @@ -54,6 +54,7 @@ public static void onExit() { // tracer's jmxfetch configs tracerResources.add("jmxfetch/jmxfetch-config.yaml"); + tracerResources.add("jmxfetch/jmxfetch-config-no-jvm-defaults.yaml"); tracerResources.add("jmxfetch/jmxfetch-websphere-config.yaml"); // jmxfetch integrations metricconfigs diff --git a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/build.gradle b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/build.gradle index 3b992b19ff1..ebc3a17f87c 100644 --- a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/build.gradle +++ b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/build.gradle @@ -22,6 +22,8 @@ dependencies { muzzleBootstrap project(path: ':dd-java-agent:agent-otel:otel-bootstrap', configuration: 'shadow') testImplementation project(path: ':dd-java-agent:agent-otel:otel-bootstrap', configuration: 'shadow') + // JvmOtlpRuntimeMetrics lives in agent-jmxfetch; the test exercises it directly. + testImplementation project(':dd-java-agent:agent-jmxfetch') testImplementation group: 'io.opentelemetry', name: 'opentelemetry-api', version: openTelemetryVersion latestDepTestImplementation group: 'io.opentelemetry', name: 'opentelemetry-api', version: '1+' diff --git a/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/test/java/opentelemetry147/metrics/JvmOtlpRuntimeMetricsTest.java b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/test/java/opentelemetry147/metrics/JvmOtlpRuntimeMetricsTest.java new file mode 100644 index 00000000000..6d220161bee --- /dev/null +++ b/dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.47/src/test/java/opentelemetry147/metrics/JvmOtlpRuntimeMetricsTest.java @@ -0,0 +1,213 @@ +package opentelemetry147.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.agent.jmxfetch.JvmOtlpRuntimeMetrics; +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; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * 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/ + */ +public class JvmOtlpRuntimeMetricsTest { + + @BeforeAll + static void setUp() { + System.setProperty("dd.metrics.otel.enabled", "true"); + JvmOtlpRuntimeMetrics.start(); + } + + @Test + void registersExpectedJvmMetrics() { + MetricCollector collector = new MetricCollector(); + OtelMetricRegistry.INSTANCE.collectMetrics(collector); + + List expectedMetrics = + Arrays.asList( + "jvm.memory.used", + "jvm.memory.committed", + "jvm.memory.limit", + "jvm.memory.init", + "jvm.memory.used_after_last_gc", + "jvm.buffer.memory.used", + "jvm.buffer.memory.limit", + "jvm.buffer.count", + "jvm.thread.count", + "jvm.class.loaded", + "jvm.class.count", + "jvm.class.unloaded", + "jvm.cpu.time", + "jvm.cpu.count", + "jvm.cpu.recent_utilization"); + + Set names = collector.metricNames; + for (String metric : expectedMetrics) { + assertTrue( + names.contains(metric), + "Expected metric '" + metric + "' not found. Got: " + new TreeSet<>(names)); + } + + assertEquals(15, names.size(), "Expected 15 metrics, got: " + new TreeSet<>(names)); + + // No DD-proprietary names should be present + List ddNames = + names.stream() + .filter(n -> n.startsWith("jvm.heap_memory") || n.startsWith("jvm.thread_count")) + .collect(Collectors.toList()); + assertTrue(ddNames.isEmpty(), "DD-proprietary names leaked: " + ddNames); + } + + @Test + void jvmMemoryUsedHasHeapAndNonHeapTypeAttributes() { + MetricCollector collector = new MetricCollector(); + OtelMetricRegistry.INSTANCE.collectMetrics(collector); + + Set types = collector.attributeValues("jvm.memory.used", "jvm.memory.type"); + assertTrue(types.contains("heap"), "jvm.memory.used should have heap attribute"); + assertTrue(types.contains("non_heap"), "jvm.memory.used should have non_heap attribute"); + } + + @Test + void jvmMemoryUsedHeapValueIsPositive() { + MetricCollector collector = new MetricCollector(); + OtelMetricRegistry.INSTANCE.collectMetrics(collector); + + List points = collector.points.get("jvm.memory.used"); + assertNotNull(points, "jvm.memory.used should have data points"); + DataPointEntry heapAggregate = + points.stream() + .filter( + p -> + "heap".equals(p.attrs.get("jvm.memory.type")) + && p.attrs.get("jvm.memory.pool.name") == null) + .findFirst() + .orElse(null); + assertNotNull(heapAggregate, "jvm.memory.used should have a heap aggregate data point"); + assertTrue( + heapAggregate.value.longValue() > 0, + "jvm.memory.used heap aggregate should be positive, got " + heapAggregate.value); + } + + @Test + void jvmThreadCountIsPositive() { + MetricCollector collector = new MetricCollector(); + OtelMetricRegistry.INSTANCE.collectMetrics(collector); + + List threadPoints = collector.points.get("jvm.thread.count"); + assertNotNull(threadPoints, "jvm.thread.count should have data points"); + assertFalse(threadPoints.isEmpty(), "jvm.thread.count should have data points"); + assertTrue( + threadPoints.get(0).value.longValue() > 0, + "jvm.thread.count value should be positive, got " + threadPoints.get(0).value); + } + + @Test + void startIsIdempotent() { + MetricCollector before = new MetricCollector(); + OtelMetricRegistry.INSTANCE.collectMetrics(before); + int countBefore = before.metricNames.size(); + + JvmOtlpRuntimeMetrics.start(); + JvmOtlpRuntimeMetrics.start(); + + MetricCollector after = new MetricCollector(); + OtelMetricRegistry.INSTANCE.collectMetrics(after); + assertEquals( + countBefore, + after.metricNames.size(), + "Repeated start() must not register duplicate instruments"); + } + + static final class DataPointEntry { + final Map attrs; + final Number value; + + DataPointEntry(Map attrs, Number value) { + this.attrs = attrs; + this.value = value; + } + } + + static final class MetricCollector + implements OtlpMetricsVisitor, OtlpScopedMetricsVisitor, OtlpMetricVisitor { + + String currentInstrument = ""; + final Map currentAttrs = new LinkedHashMap<>(); + final Set metricNames = new LinkedHashSet<>(); + final Map> points = new LinkedHashMap<>(); + + @Override + public OtlpScopedMetricsVisitor visitScopedMetrics(OtelInstrumentationScope scope) { + return this; + } + + @Override + public OtlpMetricVisitor visitMetric(OtelInstrumentDescriptor descriptor) { + currentInstrument = descriptor.getName().toString(); + metricNames.add(currentInstrument); + points.computeIfAbsent(currentInstrument, k -> new ArrayList<>()); + return this; + } + + @Override + public void visitAttribute(int type, String key, Object value) { + currentAttrs.put(key, value == null ? null : value.toString()); + } + + @Override + public void visitDataPoint(OtlpDataPoint point) { + Map 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; + } + points + .computeIfAbsent(currentInstrument, k -> new ArrayList<>()) + .add(new DataPointEntry(attrs, value)); + } + + Set attributeValues(String metricName, String attrKey) { + List entries = points.get(metricName); + if (entries == null) { + return new LinkedHashSet<>(); + } + return entries.stream() + .map(e -> e.attrs.get(attrKey)) + .filter(Objects::nonNull) + .map(Object::toString) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + } +}