From f99efa64da561a882edfac3158fc1db9850b2fe0 Mon Sep 17 00:00:00 2001 From: Kaspar Kivistik <27056536+kasparkivistik@users.noreply.github.com> Date: Mon, 10 Feb 2025 20:12:28 +0200 Subject: [PATCH] OTLP metrics sender interface (#5691) * Add capability for anyone to define their implementation of OTLP metrics sending * Fix formatting * Remove otlp proto from API and introduce Builder Keeps the API more generic without a dependency on API from the opentelemetry-proto-java project. Also adds a Builder to avoid the need for more public constructors that may change in the future. * Polish * Add docs --------- Co-authored-by: Tommy Ludwig <8924140+shakuzen@users.noreply.github.com> --- docs/build.gradle | 3 + .../ROOT/pages/implementations/otlp.adoc | 21 ++++ .../OtlpMeterRegistryCustomizationTest.java | 57 +++++++++ .../registry/otlp/MetricsSender.java | 32 +++++ .../registry/otlp/OtlpHttpMetricsSender.java | 83 +++++++++++++ .../registry/otlp/OtlpMeterRegistry.java | 110 ++++++++++-------- .../registry/otlp/OtlpMetricsSender.java | 36 ++++++ .../registry/otlp/OtlpMeterRegistryTest.java | 6 +- 8 files changed, 299 insertions(+), 49 deletions(-) create mode 100644 docs/src/test/java/io/micrometer/docs/metrics/OtlpMeterRegistryCustomizationTest.java create mode 100644 implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/MetricsSender.java create mode 100644 implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpHttpMetricsSender.java create mode 100644 implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMetricsSender.java diff --git a/docs/build.gradle b/docs/build.gradle index 1eeeb2c5e9..08f87c1814 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -47,9 +47,12 @@ dependencies { testImplementation project(':micrometer-test') testImplementation project(':micrometer-observation-test') + testImplementation project(':micrometer-registry-otlp') testImplementation libs.aspectjweaver testImplementation libs.junitJupiter testImplementation 'org.assertj:assertj-core' + // needed for OtlpMeterRegistryCustomizationTest + testRuntimeOnly libs.okhttp testImplementation(libs.spring6.context) testImplementation 'io.projectreactor:reactor-core' testImplementation 'io.projectreactor:reactor-test' diff --git a/docs/modules/ROOT/pages/implementations/otlp.adoc b/docs/modules/ROOT/pages/implementations/otlp.adoc index accb014f1f..98549724d2 100644 --- a/docs/modules/ROOT/pages/implementations/otlp.adoc +++ b/docs/modules/ROOT/pages/implementations/otlp.adoc @@ -66,6 +66,27 @@ management: If this config is empty, resource attributes are loaded from the `OTEL_RESOURCE_ATTRIBUTES` environmental variable. You can override `service.name` by setting the `OTEL_SERVICE_NAME` environmental variable, and this takes precedence over other configs. +== Customize metrics sender + +The `OtlpMeterRegistry` has an `OtlpMetricsSender` abstraction for sending batches of metrics in OTLP protobuf format. + +By default, metrics from the OTLP registry are sent via HTTP to the URL specified by `OtlpConfig#url` using an `HttpUrlConnectionSender`. +You may use a different `HttpSender` implementation by creating and configuring an instance of `OtlpHttpMetricsSender` such as in the following example using the `OkHttpSender` instead of the default `HttpUrlConnectionSender`: + +[source,java,subs=+attributes] +----- +include::{include-java}/metrics/OtlpMeterRegistryCustomizationTest.java[tags=customizeHttpSender, indent=0] +----- + +You can also provide a custom implementation of `OtlpMetricsSender` that does not use HTTP at all. +For instance, if you made a gRPC implementation, you could configure it in the following way. +Micrometer does not currently provide a gRPC implementation of `OtlpMetricsSender`. + +[source,java,subs=+attributes] +----- +include::{include-java}/metrics/OtlpMeterRegistryCustomizationTest.java[tags=customGrpcSender, indent=0] +----- + == Supported metrics https://opentelemetry.io/docs/specs/otel/metrics/data-model/#metric-points[Metric points, window=_blank] define the different data points that are supported in OTLP. Micrometer supports exporting the below data points in OTLP format, diff --git a/docs/src/test/java/io/micrometer/docs/metrics/OtlpMeterRegistryCustomizationTest.java b/docs/src/test/java/io/micrometer/docs/metrics/OtlpMeterRegistryCustomizationTest.java new file mode 100644 index 0000000000..939774fc9f --- /dev/null +++ b/docs/src/test/java/io/micrometer/docs/metrics/OtlpMeterRegistryCustomizationTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 VMware, Inc. + * + * 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 + * + * https://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.micrometer.docs.metrics; + +import io.micrometer.common.lang.NonNullApi; +import io.micrometer.core.ipc.http.OkHttpSender; +import io.micrometer.registry.otlp.OtlpConfig; +import io.micrometer.registry.otlp.OtlpHttpMetricsSender; +import io.micrometer.registry.otlp.OtlpMeterRegistry; +import io.micrometer.registry.otlp.OtlpMetricsSender; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class OtlpMeterRegistryCustomizationTest { + + @Test + void customizeHttpSender() { + // tag::customizeHttpSender[] + OtlpConfig config = OtlpConfig.DEFAULT; + OtlpHttpMetricsSender httpMetricsSender = new OtlpHttpMetricsSender(new OkHttpSender(), config); + OtlpMeterRegistry meterRegistry = OtlpMeterRegistry.builder(config).metricsSender(httpMetricsSender).build(); + // end::customizeHttpSender[] + } + + @Test + void customizeOtlpSender() { + // tag::customGrpcSender[] + OtlpConfig config = OtlpConfig.DEFAULT; + OtlpMetricsSender metricsSender = new OtlpGrpcMetricsSender(); + OtlpMeterRegistry meterRegistry = OtlpMeterRegistry.builder(config).metricsSender(metricsSender).build(); + // end::customGrpcSender[] + } + + @NonNullApi + private static class OtlpGrpcMetricsSender implements OtlpMetricsSender { + + @Override + public void send(byte[] metricsData, Map headers) { + } + + } + +} diff --git a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/MetricsSender.java b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/MetricsSender.java new file mode 100644 index 0000000000..d7a09d3002 --- /dev/null +++ b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/MetricsSender.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 VMware, Inc. + * + * 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 + * + * https://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.micrometer.registry.otlp; + +import java.util.Map; + +// intentionally not public while we incubate this concept +// if we want to use this in other registries, it should move to micrometer-core and become public API +interface MetricsSender { + + /** + * Send encoded metrics data from a {@link io.micrometer.core.instrument.MeterRegistry + * MeterRegistry}. + * @param metricsData encoded batch of metrics + * @param headers metadata to send as headers with the metrics data + */ + void send(byte[] metricsData, Map headers); + +} diff --git a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpHttpMetricsSender.java b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpHttpMetricsSender.java new file mode 100644 index 0000000000..ed503e53b4 --- /dev/null +++ b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpHttpMetricsSender.java @@ -0,0 +1,83 @@ +/* + * Copyright 2025 VMware, Inc. + * + * 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 + * + * https://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.micrometer.registry.otlp; + +import io.micrometer.common.util.internal.logging.InternalLogger; +import io.micrometer.common.util.internal.logging.InternalLoggerFactory; +import io.micrometer.core.ipc.http.HttpSender; + +import java.util.Map; + +/** + * An implementation of {@link OtlpMetricsSender} that uses an {@link HttpSender}. + * + * @since 1.15.0 + */ +public class OtlpHttpMetricsSender implements OtlpMetricsSender { + + private static final InternalLogger logger = InternalLoggerFactory.getInstance(OtlpHttpMetricsSender.class); + + private final HttpSender httpSender; + + private final OtlpConfig config; + + private final String userAgentHeader; + + public OtlpHttpMetricsSender(HttpSender httpSender, OtlpConfig config) { + this.httpSender = httpSender; + this.config = config; + this.userAgentHeader = getUserAgentHeader(); + } + + @Override + public void send(byte[] metricsData, Map headers) { + HttpSender.Request.Builder httpRequest = this.httpSender.post(config.url()) + .withHeader("User-Agent", userAgentHeader) + .withContent("application/x-protobuf", metricsData); + headers.forEach(httpRequest::withHeader); + try { + HttpSender.Response response = httpRequest.send(); + if (!response.isSuccessful()) { + logger.warn( + "Failed to publish metrics (context: {}). Server responded with HTTP status code {} and body {}", + getConfigurationContext(), response.code(), response.body()); + } + } + catch (Throwable e) { + logger.warn("Failed to publish metrics (context: {}) ", getConfigurationContext(), e); + } + } + + private String getUserAgentHeader() { + String userAgent = "Micrometer-OTLP-Exporter-Java"; + String version = getClass().getPackage().getImplementationVersion(); + if (version != null) { + userAgent += "/" + version; + } + return userAgent; + } + + /** + * Get the configuration context. + * @return A message containing enough information for the log reader to figure out + * what configuration details may have contributed to the failure. + */ + private String getConfigurationContext() { + // While other values may contribute to failures, these two are most common + return "url=" + config.url() + ", resource-attributes=" + config.resourceAttributes(); + } + +} diff --git a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java index 75e4e4769a..1a157a777f 100644 --- a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java +++ b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMeterRegistry.java @@ -18,12 +18,10 @@ import io.micrometer.common.lang.Nullable; import io.micrometer.common.util.internal.logging.InternalLogger; import io.micrometer.common.util.internal.logging.InternalLoggerFactory; -import io.micrometer.core.instrument.Gauge; -import io.micrometer.core.instrument.*; import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.*; import io.micrometer.core.instrument.config.NamingConvention; import io.micrometer.core.instrument.distribution.*; -import io.micrometer.core.instrument.distribution.Histogram; import io.micrometer.core.instrument.distribution.pause.PauseDetector; import io.micrometer.core.instrument.internal.DefaultGauge; import io.micrometer.core.instrument.internal.DefaultLongTaskTimer; @@ -36,14 +34,14 @@ import io.micrometer.core.instrument.util.MeterPartition; import io.micrometer.core.instrument.util.NamedThreadFactory; import io.micrometer.core.instrument.util.TimeUtils; -import io.micrometer.core.ipc.http.HttpSender; import io.micrometer.core.ipc.http.HttpUrlConnectionSender; import io.micrometer.registry.otlp.internal.CumulativeBase2ExponentialHistogram; import io.micrometer.registry.otlp.internal.DeltaBase2ExponentialHistogram; import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; import io.opentelemetry.proto.common.v1.AnyValue; import io.opentelemetry.proto.common.v1.KeyValue; -import io.opentelemetry.proto.metrics.v1.*; +import io.opentelemetry.proto.metrics.v1.ResourceMetrics; +import io.opentelemetry.proto.metrics.v1.ScopeMetrics; import io.opentelemetry.proto.resource.v1.Resource; import java.time.Duration; @@ -56,8 +54,7 @@ import java.util.function.ToLongFunction; /** - * Publishes meters in OTLP (OpenTelemetry Protocol) format. HTTP with Protobuf encoding - * is the only option currently supported. + * Publishes meters in OTLP (OpenTelemetry Protocol) format. * * @author Tommy Ludwig * @author Lenin Jaganathan @@ -83,7 +80,7 @@ public class OtlpMeterRegistry extends PushMeterRegistry { private final OtlpConfig config; - private final HttpSender httpSender; + private final OtlpMetricsSender metricsSender; private final Resource resource; @@ -91,8 +88,6 @@ public class OtlpMeterRegistry extends PushMeterRegistry { private final TimeUnit baseTimeUnit; - private final String userAgentHeader; - // Time when the last scheduled rollOver has started. Applicable only for delta // flavour. private volatile long lastMeterRolloverStartTime = -1; @@ -109,31 +104,38 @@ public OtlpMeterRegistry(OtlpConfig config, Clock clock) { } /** - * Create an {@code OtlpMeterRegistry} instance. + * Create an {@code OtlpMeterRegistry} instance with an HTTP metrics sender. * @param config config * @param clock clock * @param threadFactory thread factory * @since 1.14.0 */ public OtlpMeterRegistry(OtlpConfig config, Clock clock, ThreadFactory threadFactory) { - this(config, clock, threadFactory, new HttpUrlConnectionSender()); + this(config, clock, threadFactory, new OtlpHttpMetricsSender(new HttpUrlConnectionSender(), config)); } - // VisibleForTesting - // not public until we decide what we want to expose in public API - // HttpSender may not be a good idea if we will support a non-HTTP transport - OtlpMeterRegistry(OtlpConfig config, Clock clock, ThreadFactory threadFactory, HttpSender httpSender) { + private OtlpMeterRegistry(OtlpConfig config, Clock clock, ThreadFactory threadFactory, + OtlpMetricsSender metricsSender) { super(config, clock); this.config = config; this.baseTimeUnit = config.baseTimeUnit(); - this.httpSender = httpSender; + this.metricsSender = metricsSender; this.resource = Resource.newBuilder().addAllAttributes(getResourceAttributes()).build(); this.aggregationTemporality = config.aggregationTemporality(); - this.userAgentHeader = getUserAgentHeader(); config().namingConvention(NamingConvention.dot); start(threadFactory); } + /** + * Construct an {@link OtlpMeterRegistry} using the Builder pattern. + * @param config config for the registry; see {@link OtlpConfig#DEFAULT} + * @return builder + * @since 1.15.0 + */ + public static Builder builder(OtlpConfig config) { + return new Builder(config); + } + @Override public void start(ThreadFactory threadFactory) { super.start(threadFactory); @@ -178,34 +180,15 @@ protected void publish() { .build()) .build()) .build(); - HttpSender.Request.Builder httpRequest = this.httpSender.post(this.config.url()) - .withHeader("User-Agent", this.userAgentHeader) - .withContent("application/x-protobuf", request.toByteArray()); - this.config.headers().forEach(httpRequest::withHeader); - HttpSender.Response response = httpRequest.send(); - if (!response.isSuccessful()) { - logger.warn( - "Failed to publish metrics (context: {}). Server responded with HTTP status code {} and body {}", - getConfigurationContext(), response.code(), response.body()); - } + + metricsSender.send(request.toByteArray(), this.config.headers()); } catch (Throwable e) { - logger.warn(String.format("Failed to publish metrics to OTLP receiver (context: %s)", - getConfigurationContext()), e); + logger.warn("Failed to publish metrics to OTLP receiver", e); } } } - /** - * Get the configuration context. - * @return A message containing enough information for the log reader to figure out - * what configuration details may have contributed to the failure. - */ - private String getConfigurationContext() { - // While other values may contribute to failures, these two are most common - return "url=" + config.url() + ", resource-attributes=" + config.resourceAttributes(); - } - @Override protected Gauge newGauge(Meter.Id id, @Nullable T obj, ToDoubleFunction valueFunction) { return new DefaultGauge<>(id, obj, valueFunction); @@ -492,13 +475,48 @@ static double[] getSloWithPositiveInf(DistributionStatisticConfig distributionSt return sloWithPositiveInf; } - private String getUserAgentHeader() { - String userAgent = "Micrometer-OTLP-Exporter-Java"; - String version = getClass().getPackage().getImplementationVersion(); - if (version != null) { - userAgent += "/" + version; + public static class Builder { + + private final OtlpConfig otlpConfig; + + private Clock clock = Clock.SYSTEM; + + private ThreadFactory threadFactory = DEFAULT_THREAD_FACTORY; + + private OtlpMetricsSender metricsSender; + + private Builder(OtlpConfig otlpConfig) { + this.otlpConfig = otlpConfig; + this.metricsSender = new OtlpHttpMetricsSender(new HttpUrlConnectionSender(), otlpConfig); + } + + /** Override the default clock. */ + public Builder clock(Clock clock) { + this.clock = clock; + return this; } - return userAgent; + + /** Override the default {@link ThreadFactory}. */ + public Builder threadFactory(ThreadFactory threadFactory) { + this.threadFactory = threadFactory; + return this; + } + + /** + * Provide your own custom metrics sender. This can be used to send OTLP metrics + * from OtlpMeterRegistry using different transports or clients than the default + * (HTTP using the HttpUrlConnectionSender). Encoding is in OTLP protobuf format. + * @see OtlpHttpMetricsSender + */ + public Builder metricsSender(OtlpMetricsSender metricsSender) { + this.metricsSender = metricsSender; + return this; + } + + public OtlpMeterRegistry build() { + return new OtlpMeterRegistry(otlpConfig, clock, threadFactory, metricsSender); + } + } } diff --git a/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMetricsSender.java b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMetricsSender.java new file mode 100644 index 0000000000..c6e3765936 --- /dev/null +++ b/implementations/micrometer-registry-otlp/src/main/java/io/micrometer/registry/otlp/OtlpMetricsSender.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 VMware, Inc. + * + * 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 + * + * https://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.micrometer.registry.otlp; + +import java.util.Map; + +/** + * This is responsible for sending OTLP format metrics to a compatible location. Specific + * implementations can use different transports or clients for sending the metrics. + * + * @since 1.15.0 + */ +public interface OtlpMetricsSender extends MetricsSender { + + /** + * Send a batch of OTLP Protobuf format metrics to a receiver. + * @param metricsData OTLP protobuf encoded batch of metrics + * @param headers metadata to send as headers with the metrics data + */ + @Override + void send(byte[] metricsData, Map headers); + +} diff --git a/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpMeterRegistryTest.java b/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpMeterRegistryTest.java index 9ddd41918e..01a39f309f 100644 --- a/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpMeterRegistryTest.java +++ b/implementations/micrometer-registry-otlp/src/test/java/io/micrometer/registry/otlp/OtlpMeterRegistryTest.java @@ -19,7 +19,6 @@ import io.micrometer.core.instrument.Timer; import io.micrometer.core.instrument.*; import io.micrometer.core.instrument.distribution.DistributionStatisticConfig; -import io.micrometer.core.instrument.util.NamedThreadFactory; import io.micrometer.core.ipc.http.HttpSender; import io.opentelemetry.proto.metrics.v1.ExponentialHistogramDataPoint; import io.opentelemetry.proto.metrics.v1.HistogramDataPoint; @@ -67,9 +66,10 @@ abstract class OtlpMeterRegistryTest { @BeforeEach void setUp() { this.clock = new MockClock(); + OtlpConfig config = otlpConfig(); this.mockHttpSender = mock(HttpSender.class); - this.registry = new OtlpMeterRegistry(otlpConfig(), this.clock, - new NamedThreadFactory("otlp-metrics-publisher"), this.mockHttpSender); + OtlpMetricsSender metricsSender = new OtlpHttpMetricsSender(mockHttpSender, config); + this.registry = OtlpMeterRegistry.builder(config).clock(clock).metricsSender(metricsSender).build(); this.registryWithExponentialHistogram = new OtlpMeterRegistry(exponentialHistogramOtlpConfig(), clock); }