diff --git a/contrib/jmx-metrics/README.md b/contrib/jmx-metrics/README.md index 0e82768c6..66eaaebbc 100644 --- a/contrib/jmx-metrics/README.md +++ b/contrib/jmx-metrics/README.md @@ -25,18 +25,11 @@ otel.jmx.password = my-password ##### `script.groovy` example ```groovy -import io.opentelemetry.common.Labels - -def loadMatches = otel.queryJmx("org.apache.cassandra.metrics:type=Storage,name=Load") -def load = loadMatches.first() - -def lvr = otel.longValueRecorder( - "cassandra.storage.load", +def storageLoadMBean = otel.mbean("org.apache.cassandra.metrics:type=Storage,name=Load") +otel.instrument(storageLoadMBean, "cassandra.storage.load", "Size, in bytes, of the on disk data size this node manages", - "By", [myConstantLabelKey:"myConstantLabelValue"] + "By", "Count", otel.&longValueRecorder ) - -lvr.record(load.Count, Labels.of("myKey", "myVal")) ``` As configured in the example, this metric gatherer will configure an otlp gRPC metric exporter @@ -49,7 +42,7 @@ via `otel.jmx.groovy.script`, it will then run the script on the specified - `otel.queryJmx(String objectNameStr)` - This method will query the connected JMX application for the given `objectName`, which can - include wildcards. The return value will be a `List` of zero or more + include wildcards. The return value will be a sorted `List` of zero or more [`GroovyMBean` objects](http://docs.groovy-lang.org/latest/html/api/groovy/jmx/GroovyMBean.html), which are conveniently wrapped to make accessing attributes on the MBean simple. See http://groovy-lang.org/jmx.html for more information about their usage. @@ -58,6 +51,41 @@ via `otel.jmx.groovy.script`, it will then run the script on the specified - This helper has the same functionality as its other signature, but takes an `ObjectName` instance if constructing raw names is undesired. +### JMX `MBeanHelper` and `InstrumentHelper` Access Methods + +- `otel.mbean(String objectNameStr)` + - This method will query for the given `objectNameStr` using `otel.queryJmx()` as previously described, + but returns an `MBeanHelper` instance representing the alphabetically first matching MBean for usage by + subsequent `InstrumentHelper` instances (available via `otel.instrument()`) as described below. + +- `otel.mbeans(String objectNameStr)` + - This method will query for the given `objectNameStr` using `otel.queryJmx()` as previously described, + but returns an `MBeanHelper` instance representing all matching MBeans for usage by subsequent `InstrumentHelper` + instances (available via `otel.instrument()`) as described below. + +- `otel.instrument(MBeanHelper mBeanHelper, String instrumentName, String description, String unit, Map labelFuncs, String attribute, Closure instrument)` + - This method provides the ability to easily create and automatically update instrument instances from an + `MBeanHelper`'s underlying MBean instances via an OpenTelemetry instrument helper method pointer as described below. + - The method parameters map to those of the instrument helpers, while the new `Map labelFuncs` will + be used to specify updated instrument labels that have access to the inspected MBean: + + ```groovy + // This example's resulting datapoint(s) will have Labels consisting of the specified key + // and a dynamically evaluated value from the GroovyMBean being examined. + [ "myLabelKey": { mbean -> mbean.name().getKeyProperty("myObjectNameProperty") } ] + ``` + + - If the underlying MBean(s) held by the provided MBeanHelper are + [`CompositeData`](https://docs.oracle.com/javase/7/docs/api/javax/management/openmbean/CompositeData.html) instances, + each key of their `CompositeType` `keySet` will be `.`-appended to the specified `instrumentName`, whose resulting + instrument will be updated for each respective value. + +`otel.instrument()` provides additional signatures to obtain and update the returned `InstrumentHelper`: + +- `otel.instrument(MBeanHelper mBeanHelper, String name, String description, String unit, String attribute, Closure instrument)` - `labelFuncs` are empty map. +- `otel.instrument(MBeanHelper mBeanHelper, String name, String description, String attribute, Closure instrument)` - `unit` is "1" and `labelFuncs` are empty map. +- `otel.instrument(MBeanHelper mBeanHelper, String name, String attribute, Closure instrument)` - `description` is empty string, `unit` is "1" and `labelFuncs` are empty map. + ### OpenTelemetry Synchronous Instrument Helpers - `otel.doubleCounter(String name, String description, String unit, Map labels)` @@ -95,6 +123,7 @@ The currently available target systems are: | `otel.jmx.target.system` | | ------------------------ | +| [`jvm`](./docs/target-systems/jvm.md) | | [`cassandra`](./docs/target-systems/cassandra.md) | ### Configuration diff --git a/contrib/jmx-metrics/docs/target-systems/jvm.md b/contrib/jmx-metrics/docs/target-systems/jvm.md new file mode 100644 index 000000000..335dbc962 --- /dev/null +++ b/contrib/jmx-metrics/docs/target-systems/jvm.md @@ -0,0 +1,88 @@ +# JVM Metrics + +The JMX Metric Gatherer provides built in JVM metric gathering capabilities. +These metrics are sourced from Cassandra's exposed Dropwizard Metrics for each node: https://cassandra.apache.org/doc/latest/operating/metrics.html. + +## Metrics + +### Client Request Metrics + +* Name: `jvm.classes.loaded` +* Description: The number of loaded classes +* Unit: `1` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.gc.collections.count` +* Description: The total number of garbage collections that have occurred +* Unit: `1` +* Instrument Type: LongCounter + +* Name: `jvm.gc.collections.elapsed` +* Description: The approximate accumulated collection elapsed time +* Unit: `ms` +* Instrument Type: LongCounter + +* Name: `jvm.memory.heap.init` +* Description: The initial amount of memory that the JVM requests from the operating system for the heap +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.heap.max` +* Description: The maximum amount of memory can be used for the heap +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.heap.used` +* Description: The current heap memory usage +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.heap.committed` +* Description: The amount of memory that is guaranteed to be available for the heap +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.nonheap.init` +* Description: The initial amount of memory that the JVM requests from the operating system for non-heap purposes +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.nonheap.max` +* Description: The maximum amount of memory can be used for non-heap purposes +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.nonheap.used` +* Description: The current non-heap memory usage +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.nonheap.committed` +* Description: The amount of memory that is guaranteed to be available for non-heap purposes +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.pool.init` +* Description: The initial amount of memory that the JVM requests from the operating system for the memory pool +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.pool.max` +* Description: The maximum amount of memory can be used for the memory pool +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.pool.used` +* Description: The current memory pool memory usage +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.memory.pool.committed` +* Description: The amount of memory that is guaranteed to be available for the memory pool +* Unit: `by` +* Instrument Type: LongUpDownCounter + +* Name: `jvm.threads.count` +* Description: The current number of threads +* Unit: `1` +* Instrument Type: LongUpDownCounter diff --git a/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/InstrumentHelper.groovy b/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/InstrumentHelper.groovy new file mode 100644 index 000000000..cb1d2a635 --- /dev/null +++ b/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/InstrumentHelper.groovy @@ -0,0 +1,130 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.contrib.jmxmetrics + +import io.opentelemetry.metrics.DoubleCounter +import io.opentelemetry.metrics.DoubleUpDownCounter +import io.opentelemetry.metrics.LongCounter +import io.opentelemetry.metrics.LongUpDownCounter +import java.util.logging.Logger +import javax.management.openmbean.CompositeData + +/** + * A helper for easy instrument creation and updates based on an + * {@link MBeanHelper} attribute's value and passed {@link OtelHelper} + * instrument creator method pointer (e.g. &longCounter). + * + * Intended to be used via the script-bound `otel` {@link OtelHelper} instance methods: + * + * def threadCount = otel.instrument(myThreadingMBeanHelper, + * "jvm.threads.count", "number of threads", + * "1", [ + * "myLabel": { mbean -> mbean.name().getKeyProperty("myObjectNameProperty") }, + * "myOtherLabel": { "myLabelValue" } + * ], "ThreadCount", otel.&longUpDownCounter) + * + * threadCount.update() + * + * If the underlying MBean(s) held by the MBeanHelper are + * {@link CompositeData} instances, each key of their CompositeType's + * keySet will be .-appended to the specified instrumentName and + * updated for each respective value. + */ +class InstrumentHelper { + private static final Logger logger = Logger.getLogger(InstrumentHelper.class.getName()); + + private final MBeanHelper mBeanHelper + private final String instrumentName + private final String description + private final String unit + private final String attribute + private final Map labelFuncs + private final Closure instrument + + /** + * An InstrumentHelper provides the ability to easily create and update {@link io.opentelemetry.metrics.Instrument} + * instances from an MBeanHelper's underlying {@link GroovyMBean} instances via an {@link OtelHelper}'s instrument + * method pointer. + * + * @param mBeanHelper - the single or multiple {@link GroovyMBean} instance from which to access attribute values + * @param instrumentName - the resulting instruments' name to register. + * @param description - the resulting instruments' description to register. + * @param unit - the resulting instruments' unit to register. + * @param labelFuncs - A {@link Map} of label names and values to be determined by custom + * {@link GroovyMBean}-provided Closures: (e.g. [ "myLabelName" : { mbean -> "myLabelValue"} ]). The + * resulting Label instances will be used for each individual update. + * @param attribute - The {@link GroovyMBean} attribute for which to use as the instrument value. + * @param instrument - The {@link io.opentelemetry.metrics.Instrument}-producing {@link OtelHelper} method pointer: + * (e.g. new OtelHelper().&doubleValueRecorder) + */ + InstrumentHelper(MBeanHelper mBeanHelper, String instrumentName, String description, String unit, Map labelFuncs, String attribute, Closure instrument) { + this.mBeanHelper = mBeanHelper + this.instrumentName = instrumentName + this.description = description + this.unit = unit + this.labelFuncs = labelFuncs + this.attribute = attribute + this.instrument = instrument + } + + void update() { + def mbeans = mBeanHelper.getMBeans() + def values = mBeanHelper.getAttribute(attribute) + if (values.size() == 0) { + logger.warning("No valid value(s) for ${instrumentName} - ${mBeanHelper}.${attribute}") + return + } + + [mbeans, values].transpose().each { mbean, value -> + if (value instanceof CompositeData) { + value.getCompositeType().keySet().each { key -> + def val = value.get(key) + def updatedInstrumentName = "${instrumentName}.${key}" + def labels = getLabels(mbean, labelFuncs) + def inst = instrument(updatedInstrumentName, description, unit) + logger.fine("Recording ${updatedInstrumentName} - ${inst} w/ ${val} - ${labels}") + updateInstrumentWithValue(inst, val, labels) + } + } else { + def labels = getLabels(mbean, labelFuncs) + def inst = instrument(instrumentName, description, unit) + logger.fine("Recording ${instrumentName} - ${inst} w/ ${value} - ${labels}") + updateInstrumentWithValue(inst, value, labels) + } + } + } + + private static Map getLabels(GroovyMBean mbean, Map labelFuncs) { + def labels = [:] + labelFuncs.each { label, labelFunc -> + labels[label] = labelFunc(mbean) as String + } + return labels + } + + private static void updateInstrumentWithValue(inst, value, labels) { + def labelMap = GroovyMetricEnvironment.mapToLabels(labels) + if (inst instanceof DoubleCounter + || inst instanceof DoubleUpDownCounter + || inst instanceof LongCounter + || inst instanceof LongUpDownCounter) { + inst.add(value, labelMap) + } else { + inst.record(value, labelMap) + } + } +} diff --git a/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/MBeanHelper.groovy b/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/MBeanHelper.groovy new file mode 100644 index 000000000..8a50a8452 --- /dev/null +++ b/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/MBeanHelper.groovy @@ -0,0 +1,93 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.contrib.jmxmetrics + +import groovy.transform.PackageScope +import javax.management.MBeanServerConnection +import javax.management.ObjectName +import java.util.logging.Logger + +/** + * A helper for easy {@link GroovyMBean} querying, creation, and protected attribute access. + * + * Basic query functionality is provided by the static queryJmx methods, while MBeanHelper + * instances operate on an underlying GroovyMBean list field. + * + * Specifying if a single underlying GroovyMBean is expected or of interest is done via the + * isSingle argument, where resulting attribute values are only returned for the first + * match. + * + * Intended to be used via the script-bound `otel` instance: + * + * def singleMBean = otel.mbean("com.example:type=SingleType") + * def multipleMBeans = otel.mbeans("com.example:type=MultipleType,*") + * [singleMBean, multipleMBeans].each { it.fetch() } + * + */ +class MBeanHelper { + private static final Logger logger = Logger.getLogger(MBeanHelper.class.getName()); + + private final JmxClient jmxClient + private final boolean isSingle + private final String objectName + + private List mbeans + + MBeanHelper(JmxClient jmxClient, String objectName, boolean isSingle) { + this.jmxClient = jmxClient + this.objectName = objectName + this.isSingle = isSingle + } + + @PackageScope static List queryJmx(JmxClient jmxClient, String objNameStr) { + return queryJmx(jmxClient, new ObjectName(objNameStr)) + } + + @PackageScope static List queryJmx(JmxClient jmxClient, ObjectName objName) { + List names = jmxClient.query(objName) + MBeanServerConnection server = jmxClient.connection + return names.collect { new GroovyMBean(server, it) } + } + + void fetch() { + mbeans = queryJmx(jmxClient, objectName) + if (mbeans.size() == 0) { + logger.warning("Failed to fetch MBean ${objectName}.") + } else { + logger.fine("Fetched ${mbeans.size()} MBeans - ${mbeans}") + } + } + + @PackageScope List getMBeans() { + if (mbeans == null) { + logger.warning("No active MBeans. Be sure to fetch() before updating any applicable instruments.") + return [] + } + return mbeans + } + + @PackageScope List getAttribute(String attribute) { + if (mbeans == null || mbeans.size() == 0) { + return [] + } + + def ofInterest = isSingle ? [mbeans[0]]: mbeans + return ofInterest.collect { + it.getProperty(attribute) + } + } +} diff --git a/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/OtelHelper.groovy b/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/OtelHelper.groovy index 978b9d197..6e370c3fa 100644 --- a/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/OtelHelper.groovy +++ b/contrib/jmx-metrics/src/main/groovy/io/opentelemetry/contrib/jmxmetrics/OtelHelper.groovy @@ -23,7 +23,6 @@ import io.opentelemetry.metrics.LongCounter import io.opentelemetry.metrics.LongUpDownCounter import io.opentelemetry.metrics.LongValueRecorder -import javax.management.MBeanServerConnection import javax.management.ObjectName class OtelHelper { @@ -40,22 +39,66 @@ class OtelHelper { /** * Returns a list of {@link GroovyMBean} for a given object name String. * @param objNameStr - the {@link String} representation of an object name or pattern, to be - * used as the argument to the basic {@link ObjectName} constructor for the JmxClient query. + * used as the argument to the basic {@link javax.management.ObjectName} constructor for the JmxClient query. * @return a {@link List} from which to create metrics. */ List queryJmx(String objNameStr) { - return queryJmx(new ObjectName(objNameStr)) + return MBeanHelper.queryJmx(jmxClient, objNameStr); } /** - * Returns a list of {@link GroovyMBean} for a given {@link ObjectName}. - * @param objName - the {@link ObjectName} used for the JmxClient query. + * Returns a list of {@link GroovyMBean} for a given {@link javax.management.ObjectName}. + * @param objName - the {@link javax.management.ObjectName} used for the JmxClient query. * @return a {@link List} from which to create metrics. */ List queryJmx(ObjectName objName) { - Set names = jmxClient.query(objName) - MBeanServerConnection server = jmxClient.connection - return names.collect { new GroovyMBean(server, it) } + return MBeanHelper.queryJmx(jmxClient, objName); + } + + /** + * Returns a fetched, potentially multi-{@link GroovyMBean} {@link MBeanHelper} for a given object name String. + * @param objNameStr - the {@link String} representation of an object name or pattern, to be + * used as the argument to the basic {@link javax.management.ObjectName} constructor for the JmxClient query. + * @return a {@link MBeanHelper} that operates over all resulting {@link GroovyMBean} instances. + */ + MBeanHelper mbeans(String objNameStr) { + def mbeanHelper = new MBeanHelper(jmxClient, objNameStr, false) + mbeanHelper.fetch() + return mbeanHelper + } + + /** + * Returns a fetched, single {@link GroovyMBean} {@link MBeanHelper} for a given object name String. + * @param objNameStr - the {@link String} representation of an object name or pattern, to be + * used as the argument to the basic {@link javax.management.ObjectName} constructor for the JmxClient query. + * @return a {@link MBeanHelper} that operates over all resulting {@link GroovyMBean} instances. + */ + MBeanHelper mbean(String objNameStr) { + def mbeanHelper = new MBeanHelper(jmxClient, objNameStr, true) + mbeanHelper.fetch() + return mbeanHelper + } + + /** + * Returns an updated @{link InstrumentHelper} associated with the provided {@link MBeanHelper} and its specified + * attribute value(s). The parameters map to the InstrumentHelper constructor. + */ + InstrumentHelper instrument(MBeanHelper mBeanHelper, String instrumentName, String description, String unit, Map labelFuncs, String attribute, Closure otelInstrument) { + def instrumentHelper = new InstrumentHelper(mBeanHelper, instrumentName, description, unit, labelFuncs, attribute, otelInstrument) + instrumentHelper.update() + return instrumentHelper + } + + InstrumentHelper instrument(MBeanHelper mBeanHelper, String instrumentName, String description, String unit, String attribute, Closure otelInstrument) { + return instrument(mBeanHelper, instrumentName, description, unit, [:] as Map, attribute, otelInstrument) + } + + InstrumentHelper instrument(MBeanHelper mBeanHelper, String instrumentName, String description, String attribute, Closure otelInstrument) { + return instrument(mBeanHelper, instrumentName, description, OtelHelper.SCALAR, [:] as Map, attribute, otelInstrument) + } + + InstrumentHelper instrument(MBeanHelper mBeanHelper, String instrumentName, String attribute, Closure otelInstrument) { + return instrument(mBeanHelper, instrumentName, "", OtelHelper.SCALAR, [:] as Map, attribute, otelInstrument) } DoubleCounter doubleCounter(String name, String description, String unit, Map labels) { diff --git a/contrib/jmx-metrics/src/main/java/io/opentelemetry/contrib/jmxmetrics/GroovyMetricEnvironment.java b/contrib/jmx-metrics/src/main/java/io/opentelemetry/contrib/jmxmetrics/GroovyMetricEnvironment.java index 03a6ca985..6d62f17fc 100644 --- a/contrib/jmx-metrics/src/main/java/io/opentelemetry/contrib/jmxmetrics/GroovyMetricEnvironment.java +++ b/contrib/jmx-metrics/src/main/java/io/opentelemetry/contrib/jmxmetrics/GroovyMetricEnvironment.java @@ -111,7 +111,7 @@ public void shutdown() { } } - private static Labels mapToLabels(final Map labelMap) { + protected static Labels mapToLabels(final Map labelMap) { Labels.Builder labels = new Labels.Builder(); if (labelMap != null) { for (Map.Entry kv : labelMap.entrySet()) { diff --git a/contrib/jmx-metrics/src/main/java/io/opentelemetry/contrib/jmxmetrics/JmxClient.java b/contrib/jmx-metrics/src/main/java/io/opentelemetry/contrib/jmxmetrics/JmxClient.java index a6912f793..86b53409a 100644 --- a/contrib/jmx-metrics/src/main/java/io/opentelemetry/contrib/jmxmetrics/JmxClient.java +++ b/contrib/jmx-metrics/src/main/java/io/opentelemetry/contrib/jmxmetrics/JmxClient.java @@ -20,10 +20,11 @@ import java.net.MalformedURLException; import java.security.Provider; import java.security.Security; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; -import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; @@ -35,7 +36,6 @@ public class JmxClient { private static final Logger logger = Logger.getLogger(JmxClient.class.getName()); - private static final Set EMPTY_SET = Collections.emptySet(); private final JMXServiceURL url; private final String username; @@ -91,19 +91,21 @@ public MBeanServerConnection getConnection() { * Query the MBean server for a given ObjectName. * * @param objectName ObjectName to query - * @return the set of applicable ObjectName instances found by server + * @return the sorted list of applicable ObjectName instances found by server */ - public Set query(final ObjectName objectName) { + public List query(final ObjectName objectName) { MBeanServerConnection mbsc = getConnection(); if (mbsc == null) { - return EMPTY_SET; + return Collections.emptyList(); } try { - return mbsc.queryNames(objectName, null); + List objectNames = new ArrayList<>(mbsc.queryNames(objectName, null)); + Collections.sort(objectNames); + return objectNames; } catch (IOException e) { logger.log(Level.WARNING, "Could not query remote JMX server: ", e); - return EMPTY_SET; + return Collections.emptyList(); } } } diff --git a/contrib/jmx-metrics/src/main/resources/target-systems/jvm.groovy b/contrib/jmx-metrics/src/main/resources/target-systems/jvm.groovy index da1dcee0c..69d93d55a 100644 --- a/contrib/jmx-metrics/src/main/resources/target-systems/jvm.groovy +++ b/contrib/jmx-metrics/src/main/resources/target-systems/jvm.groovy @@ -14,10 +14,30 @@ * limitations under the License. */ -package io.opentelemetry.contrib.jmxmetrics +def classLoading = otel.mbean("java.lang:type=ClassLoading") +otel.instrument(classLoading, "jvm.classes.loaded", "number of loaded classes", + "1", "LoadedClassCount", otel.&longUpDownCounter) -// This is a placeholder for default metric functionality -// per https://github.com/open-telemetry/opentelemetry-java-contrib/issues/12 +def garbageCollector = otel.mbeans("java.lang:type=GarbageCollector,*") +otel.instrument(garbageCollector, "jvm.gc.collections.count", "total number of collections that have occurred", + "1", ["name" : { mbean -> mbean.name().getKeyProperty("name") }], + "CollectionCount", otel.&longCounter) +otel.instrument(garbageCollector, "jvm.gc.collections.elapsed", + "the approximate accumulated collection elapsed time in milliseconds", "ms", + ["name" : { mbean -> mbean.name().getKeyProperty("name") }], + "CollectionTime", otel.&longCounter) -def counter = otel.longCounter("placeholder.metric", "For testing purposes") -counter.add(1) +def memory = otel.mbean("java.lang:type=Memory") +otel.instrument(memory, "jvm.memory.heap", "current heap usage", + "by", "HeapMemoryUsage", otel.&longUpDownCounter) +otel.instrument(memory, "jvm.memory.nonheap", "current non-heap usage", + "by", "NonHeapMemoryUsage", otel.&longUpDownCounter) + +def memoryPool = otel.mbeans("java.lang:type=MemoryPool,*") +otel.instrument(memoryPool, "jvm.memory.pool", "current memory pool usage", + "by", ["name" : { mbean -> mbean.name().getKeyProperty("name") }], + "Usage", otel.&longUpDownCounter) + +def threading = otel.mbean("java.lang:type=Threading") +otel.instrument(threading, "jvm.threads.count", "number of threads", + "1", "ThreadCount", otel.&longUpDownCounter) diff --git a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/GroovyRunnerTest.groovy b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/GroovyRunnerTest.groovy index 59b5a530f..b67e62a05 100644 --- a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/GroovyRunnerTest.groovy +++ b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/GroovyRunnerTest.groovy @@ -16,18 +16,27 @@ package io.opentelemetry.contrib.jmxmetrics +import javax.management.ObjectName + class GroovyRunnerTest extends UnitTest { def 'target system scripts are loaded from resources'() { when: 'available target system script is used' - System.setProperty('otel.jmx.service.url', 'requiredValue') + System.setProperty('otel.jmx.service.url', 'service:jmx:rmi:///jndi/rmi://localhost:12345/jmxrmi') System.setProperty('otel.jmx.target.system', 'jvm') def config = new JmxConfig() config.validate() def exportCalled = false - def groovyRunner = new GroovyRunner(config, null, new GroovyMetricEnvironment(config) { + def stub = new JmxClient(config) { + @Override + List query(final ObjectName objectName) { + return [] as List; + } + } + + def groovyRunner = new GroovyRunner(config, stub, new GroovyMetricEnvironment(config) { @Override void exportMetrics() { exportCalled = true diff --git a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/InstrumentHelperTest.groovy b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/InstrumentHelperTest.groovy new file mode 100644 index 000000000..580d2739c --- /dev/null +++ b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/InstrumentHelperTest.groovy @@ -0,0 +1,179 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.contrib.jmxmetrics + +import static io.opentelemetry.sdk.metrics.data.MetricData.Descriptor.Type.MONOTONIC_DOUBLE +import static io.opentelemetry.sdk.metrics.data.MetricData.Descriptor.Type.MONOTONIC_LONG +import static io.opentelemetry.sdk.metrics.data.MetricData.Descriptor.Type.NON_MONOTONIC_DOUBLE +import static io.opentelemetry.sdk.metrics.data.MetricData.Descriptor.Type.NON_MONOTONIC_LONG +import static io.opentelemetry.sdk.metrics.data.MetricData.Descriptor.Type.SUMMARY +import static java.lang.management.ManagementFactory.getPlatformMBeanServer + +import io.opentelemetry.common.Labels +import io.opentelemetry.sdk.OpenTelemetrySdk +import javax.management.MBeanServer +import javax.management.ObjectName +import javax.management.remote.JMXConnectorServer +import javax.management.remote.JMXConnectorServerFactory +import javax.management.remote.JMXServiceURL +import org.junit.Rule +import org.junit.rules.TestName +import org.junit.rules.TestRule +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Unroll + +class InstrumentHelperTest extends Specification { + + @Rule public final TestRule name = new TestName() + + @Shared + MBeanServer mBeanServer + + @Shared + JMXConnectorServer jmxServer + + @Shared + JmxClient jmxClient + + @Shared + OtelHelper otel + + def setup() { + mBeanServer = getPlatformMBeanServer() + + def serviceUrl = new JMXServiceURL('rmi', 'localhost', 0) + jmxServer = JMXConnectorServerFactory.newJMXConnectorServer(serviceUrl, [:], mBeanServer) + jmxServer.start() + def completeAddress = jmxServer.getAddress() + + def jmxConfig = new JmxConfig(new Properties().tap { + it.setProperty(JmxConfig.EXPORTER_TYPE, 'inmemory') + it.setProperty(JmxConfig.SERVICE_URL, "${completeAddress}") + }) + + jmxClient = new JmxClient(jmxConfig) + + // Set up a MeterSdk per test to be able to collect its metrics alone + def gme = new GroovyMetricEnvironment(jmxConfig, name.methodName, '') + otel = new OtelHelper(jmxClient, gme) + } + + def cleanup() { + jmxServer.stop() + } + + interface ThingMBean { + double getDouble() + long getLong() + } + + static class Thing implements ThingMBean { + @Override + double getDouble() { + return 123.456 + } + + @Override + long getLong() { + return 234 + } + } + + def exportMetrics() { + def provider = OpenTelemetrySdk.meterProvider.get(name.methodName, '') + return provider.collectAll().sort { md1, md2 -> + def p1 = md1.points[0] + def p2 = md2.points[0] + def s1 = p1.startEpochNanos + def s2 = p2.startEpochNanos + if (s1 == s2) { + if (md1.descriptor.type == SUMMARY) { + return p1.percentileValues[0].value <=> p2.percentileValues[0].value + } + return p1.value <=> p2.value + } + s1 <=> s2 + } + } + + @Unroll + def "#instrumentMethod via #quantity MBeanHelper"() { + setup: "Create and register four Things and create ${quantity} MBeanHelper" + def thingName = "${quantity}:type=${instrumentMethod}.Thing" + def things = (0..3).collect { new Thing() } + things.eachWithIndex { thing, i -> + def name = "${thingName},thing=${i}" + mBeanServer.registerMBean(thing, new ObjectName(name)) + } + def mbeanHelper = new MBeanHelper(jmxClient, "${thingName},*", isSingle) + mbeanHelper.fetch() + + expect: + when: + def instrumentName = "${quantity}.${instrumentMethod}.counter" + def description = "${quantity} double counter description" + def instrument = otel.&"${instrumentMethod}" + def instrumentHelper = new InstrumentHelper( + mbeanHelper, instrumentName, description, "1", + ["labelOne" : { "labelOneValue"}, "labelTwo": { mbean -> mbean.name().getKeyProperty("thing") }], + attribute, instrument) + instrumentHelper.update() + + then: + def metrics = exportMetrics() + metrics.size() == 1 + + metrics.each { metric -> + assert metric.descriptor.name == instrumentName + assert metric.descriptor.description == description + assert metric.descriptor.unit == "1" + assert metric.descriptor.constantLabels == Labels.empty() + assert metric.descriptor.type == descriptorType + assert metric.points.size() == isSingle ? 1 : 4 + metric.points.eachWithIndex { point, i -> + assert point.labels == Labels.of("labelOne", "labelOneValue", "labelTwo", "${i}") + + if (descriptorType == SUMMARY) { + assert point.count == 1 + assert point.sum == value + assert point.percentileValues[0].percentile == 0 + assert point.percentileValues[0].value == value + assert point.percentileValues[1].percentile == 100 + assert point.percentileValues[1].value == value + } else { + assert point.value == value + } + } + } + + where: + isSingle | quantity | attribute | instrumentMethod | descriptorType | value + true | "single" | "Double" | "doubleCounter" | MONOTONIC_DOUBLE | 123.456 + false | "multiple" | "Double" | "doubleCounter" | MONOTONIC_DOUBLE | 123.456 + true | "single" | "Double" | "doubleUpDownCounter" | NON_MONOTONIC_DOUBLE | 123.456 + false | "multiple" | "Double" | "doubleUpDownCounter" | NON_MONOTONIC_DOUBLE | 123.456 + true | "single" | "Long" | "longCounter" | MONOTONIC_LONG | 234 + false | "multiple" | "Long" | "longCounter" | MONOTONIC_LONG | 234 + true | "single" | "Long" | "longUpDownCounter" | NON_MONOTONIC_LONG | 234 + false | "multiple" | "Long" | "longUpDownCounter" | NON_MONOTONIC_LONG | 234 + true | "single" | "Double" | "doubleValueRecorder" | SUMMARY | 123.456 + false | "multiple" | "Double" | "doubleValueRecorder" | SUMMARY | 123.456 + true | "single" | "Long" | "longValueRecorder" | SUMMARY | 234 + false | "multiple" | "Long" | "longValueRecorder" | SUMMARY | 234 + } +} diff --git a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/MBeanHelperTest.groovy b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/MBeanHelperTest.groovy new file mode 100644 index 000000000..423aad5b3 --- /dev/null +++ b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/MBeanHelperTest.groovy @@ -0,0 +1,106 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.opentelemetry.contrib.jmxmetrics + +import spock.lang.Unroll + +import static java.lang.management.ManagementFactory.getPlatformMBeanServer + +import javax.management.MBeanServer +import javax.management.ObjectName +import javax.management.remote.JMXConnectorServer +import javax.management.remote.JMXConnectorServerFactory +import javax.management.remote.JMXServiceURL +import spock.lang.Shared +import spock.lang.Specification + + +class MBeanHelperTest extends Specification { + + @Shared + MBeanServer mBeanServer + + @Shared + JMXConnectorServer jmxServer + + @Shared + JmxClient jmxClient + + def setup() { + mBeanServer = getPlatformMBeanServer() + + def serviceUrl = new JMXServiceURL('rmi', 'localhost', 0) + jmxServer = JMXConnectorServerFactory.newJMXConnectorServer(serviceUrl, [:], mBeanServer) + jmxServer.start() + def completeAddress = jmxServer.getAddress() + + def props = new Properties().tap { + it.setProperty(JmxConfig.SERVICE_URL, "${completeAddress}") + } + + def jmxConfig = new JmxConfig(props) + jmxClient = new JmxClient(jmxConfig) + } + + def cleanup() { + jmxServer.stop() + } + + @Unroll + def "represents #quantity MBean(s)"() { + setup: + def thingName = "io.opentelemetry.contrib.jmxmetrics:type=${quantity}Thing" + def things = (0..100).collect { new Thing(it as String) } + things.eachWithIndex { thing, i -> + def name = "${thingName},thing=${i}" + mBeanServer.registerMBean(thing, new ObjectName(name)) + } + + expect: + when: "We create and register 100 Things and create ${quantity} MBeanHelper" + def mbeanHelper = new MBeanHelper(jmxClient, "${thingName},*", isSingle) + mbeanHelper.fetch() + + then: "${quantity} returned" + def returned = mbeanHelper.getAttribute("SomeAttribute") + returned == isSingle ? ["0"]: (0..100).collect {it as String}.sort() + + where: + isSingle | quantity + true | "single" + false | "multiple" + } + + interface ThingMBean { + + String getSomeAttribute() + } + + static class Thing implements ThingMBean { + + private String attrValue + + Thing(attrValue) { + this.attrValue = attrValue + } + + @Override + String getSomeAttribute() { + return attrValue + } + } +} diff --git a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/OtelHelperJmxTest.groovy b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/OtelHelperJmxTest.groovy index 5bbba1219..26950875a 100644 --- a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/OtelHelperJmxTest.groovy +++ b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/OtelHelperJmxTest.groovy @@ -106,6 +106,32 @@ class OtelHelperJmxTest extends Specification { verifyClient(props) } + def "sorted query results"() { + when: + def serverAddr = setupServer([:]) + def config = new JmxConfig(new Properties().tap { + it.setProperty(JmxConfig.SERVICE_URL, "${serverAddr}") + it.setProperty(JmxConfig.GROOVY_SCRIPT, "myscript.groovy") + }) + config.validate() + def otel = setupHelper(config) + + def things = (0..99).collect {new Thing()} + + def mbeanServer = getPlatformMBeanServer() + things.eachWithIndex { thing, i -> + mbeanServer.registerMBean(thing, new ObjectName("sorted.query.results:type=Thing,thing=${i}")) + } + + then: + def mbeans = otel.queryJmx("sorted.query.results:type=Thing,*") + assertEquals(100, mbeans.size()) + + def names = mbeans.collect { it.name() as String } + def sortedNames = names.collect().sort() + assertEquals(sortedNames, names) + } + interface ThingMBean { String getSomeAttribute() diff --git a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/target-systems/CassandraIntegrationTests.groovy b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/target-systems/CassandraIntegrationTests.groovy index 31d0f9de0..56012f61f 100644 --- a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/target-systems/CassandraIntegrationTests.groovy +++ b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/target-systems/CassandraIntegrationTests.groovy @@ -64,7 +64,6 @@ class CassandraIntegrationTests extends OtlpIntegrationTest { metrics.sort{ a, b -> a.name <=> b.name} then: 'they are of the expected size and content' metrics.size() == 23 - println "CassandraIntegrationTests.end to end: ${metrics}" def expectedMetrics = [ [ diff --git a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/target-systems/JVMTargetSystemIntegrationTests.groovy b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/target-systems/JVMTargetSystemIntegrationTests.groovy index d4a23d7cd..8e94c589f 100644 --- a/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/target-systems/JVMTargetSystemIntegrationTests.groovy +++ b/contrib/jmx-metrics/src/test/groovy/io/opentelemetry/contrib/jmxmetrics/target-systems/JVMTargetSystemIntegrationTests.groovy @@ -16,7 +16,14 @@ package io.opentelemetry.contrib.jmxmetrics -import org.apache.hc.client5.http.fluent.Request +import io.opentelemetry.proto.metrics.v1.IntSum + +import io.opentelemetry.proto.common.v1.InstrumentationLibrary +import io.opentelemetry.proto.common.v1.StringKeyValue +import io.opentelemetry.proto.metrics.v1.InstrumentationLibraryMetrics +import io.opentelemetry.proto.metrics.v1.Metric +import io.opentelemetry.proto.metrics.v1.ResourceMetrics +import org.testcontainers.Testcontainers import spock.lang.Requires import spock.lang.Timeout @@ -24,40 +31,192 @@ import spock.lang.Timeout System.getProperty('ojc.integration.tests') == 'true' }) @Timeout(60) -class JVMTargetSystemIntegrationTests extends IntegrationTest { - - def receivedMetrics() { - def scraped = [] - for (int i = 0; i < 120; i++) { - def resp = Request.get("http://localhost:${jmxExposedPort}/metrics").execute() - def received = resp.returnContent().asString() - if (received != '') { - scraped = received.split('\n') - if (scraped.size() > 2) { - break - } - } - Thread.sleep(500) - } - return scraped - } +class JVMTargetSystemIntegrationTests extends OtlpIntegrationTest { def 'end to end'() { - setup: 'we configure JMX metrics gatherer and target server to use default JVM target system script' - configureContainers('jvm_config.properties', 0, 9123, false) + setup: 'we configure JMX metrics gatherer and target server to use JVM as target system' + Testcontainers.exposeHostPorts(otlpPort) + configureContainers('target-systems/jvm.properties', otlpPort, 0, false) expect: - when: 'we receive metrics from the prometheus endpoint' - def scraped = receivedMetrics() + when: 'we receive metrics from the JMX metrics gatherer' + List receivedMetrics = collector.receivedMetrics + then: 'they are of the expected size' + receivedMetrics.size() == 1 + + when: "we examine the received metric's instrumentation library metrics lists" + ResourceMetrics receivedMetric = receivedMetrics.get(0) + List ilMetrics = + receivedMetric.instrumentationLibraryMetricsList + then: 'they of the expected size' + ilMetrics.size() == 1 + + when: 'we examine the instrumentation library' + InstrumentationLibraryMetrics ilMetric = ilMetrics.get(0) + InstrumentationLibrary il = ilMetric.instrumentationLibrary + then: 'it is of the expected content' + il.name == 'io.opentelemetry.contrib.jmxmetrics' + il.version == '0.0.1' - then: 'they are of the expected format' - scraped.size() == 3 - scraped[0].contains( - '# HELP placeholder_metric For testing purposes') - scraped[1].contains( - '# TYPE placeholder_metric counter') - scraped[2].contains( - 'placeholder_metric 1.0') + when: 'we examine the instrumentation library metric metrics list' + ArrayList metrics = ilMetric.metricsList as ArrayList + metrics.sort{ a, b -> a.name <=> b.name} + then: 'they are of the expected size and content' + metrics.size() == 16 + + def expectedMetrics = [ + [ + 'jvm.classes.loaded', + 'number of loaded classes', + '1', + [] + ], + [ + 'jvm.gc.collections.count', + 'total number of collections that have occurred', + '1', + [ + "ConcurrentMarkSweep", + "ParNew"] + ], + [ + 'jvm.gc.collections.elapsed', + 'the approximate accumulated collection elapsed time in milliseconds', + 'ms', + [ + "ConcurrentMarkSweep", + "ParNew"] + ], + [ + 'jvm.memory.heap.committed', + 'current heap usage', + 'by', + [] + ], + [ + 'jvm.memory.heap.init', + 'current heap usage', + 'by', + [] + ], + [ + 'jvm.memory.heap.max', + 'current heap usage', + 'by', + [] + ], + [ + 'jvm.memory.heap.used', + 'current heap usage', + 'by', + [] + ], + [ + 'jvm.memory.nonheap.committed', + 'current non-heap usage', + 'by', + [] + ], + [ + 'jvm.memory.nonheap.init', + 'current non-heap usage', + 'by', + [] + ], + [ + 'jvm.memory.nonheap.max', + 'current non-heap usage', + 'by', + [] + ], + [ + 'jvm.memory.nonheap.used', + 'current non-heap usage', + 'by', + [] + ], + [ + 'jvm.memory.pool.committed', + 'current memory pool usage', + 'by', + [ + "Code Cache", + "Par Eden Space", + "CMS Old Gen", + "Compressed Class Space", + "Metaspace", + "Par Survivor Space"] + ], + [ + 'jvm.memory.pool.init', + 'current memory pool usage', + 'by', + [ + "Code Cache", + "Par Eden Space", + "CMS Old Gen", + "Compressed Class Space", + "Metaspace", + "Par Survivor Space"] + ], + [ + 'jvm.memory.pool.max', + 'current memory pool usage', + 'by', + [ + "Code Cache", + "Par Eden Space", + "CMS Old Gen", + "Compressed Class Space", + "Metaspace", + "Par Survivor Space"] + ], + [ + 'jvm.memory.pool.used', + 'current memory pool usage', + 'by', + [ + "Code Cache", + "Par Eden Space", + "CMS Old Gen", + "Compressed Class Space", + "Metaspace", + "Par Survivor Space"] + ], + [ + 'jvm.threads.count', + 'number of threads', + '1', + [] + ], + ].eachWithIndex{ item, index -> + Metric metric = metrics.get(index) + assert metric.name == item[0] + assert metric.description == item[1] + assert metric.unit == item[2] + assert metric.hasIntSum() + IntSum datapoints = metric.intSum + def expectedLabelCount = item[3].size() + def expectedLabels = item[3] as Set + + def expectedDatapointCount = expectedLabelCount == 0 ? 1 : expectedLabelCount + assert datapoints.dataPointsCount == expectedDatapointCount + + (0.. + def datapoint = datapoints.getDataPoints(i) + List labels = datapoint.labelsList + if (expectedLabelCount != 0) { + assert labels.size() == 1 + assert labels[0].key == 'name' + def value = labels[0].value + assert expectedLabels.remove(value) + } else { + assert labels.size() == 0 + } + } + + assert expectedLabels.size() == 0 + } cleanup: cassandraContainer.stop() diff --git a/contrib/jmx-metrics/src/test/resources/jvm_config.properties b/contrib/jmx-metrics/src/test/resources/target-systems/jvm.properties similarity index 74% rename from contrib/jmx-metrics/src/test/resources/jvm_config.properties rename to contrib/jmx-metrics/src/test/resources/target-systems/jvm.properties index 95a6e19c6..c74044f46 100644 --- a/contrib/jmx-metrics/src/test/resources/jvm_config.properties +++ b/contrib/jmx-metrics/src/test/resources/target-systems/jvm.properties @@ -1,10 +1,9 @@ otel.jmx.interval.milliseconds = 3000 -otel.exporter = prometheus -otel.prometheus.host = 0.0.0.0 -otel.prometheus.port = 9123 +otel.exporter = otlp otel.jmx.service.url = service:jmx:rmi:///jndi/rmi://cassandra:7199/jmxrmi otel.jmx.target.system = jvm # these will be overridden by cmd line otel.jmx.username = wrong_username otel.jmx.password = wrong_password +otel.otlp.endpoint = host.testcontainers.internal:80 diff --git a/gradle/spotless.gradle b/gradle/spotless.gradle index ada4aca31..47c2692d6 100644 --- a/gradle/spotless.gradle +++ b/gradle/spotless.gradle @@ -18,7 +18,7 @@ spotless { target '**/*.groovy' greclipse() indentWithSpaces() - licenseHeaderFile rootProject.file('gradle/java.license.header'), '(package|import|class)' + licenseHeaderFile rootProject.file('gradle/java.license.header'), '(package|import|class|def)' } groovyGradle { target '**/*.gradle'