diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsPropertiesConfigAdapter.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsPropertiesConfigAdapter.java index cd520db9e8c7..dbbcd6efe244 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsPropertiesConfigAdapter.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/export/otlp/OtlpMetricsPropertiesConfigAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,6 @@ package org.springframework.boot.actuate.autoconfigure.metrics.export.otlp; import java.util.Collections; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -27,8 +26,8 @@ import org.springframework.boot.actuate.autoconfigure.metrics.export.properties.StepRegistryPropertiesConfigAdapter; import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryProperties; +import org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryResourceAttributes; import org.springframework.core.env.Environment; -import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -78,12 +77,12 @@ public AggregationTemporality aggregationTemporality() { @Override public Map resourceAttributes() { - Map resourceAttributes = this.openTelemetryProperties.getResourceAttributes(); - Map result = new HashMap<>((!CollectionUtils.isEmpty(resourceAttributes)) ? resourceAttributes - : OtlpConfig.super.resourceAttributes()); - result.computeIfAbsent("service.name", (key) -> getApplicationName()); - result.computeIfAbsent("service.group", (key) -> getApplicationGroup()); - return Collections.unmodifiableMap(result); + Map attributes = new OpenTelemetryResourceAttributes( + this.openTelemetryProperties.getResourceAttributes()) + .asMap(); + attributes.computeIfAbsent("service.name", (key) -> getApplicationName()); + attributes.computeIfAbsent("service.group", (key) -> getApplicationGroup()); + return Collections.unmodifiableMap(attributes); } private String getApplicationName() { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java index 720e79142f61..173b76ca83bb 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,9 +16,9 @@ package org.springframework.boot.actuate.autoconfigure.opentelemetry; +import java.util.Map; + import io.opentelemetry.api.OpenTelemetry; -import io.opentelemetry.api.common.AttributeKey; -import io.opentelemetry.api.common.Attributes; import io.opentelemetry.context.propagation.ContextPropagators; import io.opentelemetry.sdk.OpenTelemetrySdk; import io.opentelemetry.sdk.OpenTelemetrySdkBuilder; @@ -54,10 +54,6 @@ public class OpenTelemetryAutoConfiguration { */ private static final String DEFAULT_APPLICATION_NAME = "unknown_service"; - private static final AttributeKey ATTRIBUTE_KEY_SERVICE_NAME = AttributeKey.stringKey("service.name"); - - private static final AttributeKey ATTRIBUTE_KEY_SERVICE_GROUP = AttributeKey.stringKey("service.group"); - @Bean @ConditionalOnMissingBean(OpenTelemetry.class) OpenTelemetrySdk openTelemetry(ObjectProvider tracerProvider, @@ -74,20 +70,27 @@ OpenTelemetrySdk openTelemetry(ObjectProvider tracerProvider, @Bean @ConditionalOnMissingBean Resource openTelemetryResource(Environment environment, OpenTelemetryProperties properties) { - String applicationName = environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME); - String applicationGroup = environment.getProperty("spring.application.group"); - Resource resource = Resource.getDefault() - .merge(Resource.create(Attributes.of(ATTRIBUTE_KEY_SERVICE_NAME, applicationName))); - if (StringUtils.hasLength(applicationGroup)) { - resource = resource.merge(Resource.create(Attributes.of(ATTRIBUTE_KEY_SERVICE_GROUP, applicationGroup))); - } - return resource.merge(toResource(properties)); + Resource resource = Resource.getDefault(); + return resource.merge(toResource(environment, properties)); } - private static Resource toResource(OpenTelemetryProperties properties) { + private Resource toResource(Environment environment, OpenTelemetryProperties properties) { ResourceBuilder builder = Resource.builder(); - properties.getResourceAttributes().forEach(builder::put); + Map attributes = new OpenTelemetryResourceAttributes(properties.getResourceAttributes()) + .asMap(); + attributes.computeIfAbsent("service.name", (key) -> getApplicationName(environment)); + attributes.computeIfAbsent("service.group", (key) -> getApplicationGroup(environment)); + attributes.forEach(builder::put); return builder.build(); } + private String getApplicationName(Environment environment) { + return environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME); + } + + private String getApplicationGroup(Environment environment) { + String applicationGroup = environment.getProperty("spring.application.group"); + return (StringUtils.hasLength(applicationGroup)) ? applicationGroup : null; + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryResourceAttributes.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryResourceAttributes.java new file mode 100644 index 000000000000..d6ba172e0dcc --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryResourceAttributes.java @@ -0,0 +1,156 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 org.springframework.boot.actuate.autoconfigure.opentelemetry; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.util.StringUtils; + +/** + * OpenTelemetryResourceAttributes retrieves information from the + * {@code OTEL_RESOURCE_ATTRIBUTES} and {@code OTEL_SERVICE_NAME} environment variables + * and merges it with the resource attributes provided by the user. + *

+ * User-provided resource attributes take precedence. + *

+ * OpenTelemetry + * Resource Specification + * + * @author Dmytro Nosan + * @since 3.5.0 + */ +public final class OpenTelemetryResourceAttributes { + + private final Map resourceAttributes; + + private final Function getEnv; + + /** + * Creates a new instance of {@link OpenTelemetryResourceAttributes}. + * @param resourceAttributes user provided resource attributes to be used + */ + public OpenTelemetryResourceAttributes(Map resourceAttributes) { + this(resourceAttributes, null); + } + + /** + * Creates a new {@link OpenTelemetryResourceAttributes} instance. + * @param resourceAttributes user provided resource attributes to be used + * @param getEnv a function to retrieve environment variables by name + */ + OpenTelemetryResourceAttributes(Map resourceAttributes, Function getEnv) { + this.resourceAttributes = (resourceAttributes != null) ? resourceAttributes : Collections.emptyMap(); + this.getEnv = (getEnv != null) ? getEnv : System::getenv; + } + + /** + * Returns resource attributes by combining attributes from environment variables and + * user-defined resource attributes. The final resource contains all attributes from + * both sources. + *

+ * If a key exists in both environment variables and user-defined resources, the value + * from the user-defined resource takes precedence, even if it is empty. + *

+ * Null keys and values are ignored. + * @return the resource attributes + */ + public Map asMap() { + Map attributes = getResourceAttributesFromEnv(); + this.resourceAttributes.forEach((name, value) -> { + if (name != null && value != null) { + attributes.put(name, value); + } + }); + return attributes; + } + + /** + * Parses resource attributes from the {@link System#getenv()}. This method fetches + * attributes defined in the {@code OTEL_RESOURCE_ATTRIBUTES} and + * {@code OTEL_SERVICE_NAME} environment variables and provides them as key-value + * pairs. + *

+ * If {@code service.name} is also provided in {@code OTEL_RESOURCE_ATTRIBUTES}, then + * {@code OTEL_SERVICE_NAME} takes precedence. + * @return resource attributes + */ + private Map getResourceAttributesFromEnv() { + Map attributes = new LinkedHashMap<>(); + for (String attribute : StringUtils.tokenizeToStringArray(getEnv("OTEL_RESOURCE_ATTRIBUTES"), ",")) { + int index = attribute.indexOf('='); + if (index > 0) { + String key = attribute.substring(0, index); + String value = attribute.substring(index + 1); + attributes.put(key.trim(), decode(value.trim())); + } + } + String otelServiceName = getEnv("OTEL_SERVICE_NAME"); + if (otelServiceName != null) { + attributes.put("service.name", otelServiceName); + } + return attributes; + } + + private String getEnv(String name) { + return this.getEnv.apply(name); + } + + /** + * Decodes a percent-encoded string. Converts sequences like '%HH' (where HH + * represents hexadecimal digits) back into their literal representations. + *

+ * Inspired by {@code org.apache.commons.codec.net.PercentCodec}. + * @param value value to decode + * @return the decoded string + */ + public static String decode(String value) { + if (value.indexOf('%') < 0) { + return value; + } + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream bos = new ByteArrayOutputStream(bytes.length); + for (int i = 0; i < bytes.length; i++) { + byte b = bytes[i]; + if (b != '%') { + bos.write(b); + continue; + } + int u = decodeHex(bytes, i + 1); + int l = decodeHex(bytes, i + 2); + if (u >= 0 && l >= 0) { + bos.write((u << 4) + l); + } + else { + throw new IllegalArgumentException( + "Failed to decode percent-encoded characters at index %d in the value: '%s'".formatted(i, + value)); + } + i += 2; + } + return bos.toString(StandardCharsets.UTF_8); + } + + private static int decodeHex(byte[] bytes, int index) { + return (index < bytes.length) ? Character.digit(bytes[index], 16) : -1; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryResourceAttributesTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryResourceAttributesTests.java new file mode 100644 index 000000000000..799ea7bc2605 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/opentelemetry/OpenTelemetryResourceAttributesTests.java @@ -0,0 +1,169 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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 org.springframework.boot.actuate.autoconfigure.opentelemetry; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Random; +import java.util.function.Function; +import java.util.stream.Stream; + +import io.opentelemetry.api.internal.PercentEscaper; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OpenTelemetryResourceAttributes}. + * + * @author Dmytro Nosan + */ +class OpenTelemetryResourceAttributesTests { + + private static Random random; + + private static final PercentEscaper escaper = PercentEscaper.create(); + + private final Map environmentVariables = new LinkedHashMap<>(); + + private final Map resourceAttributes = new LinkedHashMap<>(); + + @BeforeAll + static void beforeAll() { + long seed = new Random().nextLong(); + System.out.println("Seed: " + seed); + random = new Random(seed); + } + + @Test + void otelServiceNameShouldTakePrecedenceOverOtelResourceAttributes() { + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "service.name=ignored"); + this.environmentVariables.put("OTEL_SERVICE_NAME", "otel-service"); + OpenTelemetryResourceAttributes attributes = getAttributes(); + assertThat(attributes.asMap()).hasSize(1).containsEntry("service.name", "otel-service"); + } + + @Test + void otelServiceNameWhenEmptyShouldTakePrecedenceOverOtelResourceAttributes() { + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "service.name=ignored"); + this.environmentVariables.put("OTEL_SERVICE_NAME", ""); + OpenTelemetryResourceAttributes attributes = getAttributes(); + assertThat(attributes.asMap()).hasSize(1).containsEntry("service.name", ""); + } + + @Test + void otelResourceAttributesShouldBeUsed() { + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", + ", ,,key1=value1,key2= value2, key3=value3,key4=,=value5,key6,=,key7=spring+boot,key8=ś"); + OpenTelemetryResourceAttributes attributes = getAttributes(); + assertThat(attributes.asMap()).hasSize(6) + .containsEntry("key1", "value1") + .containsEntry("key2", "value2") + .containsEntry("key3", "value3") + .containsEntry("key4", "") + .containsEntry("key7", "spring+boot") + .containsEntry("key8", "ś"); + } + + @Test + void resourceAttributesShouldBeMergedWithEnvironmentVariables() { + this.resourceAttributes.put("service.group", "custom-group"); + this.resourceAttributes.put("key2", ""); + this.environmentVariables.put("OTEL_SERVICE_NAME", "custom-service"); + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "key1=value1,key2=value2"); + OpenTelemetryResourceAttributes attributes = getAttributes(); + assertThat(attributes.asMap()).hasSize(4) + .containsEntry("service.name", "custom-service") + .containsEntry("service.group", "custom-group") + .containsEntry("key1", "value1") + .containsEntry("key2", ""); + } + + @Test + void resourceAttributesWithNullKeyOrValueShouldBeIgnored() { + this.resourceAttributes.put("service.group", null); + this.resourceAttributes.put("service.name", null); + this.resourceAttributes.put(null, "value"); + this.environmentVariables.put("OTEL_SERVICE_NAME", "custom-service"); + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "key1=value1,key2=value2"); + OpenTelemetryResourceAttributes attributes = getAttributes(); + assertThat(attributes.asMap()).hasSize(3) + .containsEntry("service.name", "custom-service") + .containsEntry("key1", "value1") + .containsEntry("key2", "value2"); + } + + @Test + @SuppressWarnings("unchecked") + void systemGetEnvShouldBeUsedAsDefaultEnvFunctionAndResourceAttributesAreEmpty() { + OpenTelemetryResourceAttributes attributes = new OpenTelemetryResourceAttributes(null); + assertThat(attributes).extracting("resourceAttributes") + .asInstanceOf(InstanceOfAssertFactories.MAP) + .isNotNull() + .isEmpty(); + Function getEnv = assertThat(attributes).extracting("getEnv") + .asInstanceOf(InstanceOfAssertFactories.type(Function.class)) + .actual(); + System.getenv().forEach((key, value) -> assertThat(getEnv.apply(key)).isEqualTo(value)); + } + + @Test + void shouldDecodeOtelResourceAttributeValues() { + Stream.generate(this::generateRandomString).limit(10000).forEach((value) -> { + String key = "key"; + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", key + "=" + escaper.escape(value)); + OpenTelemetryResourceAttributes attributes = getAttributes(); + assertThat(attributes.asMap()).hasSize(1).containsEntry(key, value); + }); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenDecodingPercentIllegalHexChar() { + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "key=abc%ß"); + assertThatIllegalArgumentException().isThrownBy(() -> getAttributes().asMap()) + .withMessage("Failed to decode percent-encoded characters at index 3 in the value: 'abc%ß'"); + } + + @Test + void shouldUseReplacementCharWhenDecodingNonUtf8Character() { + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "key=%a3%3e"); + OpenTelemetryResourceAttributes attributes = getAttributes(); + assertThat(attributes.asMap()).containsEntry("key", "\ufffd>"); + } + + @Test + void shouldThrowIllegalArgumentExceptionWhenDecodingPercent() { + this.environmentVariables.put("OTEL_RESOURCE_ATTRIBUTES", "key=%"); + assertThatIllegalArgumentException().isThrownBy(() -> getAttributes().asMap()) + .withMessage("Failed to decode percent-encoded characters at index 0 in the value: '%'"); + } + + private OpenTelemetryResourceAttributes getAttributes() { + return new OpenTelemetryResourceAttributes(this.resourceAttributes, this.environmentVariables::get); + } + + private String generateRandomString() { + return random.ints(32, 127) + .limit(64) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc index 349ae05782ec..2a37108a344a 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/observability.adoc @@ -96,11 +96,19 @@ Spring Boot's actuator module includes basic support for OpenTelemetry. It provides a bean of type javadoc:io.opentelemetry.api.OpenTelemetry[], and if there are beans of type javadoc:io.opentelemetry.sdk.trace.SdkTracerProvider[], javadoc:io.opentelemetry.context.propagation.ContextPropagators[], javadoc:io.opentelemetry.sdk.logs.SdkLoggerProvider[] or javadoc:io.opentelemetry.sdk.metrics.SdkMeterProvider[] in the application context, they automatically get registered. Additionally, it provides a javadoc:io.opentelemetry.sdk.resources.Resource[] bean. The attributes of the auto-configured javadoc:io.opentelemetry.sdk.resources.Resource[] can be configured via the configprop:management.opentelemetry.resource-attributes[] configuration property. +Auto-configured attributes will be merged with attributes from the `OTEL_RESOURCE_ATTRIBUTES` and `OTEL_SERVICE_NAME` environment variables, with attributes configured through the configuration property taking precedence over those from the environment variables. + + If you have defined your own javadoc:io.opentelemetry.sdk.resources.Resource[] bean, this will no longer be the case. NOTE: Spring Boot does not provide auto-configuration for OpenTelemetry metrics or logging. OpenTelemetry tracing is only auto-configured when used together with xref:actuator/tracing.adoc[Micrometer Tracing]. +NOTE: The `OTEL_RESOURCE_ATTRIBUTES` environment variable consist of a list of key-value pairs. +For example: `key1=value1,key2=value2,key3=spring%20boot`. +All attribute values are treated as strings, and any characters outside the baggage-octet range must be **percent-encoded**. + + The next sections will provide more details about logging, metrics and traces.