diff --git a/bom/pom.xml b/bom/pom.xml index af2bdc86589..4aadf6b13fd 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -374,6 +374,16 @@ helidon-metrics-api ${helidon.version} + + io.helidon.metrics + helidon-metrics-testing + ${helidon.version} + + + io.helidon.metrics + helidon-metrics-micrometer + ${helidon.version} + io.helidon.metrics helidon-metrics-service-api diff --git a/dependencies/pom.xml b/dependencies/pom.xml index 2c62e745d86..ae41e1b5c41 100644 --- a/dependencies/pom.xml +++ b/dependencies/pom.xml @@ -98,8 +98,8 @@ 1.4.0 2.6.2 2.10 - 1.11.1 - 1.11.1 + 1.11.3 + 1.11.3 3.4.3 3.3.0 4.4.0 diff --git a/metrics/api/pom.xml b/metrics/api/pom.xml index af23d2cc263..7a70eac9be1 100644 --- a/metrics/api/pom.xml +++ b/metrics/api/pom.xml @@ -41,6 +41,20 @@ io.helidon.common helidon-common-config + + io.helidon.builder + helidon-builder-api + + + io.helidon.inject.configdriven + helidon-inject-configdriven-api + true + + + io.helidon.inject.configdriven + helidon-inject-configdriven-runtime + true + io.helidon.config helidon-config-metadata @@ -50,13 +64,10 @@ org.eclipse.microprofile.metrics microprofile-metrics-api - - io.micrometer - micrometer-core - io.helidon.common.testing helidon-common-testing-junit5 + test org.junit.jupiter @@ -88,11 +99,26 @@ true + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + io.helidon.config helidon-config-metadata-processor ${helidon.version} + + io.helidon.inject.configdriven + helidon-inject-configdriven-processor + ${helidon.version} + + + io.helidon.builder + helidon-builder-processor + ${helidon.version} + diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Bucket.java b/metrics/api/src/main/java/io/helidon/metrics/api/Bucket.java new file mode 100644 index 00000000000..465f9948d93 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Bucket.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.concurrent.TimeUnit; + +/** + * Representation of a histogram bucket, including the boundary value and the count of observations in that bucket. + *

+ * The boundary value is an upper bound on the observation values that can occupy the bucket. + * That is, an observation occupies a bucket if its value is less than or equal to the bucket's boundary value. + *

+ */ +public interface Bucket extends Wrapper { + + /** + * Returns the bucket boundary. + * + * @return bucket boundary value + */ + double boundary(); + + /** + * Returns the bucket boundary interpreted as a time in nanoseconds and expressed in the specified + * {@link java.util.concurrent.TimeUnit}. + * + * @param unit time unit in which to express the bucket boundary + * @return bucket boundary value + */ + double boundary(TimeUnit unit); + + /** + * Returns the number of observations in the bucket. + * + * @return observation count for the bucket + */ + long count(); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Clock.java b/metrics/api/src/main/java/io/helidon/metrics/api/Clock.java new file mode 100644 index 00000000000..12115a5bd89 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Clock.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +/** + * Reports absolute time (and, therefore, is also useful in computing elapsed times). + */ +public interface Clock extends Wrapper { + + /** + * Returns the system clock for the Helidon metrics implementation. + *

+ * The system clock methods are functionally equivalent to {@link System#currentTimeMillis()} + * and {@link System#nanoTime()}. + *

+ * + * @return the system clock + */ + static Clock system() { + return MetricsFactoryManager.getInstance().clockSystem(); + } + + /** + * Returns the current wall time in milliseconds since the epoch. + * + *

+ * Typically equivalent to {@link System#currentTimeMillis()}. Should not be used to determine durations. + * For that use {@link #monotonicTime()} instead. + *

+ * + * @return wall time in milliseconds + */ + long wallTime(); + + /** + * Returns the current time in nanoseconds from a monotonic clock source. + * + *

+ * The value is only meaningful when compared with another value returned from this method to determine the elapsed time + * for an operation. The difference between two samples will have a unit of nanoseconds. The returned value is + * typically equivalent to {@link System#nanoTime()}. + *

+ * + * @return monotonic time in nanoseconds + */ + long monotonicTime(); + + /** + * Unwraps the clock to the specified type (typically not needed for custom clocks). + * + * @param c {@link Class} to which to cast this object + * @return unwrapped clock + * @param the type of the unwrapped clock + */ + @Override + default R unwrap(Class c) { + throw new UnsupportedOperationException(); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Counter.java b/metrics/api/src/main/java/io/helidon/metrics/api/Counter.java new file mode 100644 index 00000000000..c80130ed4be --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Counter.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +/** + * Records a monotonically increasing value. + */ +public interface Counter extends Meter { + + /** + * Creates a new builder for a counter. + * + * @param name counter name + * @return new builder + */ + static Builder builder(String name) { + return MetricsFactory.getInstance().counterBuilder(name); + } + + + /** + * Updates the counter by one. + */ + void increment(); + + /** + * Updates the counter by the specified amount which should be non-negative. + * + * @param amount amount to add to the counter. + */ + void increment(long amount); + + /** + * Returns the cumulative count since this counter was registered. + * + * @return cumulative count since this counter was registered + */ + long count(); + + /** + * Builder for a new counter. + */ + interface Builder extends Meter.Builder { + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/DistributionStatisticsConfig.java b/metrics/api/src/main/java/io/helidon/metrics/api/DistributionStatisticsConfig.java new file mode 100644 index 00000000000..4d64dfc9055 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/DistributionStatisticsConfig.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.Optional; + +/** + * Configuration which controls the behavior of distribution statistics from meters that support them + * (for example, timers and distribution summaries). + * + */ +public interface DistributionStatisticsConfig extends Wrapper { + + /** + * Creates a builder for a new {@link io.helidon.metrics.api.DistributionStatisticsConfig} instance. + * + * @return new builder + */ + static Builder builder() { + return MetricsFactory.getInstance().distributionStatisticsConfigBuilder(); + } + + /** + * Returns the settings for non-aggregable percentiles. + * + * @return percentiles to compute and publish + */ + Optional> percentiles(); + + /** + * Returns the minimum expected value that the meter is expected to observe. + * + * @return minimum expected value + */ + Optional minimumExpectedValue(); + + /** + * Returns the maximum value that the meter is expected to observe. + * + * @return maximum value + */ + Optional maximumExpectedValue(); + + /** + * Returns the configured boundary boundaries. + * + * @return the boundary boundaries + */ + Optional> buckets(); + + /** + * Builder for a new {@link io.helidon.metrics.api.DistributionStatisticsConfig} instance. + */ + interface Builder extends Wrapper, io.helidon.common.Builder { + + /** + * Sets the minimum value that the meter is expected to observe. + * + * @param min minimum value that this distribution summary is expected to observe + * @return updated builder + */ + Builder minimumExpectedValue(Double min); + + /** + * Sets the maximum value that the meter is expected to observe. + * + * @param max maximum value that the meter is expected to observe + * @return updated builder + */ + Builder maximumExpectedValue(Double max); + + /** + * Specifies time series percentiles. + *

+ * The system computes these percentiles locally, so they cannot be aggregated with percentiles computed + * elsewhere. + *

+ *

+ * Specify percentiles a decimals, for example express the 95th percentile as {@code 0.95}. + *

+ * @param percentiles percentiles to compute and publish + * @return updated builder + */ + Builder percentiles(double... percentiles); + + /** + * Specifies time series percentiles. + *

+ * The system computes these percentiles locally, so they cannot be aggregated with percentiles computed + * elsewhere. + *

+ *

+ * Specify percentiles a decimals, for example express the 95th percentile as {@code 0.95}. + *

+ * @param percentiles percentiles to compute and publish + * @return updated builder + */ + Builder percentiles(Iterable percentiles); + + /** + * Sets the boundary boundaries. + * + * @param buckets boundary boundaries + * @return updated builder + */ + Builder buckets(double... buckets); + + /** + * Sets the boundary boundaries. + * + * @param buckets boundary boundaries + * @return updated builder + */ + Builder buckets(Iterable buckets); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/DistributionSummary.java b/metrics/api/src/main/java/io/helidon/metrics/api/DistributionSummary.java new file mode 100644 index 00000000000..a6e77f5f0fe --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/DistributionSummary.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +/** + * Records a distribution of values (e.g., sizes of responses returned by a server). + */ +public interface DistributionSummary extends Meter { + + /** + * Creates a builder for a new {@link io.helidon.metrics.api.DistributionSummary} using the specified statistics builder. + * + * @param name name for the summary + * @param configBuilder distribution stats config for the summary + * @return new builder + */ + static Builder builder(String name, + DistributionStatisticsConfig.Builder configBuilder) { + return MetricsFactory.getInstance() + .distributionSummaryBuilder(name, configBuilder); + } + + /** + * Creates a builder for a new {@link io.helidon.metrics.api.DistributionSummary} using default distribution statistics. + * + * @param name name for the summary + * @return new builder + */ + static Builder builder(String name) { + return MetricsFactory.getInstance() + .distributionSummaryBuilder(name, + DistributionStatisticsConfig.builder()); + } + + /** + * Updates the statistics kept by the summary with the specified amount. + * + * @param amount Amount for an event being measured. For example, if the size in bytes of responses + * from a server. If the amount is less than 0 the value will be dropped. + */ + void record(double amount); + + /** + * Returns the current count of observations in the distribution summary. + * + * @return number of observations recorded in the summary + */ + long count(); + + /** + * Returns the total of the observations recorded by the distribution summary. + * + * @return total across all recorded events + */ + double totalAmount(); + + /** + * Returns the mean of the observations recorded by the distribution summary. + * + * @return average value of events recorded in the summary + */ + double mean(); + + /** + * Returns the maximum value among the observations recorded by the distribution summary. + * + * @return maximum value of recorded events + */ + double max(); + + /** + * Returns a {@link io.helidon.metrics.api.HistogramSnapshot} of the current state of the distribution summary. + * + * @return snapshot + */ + HistogramSnapshot snapshot(); + + /** + * Builder for a {@link io.helidon.metrics.api.DistributionSummary}. + */ + interface Builder extends Meter.Builder { + + /** + * Sets the scale factor for observations recorded by the summary. + * + * @param scale scaling factor to apply to each observation + * @return updated builder + */ + Builder scale(double scale); + + /** + * Sets the config for distribution statistics for the distribution summary. + * + * @param distributionStatisticsConfigBuilder builder for the distribution statistics config + * @return updated builder + */ + Builder distributionStatisticsConfig(DistributionStatisticsConfig.Builder distributionStatisticsConfigBuilder); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Gauge.java b/metrics/api/src/main/java/io/helidon/metrics/api/Gauge.java new file mode 100644 index 00000000000..6af8e144409 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Gauge.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.function.ToDoubleFunction; + +/** + * Measures a value that can increase or decrease and is updated by external logic, not by explicit invocations + * of methods on this type. + */ +public interface Gauge extends Meter { + + /** + * Creates a builder for creating a new gauge. + * + * @param name gauge name + * @param stateObject state object which maintains the gauge value + * @param fn function which, when applied to the state object, returns the gauge value + * @return new builder + * @param type of the state object + */ + static Builder builder(String name, T stateObject, ToDoubleFunction fn) { + return MetricsFactory.getInstance().gaugeBuilder(name, stateObject, fn); + } + + /** + * Returns the value of the gauge. + *

+ * Invoking this method triggers the sampling of the value or invocation of the function provided when the gauge was + * registered. + *

+ * + * @return current value of the gauge + */ + double value(); + + /** + * Builder for a new gauge. + * + * @param type of the state object which exposes the gauge value. + */ + interface Builder extends Meter.Builder, Gauge> { + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/HistogramSnapshot.java b/metrics/api/src/main/java/io/helidon/metrics/api/HistogramSnapshot.java new file mode 100644 index 00000000000..377001f6871 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/HistogramSnapshot.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.io.PrintStream; +import java.util.concurrent.TimeUnit; + +/** + * Snapshot in time of a histogram. + */ +public interface HistogramSnapshot extends Wrapper { + + /** + * Returns an "empty" snapshot which has summary values but no data points. + * + * @param count count of observations the snapshot should report + * @param total total value of observations the snapshot should report + * @param max maximum value the snapshot should report + * @return empty snapshot reporting the values as specified + */ + static HistogramSnapshot empty(long count, double total, double max) { + return MetricsFactory.getInstance().histogramSnapshotEmpty(count, total, max); + } + + /** + * Returns the count of observations in the snapshot. + * + * @return count of observations + */ + long count(); + + /** + * Returns the total value over all observations in the snapshot. + * + * @return total value over all observations + */ + double total(); + + /** + * Returns the total value over all observations, interpreting the values as times in nanoseconds and expressing the time + * in the specified {@link java.util.concurrent.TimeUnit}. + * + * @param timeUnit time unit in which to express the total value + * @return total value expressed in the selected time unit + */ + double total(TimeUnit timeUnit); + + /** + * Returns the maximum value over all observations. + * + * @return maximum value + */ + double max(); + + /** + * Returns the average value overall observations. + * + * @return average value + */ + double mean(); + + /** + * Returns the average value over all observations, interpreting the values as times in nanoseconds and expressing the + * average in the specified {@link java.util.concurrent.TimeUnit}. + * + * @param timeUnit time unitin which to express the average + * @return average value expressed in the selected time unit + */ + double mean(TimeUnit timeUnit); + + /** + * Returns the values at the configured percentiles for the histogram. + * + * @return pairs of percentile and the histogram value at that percentile + */ + Iterable percentileValues(); + + /** + * Returns information about each of the configured buckets for the histogram. + * + * @return pairs of boundary value and count of observations in that boundary + */ + Iterable histogramCounts(); + + /** + * Dumps a summary of the snapshot to the specified {@link java.io.PrintStream} using the indicated scaling factor for + * observations. + * + * @param out {@code PrintStream} to which to dump the snapshot summary + * @param scale scale factor to apply to observations for output + */ + void outputSummary(PrintStream out, double scale); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/HistogramSupport.java b/metrics/api/src/main/java/io/helidon/metrics/api/HistogramSupport.java new file mode 100644 index 00000000000..bb171feec38 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/HistogramSupport.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +/** + * Common behavior among meters which support histograms. + */ +public interface HistogramSupport { + + /** + * Returns a snapshot of the data in a histogram. + * + * @return snapshot of the histogram + */ + HistogramSnapshot takeSnapshot(); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/KeyPerformanceIndicatorMetricsConfigBlueprint.java b/metrics/api/src/main/java/io/helidon/metrics/api/KeyPerformanceIndicatorMetricsConfigBlueprint.java new file mode 100644 index 00000000000..effa46982ef --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/KeyPerformanceIndicatorMetricsConfigBlueprint.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import io.helidon.builder.api.Prototype; +import io.helidon.config.metadata.Configured; +import io.helidon.config.metadata.ConfiguredOption; +import io.helidon.inject.configdriven.api.ConfigBean; + +/** + * Config bean for KPI metrics configuration. + */ +@ConfigBean() +@Configured(prefix = MetricsConfigBlueprint.METRICS_CONFIG_KEY + + "." + + KeyPerformanceIndicatorMetricsConfigBlueprint.KEY_PERFORMANCE_INDICATORS_CONFIG_KEY) +@Prototype.Blueprint() +interface KeyPerformanceIndicatorMetricsConfigBlueprint { + + /** + * Config key for extended key performance indicator metrics settings. + */ + String KEY_PERFORMANCE_INDICATORS_CONFIG_KEY = "key-performance-indicators"; + + /** + * Config key for {@code enabled} setting of the extended KPI metrics. + */ + String KEY_PERFORMANCE_INDICATORS_EXTENDED_CONFIG_KEY = "extended"; + + /** + * Default enabled setting for extended KPI metrics. + */ + String KEY_PERFORMANCE_INDICATORS_EXTENDED_DEFAULT = "false"; + + /** + * Config key for long-running requests threshold setting (in milliseconds). + */ + String LONG_RUNNING_REQUESTS_THRESHOLD_CONFIG_KEY = "threshold-ms"; + + /** + * Config key for long-running requests settings. + */ + String LONG_RUNNING_REQUESTS_CONFIG_KEY = "long-running-requests"; + + /** + * Default long-running requests threshold. + */ + String LONG_RUNNING_REQUESTS_THRESHOLD_MS_DEFAULT = "10000"; // 10 seconds + + /** + * Configuration key for long-running requests extended configuration. + */ + String QUALIFIED_LONG_RUNNING_REQUESTS_THRESHOLD_CONFIG_KEY = + LONG_RUNNING_REQUESTS_CONFIG_KEY + "." + LONG_RUNNING_REQUESTS_THRESHOLD_CONFIG_KEY; + + /** + * Whether KPI extended metrics are enabled. + * + * @return true if KPI extended metrics are enabled; false otherwise + */ + @ConfiguredOption(key = KEY_PERFORMANCE_INDICATORS_EXTENDED_CONFIG_KEY, + value = KEY_PERFORMANCE_INDICATORS_EXTENDED_DEFAULT) + boolean isExtended(); + + /** + * Threshold in ms that characterizes whether a request is long running. + * + * @return threshold in ms indicating a long-running request + */ + @ConfiguredOption(key = QUALIFIED_LONG_RUNNING_REQUESTS_THRESHOLD_CONFIG_KEY, + value = LONG_RUNNING_REQUESTS_THRESHOLD_MS_DEFAULT) + long longRunningRequestThresholdMs(); + +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Meter.java b/metrics/api/src/main/java/io/helidon/metrics/api/Meter.java new file mode 100644 index 00000000000..214ff56a9c5 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Meter.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +/** + * Common behavior of all meters. + */ +public interface Meter extends Wrapper { + + /** + * Common behavior of specific meter builders. + * + * @param type of the builder + * @param type of the meter the builder creates + */ + interface Builder, M extends Meter> { + + /** + * Returns the type-correct "this". + * + * @return properly-typed builder itself + */ + default B identity() { + return (B) this; + } + + /** + * Sets the tags to use in identifying the build meter. + * + * @param tags {@link io.helidon.metrics.api.Tag} instances to identify the meter + * @return updated builder + */ + B tags(Iterable tags); + + /** + * Sets the description. + * + * @param description meter description + * @return updated builder + */ + B description(String description); + + /** + * Sets the units. + * + * @param baseUnit meter unit + * @return updated builder + */ + B baseUnit(String baseUnit); + } + + /** + * Unique idenfier for a meter. + */ + interface Id { + + /** + * Returns the meter name. + * + * @return meter name + */ + String name(); + + /** + * Returns the tags which further identify the meter. + * + * @return meter tags + */ + Iterable tags(); + + /** + * Unwraps the ID as the specified type. + * + * @param c {@link Class} to which to cast this ID + * @return the ID cast as the requested type + * @param type to cast to + */ + default R unwrap(Class c) { + return c.cast(this); + } + } + + /** + * Type of meter. + */ + enum Type { + + /** + * Counter (monotonically increasing value). + */ + COUNTER, + + /** + * Gauge (can increase or decrease). + */ + GAUGE, + + /** + * Timer (measures count and distribution of completed events). + */ + TIMER, + + /** + * Distribution summary (measures distribution of samples). + */ + DISTRIBUTION_SUMMARY, + + /** + * Other. + */ + OTHER; + + } + + /** + * Returns the meter ID. + * + * @return meter ID + */ + Id id(); + + /** + * Returns the meter's base unit. + * + * @return base unit + */ + String baseUnit(); + + /** + * Returns the meter's description. + * + * @return description + */ + String description(); + + /** + * Returns the meter type. + * + * @return meter type + */ + Type type(); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/MeterRegistry.java b/metrics/api/src/main/java/io/helidon/metrics/api/MeterRegistry.java new file mode 100644 index 00000000000..906fb718ae0 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/MeterRegistry.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +/** + * Manages the look-up and registration of meters. + */ +public interface MeterRegistry extends Wrapper { + + /** + * Returns all previously-registered meters. + * + * @return registered meters + */ + List meters(); + + /** + * Returns previously-registered meters which match the specified {@link java.util.function.Predicate}. + * + * @param filter the predicate with which to evaluate each {@link io.helidon.metrics.api.Meter} + * @return meters which match the predicate + */ + Collection meters(Predicate filter); + + + /** + * Returns the scopes (if any) as represented by tags on meter IDs. + * + * @return scopes across all registered meters + */ + Iterable scopes(); + + /** + * Returns whether the specified meter is enabled or not, based on whether the meter registry as a whole is enabled and also + * whether the config settings for filtering include and exclude indicate the specific meter is enabled. + * + * @param meterId meter ID to check + * @return true if the meter (and its meter registry) are enabled; false otherwise + */ + boolean isMeterEnabled(Meter.Id meterId); + + /** + * Returns the default {@link io.helidon.metrics.api.Clock} in use by the registry. + * + * @return default clock + */ + Clock clock(); + + /** + * Locates a previously-registered meter using the name and tags in the provided builder or, if not found, registers a new + * one using the provided builder. + * + * @param builder builder to use in finding or creating a meter + * @return the previously-registered meter with the same name and tags or, if none, the newly-registered one + * @param type of the meter + * @param builder for the meter + */ + > M getOrCreate(B builder); + + /** + * Locates a previously-registered counter. + * + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-registered counter; empty if not found + */ + default Optional getCounter(String name, Iterable tags) { + return get(Counter.class, name, tags); + } + + /** + * Locates a previously-registered distribution summary. + * + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-registered distribution summary; empty if not found + */ + default Optional getSummary(String name, Iterable tags) { + return get(DistributionSummary.class, name, tags); + } + + /** + * Locates a previously-registered gauge. + * + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-registered gauge; empty if not found + */ + default Optional getGauge(String name, Iterable tags) { + return get(Gauge.class, name, tags); + } + + /** + * Locates a previously-registered timer. + * + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-registered timer; empty if not found + */ + default Optional getTimer(String name, Iterable tags) { + return get(Timer.class, name, tags); + } + + /** + * Locates a previously-registered meter of the specified type, matching the name and tags. + *

+ * The method throws an {@link java.lang.ClassCastException} if a meter exists with + * the name and tags but is not type-compatible with the provided class. + *

+ * + * @param mClass type of the meter to find + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-regsitered meter; empty if not found + * @param type of the meter to find + */ + Optional get(Class mClass, String name, Iterable tags); + + /** + * Removes a previously-registered meter. + * + * @param meter the meter to remove + * @return the removed meter; empty if the meter is not currently registered + */ + Optional remove(Meter meter); + + /** + * Removes a previously-registered meter with the specified ID. + * + * @param id ID for the meter to remove + * @return the removed meter; empty if the meter is not currently registered + */ + Optional remove(Meter.Id id); + + /** + * Removes a previously-registered meter with the specified name and tags. + * + * @param name counter name + * @param tags tags for further identifying the meter + * @return the removed meter; empty if the specified name and tags does not correspond to a registered meter + */ + Optional remove(String name, + Iterable tags); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/MeterRegistryFormatter.java b/metrics/api/src/main/java/io/helidon/metrics/api/MeterRegistryFormatter.java new file mode 100644 index 00000000000..00ae8905f95 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/MeterRegistryFormatter.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.Optional; + +/** + * Formatter of a {@link io.helidon.metrics.api.MeterRegistry} according to a given media type. + */ +public interface MeterRegistryFormatter { + + /** + * Formats the meter registry's data. + * + * @return formatted output + */ + Optional format(); + + /** + * Formats the meter registry's metadata. + * + * @return formatted metadata output + */ + Optional formatMetadata(); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Metrics.java b/metrics/api/src/main/java/io/helidon/metrics/api/Metrics.java new file mode 100644 index 00000000000..a1d7080284f --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Metrics.java @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; + +/** + * A main entry point to the Helidon metrics implementation, allowing access to the global meter registry and providing shortcut + * methods to register and locate meters in the global registry and remove meters from it. + */ +public interface Metrics { + + /** + * Returns the global meter registry. + * + * @return the global meter registry + */ + static MeterRegistry globalRegistry() { + return MetricsFactory.getInstance().globalRegistry(); + } + + /** + * Creates a meter registry, not added to the global registry, based on + * the provided metrics config. + * + * @param metricsConfig metrics config + * @return new meter registry + */ + static MeterRegistry createMeterRegistry(MetricsConfig metricsConfig) { + return MetricsFactory.getInstance().createMeterRegistry(metricsConfig); + } + + /** + * Locates a previously-registered meter using the name and tags in the provided builder or, if not found, registers a new + * one using the provided builder. + * + * @param builder builder to use in finding or creating a meter + * @return the previously-registered meter with the same name and tags or, if none, the newly-registered one + * @param type of the meter + * @param builder for the meter + */ + static > M getOrCreate(B builder) { + return globalRegistry().getOrCreate(builder); + } + + /** + * Locates a previously-registered counter. + * + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-registered counter; empty if not found + */ + static Optional getCounter(String name, Iterable tags) { + return globalRegistry().get(Counter.class, name, tags); + } + + /** + * Locates a previously-registerec counter. + * + * @param name name to match + * @return {@link java.util.Optional} of the previously-registered counter; empty if not found + */ + static Optional getCounter(String name) { + return getCounter(name, Set.of()); + } + + /** + * Locates a previously-registered distribution summary. + * + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-registered distribution summary; empty if not found + */ + static Optional getSummary(String name, Iterable tags) { + return globalRegistry().get(DistributionSummary.class, name, tags); + } + + /** + * Locates a previously-registered distribution summary. + * + * @param name name to match + * @return {@link java.util.Optional} of the previously-registered distribution summary; empty if not found + */ + static Optional getSummary(String name) { + return getSummary(name, Set.of()); + } + + /** + * Locates a previously-registered gauge. + * + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-registered gauge; empty if not found + */ + static Optional getGauge(String name, Iterable tags) { + return globalRegistry().get(Gauge.class, name, tags); + } + + /** + * Locates a previously-registered gauge. + * + * @param name name to match + * @return {@link java.util.Optional} of the previously-registered gauge; empty if not found + */ + static Optional getGauge(String name) { + return getGauge(name, Set.of()); + } + + /** + * Locates a previously-registered timer. + * + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-registered timer; empty if not found + */ + static Optional getTimer(String name, Iterable tags) { + return globalRegistry().get(Timer.class, name, tags); + } + + /** + * Locates a previously-registered timer. + * + * @param name name to match + * @return {@link java.util.Optional} of the previously-registered timer; empty if not found + */ + static Optional getTimer(String name) { + return getTimer(name, Set.of()); + } + + /** + * Locates a previously-registered meter of the specified type, matching the name and tags. + *

+ * The method throws an {@link java.lang.IllegalArgumentException} if a meter exists with + * the name and tags but is not type-compatible with the provided class. + *

+ * + * @param mClass type of the meter to find + * @param name name to match + * @param tags tags to match + * @return {@link java.util.Optional} of the previously-regsitered meter; empty if not found + * @param type of the meter to find + */ + static Optional get(Class mClass, String name, Iterable tags) { + return globalRegistry().get(mClass, name, tags); + } + + /** + * Creates a {@link Tag} for the specified key and value. + * + * @param key tag key + * @param value tag value + * @return new tag + */ + static Tag tag(String key, String value) { + return MetricsFactory.getInstance().tagCreate(key, value); + } + + /** + * Provides an {@link java.lang.Iterable} of {@link io.helidon.metrics.api.Tag} over an array of tags. + * + * @param tags tags array to convert + * @return iterator over the tags + */ + static Iterable tags(Tag... tags) { + return () -> new Iterator<>() { + + private int slot = 0; + + @Override + public boolean hasNext() { + return slot < tags.length; + } + + @Override + public Tag next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + Tag result = MetricsFactoryManager.getInstance() + .tagCreate(tags[slot].key(), + tags[slot].value()); + slot++; + return result; + } + }; + } + + /** + * Returns an {@link java.lang.Iterable} of {@link io.helidon.metrics.api.Tag} by interpreting the provided strings as + * tag name/tag value pairs. + * + * @param keyValuePairs pairs of tag name/tag value pairs + * @return tags corresponding to the tag name/tag value pairs + */ + static Iterable tags(String... keyValuePairs) { + if (keyValuePairs.length % 2 != 0) { + throw new IllegalArgumentException("Must pass an even number of strings so keys and values are evenly matched"); + } + return () -> new Iterator<>() { + + private int slot; + + @Override + public boolean hasNext() { + return slot < keyValuePairs.length / 2; + } + + @Override + public Tag next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + Tag result = Tag.create(keyValuePairs[slot * 2], keyValuePairs[slot * 2 + 1]); + slot++; + return result; + } + }; + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigBlueprint.java b/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigBlueprint.java new file mode 100644 index 00000000000..49153586d88 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigBlueprint.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.regex.Matcher; + +import io.helidon.builder.api.Prototype; +import io.helidon.common.config.Config; +import io.helidon.common.config.GlobalConfig; +import io.helidon.config.metadata.Configured; +import io.helidon.config.metadata.ConfiguredOption; +import io.helidon.inject.configdriven.api.ConfigBean; + +/** + * Blueprint for {@link io.helidon.metrics.api.MetricsConfig}. + */ +@ConfigBean() +@Configured(root = true, prefix = MetricsConfigBlueprint.METRICS_CONFIG_KEY) +@Prototype.Blueprint(decorator = MetricsConfigBlueprint.BuilderDecorator.class) +@Prototype.CustomMethods(MetricsConfigSupport.class) +interface MetricsConfigBlueprint { + + /** + * The config key containing settings for all of metrics. + */ + String METRICS_CONFIG_KEY = "metrics"; + + /** + * Config key for comma-separated, {@code tag=value} global tag settings. + */ + String GLOBAL_TAGS_CONFIG_KEY = "tags"; + + /** + * Config key for the app tag value to be applied to all metrics in this application. + */ + String APP_TAG_CONFIG_KEY = "app-name"; + + // TODO - rely on programmatic metrics config so SE can set one default, MP another + /** + * Default name for scope tags. + */ + String DEFAULT_SCOPE_TAG_NAME = "m-scope"; + + /** + * Whether metrics functionality is enabled. + * + * @return if metrics are configured to be enabled + */ + @ConfiguredOption("true") + boolean enabled(); + + /** + * Key performance indicator metrics settings. + * + * @return key performance indicator metrics settings + */ + @ConfiguredOption(key = KeyPerformanceIndicatorMetricsConfigBlueprint.KEY_PERFORMANCE_INDICATORS_CONFIG_KEY) + Optional keyPerformanceIndicatorMetricsConfig(); + + /** + * Global tags. + * + * @return name/value pairs for global tags + */ + @ConfiguredOption(key = GLOBAL_TAGS_CONFIG_KEY) + List globalTags(); + + /** + * Application tag value added to each meter ID. + * + * @return application tag value + */ + @ConfiguredOption(key = APP_TAG_CONFIG_KEY) + Optional appTagValue(); + + /** + * Metrics configuration node. + * + * @return metrics configuration + */ + Config config(); + + /** + * Tag name used for recording the scope of meters (defaults according to the active runtime). + * + * @return tag name for scope + */ + String scopeTagName(); + + + + class BuilderDecorator implements Prototype.BuilderDecorator> { + + @Override + public void decorate(MetricsConfig.BuilderBase builder) { + if (builder.config().isEmpty()) { + builder.config(GlobalConfig.config().get(METRICS_CONFIG_KEY)); + } + if (builder.scopeTagName().isEmpty()) { + builder.scopeTagName(DEFAULT_SCOPE_TAG_NAME); + } + } + } + + @Prototype.FactoryMethod + static List createGlobalTags(Config globalTagExpression) { + return createGlobalTags(globalTagExpression.asString().get()); + } + + static List createGlobalTags(String pairs) { + // Use a TreeMap to order by tag name. + Map result = new TreeMap<>(); + List allErrors = new ArrayList<>(); + String[] assignments = pairs.split("(? errorsForThisAssignment = new ArrayList<>(); + if (assignment.isBlank()) { + errorsForThisAssignment.add("empty assignment at position " + position + ": " + assignment); + } else { + // Pattern should yield group 1 = tag name and group 2 = tag value. + Matcher matcher = MetricsConfigSupport.TAG_ASSIGNMENT_PATTERN.matcher(assignment); + if (!matcher.matches()) { + errorsForThisAssignment.add("expected tag=value but found '" + assignment + "'"); + } else { + String name = matcher.group(1); + String value = matcher.group(2); + if (name.isBlank()) { + errorsForThisAssignment.add("missing tag name"); + } + if (value.isBlank()) { + errorsForThisAssignment.add("missing tag value"); + } + if (!name.matches("[A-Za-z_][A-Za-z_0-9]*")) { + errorsForThisAssignment.add( + "tag name must start with a letter and include only letters, digits, and underscores"); + } + if (errorsForThisAssignment.isEmpty()) { + result.put(name, + Tag.create(name, + value.replace("\\,", ",") + .replace("\\=", "="))); + } + } + } + if (!errorsForThisAssignment.isEmpty()) { + allErrors.add(String.format("Position %d with expression %s: %s", + position, + assignment, + errorsForThisAssignment)); + } + position++; + } + if (!allErrors.isEmpty()) { + throw new IllegalArgumentException("Error(s) in global tag expression: " + allErrors); + } + return result.values() + .stream() + .toList(); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigSupport.java b/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigSupport.java new file mode 100644 index 00000000000..7946db17d8a --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/MetricsConfigSupport.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.Optional; +import java.util.regex.Pattern; + +import io.helidon.builder.api.Prototype; + +class MetricsConfigSupport { + + /** + * Looks up a single config value within the metrics configuration by config key. + * + * @param metricsConfig the {@link io.helidon.common.config.Config} node containing the metrics configuration + * @param key config key to fetch + * @return config value + */ + @Prototype.PrototypeMethod + static Optional lookupConfig(MetricsConfig metricsConfig, String key) { + return metricsConfig.config() + .get(key) + .asString() + .asOptional(); + } + + // Pattern of a single tag assignment (tag=value): + // - capture reluctant match of anything + // - non-capturing match of an unescaped = + // - capture the rest. + static final Pattern TAG_ASSIGNMENT_PATTERN = Pattern.compile("(.*?)(? + * An implementation of this interface provides instance methods for each + * of the static methods on the Helidon metrics API interfaces. The prefix of each method + * here identifies the interface that bears the corresponding static method. For example, + * {@link #timerStart(io.helidon.metrics.api.MeterRegistry)} corresponds to the static + * {@link io.helidon.metrics.api.Timer#start(io.helidon.metrics.api.MeterRegistry)} method. + *

+ *

+ * Also, various static methods create new instances or return previously-created ones. + *

+ *

+ * Note that this is not intended to be the interface which developers use to work with Helidon metrics. + * Instead use + *

    + *
  • the {@link io.helidon.metrics.api.Metrics} interface and its static convenience methods,
  • + *
  • the static methods on the various interfaces in the API, or
  • + *
  • {@link io.helidon.metrics.api.Metrics#globalRegistry()} and use the returned + * {@link io.helidon.metrics.api.MeterRegistry} directly
  • + *
+ *

+ * Rather, implementations of Helidon metrics implement this interface and various internal parts of Helidon metrics, + * notably the static methods on {@link io.helidon.metrics.api.Metrics}, delegate to the highest-weight + * implementation of this interface. + *

+ */ +public interface MetricsFactory { + + /** + * Returns the most-recently created implementation or, if none, a new one from a highest-weight provider available at + * runtime and using the {@value MetricsConfig.Builder#METRICS_CONFIG_KEY} section from the + * {@link io.helidon.common.config.GlobalConfig}. + * + * @return current or new metrics factory + */ + static MetricsFactory getInstance() { + return MetricsFactoryManager.getInstance(); + } + + /** + * Returns a new instance from a highest-weight provider available at runtime using the provided + * {@link io.helidon.metrics.api.MetricsConfig}. + * + * @param metricsConfig metrics config + * @return new metrics factory + */ + static MetricsFactory getInstance(MetricsConfig metricsConfig) { + return MetricsFactoryManager.getInstance(metricsConfig); + } + + /** + * Returns the global meter registry. + * + * @return the global meter registry + */ + MeterRegistry globalRegistry(); + + /** + * Creates a new {@link io.helidon.metrics.api.MeterRegistry} using the provided metrics config. + * + * @param metricsConfig metrics configuration which influences the new registry + * @return new meter registry + */ + MeterRegistry createMeterRegistry(MetricsConfig metricsConfig); + + /** + * Creates a new {@link io.helidon.metrics.api.MeterRegistry} using the provided {@link io.helidon.metrics.api.Clock} and + * metrics config. + * + * @param clock default clock to associate with the meter registry + * @param metricsConfig metrics configuration which influences the new registry + * @return new meter registry + */ + MeterRegistry createMeterRegistry(Clock clock, MetricsConfig metricsConfig); + + /** + * Returns the system {@link io.helidon.metrics.api.Clock} from the + * underlying metrics implementation. + * + * @return the system clock + */ + Clock clockSystem(); + + /** + * Creates a builder for a {@link io.helidon.metrics.api.Counter}. + * + * @param name name of the counter + * @return counter builder + */ + Counter.Builder counterBuilder(String name); + + /** + * Creates a builder for a {@link io.helidon.metrics.api.DistributionStatisticsConfig}. + * + * @return statistics config builder + */ + DistributionStatisticsConfig.Builder distributionStatisticsConfigBuilder(); + + /** + * Creates a builder for a {@link io.helidon.metrics.api.DistributionSummary}. + * + * @param name name of the summary + * @param configBuilder distribution stats config the summary should use + * @return summary builder + */ + DistributionSummary.Builder distributionSummaryBuilder(String name, DistributionStatisticsConfig.Builder configBuilder); + + /** + * Creates a builder for a {@link io.helidon.metrics.api.Gauge}. + * + * @param name name of the gauge + * @param stateObject object which maintains the value to be exposed via the gauge + * @param fn function which, when applied to the state object, returns the gauge value + * @return gauge builder + * @param type of the state object + */ + Gauge.Builder gaugeBuilder(String name, T stateObject, ToDoubleFunction fn); + + /** + * Creates a builder for a {@link io.helidon.metrics.api.Timer}. + * + * @param name name of the timer + * @return timer builder + */ + Timer.Builder timerBuilder(String name); + + /** + * Returns a {@link io.helidon.metrics.api.Timer.Sample} for measuring a duration using + * the system default {@link io.helidon.metrics.api.Clock}. + * + * @return new sample + */ + Timer.Sample timerStart(); + + /** + * Returns a {@link io.helidon.metrics.api.Timer.Sample} for measuring a duration, using the + * clock associated with the specified {@link io.helidon.metrics.api.MeterRegistry}. + * + * @param registry the meter registry whose {@link io.helidon.metrics.api.Clock} is to be used + * @return new sample with the start time recorded + */ + Timer.Sample timerStart(MeterRegistry registry); + + /** + * Returns a {@link io.helidon.metrics.api.Timer.Sample} for measuring a duration using + * the specified {@link io.helidon.metrics.api.Clock}. + * + * @param clock the clock to use for measuring the duration + * @return new sample + */ + Timer.Sample timerStart(Clock clock); + + /** + * Creates a {@link io.helidon.metrics.api.Tag} from the specified key and value. + * + * @param key tag key + * @param value tag value + * @return new {@code Tag} instance + */ + Tag tagCreate(String key, String value); + + /** + * Returns an empty histogram snapshot with the specified aggregate values. + * + * @param count count + * @param total total + * @param max max value + * @return histogram snapshot + */ + HistogramSnapshot histogramSnapshotEmpty(long count, double total, double max); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/MetricsFactoryManager.java b/metrics/api/src/main/java/io/helidon/metrics/api/MetricsFactoryManager.java new file mode 100644 index 00000000000..f103a14acd1 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/MetricsFactoryManager.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.Objects; +import java.util.ServiceLoader; +import java.util.concurrent.Callable; +import java.util.concurrent.locks.ReentrantLock; + +import io.helidon.common.HelidonServiceLoader; +import io.helidon.common.LazyValue; +import io.helidon.common.config.GlobalConfig; +import io.helidon.metrics.spi.MetricsFactoryProvider; + +/** + * Provides {@link io.helidon.metrics.api.spi.MetricFactory} instances using a highest-weight implementation of + * {@link io.helidon.metrics.spi.MetricsFactoryProvider}, defaulting to a no-op implementation if no other is available. + *

+ * The {@link #getInstance()} and {@link #getInstance(MetricsConfig)} methods update and use the most-recently used + * {@link io.helidon.metrics.api.MetricsConfig} (which could be derived from {@link io.helidon.common.config.GlobalConfig}) + * and the most-recently created {@link io.helidon.metrics.api.MetricsFactory}. + *

+ *

+ * The {@link #create(MetricsConfig)} method neither reads nor updates the most-recently used config and factory. + *

+ */ +class MetricsFactoryManager { + + private static final System.Logger LOGGER = System.getLogger(MetricsFactoryManager.class.getName()); + + /** + * Instance of the highest-weight implementation of {@link io.helidon.metrics.spi.MetricsFactoryProvider}. + */ + private static final LazyValue METRICS_FACTORY_PROVIDER = + LazyValue.create(() -> { + MetricsFactoryProvider result = HelidonServiceLoader.builder(ServiceLoader.load(MetricsFactoryProvider.class)) + .addService(NoOpMetricsFactoryProvider.create(), Double.MIN_VALUE) + .build() + .iterator() + .next(); + LOGGER.log(System.Logger.Level.DEBUG, "Loaded metrics factory provider: {0}", + result.getClass().getName()); + return result; + }); + + /** + * The {@link io.helidon.metrics.api.MetricsFactory} most recently created via either {@link #getInstance} method. + */ + private static MetricsFactory metricsFactory; + + /** + * The {@link io.helidon.metrics.api.MetricsConfig} used to create {@link #metricsFactory}. + */ + private static MetricsConfig metricsConfig; + + private static final ReentrantLock LOCK = new ReentrantLock(); + + /** + * Creates a new {@link io.helidon.metrics.api.MetricsFactory} according to the specified + * {@link io.helidon.metrics.api.MetricsConfig}, saving the config as the current config and the new factory as the current + * factory. + * + * @param metricsConfig metrics config + * @return new metrics factory + */ + static MetricsFactory getInstance(MetricsConfig metricsConfig) { + return access(() -> { + MetricsFactoryManager.metricsConfig = metricsConfig; + MetricsFactoryManager.metricsFactory = METRICS_FACTORY_PROVIDER.get().create(metricsConfig); + return MetricsFactoryManager.metricsFactory; + }); + } + + /** + * Returns the current {@link io.helidon.metrics.api.MetricsFactory}, creating one if needed using the global configuration + * and saving the {@link io.helidon.metrics.api.MetricsConfig} from the global config as the current config and the new + * factory as the current factory. + * + * @return current metrics factory + */ + static MetricsFactory getInstance() { + return access(() -> metricsFactory = Objects.requireNonNullElseGet(metricsFactory, + () -> METRICS_FACTORY_PROVIDER.get() + .create(ensureMetricsConfig()))); + } + + /** + * Creates a new {@link io.helidon.metrics.api.MetricsFactory} using the specified + * {@link io.helidon.metrics.api.MetricsConfig} with no side effects: neither the config nor the new factory replace + * the current values stored in this manager. + * + * @param metricsConfig metrics config to use in creating the factory + * @return new metrics factory + */ + static MetricsFactory create(MetricsConfig metricsConfig) { + return METRICS_FACTORY_PROVIDER.get().create(metricsConfig); + } + + private static MetricsConfig ensureMetricsConfig() { + metricsConfig = Objects.requireNonNullElseGet(metricsConfig, + () -> MetricsConfig + .create(GlobalConfig.config() + .get(MetricsConfig.METRICS_CONFIG_KEY))); + return metricsConfig; + } + + private static T access(Callable c) { + LOCK.lock(); + try { + return c.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + LOCK.unlock(); + } + } + + private MetricsFactoryManager() { + } + +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMeter.java b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMeter.java new file mode 100644 index 00000000000..bd460db28ff --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMeter.java @@ -0,0 +1,617 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.io.PrintStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import java.util.function.ToDoubleFunction; + +/** + * No-op implementation of the Helidon {@link io.helidon.metrics.api.Meter} interface. + */ +class NoOpMeter implements Meter, NoOpWrapper { + + private final Id id; + private final String unit; + private final String description; + private final Type type; + + static class Id implements Meter.Id { + static Id create(String name, Iterable tags) { + return new Id(name, tags); + } + + private final String name; + private final List tags = new ArrayList<>(); // must be ordered by tag name for consistency + + private Id(String name, Iterable tags) { + this.name = name; + tags.forEach(this.tags::add); + this.tags.sort(Comparator.comparing(Tag::key)); + } + + @Override + public String name() { + return name; + } + + @Override + public List tags() { + return tags.stream().toList(); + } + + String tag(String key) { + return tags.stream() + .filter(t -> t.key().equals(key)) + .map(Tag::value) + .findFirst() + .orElse(null); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Id id = (Id) o; + return name.equals(id.name) && tags.equals(id.tags); + } + + @Override + public int hashCode() { + return Objects.hash(name, tags); + } + } + + private NoOpMeter(NoOpMeter.Builder builder) { + this(new NoOpMeter.Id(builder.name, builder.tags.values()), + builder.unit, + builder.description, + builder.type); + } + + private NoOpMeter(Id id, String baseUnit, String description, Type type) { + this.id = id; + this.unit = baseUnit; + this.description = description; + this.type = type; + } + + @Override + public Id id() { + return id; + } + + @Override + public String baseUnit() { + return unit; + } + + @Override + public String description() { + return description; + } + + @Override + public Type type() { + return type; + } + + abstract static class Builder, M extends Meter> { + + private final String name; + private final Map tags = new TreeMap<>(); // tree map for ordering by tag name + private String description; + private String unit; + private final Type type; + + private Builder(String name, Type type) { + this.name = name; + this.type = type; + } + + abstract M build(); + + public B tags(Iterable tags) { + tags.forEach(tag -> this.tags.put(tag.key(), tag)); + return identity(); + } + + public B tag(String key, String value) { + tags.put(key, Tag.create(key, value)); + return identity(); + } + + public B description(String description) { + this.description = description; + return identity(); + } + + public B baseUnit(String unit) { + this.unit = unit; + return identity(); + } + + public B identity() { + return (B) this; + } + + public String name() { + return name; + } + + public Iterable tags() { + return tags.values(); + } + + public String baseUnit() { + return unit; + } + + public String description() { + return description; + } + } + + static class Counter extends NoOpMeter implements io.helidon.metrics.api.Counter { + + static Counter create(String name, Iterable tags) { + return builder(name) + .tags(tags) + .build(); + } + + static class Builder extends NoOpMeter.Builder implements io.helidon.metrics.api.Counter.Builder { + + protected Builder(String name) { + super(name, Type.COUNTER); + } + + + @Override + public Counter build() { + return new NoOpMeter.Counter(this); + } + + } + + static Counter.Builder builder(String name) { + return new Builder(name); + } + + static FunctionalCounter.Builder builder(String name, T target, ToDoubleFunction fn) { + return new FunctionalCounter.Builder<>(name, target, fn); + } + + protected Counter(Builder builder) { + super(builder); + } + + @Override + public void increment() { + } + + @Override + public void increment(long amount) { + } + + @Override + public long count() { + return 0L; + } + } + + static class FunctionalCounter extends Counter { + + static class Builder extends Counter.Builder implements io.helidon.metrics.api.Counter.Builder { + + private Builder(String name, T target, ToDoubleFunction fn) { + super(name); + } + + @Override + public FunctionalCounter build() { + return new FunctionalCounter<>(this); + } + } + + private FunctionalCounter(Builder builder) { + super(builder); + } + + @Override + public void increment() { + throw new UnsupportedOperationException(); + } + + @Override + public void increment(long amount) { + throw new UnsupportedOperationException(); + } + } + + static class DistributionSummary extends NoOpMeter implements io.helidon.metrics.api.DistributionSummary { + + static class Builder extends NoOpMeter.Builder + implements io.helidon.metrics.api.DistributionSummary.Builder { + + private Builder(String name) { + super(name, Type.DISTRIBUTION_SUMMARY); + } + + @Override + public DistributionSummary build() { + return new DistributionSummary(this); + } + + @Override + public Builder scale(double scale) { + return identity(); + } + + @Override + public Builder distributionStatisticsConfig( + io.helidon.metrics.api.DistributionStatisticsConfig.Builder distributionStatisticsConfigBuilder) { + return identity(); + } + } + + static DistributionSummary.Builder builder(String name) { + return new DistributionSummary.Builder(name); + } + + private DistributionSummary(Builder builder) { + super(builder); + } + + @Override + public void record(double amount) { + } + + @Override + public long count() { + return 0; + } + + @Override + public double totalAmount() { + return 0; + } + + @Override + public double mean() { + return 0; + } + + @Override + public double max() { + return 0; + } + + @Override + public io.helidon.metrics.api.HistogramSnapshot snapshot() { + return new NoOpMeter.HistogramSnapshot(0L, 0D, 0D); + } + } + + static class HistogramSnapshot implements io.helidon.metrics.api.HistogramSnapshot, NoOpWrapper { + + private final long count; + private final double total; + private final double max; + + static HistogramSnapshot empty(long count, double total, double max) { + return new HistogramSnapshot(count, total, max); + } + + private HistogramSnapshot(long count, double total, double max) { + this.count = count; + this.total = total; + this.max = max; + } + + @Override + public long count() { + return count; + } + + @Override + public double total() { + return total; + } + + @Override + public double total(TimeUnit timeUnit) { + return timeUnit.convert((long) total, TimeUnit.NANOSECONDS); + } + + @Override + public double max() { + return max; + } + + @Override + public double mean() { + return total / count; + } + + @Override + public double mean(TimeUnit timeUnit) { + return timeUnit.convert((long) mean(), TimeUnit.NANOSECONDS); + } + + @Override + public Iterable percentileValues() { + return Set.of(); + } + + @Override + public Iterable histogramCounts() { + return Set.of(); + } + + @Override + public void outputSummary(PrintStream out, double scale) { + } + } + + static class Gauge extends NoOpMeter implements io.helidon.metrics.api.Gauge { + + static Builder builder(String name, T stateObject, ToDoubleFunction fn) { + return new Builder<>(name, stateObject, fn); + } + + static class Builder extends NoOpMeter.Builder, Gauge> implements io.helidon.metrics.api.Gauge.Builder { + + private final T stateObject; + private final ToDoubleFunction fn; + + private Builder(String name, T stateObject, ToDoubleFunction fn) { + super(name, Type.GAUGE); + this.stateObject = stateObject; + this.fn = fn; + } + + @Override + public Gauge build() { + return new Gauge(this); + } + } + + private Gauge(Builder builder) { + super(builder); + } + + @Override + public double value() { + return 0; + } + } + + static class Timer extends NoOpMeter implements io.helidon.metrics.api.Timer { + + static class Sample implements io.helidon.metrics.api.Timer.Sample { + + private Sample() { + } + + @Override + public long stop(io.helidon.metrics.api.Timer timer) { + return 0; + } + } + + static Sample start() { + return new Sample(); + } + + static Sample start(MeterRegistry meterRegistry) { + return new Sample(); + } + + static Sample start(Clock clock) { + return new Sample(); + } + + static class Builder extends NoOpMeter.Builder implements io.helidon.metrics.api.Timer.Builder { + + private Builder(String name) { + super(name, Type.TIMER); + } + + @Override + public Timer build() { + return new Timer(this); + } + + @Override + public Builder percentiles(double... percentiles) { + return identity(); + } + + @Override + public Builder buckets(Duration... buckets) { + return identity(); + } + + @Override + public Builder minimumExpectedValue(Duration min) { + return identity(); + } + + @Override + public Builder maximumExpectedValue(Duration max) { + return identity(); + } + } + + static Builder builder(String name) { + return new Builder(name); + } + + private Timer(Builder builder) { + super(builder); + } + + @Override + public io.helidon.metrics.api.HistogramSnapshot takeSnapshot() { + return null; + } + + @Override + public void record(long amount, TimeUnit unit) { + + } + + @Override + public void record(Duration duration) { + + } + + @Override + public T record(Supplier f) { + return null; + } + + @Override + public T record(Callable f) throws Exception { + return null; + } + + @Override + public void record(Runnable f) { + + } + + @Override + public Runnable wrap(Runnable f) { + return null; + } + + @Override + public Callable wrap(Callable f) { + return null; + } + + @Override + public Supplier wrap(Supplier f) { + return null; + } + + @Override + public long count() { + return 0; + } + + @Override + public double totalTime(TimeUnit unit) { + return 0; + } + + @Override + public double mean(TimeUnit unit) { + return 0; + } + + @Override + public double max(TimeUnit unit) { + return 0; + } + } + + static class DistributionStatisticsConfig + implements io.helidon.metrics.api.DistributionStatisticsConfig, NoOpWrapper { + + static Builder builder() { + return new Builder(); + } + + static class Builder implements io.helidon.metrics.api.DistributionStatisticsConfig.Builder, NoOpWrapper { + + @Override + public io.helidon.metrics.api.DistributionStatisticsConfig build() { + return new DistributionStatisticsConfig(this); + } + + @Override + public io.helidon.metrics.api.DistributionStatisticsConfig.Builder minimumExpectedValue(Double min) { + return identity(); + } + + @Override + public io.helidon.metrics.api.DistributionStatisticsConfig.Builder maximumExpectedValue(Double max) { + return identity(); + } + + @Override + public io.helidon.metrics.api.DistributionStatisticsConfig.Builder percentiles(double... percentiles) { + return identity(); + } + + @Override + public io.helidon.metrics.api.DistributionStatisticsConfig.Builder percentiles(Iterable percentiles) { + return identity(); + } + + @Override + public io.helidon.metrics.api.DistributionStatisticsConfig.Builder buckets(double... buckets) { + return identity(); + } + + @Override + public io.helidon.metrics.api.DistributionStatisticsConfig.Builder buckets(Iterable buckets) { + return identity(); + } + } + + private DistributionStatisticsConfig(DistributionStatisticsConfig.Builder builder) { + } + + @Override + public Optional> percentiles() { + return Optional.empty(); + } + + @Override + public Optional minimumExpectedValue() { + return Optional.empty(); + } + + @Override + public Optional maximumExpectedValue() { + return Optional.empty(); + } + + @Override + public Optional> buckets() { + return Optional.empty(); + } + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMeterRegistry.java b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMeterRegistry.java new file mode 100644 index 00000000000..2316d2fc202 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMeterRegistry.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +/** + * No-op implementation of {@link io.helidon.metrics.api.MeterRegistry}. + *

+ * Note that the no-op meter registry implementation does not actually + * store meters or their IDs, in line with the documented behavior of disabled metrics. + *

+ */ +class NoOpMeterRegistry implements MeterRegistry, NoOpWrapper { + + @Override + public List meters() { + return List.of(); + } + + @Override + public Collection meters(Predicate filter) { + return Set.of(); + } + + @Override + public Iterable scopes() { + return Set.of(); + } + + @Override + public boolean isMeterEnabled(io.helidon.metrics.api.Meter.Id meterId) { + return true; + } + + @Override + public Clock clock() { + return Clock.system(); + } + + @Override + public Optional remove(Meter.Id id) { + return Optional.empty(); + } + + @Override + public Optional remove(Meter meter) { + return Optional.empty(); + } + + @Override + public Optional remove(String name, Iterable tags) { + return remove(NoOpMeter.Id.create(name, tags)); + } + + @Override + public Optional get(Class mClass, String name, Iterable tags) { + return Optional.empty(); + } + + @Override + public > M getOrCreate(B builder) { + NoOpMeter.Builder b = (NoOpMeter.Builder) builder; + return findOrRegister(NoOpMeter.Id.create(b.name(), + b.tags()), + builder); + } + + private Optional find(Meter.Id id, Class mClass) { + return Optional.empty(); + } + + private > M findOrRegister(Meter.Id id, B builder) { + NoOpMeter.Builder noOpBuilder = (NoOpMeter.Builder) builder; + // The following cast will always succeed if we create the meter by invoking the builder, + // it will success if we retrieved a previously-registered meter of a compatible type, + // and it will (correctly) fail if we found a previously-registered meter of an incompatible + // type compared to what the caller requested. + return (M) noOpBuilder.build(); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMetricsFactory.java b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMetricsFactory.java new file mode 100644 index 00000000000..a7ec5a19884 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMetricsFactory.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.function.ToDoubleFunction; + +/** + * No-op implementation of the {@link io.helidon.metrics.api.spi.MetricFactory} interface. + */ +class NoOpMetricsFactory implements MetricsFactory { + + private final MeterRegistry meterRegistry = new NoOpMeterRegistry(); + + private static final Clock SYSTEM_CLOCK = new Clock() { + @Override + public R unwrap(Class c) { + return c.cast(this); + } + + @Override + public long wallTime() { + return System.currentTimeMillis(); + } + + @Override + public long monotonicTime() { + return System.nanoTime(); + } + }; + + static NoOpMetricsFactory create() { + return new NoOpMetricsFactory(); + } + + @Override + public MeterRegistry globalRegistry() { + return meterRegistry; + } + + @Override + public MeterRegistry createMeterRegistry(MetricsConfig metricsConfig) { + return new NoOpMeterRegistry(); + } + + @Override + public MeterRegistry createMeterRegistry(Clock clock, MetricsConfig metricsConfig) { + return createMeterRegistry(metricsConfig); + } + + @Override + public Clock clockSystem() { + return SYSTEM_CLOCK; + } + + @Override + public Tag tagCreate(String key, String value) { + return new NoOpTag(key, value); + } + + @Override + public Counter.Builder counterBuilder(String name) { + return NoOpMeter.Counter.builder(name); + } + + @Override + public DistributionSummary.Builder distributionSummaryBuilder(String name, + DistributionStatisticsConfig.Builder configBuilder) { + return NoOpMeter.DistributionSummary.builder(name) + .distributionStatisticsConfig(configBuilder); + } + + @Override + public Gauge.Builder gaugeBuilder(String name, T stateObject, ToDoubleFunction fn) { + return NoOpMeter.Gauge.builder(name, stateObject, fn); + } + + @Override + public Timer.Builder timerBuilder(String name) { + return NoOpMeter.Timer.builder(name); + } + + @Override + public DistributionStatisticsConfig.Builder distributionStatisticsConfigBuilder() { + return NoOpMeter.DistributionStatisticsConfig.builder(); + } + + @Override + public HistogramSnapshot histogramSnapshotEmpty(long count, double total, double max) { + return NoOpMeter.HistogramSnapshot.empty(count, total, max); + } + + @Override + public Timer.Sample timerStart() { + return NoOpMeter.Timer.start(); + } + + @Override + public Timer.Sample timerStart(MeterRegistry registry) { + return NoOpMeter.Timer.start(registry); + } + + @Override + public Timer.Sample timerStart(Clock clock) { + return NoOpMeter.Timer.start(clock); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMetricsFactoryProvider.java b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMetricsFactoryProvider.java new file mode 100644 index 00000000000..85a8f7cbd9a --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpMetricsFactoryProvider.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import io.helidon.metrics.spi.MetricsFactoryProvider; + +/** + * No-op implementation of {@link io.helidon.metrics.spi.MetricsFactoryProvider}. + */ +class NoOpMetricsFactoryProvider implements MetricsFactoryProvider { + static MetricsFactoryProvider create() { + return new NoOpMetricsFactoryProvider(); + } + + @Override + public MetricsFactory create(MetricsConfig metricsConfig) { + return NoOpMetricsFactory.create(); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/NoOpTag.java b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpTag.java new file mode 100644 index 00000000000..96d1c6523ca --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpTag.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +record NoOpTag(String key, String value) implements Tag, NoOpWrapper { + + static Tag create(String key, String value) { + return new NoOpTag(key, value); + } + + static Iterable tags(String... keysAndValues) { + if (keysAndValues.length % 2 != 0) { + throw new IllegalArgumentException("String array of keys and values must balance (have an even length"); + } + return () -> new Iterator<>() { + + private int slot; + + @Override + public boolean hasNext() { + return slot < keysAndValues.length / 2; + } + + @Override + public Tag next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + Tag result = Tag.create(keysAndValues[2 * slot], keysAndValues[2 * slot + 1]); + slot++; + return result; + } + }; + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/NoOpWrapper.java b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpWrapper.java new file mode 100644 index 00000000000..1dd1b6b4ebb --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/NoOpWrapper.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +interface NoOpWrapper extends Wrapper { + + @Override + default R unwrap(Class c) { + return c.cast(this); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Tag.java b/metrics/api/src/main/java/io/helidon/metrics/api/Tag.java new file mode 100644 index 00000000000..b0e28e3e626 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Tag.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +/** + * Behavior of a tag for further identifying meters. + */ +public interface Tag extends Wrapper { + + /** + * Returns the tag's key. + * + * @return the tag's key + */ + String key(); + + /** + * Returns the tag's value. + * + * @return the tag's value + */ + String value(); + + /** + * Creates a new tag using the specified key and value. + * + * @param key the tag's key + * @param value the tag's value + * @return new {@code Tag} representing the key and value + */ + static Tag create(String key, String value) { + return MetricsFactory.getInstance().tagCreate(key, value); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Timer.java b/metrics/api/src/main/java/io/helidon/metrics/api/Timer.java new file mode 100644 index 00000000000..4170f7db0af --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Timer.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * Records timing information about large numbers of short-running events (e.g., HTTP requests). + */ +public interface Timer extends Meter, HistogramSupport { + + /** + * Creates a builder for a new {@link io.helidon.metrics.api.Timer}. + * + * @param name timer name + * @return new builder + */ + static Builder builder(String name) { + return MetricsFactory.getInstance().timerBuilder(name); + } + + /** + * Starts a timing sample using the default system clock. + * + * @return new sample + */ + static Sample start() { + return MetricsFactory.getInstance().timerStart(); + } + + /** + * Starts a timing sample using the clock associated with the specified {@link io.helidon.metrics.api.MeterRegistry}. + * + * @param registry the meter registry whose clock is to be used for measuring the interval + * @return new sample with start time recorded + */ + static Sample start(MeterRegistry registry) { + return MetricsFactory.getInstance().timerStart(registry); + } + + /** + * Starts a timing sample using the specified clock. + * + * @param clock a clock to be used + * @return new sample with start time recorded + */ + static Sample start(Clock clock) { + return MetricsFactory.getInstance().timerStart(clock); + } + + /** + * Updates the statistics kept by the timer with the specified amount. + * + * @param amount duration of a single event being measured by this timer. If the amount is less than 0 + * the value will be dropped + * @param unit time unit for the amount being recorded + */ + void record(long amount, TimeUnit unit); + + /** + * Updates the statistics kept by the timer with the specified amount. + * + * @param duration duration of a single event being measured by this timer + */ + void record(Duration duration); + + /** + * Executes the {@link java.util.function.Supplier} {@code f} and records the time spent invoking the function. + * + * @param f function to be timed + * @param return type of the {@link java.util.function.Supplier} + * @return return value from invoking the function {@code f} + */ + T record(Supplier f); + + /** + * Executes the {@link java.util.concurrent.Callable} {@code f} and records the time spent it, returning the + * callable's result. + * + * @param f callable to be timed + * @param return type of the {@link java.util.concurrent.Callable} + * @return return value from invoking the callable {@code f} + * @throws Exception exception escaping from the callable + */ + T record(Callable f) throws Exception; + + /** + * Executes the {@link java.lang.Runnable} {@code f} and records the time it takes. + * + * @param f runnable to be timed + */ + void record(Runnable f); + + /** + * Wraps a {@link Runnable} so that it will be timed every time it is invoked via the return value from this method. + * + * @param f runnable to time when it is invoked + * @return the wrapped runnable + */ + Runnable wrap(Runnable f); + + /** + * Wraps a {@link Callable} so that it is will be timed every time it is invoked via the return value from this method. + * + * @param f callable to time when it is invoked + * @param return type of the callable + * @return the wrapped callable + */ + Callable wrap(Callable f); + + /** + * Wraps a {@link Supplier} so that it will be timed every time it is invoked via the return value from this method. + * + * @param f {@code Supplier} to time when it is invoked + * @param return type of the {@code Supplier} result + * @return the wrapped supplier + */ + Supplier wrap(Supplier f); + + /** + * Returns the current count of completed events measured by the timer. + * + * @return number of events recorded by the timer + */ + long count(); + + /** + * Returns the total time, expressed in the specified units, consumed by completed events + * measured by the timer. + * + * @param unit time unit in which to express the total accumulated time + * @return total time of recorded events + */ + double totalTime(TimeUnit unit); + + /** + * Returns the average time, expressed in the specified units, consumed by completed events + * measured by the timer. + * + * @param unit time unit in which to express the mean + * @return average for all events recorded by this timer + */ + double mean(TimeUnit unit); + + /** + * Returns the maximum value, expressed in the specified units, consumed by a completed event + * measured by the timer. + * + * @param unit time unit in which to express the maximum + * @return maximum time recorded by a single event measured by this timer + */ + double max(TimeUnit unit); + + /** + * Measures an interval of time from instantiation to an explicit invocation of {@link #stop(io.helidon.metrics.api.Timer)}. + *

+ * A {@code Sample} is not bound to a specific {@code Timer} until it is stopped, at + * which time the caller specifies which timer to update. + *

+ */ + interface Sample { + + /** + * Ends the interval, recording the current time as the end time of the interval and applying the elapsed time to the + * specified {@link io.helidon.metrics.api.Timer}. + * + * @param timer the timer to update with this interval + * @return duration of the sample (in nanoseconds) + */ + long stop(Timer timer); + } + + /** + * Builder for a new {@link io.helidon.metrics.api.Timer}. + */ + interface Builder extends Meter.Builder { + + /** + * Sets the percentiles to compute and publish (expressing, for example, the 95th percentile as 0.95). + * + * @param percentiles percentiles to compute and publish + * @return updated builder + */ + Builder percentiles(double... percentiles); + + /** + * Sets the boundary boundaries. + * + * @param buckets boundary boundaries + * @return updated builder + */ + Builder buckets(Duration... buckets); + + /** + * Sets the minimum expected value the timer is expected to record. + * + * @param min minimum expected value + * @return updated builder + */ + Builder minimumExpectedValue(Duration min); + + /** + * Sets the maximum expected value the timer is expected to record. + * + * @param max maximum expected value + * @return updated builder + */ + Builder maximumExpectedValue(Duration max); + } +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/ValueAtPercentile.java b/metrics/api/src/main/java/io/helidon/metrics/api/ValueAtPercentile.java new file mode 100644 index 00000000000..c981d4dcbbe --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/ValueAtPercentile.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.concurrent.TimeUnit; + +/** + * Percentile and value at that percentile within a distribution. + */ +public interface ValueAtPercentile extends Wrapper { + + /** + * Returns the percentile. + * + * @return the percentile + */ + double percentile(); + + /** + * Returns the value at this percentile. + * + * @return the percentile's value + */ + double value(); + + /** + * Returns the value of this percentile interpreted as time in nanoseconds converted to the specified + * {@link java.util.concurrent.TimeUnit}. + * + * @param unit time unit in which to express the value + * @return converted value + */ + double value(TimeUnit unit); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/api/Wrapper.java b/metrics/api/src/main/java/io/helidon/metrics/api/Wrapper.java new file mode 100644 index 00000000000..92310adbf4d --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/api/Wrapper.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +/** + * Behavior of a type that wraps a related type, typically through delegation. + */ +public interface Wrapper { + + /** + * Unwraps the delegate as the specified type. + * + * @param c {@link Class} to which to cast the delegate + * @return the delegate cast as the requested type + * @param type to cast to + * @throws java.lang.ClassCastException if the delegate is not compatible with the requested type + */ + R unwrap(Class c); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/spi/MeterRegistryFormatterProvider.java b/metrics/api/src/main/java/io/helidon/metrics/spi/MeterRegistryFormatterProvider.java new file mode 100644 index 00000000000..6ad838eab71 --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/spi/MeterRegistryFormatterProvider.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.spi; + +import java.util.Optional; + +import io.helidon.common.media.type.MediaType; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.MeterRegistryFormatter; + +/** + * Behavior for providers of meter registry formatters, which (if then can) furnish a formatter given a + * {@link io.helidon.common.media.type.MediaType}. + * + *

+ * We use a provider approach so code can obtain and run formatters that might depend heavily on particular implementations + * without the calling code having to share that heavy dependency. + *

+ */ +public interface MeterRegistryFormatterProvider { + + /** + * Returns, if possible, a {@link io.helidon.metrics.api.MeterRegistryFormatter} capable of preparing output according to + * the specified {@link io.helidon.common.media.type.MediaType}. + * @param mediaType media type of the desired output + * @param meterRegistry {@link io.helidon.metrics.api.MeterRegistry} from which to gather data + * @param scopeTagName tag name used to record scope + * @param scopeSelection scope names to format; empty means no scope-based restriction + * @param nameSelection meter names to format; empty means no name-based restriction + * @return compatible formatter; empty if none + */ + Optional formatter(MediaType mediaType, + MeterRegistry meterRegistry, + String scopeTagName, + Iterable scopeSelection, + Iterable nameSelection); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/spi/MetricsFactoryProvider.java b/metrics/api/src/main/java/io/helidon/metrics/spi/MetricsFactoryProvider.java new file mode 100644 index 00000000000..5834f3f803b --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/spi/MetricsFactoryProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.spi; + +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.MetricsFactory; + +/** + * Behavior of providers of {@link io.helidon.metrics.api.MetricsFactory} instances. + */ +public interface MetricsFactoryProvider { + + /** + * Creates a new {@link MetricsFactory} using the provided metrics config. + * + * @param metricsConfig metrics configuration settings + * @return new metrics factory + */ + MetricsFactory create(MetricsConfig metricsConfig); +} diff --git a/metrics/api/src/main/java/io/helidon/metrics/spi/package-info.java b/metrics/api/src/main/java/io/helidon/metrics/spi/package-info.java new file mode 100644 index 00000000000..0ebcc9f61ac --- /dev/null +++ b/metrics/api/src/main/java/io/helidon/metrics/spi/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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. + */ + +/** + * SPI for Helidon metrics. + */ +package io.helidon.metrics.spi; diff --git a/metrics/api/src/main/java/module-info.java b/metrics/api/src/main/java/module-info.java index 0f1e122e81b..9536ac1670a 100644 --- a/metrics/api/src/main/java/module-info.java +++ b/metrics/api/src/main/java/module-info.java @@ -16,6 +16,7 @@ import io.helidon.metrics.api.spi.ExemplarService; import io.helidon.metrics.api.spi.RegistryFactoryProvider; +import io.helidon.metrics.api.MetricsFactory; /** * Helidon metrics API. @@ -25,14 +26,22 @@ requires io.helidon.http; requires transitive io.helidon.common.config; - requires transitive microprofile.metrics.api; + requires io.helidon.builder.api; requires static io.helidon.config.metadata; - requires micrometer.core; + + // TODO remove next once we no longer need MP metrics APIs + requires transitive microprofile.metrics.api; + requires io.helidon.inject.configdriven.api; exports io.helidon.metrics.api; exports io.helidon.metrics.api.spi; + exports io.helidon.metrics.spi; uses RegistryFactoryProvider; uses ExemplarService; uses io.helidon.metrics.api.MetricsProgrammaticSettings; + uses io.helidon.metrics.spi.MetricsFactoryProvider; + uses io.helidon.metrics.spi.MeterRegistryFormatterProvider; + + uses MetricsFactory; } diff --git a/metrics/api/src/test/java/io/helidon/metrics/api/SimpleApiTest.java b/metrics/api/src/test/java/io/helidon/metrics/api/SimpleApiTest.java new file mode 100644 index 00000000000..0c7d8b7f7bd --- /dev/null +++ b/metrics/api/src/test/java/io/helidon/metrics/api/SimpleApiTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import io.helidon.common.testing.junit5.OptionalMatcher; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.sameInstance; + +public class SimpleApiTest { + + private static final String COUNTER_1_DESC = "counter 1 description"; + + private static Counter counter1; + private static Counter counter2; + private static Timer timer1; + + @BeforeAll + static void prep() { + MeterRegistry registry = Metrics.globalRegistry(); + assertThat("Global registry", registry, notNullValue()); + counter1 = Metrics.getOrCreate(Counter.builder("counter1") + .description(COUNTER_1_DESC)); + counter2 = Metrics.getOrCreate(Counter.builder("counter2")); + + timer1 = Metrics.getOrCreate(Timer.builder("timer1") + .tags(Metrics.tags("t1", "v1", + "t2", "v2"))); + } + + @Test + void testNoOpRegistrations() { + + Optional fetchedCounter = Metrics.getCounter("counter1"); + assertThat("Fetched counter 1", fetchedCounter, OptionalMatcher.optionalEmpty()); + fetchedCounter = Metrics.getCounter("counter2", Set.of()); + assertThat("Fetched counter 2", fetchedCounter, OptionalMatcher.optionalEmpty()); + + Optional fetchedTimer = Metrics.getTimer("timer1", Metrics.tags("t1", "v1", + "t2", "v2")); + assertThat("Fetched timer", fetchedTimer, OptionalMatcher.optionalEmpty()); + } +} diff --git a/metrics/api/src/test/java/io/helidon/metrics/api/TestMetricsConfigTagsHandling.java b/metrics/api/src/test/java/io/helidon/metrics/api/TestMetricsConfigTagsHandling.java new file mode 100644 index 00000000000..b3421671469 --- /dev/null +++ b/metrics/api/src/test/java/io/helidon/metrics/api/TestMetricsConfigTagsHandling.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.api; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TestMetricsConfigTagsHandling { + + @Test + void checkSingle() { + var pairs = MetricsConfigBlueprint.createGlobalTags("a=4"); + assertThat("Result", pairs, hasSize(1)); + assertThat("Tag", pairs, hasItem(Tag.create("a", "4"))); + } + + @Test + void checkMultiple() { + var pairs = MetricsConfigBlueprint.createGlobalTags("a=11,b=12,c=13"); + assertThat("Result", pairs, hasSize(3)); + assertThat("Tags", pairs, allOf(hasItem(Tag.create("a", "11")), + hasItem(Tag.create("b", "12")), + hasItem(Tag.create("c", "13")))); + } + + @Test + void checkQuoted() { + var pairs = MetricsConfigBlueprint.createGlobalTags("d=r\\=3,e=4,f=0\\,1,g=hi"); + assertThat("Result", pairs, hasSize(4)); + assertThat("Tags", pairs, allOf(hasItem(Tag.create("d", "r=3")), + hasItem(Tag.create("e", "4")), + hasItem(Tag.create("f", "0,1")), + hasItem(Tag.create("g", "hi")))); + } + + @Test + void checkEmptyAssignment() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + MetricsConfigBlueprint.createGlobalTags(",a=1")); + assertThat("Empty assignment", ex.getMessage(), containsString("empty")); + } + + @Test + void checkNoRightSide() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + MetricsConfigBlueprint.createGlobalTags("a=")); + assertThat("No right side", ex.getMessage(), containsString("missing tag value")); + } + + @Test + void checkNoLeftSide() { + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + MetricsConfigBlueprint.createGlobalTags("=1")); + assertThat("No left side", ex.getMessage(), containsString("missing tag name")); + } + + @Test + void checkInvalidTagName() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> + MetricsConfigBlueprint.createGlobalTags("a*=1,")); + assertThat("Invalid tag name", ex.getMessage(), containsString("tag name must")); + } +} diff --git a/metrics/metrics/src/main/java/io/helidon/metrics/MicrometerPrometheusFormatter.java b/metrics/metrics/src/main/java/io/helidon/metrics/MicrometerPrometheusFormatter.java index de2588ea18c..3f49a1cb8a3 100644 --- a/metrics/metrics/src/main/java/io/helidon/metrics/MicrometerPrometheusFormatter.java +++ b/metrics/metrics/src/main/java/io/helidon/metrics/MicrometerPrometheusFormatter.java @@ -42,6 +42,7 @@ * "m_" if the actual meter name starts with a digit or underscore and underscores replace special characters. *

*/ +// TODO remove this class once we've converted to use the one in helidon-metrics-micrometer. public class MicrometerPrometheusFormatter { /** * Mapping from supported media types to the corresponding Prometheus registry content types. diff --git a/metrics/pom.xml b/metrics/pom.xml index fe55a9edab9..5532e74fbe9 100644 --- a/metrics/pom.xml +++ b/metrics/pom.xml @@ -31,9 +31,11 @@ metrics + providers prometheus trace-exemplar api service-api + testing diff --git a/metrics/providers/micrometer/pom.xml b/metrics/providers/micrometer/pom.xml new file mode 100644 index 00000000000..b6c11edae74 --- /dev/null +++ b/metrics/providers/micrometer/pom.xml @@ -0,0 +1,58 @@ + + + + + 4.0.0 + + io.helidon.metrics + helidon-metrics-providers-project + 4.0.0-SNAPSHOT + + helidon-metrics-micrometer + Helidon Metrics Micrometer Adapter + Micrometer implementation of the Helidon metrics API + + + + io.helidon.metrics + helidon-metrics-api + + + io.micrometer + micrometer-core + + + io.micrometer + micrometer-registry-prometheus + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + + diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MBucket.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MBucket.java new file mode 100644 index 00000000000..8218520c1f6 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MBucket.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import io.helidon.metrics.api.Bucket; + +import io.micrometer.core.instrument.distribution.CountAtBucket; + +class MBucket implements Bucket { + + static MBucket create(CountAtBucket delegate) { + return new MBucket(delegate); + } + + private final CountAtBucket delegate; + + private MBucket(CountAtBucket delegate) { + this.delegate = delegate; + } + + @Override + public double boundary() { + return delegate.bucket(); + } + + @Override + public double boundary(TimeUnit unit) { + return delegate.bucket(unit); + } + + @Override + public long count() { + return (long) delegate.count(); + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + // Simplifies the use of test implementations in unit tests if equals does not insist that the other object + // also be a MBucket but merely implements Bucket. + if (!(o instanceof Bucket that)) { + return false; + } + return Objects.equals(delegate.bucket(), that.boundary()) + && Objects.equals((long) delegate.count(), that.count()); + } + + @Override + public int hashCode() { + return Objects.hash((long) delegate.bucket(), delegate.count()); + } + + @Override + public String toString() { + return String.format("MBucket[boundary=%f,count=%d]", boundary(), count()); + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MClock.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MClock.java new file mode 100644 index 00000000000..01777b0addb --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MClock.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import io.micrometer.core.instrument.Clock; + +/** + * Wrapper for a {@link io.micrometer.core.instrument.Clock} when one is returned from + * Micrometer. + */ +class MClock implements io.helidon.metrics.api.Clock { + + static MClock create(Clock delegate) { + return new MClock(delegate); + } + + private final Clock delegate; + + private MClock(Clock delegate) { + this.delegate = delegate; + } + @Override + public long wallTime() { + return delegate.wallTime(); + } + + @Override + public long monotonicTime() { + return delegate.monotonicTime(); + } + + Clock delegate() { + return delegate; + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MCounter.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MCounter.java new file mode 100644 index 00000000000..ed5a36212fa --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MCounter.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import io.micrometer.core.instrument.Counter; + +class MCounter extends MMeter implements io.helidon.metrics.api.Counter { + + static Builder builder(String name) { + return new Builder(name); + } + + static MCounter create(Counter counter) { + return new MCounter(counter); + } + + private MCounter(Counter delegate) { + super(delegate); + } + + @Override + public void increment() { + delegate().increment(); + } + + @Override + public void increment(long amount) { + delegate().increment(amount); + } + + @Override + public long count() { + return (long) delegate().count(); + } + + static class Builder extends MMeter.Builder + implements io.helidon.metrics.api.Counter.Builder { + + private Builder(String name) { + super(Counter.builder(name)); + prep(delegate()::tags, + delegate()::description, + delegate()::baseUnit); + } + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MDistributionStatisticsConfig.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MDistributionStatisticsConfig.java new file mode 100644 index 00000000000..bfd7a6d8def --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MDistributionStatisticsConfig.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; + +class MDistributionStatisticsConfig implements io.helidon.metrics.api.DistributionStatisticsConfig { + + static Builder builder() { + return new Builder(); + } + + private final DistributionStatisticConfig delegate; + + /** + * Creates a new config instance from a builder (which wraps a Micrometer config builder). + * + * @param builder builder which wraps the Micrometer config builder + */ + private MDistributionStatisticsConfig(Builder builder) { + delegate = builder.delegate.build(); + } + + /** + * Creates a new config instance, primarily from a merge operation. + * + * @param delegate pre-existing delegate + */ + private MDistributionStatisticsConfig(DistributionStatisticConfig delegate) { + this.delegate = delegate; + } + + @Override + public Optional> percentiles() { + return Optional.ofNullable(Util.iterable(delegate.getPercentiles())); + } + + @Override + public Optional minimumExpectedValue() { + return Optional.ofNullable(delegate.getMinimumExpectedValueAsDouble()); + } + + @Override + public Optional maximumExpectedValue() { + return Optional.ofNullable(delegate.getMaximumExpectedValueAsDouble()); + } + + @Override + public Optional> buckets() { + return Optional.ofNullable(Util.iterable(delegate.getServiceLevelObjectiveBoundaries())); + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } + + io.micrometer.core.instrument.distribution.DistributionStatisticConfig delegate() { + return delegate; + } + + static class Builder implements io.helidon.metrics.api.DistributionStatisticsConfig.Builder { + + private final DistributionStatisticConfig.Builder delegate; + + private Builder() { + delegate = DistributionStatisticConfig.builder(); + } + + @Override + public MDistributionStatisticsConfig build() { + return new MDistributionStatisticsConfig(this); + } + + @Override + public Builder minimumExpectedValue(Double min) { + delegate.minimumExpectedValue(min); + return this; + } + + @Override + public Builder maximumExpectedValue(Double max) { + delegate.maximumExpectedValue(max); + return this; + } + + @Override + public Builder percentiles(double... percentiles) { + delegate.percentiles(percentiles); + return this; + } + + @Override + public Builder percentiles(Iterable percentiles) { + delegate.percentiles(Util.doubleArray(percentiles)); + return this; + } + + @Override + public Builder buckets(double... buckets) { + delegate.serviceLevelObjectives(buckets); + return this; + } + + @Override + public Builder buckets(Iterable buckets) { + delegate.serviceLevelObjectives(Util.doubleArray(buckets)); + return this; + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } + + DistributionStatisticConfig.Builder delegate() { + return delegate; + } + } + + static T chooseOpt(T fromChild, Supplier> fromParent) { + return Objects.requireNonNullElseGet(fromChild, + () -> fromParent.get().orElse(null)); + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MDistributionSummary.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MDistributionSummary.java new file mode 100644 index 00000000000..ee9786c37c2 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MDistributionSummary.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import io.helidon.metrics.api.DistributionStatisticsConfig; +import io.helidon.metrics.api.HistogramSnapshot; + +import io.micrometer.core.instrument.DistributionSummary; + +import static io.micrometer.core.instrument.distribution.DistributionStatisticConfig.DEFAULT; + +class MDistributionSummary extends MMeter implements io.helidon.metrics.api.DistributionSummary { + + static Builder builder(String name, + DistributionStatisticsConfig.Builder configBuilder) { + return new Builder(name, configBuilder); + } + + static MDistributionSummary create(DistributionSummary summary) { + return new MDistributionSummary(summary); + } + + private MDistributionSummary(DistributionSummary delegate) { + super(delegate); + } + + @Override + public void record(double amount) { + delegate().record(amount); + } + + @Override + public long count() { + return delegate().count(); + } + + @Override + public double totalAmount() { + return delegate().totalAmount(); + } + + @Override + public double mean() { + return delegate().mean(); + } + + @Override + public double max() { + return delegate().max(); + } + + @Override + public HistogramSnapshot snapshot() { + return MHistogramSnapshot.create(delegate().takeSnapshot()); + } + + static class Builder extends MMeter.Builder + implements io.helidon.metrics.api.DistributionSummary.Builder { + + private Builder(String name, DistributionStatisticsConfig.Builder configBuilder) { + this(name, configBuilder.build()); + } + private Builder(String name, DistributionStatisticsConfig config) { + super(DistributionSummary.builder(name) + .publishPercentiles(config.percentiles() + .map(Util::doubleArray) + .orElse(DEFAULT.getPercentiles())) + .serviceLevelObjectives(config.buckets() + .map(Util::doubleArray) + .orElse(DEFAULT.getServiceLevelObjectiveBoundaries())) + .minimumExpectedValue(config.minimumExpectedValue() + .orElse(DEFAULT.getMinimumExpectedValueAsDouble())) + .maximumExpectedValue(config.maximumExpectedValue() + .orElse(DEFAULT.getMaximumExpectedValueAsDouble()))); + prep(delegate()::tags, + delegate()::description, + delegate()::baseUnit); + } + + @Override + public Builder scale(double scale) { + delegate().scale(scale); + return identity(); + } + + @Override + public Builder distributionStatisticsConfig( + io.helidon.metrics.api.DistributionStatisticsConfig.Builder distributionStatisticsConfigBuilder) { + io.helidon.metrics.api.DistributionStatisticsConfig config = distributionStatisticsConfigBuilder.build(); + DistributionSummary.Builder delegate = delegate(); + + config.percentiles().ifPresent(p -> delegate.publishPercentiles(Util.doubleArray(p))); + config.buckets().ifPresent(slos -> delegate.serviceLevelObjectives(Util.doubleArray(slos))); + config.minimumExpectedValue().ifPresent(delegate::minimumExpectedValue); + config.maximumExpectedValue().ifPresent(delegate::maximumExpectedValue); + + return identity(); + } + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MGauge.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MGauge.java new file mode 100644 index 00000000000..56e3c04f561 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MGauge.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.function.ToDoubleFunction; + +import io.micrometer.core.instrument.Gauge; + +class MGauge extends MMeter implements io.helidon.metrics.api.Gauge { + + static MGauge.Builder builder(String name, T stateObject, ToDoubleFunction fn) { + return new MGauge.Builder<>(name, stateObject, fn); + } + static MGauge create(Gauge gauge) { + return new MGauge(gauge); + } + + private MGauge(Gauge delegate) { + super(delegate); + } + + @Override + public double value() { + return delegate().value(); + } + + static class Builder extends MMeter.Builder, MGauge.Builder, MGauge> + implements io.helidon.metrics.api.Gauge.Builder { + + private Builder(String name, T stateObject, ToDoubleFunction fn) { + super(Gauge.builder(name, stateObject, fn)); + prep(delegate()::tags, + delegate()::description, + delegate()::baseUnit); + } + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MHistogramSnapshot.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MHistogramSnapshot.java new file mode 100644 index 00000000000..8dea154364e --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MHistogramSnapshot.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.io.PrintStream; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.concurrent.TimeUnit; + +import io.helidon.metrics.api.Bucket; + +import io.micrometer.core.instrument.distribution.CountAtBucket; +import io.micrometer.core.instrument.distribution.HistogramSnapshot; +import io.micrometer.core.instrument.distribution.ValueAtPercentile; + +class MHistogramSnapshot implements io.helidon.metrics.api.HistogramSnapshot { + + static MHistogramSnapshot create(HistogramSnapshot delegate) { + return new MHistogramSnapshot(delegate); + } + + private final HistogramSnapshot delegate; + + private MHistogramSnapshot(HistogramSnapshot delegate) { + this.delegate = delegate; + } + + @Override + public long count() { + return delegate.count(); + } + + @Override + public double total() { + return delegate.total(); + } + + @Override + public double total(TimeUnit timeUnit) { + return delegate.total(timeUnit); + } + + @Override + public double max() { + return delegate.max(); + } + + @Override + public double mean() { + return delegate.mean(); + } + + @Override + public double mean(TimeUnit timeUnit) { + return delegate.mean(timeUnit); + } + + @Override + public Iterable percentileValues() { + return () -> new Iterator<>() { + + private final ValueAtPercentile[] values = delegate.percentileValues(); + private int slot; + + @Override + public boolean hasNext() { + return slot < values.length; + } + + @Override + public io.helidon.metrics.api.ValueAtPercentile next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return MValueAtPercentile.create(values[slot++]); + } + }; + } + + @Override + public Iterable histogramCounts() { + return () -> new Iterator<>() { + + private final CountAtBucket[] counts = delegate.histogramCounts(); + private int slot; + + @Override + public boolean hasNext() { + return slot < counts.length; + } + + @Override + public Bucket next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return MBucket.create(counts[slot++]); + } + }; + } + + @Override + public void outputSummary(PrintStream out, double scale) { + delegate.outputSummary(out, scale); + + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MMeter.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MMeter.java new file mode 100644 index 00000000000..54753baa225 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MMeter.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Iterator; +import java.util.Objects; +import java.util.function.Function; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.Tag; + +/** + * Adapter to Micrometer meter for Helidon metrics. + */ +class MMeter implements io.helidon.metrics.api.Meter { + + private final M delegate; + private final io.helidon.metrics.api.Meter.Id id; + + protected MMeter(M delegate) { + this.delegate = delegate; + id = Id.of(delegate.getId()); + } + + @Override + public io.helidon.metrics.api.Meter.Id id() { + return id; + } + + @Override + public String baseUnit() { + return delegate.getId().getBaseUnit(); + } + + @Override + public String description() { + return delegate.getId().getDescription(); + } + + @Override + public Type type() { + return io.helidon.metrics.api.Meter.Type.valueOf(delegate.getId() + .getType() + .name()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MMeter mMeter = (MMeter) o; + return Objects.equals(delegate, mMeter.delegate); + } + + @Override + public int hashCode() { + return Objects.hash(delegate); + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } + + protected M delegate() { + return delegate; + } + + abstract static class Builder, HM extends io.helidon.metrics.api.Meter> { + + private final B delegate; + private Function, B> tagsSetter; + private Function descriptionSetter; + private Function baseUnitSetter; + + protected Builder(B delegate) { + this.delegate = delegate; + } + + protected void prep(Function, B> tagsSetter, + Function descriptionSetter, + Function baseUnitSetter) { + this.tagsSetter = tagsSetter; + this.descriptionSetter = descriptionSetter; + this.baseUnitSetter = baseUnitSetter; + } + + protected B delegate() { + return delegate; + } + + public HB tags(Iterable tags) { + tagsSetter.apply(MTag.tags(tags)); + return identity(); + } + + public HB description(String description) { + descriptionSetter.apply(description); + return identity(); + } + + public HB baseUnit(String baseUnit) { + baseUnitSetter.apply(baseUnit); + return identity(); + } + + public HB identity() { + return (HB) this; + } + +// abstract HM register(MMeterRegistry meterRegistry); + + } + + static class Id implements io.helidon.metrics.api.Meter.Id { + + static Id of(Meter.Id id) { + return new Id(id); + } + + private final Meter.Id delegate; + + private Id(Meter.Id delegate) { + this.delegate = delegate; + } + + @Override + public String name() { + return delegate.getName(); + } + + @Override + public Iterable tags() { + return new Iterable<>() { + + private final Iterator iter = delegate.getTags().iterator(); + @Override + public Iterator iterator() { + return new Iterator<>() { + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public io.helidon.metrics.api.Tag next() { + return MTag.create(iter.next()); + } + }; + } + }; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Id id = (Id) o; + return Objects.equals(delegate, id.delegate); + } + + @Override + public int hashCode() { + return Objects.hash(delegate); + } + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MMeterRegistry.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MMeterRegistry.java new file mode 100644 index 00000000000..5fb90ccba25 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MMeterRegistry.java @@ -0,0 +1,371 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Predicate; + +import io.helidon.metrics.api.Clock; +import io.helidon.metrics.api.MetricsConfig; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.core.instrument.config.MeterFilter; +import io.micrometer.core.instrument.search.Search; +import io.micrometer.prometheus.PrometheusMeterRegistry; + +class MMeterRegistry implements io.helidon.metrics.api.MeterRegistry { + + private static final System.Logger LOGGER = System.getLogger(MMeterRegistry.class.getName()); + + /** + * Creates a new meter registry which wraps the specified Micrometer meter registry, ensuring that if + * the meter registry is a composite registry it has a Prometheus meter registry attached (adding a new one if needed). + *

+ * The {@link io.helidon.metrics.api.MetricsConfig} does not override the settings of the pre-existing Micrometer + * meter registry but augments the behavior of this wrapper around it, for example specifying + * global tags. + *

+ * + * @param meterRegistry existing Micrometer meter registry to wrap + * @param metricsConfig metrics config + * @return new wrapper around the specified Micrometer meter registry + */ + static MMeterRegistry create(MeterRegistry meterRegistry, + MetricsConfig metricsConfig) { + // The caller passed a pre-existing meter registry, with its own clock, so wrap that clock + // with a Helidon clock adapter (MClock). + return new MMeterRegistry(ensurePrometheusRegistryIsPresent(meterRegistry, metricsConfig), + MClock.create(meterRegistry.config().clock()), + metricsConfig); + } + + /** + * Creates a new meter registry which wraps an automatically-created new Micrometer + * {@link io.micrometer.core.instrument.composite.CompositeMeterRegistry} with a Prometheus meter registry + * automatically added. + * + * @param metricsConfig metrics config + * @return new wrapper around a new Micrometer composite meter registry + */ + static MMeterRegistry create(MetricsConfig metricsConfig) { + CompositeMeterRegistry delegate = new CompositeMeterRegistry(); + return create(ensurePrometheusRegistryIsPresent(delegate, metricsConfig), + MClock.create(delegate.config().clock()), + metricsConfig); + } + + /** + * Creates a new meter registry which wraps an automatically-created new Micrometer + * {@link io.micrometer.core.instrument.composite.CompositeMeterRegistry} with a Prometheus meter registry + * automatically added, using the specified clock. + * + * @param clock default clock to associate with the new meter registry + * @param metricsConfig metrics config + * @return new wrapper around a new Micrometer composite meter registry + */ + static MMeterRegistry create(Clock clock, + MetricsConfig metricsConfig) { + CompositeMeterRegistry delegate = new CompositeMeterRegistry(ClockWrapper.create(clock)); + // The specified clock is already a Helidon one so pass it directly; no need to wrap it. + return create(ensurePrometheusRegistryIsPresent(delegate, metricsConfig), + clock, + metricsConfig); + } + + private static MMeterRegistry create(MeterRegistry delegate, + Clock neutralClock, + MetricsConfig metricsConfig) { + return new MMeterRegistry(delegate, neutralClock, metricsConfig); + } + + private static MeterRegistry ensurePrometheusRegistryIsPresent(MeterRegistry meterRegistry, + MetricsConfig metricsConfig) { + if (meterRegistry instanceof CompositeMeterRegistry compositeMeterRegistry) { + if (compositeMeterRegistry.getRegistries() + .stream() + .noneMatch(r -> r instanceof PrometheusMeterRegistry)) { + compositeMeterRegistry.add( + new PrometheusMeterRegistry(key -> metricsConfig.lookupConfig(key).orElse(null))); + } + } + return meterRegistry; + } + + private final MeterRegistry delegate; + + /** + * Helidon API clock to be returned by the {@link #clock} method. + */ + private final Clock clock; + + private final ConcurrentHashMap meters = new ConcurrentHashMap<>(); + private final Map scopes = new ConcurrentHashMap<>(); + private final ReentrantLock lock = new ReentrantLock(); + private final String scopeTagName; + + private MMeterRegistry(MeterRegistry delegate, + Clock clock, + MetricsConfig metricsConfig) { + this.delegate = delegate; + this.clock = clock; + delegate.config() + .onMeterAdded(this::recordAdd) + .onMeterRemoved(this::recordRemove); + List globalTags = metricsConfig.globalTags(); + if (!globalTags.isEmpty()) { + delegate.config().meterFilter(MeterFilter.commonTags(Util.tags(globalTags))); + } + + scopeTagName = metricsConfig.scopeTagName(); + MeterFilter scopeTagAdder = new MeterFilter() { + @Override + public Meter.Id map(Meter.Id id) { + return id.getTag(scopeTagName) == null + ? id.withTag(Tag.of(scopeTagName, "application")) + : id; + } + }; + + delegate.config().meterFilter(scopeTagAdder); + } + + @Override + public List meters() { + return meters.values().stream().toList(); + } + + @Override + public Collection meters(Predicate filter) { + return meters.values() + .stream() + .filter(filter) + .toList(); + } + + @Override + public Iterable scopes() { + return scopes.keySet(); + } + + // TODO enhance after adding back the filtering config + @Override + public boolean isMeterEnabled(io.helidon.metrics.api.Meter.Id meterId) { + return true; + } + + @Override + public Clock clock() { + return clock; + } + + @Override + public > HM getOrCreate(HB builder) { + + + // The Micrometer builders do not have a shared inherited declaration of the register method. + // Each type of builder declares its own so we need to decide here which specific one to invoke. + // That's so we can invoke the Micrometer builder's register method, which acts as + // get-or-create. + + // Micrometer's register methods will throw an IllegalArgumentException if the caller specifies a builder that finds + // a previously-registered meter of a different type from that implied by the builder. + + // Also, the register methods actually are get-or-register. + + Meter meter; + // TODO Convert to switch instanceof expressions once checkstyle understand the syntax. + if (builder instanceof MCounter.Builder cBuilder) { + Counter counter = cBuilder.delegate().register(delegate); + meter = counter; + } else if (builder instanceof MDistributionSummary.Builder sBuilder) { + DistributionSummary summary = sBuilder.delegate().register(delegate); + meter = summary; + } else if (builder instanceof MGauge.Builder gBuilder) { + Gauge gauge = gBuilder.delegate().register(delegate); + meter = gauge; + } else if (builder instanceof MTimer.Builder tBuilder) { + Timer timer = tBuilder.delegate().register(delegate); + meter = timer; + } else { + throw new IllegalArgumentException(String.format("Unexpected builder type %s, expected one of %s", + builder.getClass().getName(), + List.of(MCounter.Builder.class.getName(), + MDistributionSummary.Builder.class.getName(), + MGauge.Builder.class.getName(), + MTimer.Builder.class.getName()))); + } + return (HM) meters.get(meter); + } + + @Override + public Optional get(Class mClass, + String name, + Iterable tags) { + + Search search = delegate().find(name) + .tags(Util.tags(tags)); + Meter match = search.meter(); + + if (match == null) { + return Optional.empty(); + } + io.helidon.metrics.api.Meter neutralMeter = meters.get(match); + if (neutralMeter == null) { + LOGGER.log(System.Logger.Level.WARNING, String.format("Found no Helidon counterpart for Micrometer meter %s %s", + name, + Util.list(tags))); + return Optional.empty(); + } + if (mClass.isInstance(neutralMeter)) { + return Optional.of(mClass.cast(neutralMeter)); + } + throw new IllegalArgumentException( + String.format("Matching meter is of type %s but %s was requested", + match.getClass().getName(), + mClass.getName())); + + } + + @Override + public Optional remove(io.helidon.metrics.api.Meter meter) { + return remove(meter.id()); + } + + @Override + public Optional remove(io.helidon.metrics.api.Meter.Id id) { + return internalRemove(id.name(), Util.tags(id.tags())); + } + + @Override + public Optional remove(String name, + Iterable tags) { + return internalRemove(name, Util.tags(tags)); + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } + + MeterRegistry delegate() { + return delegate; + } + + private Optional internalRemove(String name, + Iterable tags) { + Meter nativeMeter = delegate.find(name) + .tags(tags) + .meter(); + if (nativeMeter != null) { + io.helidon.metrics.api.Meter result = meters.get(nativeMeter); + delegate.remove(nativeMeter); + return Optional.of(result); + } + return Optional.empty(); + } + + private void recordAdd(Meter addedMeter) { + lock.lock(); + try { + Meter meter = null; + if (addedMeter instanceof Counter counter) { + meter = addedMeter; + meters.put(addedMeter, MCounter.create(counter)); + } else if (addedMeter instanceof DistributionSummary summary) { + meter = addedMeter; + meters.put(addedMeter, MDistributionSummary.create(summary)); + } else if (addedMeter instanceof Gauge gauge) { + meter = addedMeter; + meters.put(addedMeter, MGauge.create(gauge)); + } else if (addedMeter instanceof Timer timer) { + meter = addedMeter; + meters.put(addedMeter, MTimer.create(timer)); + } else { + LOGGER.log(System.Logger.Level.WARNING, + "Attempt to record addition of unrecognized meter type " + addedMeter.getClass().getName()); + } + if (meter != null) { + String scope = meter.getId().getTag(scopeTagName); + if (scope != null && !scope.isBlank()) { + AtomicInteger metersInScope = scopes.computeIfAbsent(scope, v -> new AtomicInteger()); + metersInScope.incrementAndGet(); + } + } + } finally { + lock.unlock(); + } + } + + private void recordRemove(Meter removedMeter) { + lock.lock(); + try { + io.helidon.metrics.api.Meter removedNeutralMeter = meters.remove(removedMeter); + if (removedNeutralMeter == null) { + LOGGER.log(System.Logger.Level.WARNING, "No matching neutral meter for implementation meter " + removedMeter); + } else { + String scope = removedMeter.getId().getTag(scopeTagName); + if (scope != null && !scope.isBlank()) { + AtomicInteger metersInScope = scopes.get(scope); + if (metersInScope != null) { + metersInScope.decrementAndGet(); + } + } + } + } finally { + lock.unlock(); + } + } + + /** + * Micrometer-friendly wrapper around a Helidon clock. + */ + private static class ClockWrapper implements io.micrometer.core.instrument.Clock { + + static ClockWrapper create(Clock clock) { + return new ClockWrapper(clock); + } + + private final Clock neutralClock; + + private ClockWrapper(Clock neutralClock) { + this.neutralClock = neutralClock; + } + + @Override + public long wallTime() { + return neutralClock.wallTime(); + } + + @Override + public long monotonicTime() { + return neutralClock.monotonicTime(); + } + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MTag.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MTag.java new file mode 100644 index 00000000000..f4492a721f9 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MTag.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Iterator; +import java.util.Objects; +import java.util.StringJoiner; + +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; + +class MTag implements io.helidon.metrics.api.Tag { + + /** + * Adapts an {@link java.lang.Iterable} of Micrometer tag to an iterable of Helidon tag. + * + * @param tags Micrometer tags + * @return Helidon tags + */ + static Iterable neutralTags(Iterable tags) { + return () -> new Iterator<>() { + + private final Iterator iter = tags.iterator(); + + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public io.helidon.metrics.api.Tag next() { + return MTag.create(iter.next()); + } + }; + } + + static Tags mTags(Iterable tags) { + return Tags.of(tags(tags)); + } + + static Iterable tags(Iterable tags) { + return () -> new Iterator<>() { + + private final Iterator iter = tags.iterator(); + + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public Tag next() { + io.helidon.metrics.api.Tag next = iter.next(); + return Tag.of(next.key(), next.value()); + } + }; + } + + /** + * Adapts a Micrometer tag to a Helidon tag. + * + * @param tag Micrometer tag + * @return Helidon tag + */ + static MTag create(Tag tag) { + return new MTag(tag); + } + + static MTag of(String key, String value) { + return MTag.create(Tag.of(key, value)); + } + + private final Tag delegate; + + private MTag(Tag delegate) { + this.delegate = delegate; + } + + @Override + public String key() { + return delegate.getKey(); + } + + @Override + public String value() { + return delegate.getValue(); + } + + @Override + public String toString() { + return new StringJoiner(", ", MTag.class.getSimpleName() + "[", "]") + .add(key() + "=" + value()) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MTag mTag = (MTag) o; + return Objects.equals(delegate, mTag.delegate); + } + + @Override + public int hashCode() { + return Objects.hash(delegate.hashCode()); + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MTimer.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MTimer.java new file mode 100644 index 00000000000..6c6ca8fdf51 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MTimer.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import io.helidon.metrics.api.HistogramSnapshot; + +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.Timer; + +class MTimer extends MMeter implements io.helidon.metrics.api.Timer { + + static MTimer create(Timer timer) { + return new MTimer(timer); + } + + static io.helidon.metrics.api.Timer.Builder builder(String name) { + return new Builder(name); + } + + static Sample start() { + return Sample.create(Timer.start()); + } + + static Sample start(io.helidon.metrics.api.MeterRegistry meterRegistry) { + if (meterRegistry instanceof MMeterRegistry mMeterRegistry) { + return Sample.create(Timer.start(mMeterRegistry.delegate())); + } + throw new IllegalArgumentException("Expected meter registry type " + MMeterRegistry.class.getName() + + " but was " + meterRegistry.getClass().getName()); + } + + static Sample start(io.helidon.metrics.api.Clock clock) { + // This is a relatively infrequently-used method, so it is not overly costly + // to create a new instance of Micrometer's Clock each invocation. + return Sample.create(Timer.start(new Clock() { + @Override + public long wallTime() { + return clock.wallTime(); + } + + @Override + public long monotonicTime() { + return clock.monotonicTime(); + } + })); + } + + static class Sample implements io.helidon.metrics.api.Timer.Sample { + + static Sample create(io.micrometer.core.instrument.Timer.Sample delegate) { + return new Sample(delegate); + } + + private final Timer.Sample delegate; + + private Sample(io.micrometer.core.instrument.Timer.Sample delegate) { + this.delegate = delegate; + } + + @Override + public long stop(io.helidon.metrics.api.Timer timer) { + if (timer instanceof MTimer mTimer) { + return delegate.stop(mTimer.delegate()); + } + throw new IllegalArgumentException("Expected timer type " + MTimer.class.getName() + + " but was " + timer.getClass().getName()); + } + } + + private MTimer(Timer delegate) { + super(delegate); + } + + @Override + public HistogramSnapshot takeSnapshot() { + return MHistogramSnapshot.create(delegate().takeSnapshot()); + } + + @Override + public void record(long amount, TimeUnit unit) { + delegate().record(amount, unit); + } + + @Override + public void record(Duration duration) { + delegate().record(duration); + } + + @Override + public T record(Supplier f) { + return delegate().record(f); + } + + @Override + public T record(Callable f) throws Exception { + return delegate().recordCallable(f); + } + + @Override + public void record(Runnable f) { + delegate().record(f); + } + + @Override + public Runnable wrap(Runnable f) { + return delegate().wrap(f); + } + + @Override + public Callable wrap(Callable f) { + return delegate().wrap(f); + } + + @Override + public Supplier wrap(Supplier f) { + return delegate().wrap(f); + } + + @Override + public long count() { + return delegate().count(); + } + + @Override + public double totalTime(TimeUnit unit) { + return delegate().totalTime(unit); + } + + @Override + public double mean(TimeUnit unit) { + return delegate().mean(unit); + } + + @Override + public double max(TimeUnit unit) { + return delegate().max(unit); + } + + @Override + public String toString() { + TimeUnit baseTimeUnit = delegate().baseTimeUnit(); + return String.format("MTimer[count=%d,totalTime=%s]", delegate().count(), + Duration.of((long) delegate().totalTime(baseTimeUnit), + baseTimeUnit.toChronoUnit())); + } + + static class Builder extends MMeter.Builder + implements io.helidon.metrics.api.Timer.Builder { + + private Builder(String name) { + super(Timer.builder(name)); + prep(delegate()::tags, + delegate()::description, + baseUnit -> null); + } + + @Override + public Builder percentiles(double... percentiles) { + delegate().publishPercentiles(percentiles); + return identity(); + } + + @Override + public Builder buckets(Duration... buckets) { + delegate().serviceLevelObjectives(buckets); + return identity(); + } + + @Override + public Builder minimumExpectedValue(Duration min) { + delegate().minimumExpectedValue(min); + return identity(); + } + + @Override + public Builder maximumExpectedValue(Duration max) { + delegate().maximumExpectedValue(max); + return identity(); + } + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MValueAtPercentile.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MValueAtPercentile.java new file mode 100644 index 00000000000..9259615b6e4 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MValueAtPercentile.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.distribution.ValueAtPercentile; + +class MValueAtPercentile implements io.helidon.metrics.api.ValueAtPercentile { + + static MValueAtPercentile create(ValueAtPercentile delegate) { + return new MValueAtPercentile(delegate); + } + + private final ValueAtPercentile delegate; + + MValueAtPercentile(ValueAtPercentile delegate) { + this.delegate = delegate; + } + + @Override + public double percentile() { + return delegate.percentile(); + } + + @Override + public double value() { + return delegate.value(); + } + + @Override + public double value(TimeUnit unit) { + return delegate.value(unit); + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + // Simplifies the use of test implementations in unit tests if equals does not insist that the other object + // also be a MValueAtPercentile but merely implements ValueAtPercentile. + if (!(o instanceof io.helidon.metrics.api.ValueAtPercentile that)) { + return false; + } + return Objects.equals(delegate.percentile(), that.percentile()) + && Objects.equals(delegate.value(), that.value()); + } + + @Override + public int hashCode() { + return Objects.hash(delegate.percentile(), delegate.value()); + } + + @Override + public String toString() { + return String.format("MValueAtPercentile[percentile=%f,value=%f]", percentile(), value()); + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerMetricsFactory.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerMetricsFactory.java new file mode 100644 index 00000000000..26b859487fb --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerMetricsFactory.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.function.ToDoubleFunction; + +import io.helidon.common.LazyValue; +import io.helidon.metrics.api.Clock; +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.DistributionStatisticsConfig; +import io.helidon.metrics.api.DistributionSummary; +import io.helidon.metrics.api.Gauge; +import io.helidon.metrics.api.HistogramSnapshot; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.MetricsFactory; +import io.helidon.metrics.api.Tag; +import io.helidon.metrics.api.Timer; + +import io.micrometer.core.instrument.Metrics; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.prometheus.PrometheusMeterRegistry; + +/** + * Implementation of the neutral Helidon metrics factory based on Micrometer. + */ +class MicrometerMetricsFactory implements MetricsFactory { + + static MicrometerMetricsFactory create(MetricsConfig metricsConfig) { + return new MicrometerMetricsFactory(metricsConfig); + } + + private final LazyValue globalMeterRegistry; + + private MicrometerMetricsFactory(MetricsConfig metricsConfig) { + globalMeterRegistry = LazyValue.create(() -> { + ensurePrometheusRegistry(Metrics.globalRegistry, metricsConfig); + return MMeterRegistry.create(Metrics.globalRegistry, metricsConfig); + }); + } + + @Override + public MeterRegistry createMeterRegistry(MetricsConfig metricsConfig) { + return MMeterRegistry.create(metricsConfig); + } + + @Override + public MeterRegistry createMeterRegistry(Clock clock, MetricsConfig metricsConfig) { + return MMeterRegistry.create(clock, metricsConfig); + } + + @Override + public MeterRegistry globalRegistry() { + return globalMeterRegistry.get(); + } + + private static void ensurePrometheusRegistry(CompositeMeterRegistry compositeMeterRegistry, + MetricsConfig metricsConfig) { + if (compositeMeterRegistry + .getRegistries() + .stream() + .noneMatch(mr -> mr instanceof PrometheusMeterRegistry)) { + compositeMeterRegistry.add(new PrometheusMeterRegistry(key -> metricsConfig.lookupConfig(key).orElse(null))); + } + } + + @Override + public Clock clockSystem() { + return new Clock() { + + private final io.micrometer.core.instrument.Clock delegate = io.micrometer.core.instrument.Clock.SYSTEM; + + @Override + public long wallTime() { + return delegate.wallTime(); + } + + @Override + public long monotonicTime() { + return delegate.monotonicTime(); + } + + @Override + public R unwrap(Class c) { + return c.cast(delegate); + } + }; + } + + @Override + public Counter.Builder counterBuilder(String name) { + return MCounter.builder(name); + } + + @Override + public DistributionStatisticsConfig.Builder distributionStatisticsConfigBuilder() { + return MDistributionStatisticsConfig.builder(); + } + + @Override + public DistributionSummary.Builder distributionSummaryBuilder(String name, + DistributionStatisticsConfig.Builder configBuilder) { + return MDistributionSummary.builder(name, configBuilder); + } + + @Override + public Gauge.Builder gaugeBuilder(String name, T stateObject, ToDoubleFunction fn) { + return MGauge.builder(name, stateObject, fn); + } + + @Override + public Timer.Builder timerBuilder(String name) { + return MTimer.builder(name); + } + + @Override + public Timer.Sample timerStart() { + return MTimer.start(); + } + + @Override + public Timer.Sample timerStart(MeterRegistry registry) { + return MTimer.start(registry); + } + + @Override + public Timer.Sample timerStart(Clock clock) { + return MTimer.start(clock); + } + + @Override + public Tag tagCreate(String key, String value) { + return MTag.of(key, value); + } + + @Override + public HistogramSnapshot histogramSnapshotEmpty(long count, double total, double max) { + return MHistogramSnapshot.create(io.micrometer.core.instrument.distribution.HistogramSnapshot.empty(count, total, max)); + } +// TODO remove commented code below when it's been transplanted + +// @Override +// public Optional scrape(MeterRegistry meterRegistry, +// MediaType mediaType, +// Iterable scopeSelection, +// Iterable meterNameSelection) { +// if (mediaType.equals(MediaTypes.TEXT_PLAIN) || mediaType.equals(MediaTypes.APPLICATION_OPENMETRICS_TEXT)) { +// var formatter = +// MicrometerPrometheusFormatter +// .builder(meterRegistry) +// .resultMediaType(mediaType) +// .scopeTagName(MetricsProgrammaticSettings.instance().scopeTagName()) +// .scopeSelection(scopeSelection) +// .meterNameSelection(meterNameSelection) +// .build(); +// +// return formatter.filteredOutput(); +// } else if (mediaType.equals(MediaTypes.APPLICATION_JSON)) { +// var formatter = JsonFormatter.builder(meterRegistry) +// .scopeTagName(MetricsProgrammaticSettings.instance().scopeTagName()) +// .scopeSelection(scopeSelection) +// .meterNameSelection(meterNameSelection) +// .build(); +// return formatter.data(true); +// } +// throw new UnsupportedOperationException(); +// } +// +// // TODO return something better +// @Override +// public Optional scrapeMetadata(MeterRegistry meterRegistry, +// MediaType mediaType, +// Iterable scopeSelection, +// Iterable meterNameSelection) { +// return Optional.empty(); +// } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerMetricsFactoryProvider.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerMetricsFactoryProvider.java new file mode 100644 index 00000000000..80ef16bf700 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerMetricsFactoryProvider.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.MetricsFactory; +import io.helidon.metrics.spi.MetricsFactoryProvider; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Metrics; + +/** + * Provides the Micrometer meter registry to use as a delegate for the implementation of the Helidon metrics API. + */ +public class MicrometerMetricsFactoryProvider implements MetricsFactoryProvider { + +// private static final String PROMETHEUS_CONFIG_CLASS_NAME = PrometheusConfig.class.getName(); +// private static final String PROMETHEUS_METER_REGISTRY_CLASS_NAME = PrometheusMeterRegistry.class.getName(); +// private LazyValue meterRegistry = LazyValue.create(this::getRegistry); + +// static { +// try { +// Class prometheusConfigClass = Class.forName(PROMETHEUS_CONFIG_CLASS_NAME); +// Class prometheusMeterRegistryClass = Class.forName(PROMETHEUS_METER_REGISTRY_CLASS_NAME); +// try { +// +// Constructor ctor = prometheusMeterRegistryClass.getConstructor(PrometheusConfig.class); +// meterRegistry = (PrometheusMeterRegistry) ctor.newInstance(); +// Metrics.globalRegistry.add(meterRegistry); +// } catch (NoSuchMethodException e) { +// throw new RuntimeException("Found " + PrometheusMeterRegistry.class.getName() +// + " but unable to locate the expected constructor", e); +// } catch (InvocationTargetException | InstantiationException | IllegalAccessException e) { +// throw new RuntimeException(e); +// } +// } catch (ClassNotFoundException e) { +// meterRegistry = null; +// } +// } + + @Override + public MetricsFactory create(MetricsConfig metricsConfig) { + return MicrometerMetricsFactory.create(metricsConfig); + } + + /** + * Creates a new {@link io.helidon.metrics.api.MetricsFactory} based on Micrometer. + */ + public MicrometerMetricsFactoryProvider() { + } + + private MeterRegistry getRegistry() { + return Metrics.globalRegistry; + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerPrometheusFormatter.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerPrometheusFormatter.java new file mode 100644 index 00000000000..e1c55ef90ad --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerPrometheusFormatter.java @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.MeterRegistryFormatter; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.prometheus.PrometheusMeterRegistry; +import io.prometheus.client.exporter.common.TextFormat; + +/** + * Retrieves and prepares meter output from the specified meter registry according to the formats supported by the Prometheus + * meter registry. + *

+ * Because the Prometheus exposition format is flat, and because some meter types have multiple values, the meter names + * in the output repeat the actual meter name with suffixes to indicate the specific quantities (e.g., + * count, total, max) each reported value conveys. Further, meter names in the output might need the prefix + * "m_" if the actual meter name starts with a digit or underscore and underscores replace special characters. + *

+ */ +class MicrometerPrometheusFormatter implements MeterRegistryFormatter { + /** + * Mapping from supported media types to the corresponding Prometheus registry content types. + */ + public static final Map MEDIA_TYPE_TO_FORMAT = Map.of( + MediaTypes.TEXT_PLAIN, TextFormat.CONTENT_TYPE_004, + MediaTypes.APPLICATION_OPENMETRICS_TEXT, TextFormat.CONTENT_TYPE_OPENMETRICS_100); + + /** + * Returns a new builder for constructing a formatter. + * + * @param meterRegistry the {@link io.helidon.metrics.api.MeterRegistry} from which to build the Prometheus output + * @return new builder + */ + public static Builder builder(MeterRegistry meterRegistry) { + return new Builder(meterRegistry); + } + + private final String scopeTagName; + private final Iterable scopeSelection; + private final Iterable meterNameSelection; + private final MediaType resultMediaType; + private final MeterRegistry meterRegistry; + + private MicrometerPrometheusFormatter(Builder builder) { + scopeTagName = builder.scopeTagName; + scopeSelection = builder.scopeSelection; + meterNameSelection = builder.meterNameSelection; + resultMediaType = builder.resultMediaType; + meterRegistry = Objects.requireNonNullElseGet(builder.meterRegistry, + io.helidon.metrics.api.Metrics::globalRegistry); + } + + /** + * Returns the Prometheus output governed by the previously-specified media type, optionally filtered + * by the previously-specified scope and meter name selections. + * + * @return filtered Prometheus output + */ + @Override + public Optional format() { + + Optional prometheusMeterRegistry = prometheusMeterRegistry(meterRegistry); + if (prometheusMeterRegistry.isPresent()) { + + // Scraping the Prometheus registry lets us limit the output to include only specified names. + Set meterNamesOfInterest = meterNamesOfInterest(prometheusMeterRegistry.get(), + scopeSelection, + meterNameSelection); + if (meterNamesOfInterest.isEmpty()) { + return Optional.empty(); + } + + String prometheusOutput = filter(prometheusMeterRegistry.get() + .scrape(MicrometerPrometheusFormatter.MEDIA_TYPE_TO_FORMAT.get( + resultMediaType), + meterNamesOfInterest)); + + return prometheusOutput.isBlank() ? Optional.empty() : Optional.of(prometheusOutput); + } + return Optional.empty(); + } + + @Override + public Optional formatMetadata() { + return Optional.empty(); + } + + /** + * Prepares a set containing the names of meters from the specified Prometheus meter registry which match + * the specified scope and meter name selections. + *

+ * For meters with multiple values, the Prometheus registry essentially creates and actually displays in its output + * additional or "child" meters. A child meter's name is the parent's name plus a suffix consisting + * of the child meter's units (if any) plus the child name. For example, the timer {@code myDelay} has child meters + * {@code myDelay_seconds_count}, {@code myDelay_seconds_sum}, and {@code myDelay_seconds_max}. (The output contains + * repetitions of the parent meter's name for each quantile, but that does not affect the meter names we need to ask + * the Prometheus meter registry to retrieve for us when we scrape.) + *

+ *

+ * We interpret any name selection passed to this method as specifying a parent name. We can ask the Prometheus meter + * registry to select specific meters by meter name when we scrape, but we need to pass it an expanded name selection that + * includes the relevant child meter names as well as the parent name. One way to choose those is first to collect the + * names from the Prometheus meter registry itself and derive the names to have the meter registry select by from those + * matching meters, their units, etc. + *

+ * + * @param prometheusMeterRegistry Prometheus meter registry to query + * @param scopeSelection scope names to select + * @param meterNameSelection meter names to select + * @return set of matching meter names (with units and suffixes as needed) to match the names as stored in the meter registry + */ + Set meterNamesOfInterest(PrometheusMeterRegistry prometheusMeterRegistry, + Iterable scopeSelection, + Iterable meterNameSelection) { + + Set result = new HashSet<>(); + + var scopes = new HashSet<>(); + scopeSelection.forEach(scopes::add); + + var names = new HashSet<>(); + meterNameSelection.forEach(names::add); + + Predicate scopePredicate = scopes.isEmpty() || scopeTagName == null || scopeTagName.isBlank() + ? m -> true + : m -> scopes.contains(m.getId().getTag(scopeTagName)); + + Predicate namePredicate = names.isEmpty() ? n -> true : names::contains; + + for (Meter meter : prometheusMeterRegistry.getMeters()) { + String meterName = meter.getId().getName(); + if (!namePredicate.test(meterName)) { + continue; + } + Set allUnitsForMeterName = new HashSet<>(); + allUnitsForMeterName.add(""); + Set allSuffixesForMeterName = new HashSet<>(); + allSuffixesForMeterName.add(""); + + prometheusMeterRegistry.find(meterName) + .meters() + .forEach(m -> { + Meter.Id meterId = m.getId(); + if (scopePredicate.test(m)) { + allUnitsForMeterName.add("_" + normalizeUnit(meterId.getBaseUnit())); + allSuffixesForMeterName.addAll(meterNameSuffixes(meterId.getType())); + } + }); + + String normalizedMeterName = normalizeMeterName(meterName); + + allUnitsForMeterName + .forEach(units -> allSuffixesForMeterName + .forEach(suffix -> result.add(normalizedMeterName + units + suffix))); + } + return result; + } + + /** + * Filter the Prometheus-format report. + * + * @param output Prometheus-format report + * @return output filtered + */ + private static String filter(String output) { + return output.replaceFirst("# EOF\r?\n?", ""); + } + + private static Optional prometheusMeterRegistry(MeterRegistry meterRegistry) { + io.micrometer.core.instrument.MeterRegistry mMeterRegistry = + meterRegistry.unwrap(io.micrometer.core.instrument.MeterRegistry.class); + if (mMeterRegistry instanceof CompositeMeterRegistry compositeMeterRegistry) { + return compositeMeterRegistry.getRegistries().stream() + .filter(PrometheusMeterRegistry.class::isInstance) + .findFirst() + .map(PrometheusMeterRegistry.class::cast); + } + return Optional.empty(); + } + + private static String flushForMeterAndClear(StringBuilder helpAndType, StringBuilder metricData) { + StringBuilder result = new StringBuilder(); + if (!metricData.isEmpty()) { + result.append(helpAndType.toString()) + .append(metricData); + } + helpAndType.setLength(0); + metricData.setLength(0); + return result.toString(); + } + + + /** + * Returns the Prometheus-format meter name suffixes for the given meter type. + * + * @param meterType {@link io.micrometer.core.instrument.Meter.Type} of interest + * @return suffixes used in reporting the corresponding meter's value(s) + */ + static Set meterNameSuffixes(Meter.Type meterType) { + return switch (meterType) { + case COUNTER -> Set.of("_total"); + case DISTRIBUTION_SUMMARY, LONG_TASK_TIMER, TIMER -> Set.of("_count", "_sum", "_max"); + case GAUGE, OTHER -> Set.of(); + }; + } + + /** + * Convert the meter name to the format used by the Prometheus simple client. + * + * @param meterName name of the meter + * @return normalized meter name + */ + static String normalizeMeterName(String meterName) { + String result = meterName; + + // Convert special characters to underscores. + result = result.replaceAll("[-+.!?@#$%^&*`'\\s]+", "_"); + + // Prometheus simple client adds the prefix "m_" if a meter name starts with a digit or an underscore. + if (result.matches("^[0-9_]+.*")) { + result = "m_" + result; + } + + // Replace non-identifier characters. + result = result.replaceAll("[^A-Za-z0-9_]", "_"); + + return result; + } + + private static String normalizeUnit(String unit) { + return unit == null ? "" : unit; + } + + /** + * Builder for creating a tailored Prometheus formatter. + */ + public static class Builder implements io.helidon.common.Builder { + + private Iterable meterNameSelection = Set.of(); + private String scopeTagName; + private Iterable scopeSelection = Set.of(); + private MediaType resultMediaType = MediaTypes.TEXT_PLAIN; + private MeterRegistry meterRegistry; + + /** + * Used only internally. + */ + private Builder() { + } + + private Builder(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public MicrometerPrometheusFormatter build() { + return new MicrometerPrometheusFormatter(this); + } + + /** + * Sets the meter name with which to filter the output. + * + * @param meterNameSelection meter name to select + * @return updated builder + */ + public Builder meterNameSelection(Iterable meterNameSelection) { + this.meterNameSelection = meterNameSelection; + return identity(); + } + + /** + * Sets the scope value with which to filter the output. + * + * @param scopeSelection scope to select + * @return updated builder + */ + public Builder scopeSelection(Iterable scopeSelection) { + this.scopeSelection = scopeSelection; + return identity(); + } + + /** + * Sets the scope tag name with which to filter the output. + * + * @param scopeTagName scope tag name + * @return updated builder + */ + public Builder scopeTagName(String scopeTagName) { + this.scopeTagName = scopeTagName; + return identity(); + } + + /** + * Sets the {@link io.helidon.common.media.type.MediaType} which controls the formatting of the resulting output. + * + * @param resultMediaType media type + * @return updated builder + */ + public Builder resultMediaType(MediaType resultMediaType) { + this.resultMediaType = resultMediaType; + return identity(); + } + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerPrometheusFormatterProvider.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerPrometheusFormatterProvider.java new file mode 100644 index 00000000000..b1d7937a6ce --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/MicrometerPrometheusFormatterProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Optional; + +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.MeterRegistryFormatter; +import io.helidon.metrics.spi.MeterRegistryFormatterProvider; + +/** + * Micrometer (and Prometheus, particularly) specific formatter. + */ +public class MicrometerPrometheusFormatterProvider implements MeterRegistryFormatterProvider { + @Override + public Optional formatter(MediaType mediaType, + MeterRegistry meterRegistry, + String scopeTagName, + Iterable scopeSelection, + Iterable nameSelection) { + return matches(mediaType, MediaTypes.TEXT_PLAIN) || matches(mediaType, MediaTypes.APPLICATION_OPENMETRICS_TEXT) + ? Optional.of(MicrometerPrometheusFormatter.builder(meterRegistry) + .scopeTagName(scopeTagName) + .scopeSelection(scopeSelection) + .meterNameSelection(nameSelection) + .build()) + : Optional.empty(); + } + + private static boolean matches(MediaType a, MediaType b) { + return a.type().equals(b.type()) && a.subtype().equals(b.subtype()); + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/Util.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/Util.java new file mode 100644 index 00000000000..b0f7773bbba --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/Util.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +import io.micrometer.core.instrument.Tag; + +class Util { + + private Util() { + } + + static double[] doubleArray(Iterable iter) { + List values = new ArrayList<>(); + iter.forEach(values::add); + double[] result = new double[values.size()]; + for (int i = 0; i < values.size(); i++) { + result[i] = values.get(i); + } + return result; + } + + static double[] doubleArray(Optional> iter) { + return iter.map(Util::doubleArray).orElse(null); + } + + static List list(Iterable iterable) { + List result = new ArrayList<>(); + iterable.forEach(result::add); + return result; + } + + static Iterable iterable(double[] items) { + return items == null + ? null + : () -> new Iterator<>() { + + private int slot; + + @Override + public boolean hasNext() { + return slot < items.length; + } + + @Override + public Double next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return items[slot++]; + } + }; + } + + static Iterable neutralTags(Iterable tags) { + return () -> new Iterator<>() { + + private final Iterator tagsIter = tags.iterator(); + + @Override + public boolean hasNext() { + return tagsIter.hasNext(); + } + + @Override + public io.helidon.metrics.api.Tag next() { + Tag next = tagsIter.next(); + return io.helidon.metrics.api.Tag.create(next.getKey(), next.getValue()); + } + }; + } + + static Iterable tags(Iterable tags) { + return () -> new Iterator<>() { + + private final Iterator tagsIter = tags.iterator(); + + @Override + public boolean hasNext() { + return tagsIter.hasNext(); + } + + @Override + public Tag next() { + io.helidon.metrics.api.Tag next = tagsIter.next(); + return Tag.of(next.key(), next.value()); + } + }; + } +} diff --git a/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/package-info.java b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/package-info.java new file mode 100644 index 00000000000..09353081fc0 --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/io/helidon/metrics/micrometer/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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. + */ + +/** + * Micrometer wrapper for Helidon metrics API. + */ +package io.helidon.metrics.micrometer; diff --git a/metrics/providers/micrometer/src/main/java/module-info.java b/metrics/providers/micrometer/src/main/java/module-info.java new file mode 100644 index 00000000000..2674ff232bf --- /dev/null +++ b/metrics/providers/micrometer/src/main/java/module-info.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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. + */ + +import io.helidon.metrics.micrometer.MicrometerMetricsFactoryProvider; +import io.helidon.metrics.micrometer.MicrometerPrometheusFormatterProvider; + +/** + * Micrometer adapter for Helidon metrics API. + */ +module io.helidon.metrics.micrometer { + + requires io.helidon.metrics.api; + requires micrometer.core; + requires static micrometer.registry.prometheus; + requires io.helidon.common; + requires io.helidon.common.config; + requires io.helidon.common.media.type; + requires simpleclient.common; + + provides io.helidon.metrics.spi.MetricsFactoryProvider with MicrometerMetricsFactoryProvider; + provides io.helidon.metrics.spi.MeterRegistryFormatterProvider with MicrometerPrometheusFormatterProvider; +} \ No newline at end of file diff --git a/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestCounter.java b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestCounter.java new file mode 100644 index 00000000000..0cbf66e7aa8 --- /dev/null +++ b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestCounter.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; + +class TestCounter { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + @Test + void testUnwrap() { + Counter c = meterRegistry.getOrCreate(Counter.builder("c4")); + io.micrometer.core.instrument.Counter mCounter = c.unwrap(io.micrometer.core.instrument.Counter.class); + assertThat("Initial value", c.count(), is(0L)); + mCounter.increment(); + assertThat("Updated value", c.count(), is(1L)); + } +} diff --git a/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestDistributionSummary.java b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestDistributionSummary.java new file mode 100644 index 00000000000..32803f75803 --- /dev/null +++ b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestDistributionSummary.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.List; + +import io.helidon.metrics.api.DistributionSummary; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class TestDistributionSummary { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + @Test + void testUnwrap() { + DistributionSummary summary = meterRegistry.getOrCreate(DistributionSummary.builder("a")); + List.of(1D, 3D, 5D) + .forEach(summary::record); + io.micrometer.core.instrument.DistributionSummary mSummary = + summary.unwrap(io.micrometer.core.instrument.DistributionSummary.class); + + mSummary.record(7D); + + assertThat("Mean", summary.mean(), is(4D)); + assertThat("Min", summary.max(), is(7D)); + assertThat("Count", summary.count(), is(4L)); + assertThat("Total", summary.totalAmount(), is(16D)); + + assertThat("Mean (Micrometer)", mSummary.mean(), is(4D)); + assertThat("Min (Micrometer)", mSummary.max(), is(7D)); + assertThat("Count (Micrometer)", mSummary.count(), is(4L)); + assertThat("Total (Micrometer)", mSummary.totalAmount(), is(16D)); + } +} diff --git a/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestGauge.java b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestGauge.java new file mode 100644 index 00000000000..b8a8059af23 --- /dev/null +++ b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestGauge.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.concurrent.atomic.AtomicLong; + +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.Gauge; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class TestGauge { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + @Test + void testUnwrap() { + long initialValue = 4L; + long incr = 2L; + AtomicLong value = new AtomicLong(initialValue); + Gauge g = meterRegistry.getOrCreate(Gauge.builder("a", + value, + v -> (double)v.get())); + + io.micrometer.core.instrument.Gauge mGauge = g.unwrap(io.micrometer.core.instrument.Gauge.class); + assertThat("Initial value", mGauge.value(), is((double) initialValue)); + value.addAndGet(incr); + assertThat("Updated value", mGauge.value(), is((double) initialValue + incr)); + } +} diff --git a/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestPrometheusFormatting.java b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestPrometheusFormatting.java new file mode 100644 index 00000000000..17a4da30f51 --- /dev/null +++ b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestPrometheusFormatting.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.common.testing.junit5.OptionalMatcher; +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.Tag; +import io.helidon.metrics.api.Timer; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +class TestPrometheusFormatting { + + private static final String SCOPE_TAG_NAME = "this-scope"; + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + MetricsConfig.Builder metricsConfigBuilder = MetricsConfig.builder() + .scopeTagName(SCOPE_TAG_NAME); + + meterRegistry = Metrics.createMeterRegistry(metricsConfigBuilder.build()); + } + + @Test + void testRetrievingAll() { + Counter c = meterRegistry.getOrCreate(Counter.builder("c1") + .tags(Set.of(Tag.create(SCOPE_TAG_NAME, "app")))); + assertThat("Initial counter value", c.count(), Matchers.is(0L)); + c.increment(); + assertThat("After increment", c.count(), Matchers.is(1L)); + + Timer d = meterRegistry.getOrCreate(Timer.builder("t1") + .tags(Set.of(Tag.create(SCOPE_TAG_NAME, "other")))); + d.record(3, TimeUnit.SECONDS); + + Timer e = meterRegistry.getOrCreate(Timer.builder("t1-1") + .tags(Set.of(Tag.create(SCOPE_TAG_NAME, "app")))); + e.record(2, TimeUnit.SECONDS); + + MicrometerPrometheusFormatter formatter = MicrometerPrometheusFormatter.builder(meterRegistry) + .scopeTagName(SCOPE_TAG_NAME) + .build(); + Optional output = formatter.format(); + + assertThat("Formatted output", + output, + OptionalMatcher.optionalValue( + allOf(containsString(scopeExpr("c1_total", + "this_scope", + "app", + "1.0")), + containsString(scopeExpr("t1_seconds_count", + "this_scope", + "other", + "1.0")), + containsString(scopeExpr("t1_seconds_sum", + "this_scope", + "other", + "3.0")), + containsString(scopeExpr("t1_1_seconds_count", + "this_scope", + "app", + "1.0"))))); + } + + @Test + void testRetrievingByName() { + Counter c = meterRegistry.getOrCreate(Counter.builder("c2")); + assertThat("Initial counter value", c.count(), Matchers.is(0L)); + c.increment(); + assertThat("After increment", c.count(), Matchers.is(1L)); + + Timer d = meterRegistry.getOrCreate(Timer.builder("t2")); + d.record(7, TimeUnit.SECONDS); + + MicrometerPrometheusFormatter formatter = MicrometerPrometheusFormatter.builder(meterRegistry) + .scopeTagName(SCOPE_TAG_NAME) + .meterNameSelection(Set.of("c2")) + .build(); + Optional output = formatter.format(); + + assertThat("Formatted output", + output, + OptionalMatcher.optionalValue( + allOf(containsString(scopeExpr("c2_total", + "this_scope", + "application", + "1.0")), + not(containsString(scopeExpr("t2_seconds_count", + "this_scope", + "application", + "1.0"))), + not(containsString(scopeExpr("t2_seconds_sum", + "this_scope", + "applicaiton", "7.0")))))); + + } + + @Test + void testRetrievingByScope() { + + Counter c = meterRegistry.getOrCreate(Counter.builder("c3") + .tags(Set.of(Tag.create(SCOPE_TAG_NAME, "app")))); + assertThat("Initial counter value", c.count(), is(0L)); + c.increment(); + assertThat("After increment", c.count(), is(1L)); + + Timer d = meterRegistry.getOrCreate(Timer.builder("t3") + .tags(Set.of(Tag.create(SCOPE_TAG_NAME, "other-scope")))); + d.record(7, TimeUnit.SECONDS); + + Timer e = meterRegistry.getOrCreate(Timer.builder("t3-1") + .tags(Set.of(Tag.create(SCOPE_TAG_NAME, "app")))); + e.record(2, TimeUnit.SECONDS); + + + MicrometerPrometheusFormatter formatter = MicrometerPrometheusFormatter.builder(meterRegistry) + .scopeTagName(SCOPE_TAG_NAME) + .scopeSelection(Set.of("app")) + .build(); + + Optional output = formatter.format(); + + assertThat("Formatted output", + output, + OptionalMatcher.optionalValue( + allOf(containsString(scopeExpr("c3_total", + "this_scope", + "app", + "1.0")), + not(containsString(scopeExpr("t3_seconds_count", + "this_scope", + "other-scope", + "1.0"))), + not(containsString(scopeExpr("t3_seconds_sum", + "this_scope", + "other-scope", + "3.0"))), + containsString(scopeExpr("t3_1_seconds_count", + "this_scope", + "app", + "1.0"))))); + } + + private static String scopeExpr(String meterName, String key, String value, String suffix) { + return meterName + "{" + key + "=\"" + value + "\",} " + suffix; + } +} diff --git a/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestScopes.java b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestScopes.java new file mode 100644 index 00000000000..1a760bbace5 --- /dev/null +++ b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestScopes.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.Set; + +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.Tag; +import io.helidon.metrics.api.Timer; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + +class TestScopes { + + private static final String SCOPE_TAG_NAME = "my-tag"; + + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.builder() + .scopeTagName(SCOPE_TAG_NAME) + .build()); + } + + @Test + void testScopeManagement() { + Counter c = meterRegistry.getOrCreate(Counter.builder("c1")); + Timer t = meterRegistry.getOrCreate(Timer.builder("t1") + .tags(Set.of(Tag.create("color", "red")))); + io.micrometer.core.instrument.Counter mCounter = c.unwrap(io.micrometer.core.instrument.Counter.class); + assertThat("Initial value", c.count(), is(0L)); + mCounter.increment(); + assertThat("Updated value", c.count(), is(1L)); + + // If scope is not explicitly set, "application" should be automatically added. + assertThat("Scopes in meter registry", meterRegistry.scopes(), allOf(contains("application"), + not(contains("color")))); + } +} diff --git a/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestTimer.java b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestTimer.java new file mode 100644 index 00000000000..3a64438d865 --- /dev/null +++ b/metrics/providers/micrometer/src/test/java/io/helidon/metrics/micrometer/TestTimer.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.micrometer; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import io.helidon.metrics.api.Gauge; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.Timer; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; + +class TestTimer { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + @Test + void testUnwrap() { + long initialValue = 0L; + long incrA = 2L; + long incrB = 7L; + Timer t = meterRegistry.getOrCreate(Timer.builder("a")); + + io.micrometer.core.instrument.Timer mTimer = t.unwrap(io.micrometer.core.instrument.Timer.class); + assertThat("Initial value", mTimer.count(), is(initialValue)); + + t.record(incrA, TimeUnit.MILLISECONDS); + mTimer.record(incrB, TimeUnit.MILLISECONDS); + + assertThat("Neutral count after updates", t.count(), is(2L)); + assertThat("Micrometer count after updates", mTimer.count(), is(2L)); + + double fromMicrometer = mTimer.totalTime(TimeUnit.MILLISECONDS); + assertThat("Updated Micrometer value", + fromMicrometer, + greaterThanOrEqualTo((double) incrA + incrB)); + assertThat("Updated Micrometer value", + t.totalTime(TimeUnit.MILLISECONDS), + is(fromMicrometer)); + } +} diff --git a/metrics/providers/pom.xml b/metrics/providers/pom.xml new file mode 100644 index 00000000000..2905bb3a418 --- /dev/null +++ b/metrics/providers/pom.xml @@ -0,0 +1,62 @@ + + + + + 4.0.0 + + io.helidon.metrics + helidon-metrics-project + 4.0.0-SNAPSHOT + + pom + helidon-metrics-providers-project + Helidon Metrics Providers Project + Providers of the Helidon Metrics API + + + micrometer + + + + + + io.helidon.metrics + helidon-metrics-testing + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + io.helidon.metrics:helidon-metrics-testing + + + + + + + diff --git a/metrics/service-api/src/main/java/module-info.java b/metrics/service-api/src/main/java/module-info.java index 7f89a7a5996..abe1941e6bb 100644 --- a/metrics/service-api/src/main/java/module-info.java +++ b/metrics/service-api/src/main/java/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, 2022 Oracle and/or its affiliates. + * Copyright (c) 2021, 2023 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/metrics/testing/pom.xml b/metrics/testing/pom.xml new file mode 100644 index 00000000000..71e9b21512c --- /dev/null +++ b/metrics/testing/pom.xml @@ -0,0 +1,55 @@ + + + + + 4.0.0 + + + io.helidon.metrics + helidon-metrics-project + 4.0.0-SNAPSHOT + ../pom.xml + + helidon-metrics-testing + Helidon Metrics Common Testing + + + + io.helidon.metrics + helidon-metrics-api + + + io.helidon.config + helidon-config + + + io.helidon.common.testing + helidon-common-testing-junit5 + + + org.junit.jupiter + junit-jupiter-api + + + org.hamcrest + hamcrest-all + + + + diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/SimpleMeterRegistryTests.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/SimpleMeterRegistryTests.java new file mode 100644 index 00000000000..eb6b92077d7 --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/SimpleMeterRegistryTests.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.testing; + + +import java.util.List; + +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.Tag; +import io.helidon.metrics.api.Timer; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SimpleMeterRegistryTests { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + @Test + void testConflictingMetadata() { + meterRegistry.getOrCreate(Counter.builder("b")); + + assertThrows(IllegalArgumentException.class, () -> + meterRegistry.getOrCreate(Timer.builder("b"))); + } + + @Test + void testSameNameNoTags() { + Counter counter1 = meterRegistry.getOrCreate(Counter.builder("a")); + Counter counter2 = meterRegistry.getOrCreate(Counter.builder("a")); + assertThat("Counter with same name, no tags", counter1, is(sameInstance(counter2))); + } + + @Test + void testSameNameSameTwoTags() { + var tags = List.of(Tag.create("foo", "1"), + Tag.create("bar", "1")); + + Counter counter1 = meterRegistry.getOrCreate(Counter.builder("c") + .tags(tags)); + Counter counter2 = meterRegistry.getOrCreate(Counter.builder("c") + .tags(tags)); + assertThat("Counter with same name, same two tags", counter1, is(sameInstance(counter2))); + } +} diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/TestCounter.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestCounter.java new file mode 100644 index 00000000000..15cab606176 --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestCounter.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.testing; + +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; + +class TestCounter { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + @Test + void testIncr() { + Counter c = meterRegistry.getOrCreate(Counter.builder("c1")); + assertThat("Initial counter value", c.count(), is(0L)); + c.increment(); + assertThat("After increment", c.count(), is(1L)); + } + + @Test + void incrWithValue() { + Counter c = meterRegistry.getOrCreate(Counter.builder("c2")); + assertThat("Initial counter value", c.count(), is(0L)); + c.increment(3L); + assertThat("After increment", c.count(), is(3L)); + } + + @Test + void incrBoth() { + long initialValue = 0; + long incr = 2L; + Counter c = meterRegistry.getOrCreate(Counter.builder("c3")); + assertThat("Initial counter value", c.count(), is(initialValue)); + c.increment(incr); + assertThat("After increment", c.count(), is(initialValue + incr)); + + initialValue += incr; + incr = 3L; + + Counter cAgain = meterRegistry.getOrCreate(Counter.builder("c3")); + assertThat("Looked up instance", cAgain, is(sameInstance(c))); + assertThat("Value after one update", cAgain.count(), is(initialValue)); + + cAgain.increment(incr); + assertThat("Value after second update", cAgain.count(), is(initialValue + incr)); + } +} diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/TestDeletions.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestDeletions.java new file mode 100644 index 00000000000..359b231f2d2 --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestDeletions.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.testing; + + +import java.util.List; +import java.util.Optional; + +import io.helidon.common.testing.junit5.OptionalMatcher; +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.Meter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.Tag; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; + + +class TestDeletions { + + private static final String COMMON_COUNTER_NAME = "theCounter"; + + private static MeterRegistry reg; + + @BeforeAll + static void setup() { + reg = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + @BeforeEach + void clear() { + reg.meters().forEach(reg::remove); + } + + @Test + void testDeleteByNameAndTags() { + Counter counter1 = reg.getOrCreate(Counter.builder("a") + .tags(List.of(Tag.create("myTag", "a")))); + Counter counter2 = reg.getOrCreate(Counter.builder("b") + .tags(List.of(Tag.create("myTag", "b")))); + + Optional counter1Again = reg.getCounter("a", List.of(Tag.create("myTag", "a"))); + assertThat("Counter before removal", counter1Again, OptionalMatcher.optionalValue(is(sameInstance(counter1)))); + Optional del1 = reg.remove("a", List.of(Tag.create("myTag", "a"))); + + assertThat("Deleted meter", del1, OptionalMatcher.optionalValue(sameInstance(counter1))); + assertThat("Check for deleted meter", + reg.getCounter("a", + List.of(Tag.create("myTag", "a"))), + OptionalMatcher.optionalEmpty()); + + assertThat("Check for undeleted meter", + reg.getCounter("b", + List.of(Tag.create("myTag", "b"))), + OptionalMatcher.optionalValue(sameInstance(counter2))); + } + @Test + void testDeleteByMeter() { + Counter counter1 = reg.getOrCreate(Counter.builder("a") + .tags(List.of(Tag.create("myTag", "a")))); + Counter counter2 = reg.getOrCreate(Counter.builder("b") + .tags(List.of(Tag.create("myTag", "b")))); + + Optional counter1Again = reg.getCounter("a", List.of(Tag.create("myTag", "a"))); + assertThat("Counter before removal", counter1Again, OptionalMatcher.optionalValue(is(sameInstance(counter1)))); + Optional del1 = reg.remove(counter1); + + assertThat("Deleted meter", del1, OptionalMatcher.optionalValue(sameInstance(counter1))); + assertThat("Check for deleted meter", + reg.getCounter("a", + List.of(Tag.create("myTag", "a"))), + OptionalMatcher.optionalEmpty()); + + assertThat("Check for undeleted meter", + reg.getCounter("b", + List.of(Tag.create("myTag", "b"))), + OptionalMatcher.optionalValue(sameInstance(counter2))); + } + @Test + void testDeleteById() { + Counter counter1 = reg.getOrCreate(Counter.builder("a") + .tags(List.of(Tag.create("myTag", "a")))); + Counter counter2 = reg.getOrCreate(Counter.builder("b") + .tags(List.of(Tag.create("myTag", "b")))); + + Optional counter1Again = reg.getCounter("a", List.of(Tag.create("myTag", "a"))); + assertThat("Counter before removal", counter1Again, OptionalMatcher.optionalValue(is(sameInstance(counter1)))); + Optional del1 = reg.remove(counter1.id()); + + assertThat("Deleted meter", del1, OptionalMatcher.optionalValue(sameInstance(counter1))); + assertThat("Check for deleted meter", + reg.getCounter("a", + List.of(Tag.create("myTag", "a"))), + OptionalMatcher.optionalEmpty()); + + assertThat("Check for undeleted meter", + reg.getCounter("b", + List.of(Tag.create("myTag", "b"))), + OptionalMatcher.optionalValue(sameInstance(counter2))); + } +} diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/TestDistributionSummary.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestDistributionSummary.java new file mode 100644 index 00000000000..4569c8088ee --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestDistributionSummary.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.testing; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import io.helidon.metrics.api.Bucket; +import io.helidon.metrics.api.DistributionStatisticsConfig; +import io.helidon.metrics.api.DistributionSummary; +import io.helidon.metrics.api.HistogramSnapshot; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.ValueAtPercentile; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +class TestDistributionSummary { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + private static DistributionSummary commonPrep(String name, DistributionStatisticsConfig.Builder statsConfigBuilder) { + DistributionSummary summary = meterRegistry.getOrCreate(DistributionSummary.builder(name, statsConfigBuilder)); + List.of(1D, 3D, 5D, 7D) + .forEach(summary::record); + return summary; + } + + + @Test + void testBasicStats() { + DistributionSummary summary = commonPrep("a", + DistributionStatisticsConfig.builder()); + assertThat("Mean", summary.mean(), is(4D)); + assertThat("Min", summary.max(), is(7D)); + assertThat("Count", summary.count(), is(4L)); + assertThat("Total", summary.totalAmount(), is(16D)); + } + + @Test + void testBasicSnapshot() { + DistributionSummary summary = commonPrep("c", + DistributionStatisticsConfig.builder()); + HistogramSnapshot snapshot = summary.snapshot(); + assertThat("Snapshot count", snapshot.count(), is(4L)); + assertThat("Snapshot total", snapshot.total(), is(16D)); + assertThat("Snapshot total as time (microseconds)", snapshot.total(TimeUnit.MICROSECONDS), is(0.016)); + } + + @Test + void testPercentiles() { + DistributionSummary summary = commonPrep("d", + DistributionStatisticsConfig.builder() + .percentiles(0.5, 0.9, 0.99, 0.999)); + HistogramSnapshot snapshot = summary.snapshot(); + + List vaps = Util.list(snapshot.percentileValues()); + + assertThat("Values at percentile", + vaps, + contains( + equalTo(Vap.create(0.50D, 3.0625D)), + equalTo(Vap.create(0.90D, 7.1875D)), + equalTo(Vap.create(0.99D, 7.1875D)), + equalTo(Vap.create(0.999D, 7.1875D)))); + } + + @Test + void testBuckets() { + DistributionSummary summary = commonPrep("e", + DistributionStatisticsConfig.builder() + .buckets(5.0D, 10.0D, 15.0D)); + + HistogramSnapshot snapshot = summary.snapshot(); + + List cabs = Util.list(snapshot.histogramCounts()); + + assertThat("Counts at buckets", + cabs, + contains( + equalTo(Cab.create(5.0D, 3)), + equalTo(Cab.create(10.0D, 4)), + equalTo(Cab.create(15.0D, 4)))); + + } + + private record Vap(double percentile, double value) implements ValueAtPercentile { + + private static ValueAtPercentile create(double percentile, double value) { + return new Vap(percentile, value); + } + + @Override + public double value(TimeUnit unit) { + return unit.convert((long) value, TimeUnit.NANOSECONDS); + } + + @Override + public R unwrap(Class c) { + throw new UnsupportedOperationException(); + } + + @Override + public String toString() { + return String.format("Vap[percentile=%f,value=%f]", percentile, value); + } + } + + private record Cab(double boundary, long count) implements Bucket { + + private static Cab create(double bucket, long count) { + return new Cab(bucket, count); + } + + @Override + public double boundary(TimeUnit unit) { + return unit.convert((long) boundary, TimeUnit.NANOSECONDS); + } + + @Override + public R unwrap(Class c) { + throw new UnsupportedOperationException(); + } + + @Override + public String toString() { + return String.format("Vap[boundary=%f,count=%d]", boundary, count); + } + } +} diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/TestGauge.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestGauge.java new file mode 100644 index 00000000000..a30f2faf6c9 --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestGauge.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.testing; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.helidon.metrics.api.Gauge; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class TestGauge { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + @Test + void testGaugeAroundObject() { + + long initial = 4L; + long incr = 3L; + Custom c = new Custom(initial); + Gauge g = meterRegistry.getOrCreate(Gauge.builder("a", + c, + Custom::value)); + + assertThat("Gauge before update", g.value(), is((double) initial)); + + c.add(3L); + + assertThat("Gauge after update", g.value(), is((double) initial + incr)); + } + + @Test + void testGaugeWithLamdba() { + int initial = 11; + int incr = 4; + AtomicInteger i = new AtomicInteger(initial); + Gauge g = meterRegistry.getOrCreate(Gauge.builder("b", + i, + theInt -> (double) theInt.get())); + assertThat("Gauge before update", i.get(), is(initial)); + + i.getAndAdd(incr); + + assertThat("Gauge after update", g.value(), is((double) initial + incr)); + } + + private static class Custom { + + private long value; + + private Custom(long initialValue) { + value = initialValue; + } + + private void add(long delta) { + value += delta; + } + + private long value() { + return value; + } + } +} diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/TestGlobalTags.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestGlobalTags.java new file mode 100644 index 00000000000..ae08679f273 --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestGlobalTags.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.testing; + +import java.util.List; +import java.util.Map; + +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.Tag; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; + +class TestGlobalTags { + + @Test + void testWithoutConfig() { + List globalTags = List.of(Tag.create("g1", "v1"), + Tag.create("g2", "v2")); + MeterRegistry meterRegistry = Metrics.createMeterRegistry( + MetricsConfig.builder() + .globalTags(globalTags) + .build()); + + Counter counter1 = meterRegistry.getOrCreate(Counter.builder("a") + .tags(List.of(Tag.create("local1", "a")))); + assertThat("New counter's global tags", + counter1.id().tags(), + hasItems(globalTags.toArray(new Tag[0]))); + assertThat("New counter's original tags", + counter1.id().tags(), + hasItem(Tag.create("local1", "a"))); + } + + @Test + void testWithConfig() { + var settings = Map.of("metrics.tags", "g1=v1,g2=v2"); + + Config config = Config.just(ConfigSources.create(settings)); + + MeterRegistry meterRegistry = Metrics.createMeterRegistry( + MetricsConfig.create(config.get("metrics"))); + + Counter counter1 = meterRegistry.getOrCreate(Counter.builder("a") + .tags(List.of(Tag.create("local1", "a")))); + assertThat("New counter's global tags", + counter1.id().tags(), + hasItems(List.of(Tag.create("g1", "v1"), + Tag.create("g2", "v2")).toArray(new Tag[0]))); + assertThat("New counter's explicit tags", + counter1.id().tags(), + hasItem(Tag.create("local1", "a"))); + + } +} diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/TestTimer.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestTimer.java new file mode 100644 index 00000000000..02a293ab98e --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/TestTimer.java @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.testing; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import io.helidon.metrics.api.Clock; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.MetricsFactory; +import io.helidon.metrics.api.Timer; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; + +class TestTimer { + + private static MeterRegistry meterRegistry; + + @BeforeAll + static void prep() { + meterRegistry = Metrics.createMeterRegistry(MetricsConfig.create()); + } + + + @Test + void testSimpleRecord() { + Timer t = meterRegistry.getOrCreate(Timer.builder("a")); + + long initialValue = 0L; + + assertThat("Initial value", + t.count(), + is(0L)); + assertThat("Initial value", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) initialValue)); + + long update = 12L; + t.record(update, TimeUnit.MILLISECONDS); + assertThat("Updated value", + t.count(), + is(1L)); + assertThat("Updated value", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) initialValue + update)); + + initialValue += update; + update = 7L; + t.record(Duration.ofMillis(update)); + assertThat("Second updated value", + t.count(), + is(2L)); + assertThat("Second updated value", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) initialValue + update)); + } + + @Test + void testCallable() throws Exception { + Timer t = meterRegistry.getOrCreate(Timer.builder("b")); + + long initialValue = 0L; + long update = 12L; + + t.record((Callable) () -> { + TimeUnit.MILLISECONDS.sleep(update); + return null; + }); + + assertThat("After update", + t.count(), + is(1L)); + assertThat("After update", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) initialValue + update)); + } + + @Test + void testSupplier() { + Timer t = meterRegistry.getOrCreate(Timer.builder("c")); + long initialValue = 0L; + long update = 8L; + + t.record((Supplier) () -> { + try { + TimeUnit.MILLISECONDS.sleep(update); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return null; + }); + + assertThat("After update", + t.count(), + is(1L)); + assertThat("After update", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) initialValue + update)); + } + + @Test + void testWrapCallable() throws Exception { + Timer t = meterRegistry.getOrCreate(Timer.builder("d")); + long initialValue = 0L; + long update = 18L; + + Callable c = t.wrap((Callable) () -> { + try { + TimeUnit.MILLISECONDS.sleep(update); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return null; + }); + + assertThat("Before running", + t.count(), + is(0L)); + assertThat("Before running", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) initialValue)); + + c.call(); + + assertThat("After running", + t.count(), + is(1L)); + assertThat("After running", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) initialValue + update)); + } + + @Test + void testSample() throws InterruptedException { + Timer t = meterRegistry.getOrCreate(Timer.builder("e")); + long initialValue = 0L; + long update = 18L; + + Timer.Sample sample = Timer.start(); + + long waitTime = 110L; + TimeUnit.MILLISECONDS.sleep(waitTime); + + sample.stop(t); + + assertThat("After sample stop", + t.count(), + is(1L)); + assertThat("After sample stop", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) waitTime)); + + + } + + @Test + void testSampleWithExplicitClock() { + Timer t = meterRegistry.getOrCreate(Timer.builder("f")); + AdjustableClock clock = new AdjustableClock(); + + Timer.Sample sample = Timer.start(clock); + + long waitTime = 55L; + clock.advance(waitTime); + + sample.stop(t); + + assertThat("After sample stop", + t.count(), + is(1L)); + assertThat("After sample stop", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) waitTime)); + } + + @Test + void testSampleWithImplicitClock() { + AdjustableClock clock = new AdjustableClock(); + + MeterRegistry registry = MetricsFactory.getInstance() + .createMeterRegistry(clock, MetricsConfig.builder().build()); + + Timer t = registry.getOrCreate(Timer.builder("g")); + + Timer.Sample sample = Timer.start(registry); + + long waitTime = 35L; + clock.advance(waitTime); + + sample.stop(t); + + assertThat("After sample stop", + t.count(), + is(1L)); + assertThat("After sample stop", + t.totalTime(TimeUnit.MILLISECONDS), + greaterThanOrEqualTo((double) waitTime)); + + } + + private static class AdjustableClock implements Clock { + + private long wallTime; + private long monotonicTime; + + private AdjustableClock() { + this.wallTime = System.currentTimeMillis(); + this.monotonicTime = System.nanoTime(); + } + + @Override + public long wallTime() { + return wallTime; + } + + @Override + public long monotonicTime() { + return monotonicTime; + } + + private void advance(long ms) { + wallTime += ms; + monotonicTime += ms * 1000 * 1000; + } + } +} diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/Util.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/Util.java new file mode 100644 index 00000000000..3ed1f148ffe --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/Util.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.metrics.testing; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +/** + * Test utility code. + */ +public class Util { + + private Util() { + } + + /** + * Converts an iterable of double to a double array. + * + * @param iter iterable + * @return double array + */ + static double[] doubleArray(Iterable iter) { + List values = new ArrayList<>(); + iter.forEach(values::add); + double[] result = new double[values.size()]; + for (int i = 0; i < values.size(); i++) { + result[i] = values.get(i); + } + return result; + } + + /** + * Converts an interable of Double to a double array. + * + * @param iter iterable of Double + * @return double array + */ + static double[] doubleArray(Optional> iter) { + return iter.map(Util::doubleArray).orElse(null); + } + + /** + * Creates a new {@link java.util.List} from an {@link java.lang.Iterable}. + * + * @param iterable iterable to convert + * @return new list containing the elements reported by the iterable + * @param type of the items + */ + static List list(Iterable iterable) { + List result = new ArrayList<>(); + iterable.forEach(result::add); + return result; + } + + static Iterable iterable(double[] items) { + return items == null + ? null + : () -> new Iterator<>() { + + private int slot; + + @Override + public boolean hasNext() { + return slot < items.length; + } + + @Override + public Double next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return items[slot++]; + } + }; + } + +// static Iterable neutralTags(Iterable tags) { +// return () -> new Iterator<>() { +// +// private final Iterator tagsIter = tags.iterator(); +// +// @Override +// public boolean hasNext() { +// return tagsIter.hasNext(); +// } +// +// @Override +// public io.helidon.metrics.api.Tag next() { +// Tag next = tagsIter.next(); +// return io.helidon.metrics.api.Tag.create(next.getKey(), next.getValue()); +// } +// }; +// } + +// static Iterable tags(Iterable tags) { +// return () -> new Iterator<>() { +// +// private final Iterator tagsIter = tags.iterator(); +// +// @Override +// public boolean hasNext() { +// return tagsIter.hasNext(); +// } +// +// @Override +// public Tag next() { +// io.helidon.metrics.api.Tag next = tagsIter.next(); +// return Tag.of(next.key(), next.value()); +// } +// }; +// } +} diff --git a/metrics/testing/src/main/java/io/helidon/metrics/testing/package-info.java b/metrics/testing/src/main/java/io/helidon/metrics/testing/package-info.java new file mode 100644 index 00000000000..9dcef41e61e --- /dev/null +++ b/metrics/testing/src/main/java/io/helidon/metrics/testing/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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. + */ + +/** + * Shared unit tests and supporting code for unit tests of implementations of the metrics API. + */ +package io.helidon.metrics.testing; diff --git a/metrics/testing/src/main/java/module-info.java b/metrics/testing/src/main/java/module-info.java new file mode 100644 index 00000000000..3e541c846eb --- /dev/null +++ b/metrics/testing/src/main/java/module-info.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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. + */ + +/** + * Shared unit tests and support for metrics implementations. + */ +module helidon.metrics.testing { + + requires io.helidon.common.config; + requires io.helidon.metrics.api; + requires io.helidon.common.testing.junit5; + requires org.junit.jupiter.api; + requires hamcrest.all; + requires io.helidon.config; +} \ No newline at end of file diff --git a/webserver/observe/metrics/pom.xml b/webserver/observe/metrics/pom.xml index 28dfe9e92a8..89263aa604c 100644 --- a/webserver/observe/metrics/pom.xml +++ b/webserver/observe/metrics/pom.xml @@ -53,6 +53,24 @@ io.helidon.common helidon-common-context + + io.micrometer + micrometer-core + provided + true + + + io.micrometer + micrometer-registry-prometheus + provided + true + + + io.prometheus + simpleclient + provided + true + @@ -79,6 +97,16 @@ io.helidon.config helidon-config-metadata + + io.helidon.metrics + helidon-metrics-micrometer + test + + + io.helidon.common.testing + helidon-common-testing-junit5 + test + org.junit.jupiter junit-jupiter-api diff --git a/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/JsonFormatter.java b/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/JsonFormatter.java new file mode 100644 index 00000000000..bf51b790d50 --- /dev/null +++ b/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/JsonFormatter.java @@ -0,0 +1,502 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.webserver.observe.metrics; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.StringJoiner; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.DistributionSummary; +import io.helidon.metrics.api.Meter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.MeterRegistryFormatter; +import io.helidon.metrics.api.Registry; +import io.helidon.metrics.api.RegistryFactory; +import io.helidon.metrics.api.Timer; + +import jakarta.json.Json; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonBuilderFactory; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; + +/** + * JSON formatter for a meter registry (independent of the underlying registry implementation). + */ +class JsonFormatter implements MeterRegistryFormatter { + + /** + * Returns a new builder for a formatter. + * + * @return new builder + */ + static JsonFormatter.Builder builder(MeterRegistry meterRegistry) { + return new Builder(meterRegistry); + } + + private static final JsonBuilderFactory JSON = Json.createBuilderFactory(Map.of()); + + private static final Map JSON_ESCAPED_CHARS_MAP = initEscapedCharsMap(); + + private static final Pattern JSON_ESCAPED_CHARS_REGEX = Pattern + .compile(JSON_ESCAPED_CHARS_MAP + .keySet() + .stream() + .map(Pattern::quote) + .collect(Collectors.joining("", "[", "]"))); + + + private final Iterable meterNameSelection; + private final Iterable scopeSelection; + private final String scopeTagName; + private final MeterRegistry meterRegistry; + + private JsonFormatter(Builder builder) { + meterNameSelection = builder.meterNameSelection; + scopeSelection = builder.scopeSelection; + scopeTagName = builder.scopeTagName; + meterRegistry = builder.meterRegistry; + } + + /** + * Returns a JSON object conveying all the meter identification and data (but no metadata), organized by scope. + * + * @return meter data + */ + @Override + public Optional format() { + + boolean organizeByScope = shouldOrganizeByScope(); + + Map> meterOutputBuildersByScope = organizeByScope ? new HashMap<>() : null; + Map meterOutputBuildersIgnoringScope = organizeByScope ? null : new HashMap<>(); + + /* + * If we organize by multiple scopes, then meterOutputBuildersByScope will have one top-level entry per scope we find, + * keyed by the scope name. We will gather the output for the metrics in each scope under a JSON node for that scope. + * + * If the scope selection accepts only one scope, or if we are NOT organizing by scopes, then we don't use that map and + * instead use meterOutputBuildersIgnoringScope to gather all JSON for the meters under the same parent. + * + * The JSON output format has one "flat" entry for each single-valued meter--counter or gauge--with the JSON + * key set to the name and tags from the meter ID and the JSON value reporting the single value. + * + * In contrast, the JSON output has a "structured" entry for each multi-valued meter--distribution summary or timer. + * The JSON node key is the name only--no tags--from the meter ID, and the corresponding JSON structure has a child + * for each distinct value-name plus tags group. + * + * Here is an example: + * + { + "carsCounter;car=suv;colour=red": 0, + "carsCounter;car=sedan;colour=blue": 0, + "carsTimer": { + "count;colour=red": 0, + "sum;colour=red": 0.0, + "max;colour=red": 0.0, + "count;colour=blue": 0, + "sum;colour=blue": 0.0, + "max;colour=blue": 0.0 + } + } + */ + + + var scopes = new HashSet<>(); + scopeSelection.forEach(scopes::add); + + var names = new HashSet<>(); + meterNameSelection.forEach(names::add); + + Predicate namePredicate = names.isEmpty() ? n -> true : names::contains; + + AtomicBoolean isAnyOutput = new AtomicBoolean(false); + + meterRegistry.scopes().forEach(scope -> { + String matchingScope = matchingScope(scope); + if (matchingScope != null) { + meterRegistry.meters().forEach(meter -> { + if (meterRegistry.isMeterEnabled(meter.id()) && namePredicate.test(meter.id().name())) { + if (matchesName(meter.id().name())) { + + Map meterOutputBuildersWithinParent = + organizeByScope ? meterOutputBuildersByScope + .computeIfAbsent(matchingScope, + ms -> new HashMap<>()) + : meterOutputBuildersIgnoringScope; + + // Find the output builder for the key relevant to this meter and then add this meter's contribution + // to the output. + MetricOutputBuilder metricOutputBuilder = meterOutputBuildersWithinParent + .computeIfAbsent(metricOutputKey(meter), + k -> MetricOutputBuilder.create(meter)); + metricOutputBuilder.add(meter); + isAnyOutput.set(true); + + } + } + }); + } + }); + + JsonObjectBuilder top = JSON.createObjectBuilder(); + if (organizeByScope) { + meterOutputBuildersByScope.forEach((scope, outputBuilders) -> { + JsonObjectBuilder scopeBuilder = JSON.createObjectBuilder(); + outputBuilders.forEach((key, outputBuilder) -> outputBuilder.apply(scopeBuilder)); + top.add(scope, scopeBuilder); + }); + } else { + meterOutputBuildersIgnoringScope.forEach((key, outputBuilder) -> outputBuilder.apply(top)); + } + + return isAnyOutput.get() ? Optional.of(top.build()) : Optional.empty(); + } + + @Override + public Optional formatMetadata() { + + boolean organizeByScope = shouldOrganizeByScope(); + + Map> metadataOutputBuildersByScope = organizeByScope ? new HashMap<>() : null; + Map metadataOutputBuildersIgnoringScope = organizeByScope ? null : new HashMap<>(); + + AtomicBoolean isAnyOutput = new AtomicBoolean(false); + RegistryFactory registryFactory = RegistryFactory.getInstance(); + registryFactory.scopes().forEach(scope -> { + String matchingScope = matchingScope(scope); + if (matchingScope != null) { + Registry registry = registryFactory.getRegistry(scope); + registry.getMetadata().forEach((name, metadata) -> { + if (matchesName(name)) { + + Map metadataOutputBuilderWithinParent = + organizeByScope ? metadataOutputBuildersByScope + .computeIfAbsent(matchingScope, ms -> new HashMap<>()) + : metadataOutputBuildersIgnoringScope; + + JsonObjectBuilder builderForThisName = metadataOutputBuilderWithinParent + .computeIfAbsent(name, k -> JSON.createObjectBuilder()); + addNonEmpty(builderForThisName, "unit", metadata.getUnit()); + addNonEmpty(builderForThisName, "description", metadata.getDescription()); + isAnyOutput.set(true); + + List> tagGroups = new ArrayList<>(); + + registry.metricIdsByName(name).forEach(metricId -> { + if (registry.enabled(name)) { + List tags = metricId.getTags().entrySet().stream() + .map(entry -> jsonEscape(entry.getKey()) + "=" + jsonEscape(entry.getValue())) + .toList(); + if (!tags.isEmpty()) { + tagGroups.add(tags); + } + } + }); + if (!tagGroups.isEmpty()) { + JsonArrayBuilder tagsOverAllMetricsWithSameName = JSON.createArrayBuilder(); + for (List tagGroup : tagGroups) { + JsonArrayBuilder tagsForMetricBuilder = JSON.createArrayBuilder(); + tagGroup.forEach(tagsForMetricBuilder::add); + tagsOverAllMetricsWithSameName.add(tagsForMetricBuilder); + } + builderForThisName.add("tags", tagsOverAllMetricsWithSameName); + isAnyOutput.set(true); + } + } + }); + } + }); + JsonObjectBuilder top = JSON.createObjectBuilder(); + if (organizeByScope) { + metadataOutputBuildersByScope.forEach((scope, builders) -> { + JsonObjectBuilder scopeBuilder = JSON.createObjectBuilder(); + builders.forEach(scopeBuilder::add); + top.add(scope, scopeBuilder); + }); + } else { + metadataOutputBuildersIgnoringScope.forEach(top::add); + } + return isAnyOutput.get() ? Optional.of(top.build()) : Optional.empty(); + } + + static String jsonEscape(String s) { + final Matcher m = JSON_ESCAPED_CHARS_REGEX.matcher(s); + final StringBuilder sb = new StringBuilder(); + while (m.find()) { + m.appendReplacement(sb, JSON_ESCAPED_CHARS_MAP.get(m.group())); + } + m.appendTail(sb); + return sb.toString(); + } + + + private static Map initEscapedCharsMap() { + final Map result = new HashMap<>(); + result.put("\b", bsls("b")); + result.put("\f", bsls("f")); + result.put("\n", bsls("n")); + result.put("\r", bsls("r")); + result.put("\t", bsls("t")); + result.put("\"", bsls("\"")); + result.put("\\", bsls("\\\\")); + result.put(";", "_"); + return result; + } + + private static String bsls(String s) { + return "\\\\" + s; + } + + private static void addNonEmpty(JsonObjectBuilder builder, String name, String value) { + if ((null != value) && !value.isEmpty()) { + builder.add(name, value); + } + } + + private boolean shouldOrganizeByScope() { + var it = scopeSelection.iterator(); + if (it.hasNext()) { + it.next(); + // return false if exactly one selection; true if at least two. + return it.hasNext(); + } + return true; + } + + private static String metricOutputKey(Meter meter) { + return meter instanceof Counter || meter instanceof io.helidon.metrics.api.Gauge + ? flatNameAndTags(meter.id()) + : structureName(meter.id()); + } + + private static String flatNameAndTags(Meter.Id meterId) { + StringJoiner sj = new StringJoiner(";"); + sj.add(meterId.name()); + meterId.tags().forEach(tag -> sj.add(tag.key() + "=" + tag.value())); + return sj.toString(); + } + + private static String structureName(Meter.Id meterId) { + return meterId.name(); + } + + private String matchingScope(String scope) { + Iterator scopeIterator = scopeSelection.iterator(); + if (!scopeIterator.hasNext()) { + return scope; + } + if (scope == null) { + return null; + } + + while (scopeIterator.hasNext()) { + if (scopeIterator.next().equals(scope)) { + return scope; + } + } + return null; + } + + private boolean matchesName(String metricName) { + Iterator nameIterator = meterNameSelection.iterator(); + if (!nameIterator.hasNext()) { + return true; + } + while (nameIterator.hasNext()) { + if (nameIterator.next().equals(metricName)) { + return true; + } + } + return false; + } + + private abstract static class MetricOutputBuilder { + + private static MetricOutputBuilder create(Meter meter) { + return meter instanceof Counter + || meter instanceof io.helidon.metrics.api.Gauge + ? new Flat(meter) + : new Structured(meter); + } + + + + private final Meter meter; + + protected MetricOutputBuilder(Meter meter) { + this.meter = meter; + } + + protected Meter meter() { + return meter; + } + + protected abstract void add(Meter meter); + protected abstract void apply(JsonObjectBuilder builder); + + private static class Flat extends MetricOutputBuilder { + + private Flat(Meter meter) { + super(meter); + } + + @Override + protected void apply(JsonObjectBuilder builder) { + if (meter() instanceof Counter counter) { + builder.add(flatNameAndTags(meter().id()), counter.count()); + return; + } + if (meter() instanceof io.helidon.metrics.api.Gauge gauge) { + + String nameWithTags = flatNameAndTags(meter().id()); + builder.add(nameWithTags, gauge.value()); + return; + } + throw new IllegalArgumentException("Attempt to format meter with structured data as flat JSON " + + meter().getClass().getName()); + } + + @Override + protected void add(Meter meter) { + } + } + + private static class Structured extends MetricOutputBuilder { + + private final List children = new ArrayList<>(); + private final JsonObjectBuilder sameNameBuilder = JSON.createObjectBuilder(); + + Structured(Meter meter) { + super(meter); + } + + @Override + protected void add(Meter meter) { + if (!meter().getClass().isInstance(meter)) { + throw new IllegalArgumentException( + String.format("Attempt to add meter of type %s to existing output for a meter of type %s", + meter.getClass().getName(), + meter().getClass().getName())); + } + children.add(meter); + } + + @Override + protected void apply(JsonObjectBuilder builder) { + Meter.Id meterId = meter().id(); + children.forEach(child -> { + Meter.Id childID = child.id(); + + if (meter() instanceof DistributionSummary summary) { + DistributionSummary typedChild = (DistributionSummary) child; + sameNameBuilder.add(valueId("count", childID), typedChild.count()); + sameNameBuilder.add(valueId("max", childID), typedChild.snapshot().max()); + sameNameBuilder.add(valueId("mean", childID), typedChild.snapshot().mean()); + sameNameBuilder.add(valueId("total", childID), typedChild.totalAmount()); + } else if (meter() instanceof Timer timer) { + Timer typedChild = (Timer) child; + sameNameBuilder.add(valueId("count", childID), typedChild.count()); + sameNameBuilder.add(valueId("elapsedTime", childID), typedChild.totalTime(TimeUnit.SECONDS)); + sameNameBuilder.add(valueId("max", childID), typedChild.max(TimeUnit.SECONDS)); + sameNameBuilder.add(valueId("mean", childID), typedChild.mean(TimeUnit.SECONDS)); + } else { + throw new IllegalArgumentException("Unrecognized meter type " + + meter().getClass().getName()); + } + }); + builder.add(meterId.name(), sameNameBuilder); + } + + + private static String valueId(String valueName, Meter.Id meterId) { + return valueName + tagsPortion(meterId); + } + + private static String tagsPortion(Meter.Id metricID) { + StringJoiner sj = new StringJoiner(";", ";", ""); + sj.setEmptyValue(""); + metricID.tags().forEach(tag -> sj.add(tag.key() + "=" + tag.value())); + return sj.toString(); + } + } + } + + static class Builder implements io.helidon.common.Builder { + + private final MeterRegistry meterRegistry; + private Iterable meterNameSelection = Set.of(); + private String scopeTagName; + private Iterable scopeSelection = Set.of(); + + /** + * Used only internally. + */ + private Builder(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public JsonFormatter build() { + return new JsonFormatter(this); + } + + /** + * Sets the meter name with which to filter the output. + * + * @param meterNameSelection meter name to select + * @return updated builder + */ + public Builder meterNameSelection(Iterable meterNameSelection) { + this.meterNameSelection = meterNameSelection; + return identity(); + } + + /** + * Sets the scope value with which to filter the output. + * + * @param scopeSelection scope to select + * @return updated builder + */ + public Builder scopeSelection(Iterable scopeSelection) { + this.scopeSelection = scopeSelection; + return identity(); + } + + /** + * Sets the scope tag name with which to filter the output. + * + * @param scopeTagName scope tag name + * @return updated builder + */ + public Builder scopeTagName(String scopeTagName) { + this.scopeTagName = scopeTagName; + return identity(); + } + } +} diff --git a/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/JsonMeterRegistryFormatterProvider.java b/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/JsonMeterRegistryFormatterProvider.java new file mode 100644 index 00000000000..93cfae95e0a --- /dev/null +++ b/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/JsonMeterRegistryFormatterProvider.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.webserver.observe.metrics; + +import java.util.Optional; + +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.MeterRegistryFormatter; +import io.helidon.metrics.spi.MeterRegistryFormatterProvider; + +/** + * JSON formatter provider. + */ +public class JsonMeterRegistryFormatterProvider implements MeterRegistryFormatterProvider { + + @Override + public Optional formatter(MediaType mediaType, + MeterRegistry meterRegistry, + String scopeTagName, + Iterable scopeSelection, + Iterable nameSelection) { + return mediaType.type().equals(MediaTypes.APPLICATION_JSON.type()) + && mediaType.subtype().equals(MediaTypes.APPLICATION_JSON.subtype()) + ? Optional.of(JsonFormatter.builder(meterRegistry) + .scopeTagName(scopeTagName) + .scopeSelection(scopeSelection) + .meterNameSelection(nameSelection) + .build()) + : Optional.empty(); + } +} diff --git a/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/KeyPerformanceIndicatorMetricsImpls.java b/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/KeyPerformanceIndicatorMetricsImpls.java index e32cbfa60ae..9c0a4c633ee 100644 --- a/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/KeyPerformanceIndicatorMetricsImpls.java +++ b/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/KeyPerformanceIndicatorMetricsImpls.java @@ -18,6 +18,7 @@ import java.util.HashMap; import java.util.Map; +import io.helidon.metrics.api.KeyPerformanceIndicatorMetricsConfig; import io.helidon.metrics.api.KeyPerformanceIndicatorMetricsSettings; import io.helidon.metrics.api.Registry; import io.helidon.metrics.api.RegistryFactory; @@ -68,6 +69,7 @@ class KeyPerformanceIndicatorMetricsImpls { private KeyPerformanceIndicatorMetricsImpls() { } + // TODO remove /** * Provides a KPI metrics instance. * @@ -83,6 +85,21 @@ static KeyPerformanceIndicatorSupport.Metrics get(String metricsNamePrefix, : new Basic(metricsNamePrefix)); } + /** + * Provides a KPI metrics instance. + * + * @param metricsNamePrefix prefix to use for the created metrics + * @param kpiConfig KPI metrics config which may influence the construction of the metrics + * @return properly prepared new KPI metrics instance + */ + static KeyPerformanceIndicatorSupport.Metrics get(String metricsNamePrefix, + KeyPerformanceIndicatorMetricsConfig kpiConfig) { + return KPI_METRICS.computeIfAbsent(metricsNamePrefix, prefix -> + kpiConfig.isExtended() + ? new Extended(metricsNamePrefix, kpiConfig) + : new Basic(metricsNamePrefix)); + } + /** * Basic KPI metrics. */ @@ -134,9 +151,18 @@ private static class Extended extends Basic { protected static final String LOAD_DESCRIPTION = "Measures the total number of in-flight requests and rates at which they occur"; + // TODO remove protected Extended(String metricsNamePrefix, KeyPerformanceIndicatorMetricsSettings kpiConfig) { + this(metricsNamePrefix, kpiConfig.longRunningRequestThresholdMs()); + } + + protected Extended(String metricsNamePrefix, KeyPerformanceIndicatorMetricsConfig kpiConfig) { + this(metricsNamePrefix, kpiConfig.longRunningRequestThresholdMs()); + } + + private Extended(String metricsNamePrefix, long longRunningRequestThresholdMs) { super(metricsNamePrefix); - longRunningRequestThresdholdMs = kpiConfig.longRunningRequestThresholdMs(); + this.longRunningRequestThresdholdMs = longRunningRequestThresholdMs; inflightRequests = kpiMetricRegistry().gauge(Metadata.builder() .withName(metricsNamePrefix + INFLIGHT_REQUESTS_NAME) diff --git a/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/MetricsFeature.java b/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/MetricsFeature.java index a77de34328b..f897be0c078 100644 --- a/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/MetricsFeature.java +++ b/webserver/observe/metrics/src/main/java/io/helidon/webserver/observe/metrics/MetricsFeature.java @@ -15,22 +15,31 @@ */ package io.helidon.webserver.observe.metrics; +import java.lang.System.Logger.Level; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.ServiceLoader; import java.util.Set; +import java.util.function.Supplier; import java.util.stream.Stream; +import io.helidon.common.HelidonServiceLoader; import io.helidon.common.LazyValue; import io.helidon.common.media.type.MediaType; import io.helidon.common.media.type.MediaTypes; import io.helidon.config.Config; import io.helidon.config.metadata.ConfiguredOption; import io.helidon.http.Http; -import io.helidon.metrics.api.MetricsSettings; +import io.helidon.metrics.api.KeyPerformanceIndicatorMetricsConfig; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.MeterRegistryFormatter; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.MetricsFactory; import io.helidon.metrics.api.Registry; import io.helidon.metrics.api.RegistryFactory; -import io.helidon.metrics.api.SystemTagsManager; +import io.helidon.metrics.spi.MeterRegistryFormatterProvider; import io.helidon.webserver.KeyPerformanceIndicatorSupport; import io.helidon.webserver.http.Handler; import io.helidon.webserver.http.HttpRouting; @@ -40,12 +49,17 @@ import io.helidon.webserver.http.ServerResponse; import io.helidon.webserver.servicecommon.HelidonFeatureSupport; +import static io.helidon.http.Http.HeaderNames.ALLOW; +import static io.helidon.http.Http.Status.METHOD_NOT_ALLOWED_405; +import static io.helidon.http.Http.Status.NOT_ACCEPTABLE_406; +import static io.helidon.http.Http.Status.NOT_FOUND_404; +import static io.helidon.http.Http.Status.OK_200; + /** * Support for metrics for Helidon WebServer. * *

- * By defaults creates the /metrics endpoint with three sub-paths: application, - * vendor and base. + * By default creates the /metrics endpoint. *

* To register with web server: *

{@code
@@ -72,19 +86,20 @@
  */
 public class MetricsFeature extends HelidonFeatureSupport {
     private static final System.Logger LOGGER = System.getLogger(MetricsFeature.class.getName());
-    private static final Handler DISABLED_ENDPOINT_HANDLER = (req, res) -> res.status(Http.Status.NOT_FOUND_404)
+    private static final Handler DISABLED_ENDPOINT_HANDLER = (req, res) -> res.status(NOT_FOUND_404)
             .send("Metrics are disabled");
 
     private static final Iterable EMPTY_ITERABLE = Collections::emptyIterator;
-    private final MetricsSettings metricsSettings;
-    private final RegistryFactory registryFactory;
+    private final MetricsConfig metricsConfig;
+    private final MetricsFactory metricsFactory;
+    private final MeterRegistry meterRegistry;
 
     private MetricsFeature(Builder builder) {
         super(LOGGER, builder, "Metrics");
 
-        this.registryFactory = builder.registryFactory();
-        this.metricsSettings = builder.metricsSettings();
-        SystemTagsManager.create(metricsSettings);
+        this.metricsConfig = builder.metricsConfigBuilder.build();
+        this.metricsFactory = builder.metricsFactory.get();
+        meterRegistry = Objects.requireNonNullElseGet(builder.meterRegistry, metricsFactory::globalRegistry);
     }
 
     /**
@@ -124,7 +139,7 @@ public static Builder builder() {
     public Optional service() {
         // main service is responsible for exposing metrics endpoints over HTTP
         return Optional.of(rules -> {
-            if (registryFactory.enabled()) {
+            if (metricsConfig.enabled()) {
                 setUpEndpoints(rules);
             } else {
                 setUpDisabledEndpoints(rules);
@@ -142,8 +157,9 @@ public void configureVendorMetrics(HttpRouting.Builder rules) {
 
         KeyPerformanceIndicatorSupport.Metrics kpiMetrics =
                 KeyPerformanceIndicatorMetricsImpls.get(metricPrefix,
-                                                        metricsSettings
-                                                                .keyPerformanceIndicatorSettings());
+                                                        metricsConfig
+                                                                .keyPerformanceIndicatorMetricsConfig()
+                                                                .orElseGet(KeyPerformanceIndicatorMetricsConfig::create));
 
         rules.addFilter((chain, req, res) -> {
             KeyPerformanceIndicatorSupport.Context kpiContext = kpiContext(req);
@@ -160,20 +176,6 @@ public void configureVendorMetrics(HttpRouting.Builder rules) {
         });
     }
 
-    @Override
-    public void beforeStart() {
-        if (registryFactory.enabled()) {
-            registryFactory.start();
-        }
-    }
-
-    @Override
-    public void afterStop() {
-        if (registryFactory.enabled()) {
-            registryFactory.stop();
-        }
-    }
-
     @Override
     protected void context(String context) {
         super.context(context);
@@ -185,6 +187,42 @@ protected void postSetup(HttpRouting.Builder defaultRouting, HttpRouting.Builder
         RegistryFactory.getInstance().getRegistry(Registry.BASE_SCOPE); // to trigger lazy creation if it's not already done.
     }
 
+    Optional output(MediaType mediaType,
+                       Iterable scopeSelection,
+                       Iterable nameSelection) {
+        MeterRegistryFormatter formatter = chooseFormatter(meterRegistry,
+                                                           mediaType,
+                                                           metricsConfig.scopeTagName(),
+                                                           scopeSelection,
+                                                           nameSelection);
+
+        return formatter.format();
+    }
+
+    private MeterRegistryFormatter chooseFormatter(MeterRegistry meterRegistry,
+                                                   MediaType mediaType,
+                                                   String scopeTagName,
+                                                   Iterable scopeSelection,
+                                                   Iterable nameSelection) {
+        Optional formatter = HelidonServiceLoader.builder(
+                        ServiceLoader.load(MeterRegistryFormatterProvider.class))
+                .build()
+                .stream()
+                .map(provider -> provider.formatter(mediaType,
+                                                    meterRegistry,
+                                                    scopeTagName,
+                                                    scopeSelection,
+                                                    nameSelection))
+                .filter(Optional::isPresent)
+                .map(Optional::get)
+                .findFirst();
+
+        if (formatter.isPresent()) {
+            return formatter.get();
+        }
+        throw new UnsupportedOperationException("Unable to find a meter registry formatter for media type " + mediaType);
+    }
+
     private void getAll(ServerRequest req, ServerResponse res) {
         getMatching(req, res, req.query().all("scope", List::of), req.query().all("name", List::of));
     }
@@ -196,26 +234,37 @@ private void getMatching(ServerRequest req,
         MediaType mediaType = bestAccepted(req);
         res.header(Http.Headers.CACHE_NO_CACHE);
         if (mediaType == null) {
-            res.status(Http.Status.NOT_ACCEPTABLE_406);
+            res.status(NOT_ACCEPTABLE_406);
             res.send();
         }
 
+        getOrOptionsMatching(mediaType, res, () -> output(mediaType,
+                                                          scopeSelection,
+                                                          nameSelection));
+    }
+
+    private void getOrOptionsMatching(MediaType mediaType,
+                                      ServerResponse res,
+                                      Supplier> dataSupplier) {
         try {
-            Optional output = RegistryFactory.getInstance().scrape(mediaType,
-                                                                           scopeSelection,
-                                                                           nameSelection);
+            Optional output = dataSupplier.get();
+
             if (output.isPresent()) {
-                res.status(Http.Status.OK_200)
+                res.status(OK_200)
                         .headers().contentType(mediaType);
                 res.send(output.get());
             } else {
-                res.status(Http.Status.NOT_FOUND_404);
+                res.status(NOT_FOUND_404);
                 res.send();
             }
         } catch (UnsupportedOperationException ex) {
-            // The registry factory does not support that media type.
-            res.status(Http.Status.NOT_ACCEPTABLE_406);
+            // We could not find a formatter for that media type from any provider we could locate.
+            res.status(NOT_ACCEPTABLE_406);
             res.send();
+        } catch (NoClassDefFoundError ex) {
+            // Prometheus seems not to be on the path.
+            LOGGER.log(Level.DEBUG, "Unable to find Micrometer Prometheus types to scrape the registry");
+            res.status(NOT_FOUND_404);
         }
     }
 
@@ -227,6 +276,12 @@ private static MediaType bestAccepted(ServerRequest req) {
                 .orElse(null);
     }
 
+    private static MediaType bestAcceptedForMetadata(ServerRequest req) {
+        return req.headers()
+                .bestAccepted(MediaTypes.APPLICATION_JSON)
+                .orElse(null);
+    }
+
     private static KeyPerformanceIndicatorSupport.Context kpiContext(ServerRequest request) {
         return request.context()
                 .get(KeyPerformanceIndicatorSupport.Context.class)
@@ -238,7 +293,7 @@ private void setUpEndpoints(HttpRules rules) {
         // As of Helidon 4, this is the only path we should need because scope-based or metric-name-based
         // selection should use query parameters instead of paths.
         rules.get("/", this::getAll)
-                .options("/", this::rejectOptions);
+                .options("/", this::optionsAll);
 
         // routing to each scope
         // As of Helidon 4, users should use /metrics?scope=xyz instead of /metrics/xyz, and
@@ -248,15 +303,12 @@ private void setUpEndpoints(HttpRules rules) {
         Stream.of(Registry.APPLICATION_SCOPE,
                   Registry.BASE_SCOPE,
                   Registry.VENDOR_SCOPE)
-                .map(registryFactory::getRegistry)
-                .forEach(registry -> {
-                    String type = registry.scope();
-
-                    rules.get("/" + type, (req, res) -> getMatching(req, res, Set.of(type), Set.of()))
-                            .get("/" + type + "/{metric}", (req, res) -> getByName(req, res, Set.of(type))) // should use ?scope=
-                            .options("/" + type, this::rejectOptions)
-                            .options("/" + type + "/{metric}", this::rejectOptions);
-                });
+                .forEach(scope -> rules
+                        .get("/" + scope, (req, res) -> getMatching(req, res, Set.of(scope), Set.of()))
+                        .get("/" + scope + "/{metric}",
+                             (req, res) -> getByName(req, res, Set.of(scope))) // should use ?scope=
+                        .options("/" + scope, (req, res) -> optionsMatching(req, res, Set.of(scope), Set.of()))
+                        .options("/" + scope + "/{metric}", (req, res) -> optionsByName(req, res, Set.of(scope))));
     }
 
     private void getByName(ServerRequest req, ServerResponse res, Iterable scopeSelection) {
@@ -273,16 +325,34 @@ private void postRequestProcessing(PostRequestMetricsSupport prms,
         prms.runTasks(request, response, throwable);
     }
 
-    private void rejectOptions(ServerRequest req, ServerResponse res) {
-        // Options used to return metadata but it's no longer supported unless we restore JSON support.
-        res.header(Http.HeaderNames.ALLOW, "GET");
-        res.status(Http.Status.METHOD_NOT_ALLOWED_405);
-        res.send();
+    private void optionsAll(ServerRequest req, ServerResponse res) {
+        optionsMatching(req, res, req.query().all("scope", List::of), req.query().all("name", List::of));
+    }
+
+    private void optionsByName(ServerRequest req, ServerResponse res, Iterable scopeSelection) {
+        String metricName = req.path().pathParameters().value("metric");
+        optionsMatching(req, res, scopeSelection, Set.of(metricName));
+    }
+
+    private void optionsMatching(ServerRequest req,
+                                 ServerResponse res,
+                                 Iterable scopeSelection,
+                                 Iterable nameSelection) {
+        MediaType mediaType = bestAcceptedForMetadata(req);
+        if (mediaType == null) {
+            res.header(ALLOW, "GET");
+            res.status(METHOD_NOT_ALLOWED_405);
+            res.send();
+        }
+
+        getOrOptionsMatching(mediaType, res, () -> output(mediaType,
+                                                          scopeSelection,
+                                                          nameSelection));
     }
 
     private void setUpDisabledEndpoints(HttpRules rules) {
         rules.get("/", DISABLED_ENDPOINT_HANDLER)
-                .options("/", this::rejectOptions);
+                .options("/", this::optionsAll);
 
         // routing to GET and OPTIONS for each metrics scope (registry type) and a specific metric within each scope:
         // application, base, vendor
@@ -290,7 +360,7 @@ private void setUpDisabledEndpoints(HttpRules rules) {
                 .forEach(type -> Stream.of("", "/{metric}") // for the whole scope and for a specific metric within that scope
                         .map(suffix -> "/" + type + suffix)
                         .forEach(path -> rules.get(path, DISABLED_ENDPOINT_HANDLER)
-                                .options(path, this::rejectOptions)
+                                .options(path, this::optionsAll)
                         ));
     }
 
@@ -298,8 +368,10 @@ private void setUpDisabledEndpoints(HttpRules rules) {
      * A fluent API builder to build instances of {@link MetricsFeature}.
      */
     public static final class Builder extends HelidonFeatureSupport.Builder {
-        private LazyValue registryFactory;
-        private MetricsSettings.Builder metricsSettingsBuilder = MetricsSettings.builder();
+
+        private LazyValue metricsFactory;
+        private MeterRegistry meterRegistry;
+        private MetricsConfig.Builder metricsConfigBuilder = MetricsConfig.builder();
 
         private Builder() {
             super("metrics");
@@ -307,9 +379,9 @@ private Builder() {
 
         @Override
         public MetricsFeature build() {
-            if (registryFactory == null) {
-                registryFactory = LazyValue.create(() -> RegistryFactory.getInstance(metricsSettingsBuilder.build()));
-            }
+            metricsFactory = Objects.requireNonNullElseGet(metricsFactory,
+                                                           () -> LazyValue.create(() -> MetricsFactory.getInstance(
+                                                                   metricsConfigBuilder.build())));
             return new MetricsFeature(this);
         }
 
@@ -323,50 +395,32 @@ public MetricsFeature build() {
          */
         public Builder config(Config config) {
             super.config(config);
-            metricsSettingsBuilder.config(config);
+            metricsConfigBuilder.config(config);
             return this;
         }
 
         /**
-         * Assigns {@code MetricsSettings} which will be used in creating the {@code MetricsSupport} instance at build-time.
+         * Assigns {link io.helidon.metrics.api.MetricsConfig} which will be used in creating the instance at build-time.
          *
-         * @param metricsSettingsBuilder the metrics settings to assign for use in building the {@code MetricsSupport} instance
+         * @param metricsConfigBuilder the metrics config to assign for use in building the {@code MetricsSupport} instance
          * @return updated builder
          */
         @ConfiguredOption(mergeWithParent = true,
-                          type = MetricsSettings.class)
-        public Builder metricsSettings(MetricsSettings.Builder metricsSettingsBuilder) {
-            this.metricsSettingsBuilder = metricsSettingsBuilder;
+                          type = MetricsConfig.class)
+        public Builder metricsConfig(MetricsConfig.Builder metricsConfigBuilder) {
+            this.metricsConfigBuilder = metricsConfigBuilder;
             return this;
         }
 
         /**
-         * If you want to have multiple registry factories with different
-         * endpoints, you may create them using
-         * {@link RegistryFactory#create(MetricsSettings)} or
-         * {@link RegistryFactory#create()} and create multiple
-         * {@link MetricsFeature} instances with different
-         * {@link #webContext(String)} contexts}.
-         * 

- * If this method is not called, - * {@link MetricsFeature} would use the shared - * instance as provided by - * {@link io.helidon.metrics.api.RegistryFactory#getInstance(io.helidon.config.Config)} + * Assigns the {@link io.helidon.metrics.api.MeterRegistry} to query for formatting output. * - * @param factory factory to use in this metric support - * @return updated builder instance + * @param meterRegistry the meter registry to use + * @return updated builder */ - public Builder registryFactory(RegistryFactory factory) { - registryFactory = LazyValue.create(() -> factory); + public Builder meterRegistry(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; return this; } - - RegistryFactory registryFactory() { - return registryFactory.get(); - } - - MetricsSettings metricsSettings() { - return metricsSettingsBuilder.build(); - } } } diff --git a/webserver/observe/metrics/src/main/java/module-info.java b/webserver/observe/metrics/src/main/java/module-info.java index b642b9dac70..ab2f9e818eb 100644 --- a/webserver/observe/metrics/src/main/java/module-info.java +++ b/webserver/observe/metrics/src/main/java/module-info.java @@ -36,7 +36,15 @@ requires io.helidon.common.context; requires io.helidon.common.features.api; + requires static micrometer.core; + requires static micrometer.registry.prometheus; + requires static simpleclient.common; + exports io.helidon.webserver.observe.metrics; provides ObserveProvider with MetricsObserveProvider; + provides io.helidon.metrics.spi.MeterRegistryFormatterProvider + with io.helidon.webserver.observe.metrics.JsonMeterRegistryFormatterProvider; + uses io.helidon.metrics.spi.MeterRegistryFormatterProvider; + } \ No newline at end of file diff --git a/webserver/observe/metrics/src/test/java/io/helidon/webserver/observe/metrics/TestJsonFormatting.java b/webserver/observe/metrics/src/test/java/io/helidon/webserver/observe/metrics/TestJsonFormatting.java new file mode 100644 index 00000000000..640473c7779 --- /dev/null +++ b/webserver/observe/metrics/src/test/java/io/helidon/webserver/observe/metrics/TestJsonFormatting.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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.helidon.webserver.observe.metrics; + +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import io.helidon.common.testing.junit5.OptionalMatcher; +import io.helidon.metrics.api.Counter; +import io.helidon.metrics.api.MeterRegistry; +import io.helidon.metrics.api.Metrics; +import io.helidon.metrics.api.MetricsConfig; +import io.helidon.metrics.api.Tag; +import io.helidon.metrics.api.Timer; + +import jakarta.json.JsonObject; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +class TestJsonFormatting { + private static final String SCOPE_TAG_NAME = "the-scope"; + private static MeterRegistry meterRegistry; + private static MetricsFeature feature; + + @BeforeAll + static void prep() { + MetricsConfig.Builder metricsConfigBuilder = MetricsConfig.builder() + .scopeTagName(SCOPE_TAG_NAME); + + meterRegistry = Metrics.createMeterRegistry(metricsConfigBuilder.build()); + feature = MetricsFeature.builder() + .meterRegistry(meterRegistry) + .metricsConfig(metricsConfigBuilder) + .build(); + } + + @Test + void testRetrievingAll() { + Counter c = meterRegistry.getOrCreate(Counter.builder("c1")); + assertThat("Initial counter value", c.count(), is(0L)); + c.increment(); + assertThat("After increment", c.count(), is(1L)); + + Counter c1WithTag = meterRegistry.getOrCreate(Counter.builder("c1") + .tags(Set.of(Tag.create("t1", "v1")))); + c1WithTag.increment(4L); + + Timer d = meterRegistry.getOrCreate(Timer.builder("t1")); + d.record(3, TimeUnit.SECONDS); + + + JsonFormatter formatter = JsonFormatter.builder(meterRegistry) + .scopeTagName(SCOPE_TAG_NAME) + .build(); + + Optional result = formatter.format(); + + assertThat("Result", result, OptionalMatcher.optionalPresent()); + JsonObject app = result.get().getJsonObject("application"); + assertThat("Counter 1", + app.getJsonNumber("c1;t1=v1;the-scope=application").intValue(), + is(4)); + assertThat("Counter 2", + app.getJsonNumber("c1;the-scope=application").intValue(), + is(1)); + JsonObject timerJson = app.getJsonObject("t1"); + assertThat("Timer", timerJson, notNullValue()); + assertThat("Timer count", timerJson.getJsonNumber("count;the-scope=application").intValue(), is(1)); + } + + + @Test + void testRetrievingByName() { + Counter c = meterRegistry.getOrCreate(Counter.builder("c2")); + assertThat("Initial counter value", c.count(), is(0L)); + c.increment(); + assertThat("After increment", c.count(), is(1L)); + + Timer d = meterRegistry.getOrCreate(Timer.builder("t2")); + d.record(7, TimeUnit.SECONDS); + + JsonFormatter formatter = JsonFormatter.builder(meterRegistry) + .meterNameSelection(Set.of("c2")) + .build(); + + Optional result = formatter.format(); + assertThat("Result", result, OptionalMatcher.optionalPresent()); + + JsonObject app = result.get().getJsonObject("application"); + assertThat("Counter 2", app.getJsonNumber("c2;the-scope=application").intValue(), is(1)); + + assertThat("Timer", app.getJsonObject("t2"), nullValue()); + + } +}