diff --git a/spring-cloud-config-client/pom.xml b/spring-cloud-config-client/pom.xml index c38d75265..2f92b8291 100644 --- a/spring-cloud-config-client/pom.xml +++ b/spring-cloud-config-client/pom.xml @@ -58,6 +58,11 @@ spring-boot-starter-actuator true + + org.springframework.boot + spring-boot-restclient + true + org.springframework.boot spring-boot-starter-aspectj diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerBootstrapper.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerBootstrapper.java index a95a26275..6f1d30e32 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerBootstrapper.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerBootstrapper.java @@ -35,7 +35,7 @@ public class ConfigServerBootstrapper implements BootstrapRegistryInitializer { private LoaderInterceptor loaderInterceptor; - static ConfigServerBootstrapper create() { + public static ConfigServerBootstrapper create() { return new ConfigServerBootstrapper(); } diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java index 5e94ab0a6..62111d566 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java @@ -240,8 +240,14 @@ public List resolveProfileSpecific( .registerSingleton("configDataConfigClientProperties", event.getBootstrapContext().get(ConfigClientProperties.class))); - bootstrapContext.registerIfAbsent(ConfigClientRequestTemplateFactory.class, - context -> new ConfigClientRequestTemplateFactory(log, context.get(ConfigClientProperties.class))); + bootstrapContext.registerIfAbsent(ConfigClientRequestTemplateFactory.class, context -> { + ConfigClientProperties props = context.get(ConfigClientProperties.class); + if (ClassUtils + .isPresent("org.springframework.boot.restclient.observation.ObservationRestTemplateCustomizer", null)) { + return ObservationConfigClientRequestTemplateFactory.createWithObservation(context, log, props); + } + return new ConfigClientRequestTemplateFactory(log, props); + }); bootstrapContext.registerIfAbsent(RestTemplate.class, context -> { ConfigClientRequestTemplateFactory factory = context.get(ConfigClientRequestTemplateFactory.class); diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ObservationConfigClientRequestTemplateFactory.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ObservationConfigClientRequestTemplateFactory.java new file mode 100644 index 000000000..8e3f58ce0 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ObservationConfigClientRequestTemplateFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026-present 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.cloud.config.client; + +import io.micrometer.observation.ObservationRegistry; +import org.apache.commons.logging.Log; + +import org.springframework.boot.bootstrap.BootstrapContext; +import org.springframework.boot.restclient.observation.ObservationRestTemplateCustomizer; +import org.springframework.http.client.observation.DefaultClientRequestObservationConvention; +import org.springframework.web.client.RestTemplate; + +public class ObservationConfigClientRequestTemplateFactory extends ConfigClientRequestTemplateFactory { + + private final ObservationRestTemplateCustomizer observationRestTemplateCustomizer; + + public ObservationConfigClientRequestTemplateFactory(Log log, ConfigClientProperties properties, + ObservationRegistry observationRegistry) { + super(log, properties); + this.observationRestTemplateCustomizer = observationRegistry != ObservationRegistry.NOOP + ? new ObservationRestTemplateCustomizer(observationRegistry, + new DefaultClientRequestObservationConvention()) + : null; + } + + @Override + public RestTemplate create() { + RestTemplate template = super.create(); + if (observationRestTemplateCustomizer != null) { + observationRestTemplateCustomizer.customize(template); + } + return template; + } + + static ConfigClientRequestTemplateFactory createWithObservation(BootstrapContext context, Log log, + ConfigClientProperties props) { + ObservationRegistry registry = context.getOrElse(ObservationRegistry.class, ObservationRegistry.NOOP); + return new ObservationConfigClientRequestTemplateFactory(log, props, registry); + } + +} diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ObservationConfigServerBootstrapper.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ObservationConfigServerBootstrapper.java new file mode 100644 index 000000000..5c8dbf7e7 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ObservationConfigServerBootstrapper.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026-present 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.cloud.config.client; + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.boot.bootstrap.BootstrapRegistry; + +public class ObservationConfigServerBootstrapper extends ConfigServerBootstrapper { + + private ObservationRegistry observationRegistry; + + public static ObservationConfigServerBootstrapper create() { + return new ObservationConfigServerBootstrapper(); + } + + public ObservationConfigServerBootstrapper withObservationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + @Override + public void initialize(BootstrapRegistry registry) { + super.initialize(registry); + if (observationRegistry != null) { + registry.register(ObservationRegistry.class, BootstrapRegistry.InstanceSupplier.of(observationRegistry)); + } + } + +} diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactoryTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactoryTests.java new file mode 100644 index 000000000..464d9fb27 --- /dev/null +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactoryTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026-present 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.cloud.config.client; + +import io.micrometer.observation.ObservationRegistry; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.Test; + +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Luccas Asaphe + * + */ +public class ConfigClientRequestTemplateFactoryTests { + + @Test + void shouldInstrumentRestTemplateWhenObservationRegistryProvided() { + // 1. set up the factory + ConfigClientProperties properties = new ConfigClientProperties(); + ObservationRegistry registry = ObservationRegistry.create(); + ObservationConfigClientRequestTemplateFactory factory = new ObservationConfigClientRequestTemplateFactory( + LogFactory.getLog(getClass()), properties, registry); + + // 2. create the RestTemplate + RestTemplate restTemplate = factory.create(); + + // 3. verify the observation registry + assertThat(restTemplate.getObservationRegistry()).isEqualTo(registry); + } + + @Test + void shouldNotInstrumentRestTemplateWhenObservationRegistryNotProvided() { + // 1. set up the factory + ConfigClientProperties properties = new ConfigClientProperties(); + ConfigClientRequestTemplateFactory factory = new ConfigClientRequestTemplateFactory( + LogFactory.getLog(getClass()), properties); + + // 2. create the RestTemplate + RestTemplate restTemplate = factory.create(); + + // 3. verify the observation registry + assertThat(restTemplate.getObservationRegistry()).isEqualTo(ObservationRegistry.NOOP); + } + + @Test + void shouldNotInstrumentRestTemplateWhenObservationRegistryIsNoop() { + // 1. set up the factory + ConfigClientProperties properties = new ConfigClientProperties(); + ObservationRegistry registry = ObservationRegistry.NOOP; + ObservationConfigClientRequestTemplateFactory factory = new ObservationConfigClientRequestTemplateFactory( + LogFactory.getLog(getClass()), properties, registry); + + // 2. create the RestTemplate + RestTemplate restTemplate = factory.create(); + + // 3. verify the observation registry + assertThat(restTemplate.getObservationRegistry()).isEqualTo(ObservationRegistry.NOOP); + } + +} diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServerConfigDataCustomizationIntegrationTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServerConfigDataCustomizationIntegrationTests.java index 17aea6ed5..b207afa57 100644 --- a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServerConfigDataCustomizationIntegrationTests.java +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServerConfigDataCustomizationIntegrationTests.java @@ -18,6 +18,7 @@ import java.util.Optional; +import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; @@ -91,6 +92,34 @@ void customizableRestTemplate() { } } + @Test + void customizableObservationRegistry() { + ConfigurableApplicationContext context = null; + try { + ObservationRegistry registry = ObservationRegistry.create(); + context = new SpringApplicationBuilder(TestConfig.class) + .addBootstrapRegistryInitializer( + ObservationConfigServerBootstrapper.create().withObservationRegistry(registry)) + .addBootstrapRegistryInitializer(reg -> reg.addCloseListener(event -> { + BootstrapContext bootstrapContext = event.getBootstrapContext(); + ConfigurableListableBeanFactory beanFactory = event.getApplicationContext().getBeanFactory(); + + RestTemplate restTemplate = bootstrapContext.get(RestTemplate.class); + beanFactory.registerSingleton("holder", new RestTemplateHolder(restTemplate)); + })) + .run("--spring.config.import=optional:configserver:"); + + RestTemplateHolder holder = context.getBean(RestTemplateHolder.class); + assertThat(holder).isNotNull(); + assertThat(holder.restTemplate.getObservationRegistry()).isEqualTo(registry); + } + finally { + if (context != null) { + context.close(); + } + } + } + CustomRestTemplate restTemplate(BootstrapContext context) { ConfigClientProperties properties = context.get(ConfigClientProperties.class); String custom = context.get(Binder.class).bind("custom.prop", String.class).orElse("default-custom-prop"); diff --git a/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServerConfigDataWithoutMicrometerTests.java b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServerConfigDataWithoutMicrometerTests.java new file mode 100644 index 000000000..8281fc7d2 --- /dev/null +++ b/spring-cloud-config-client/src/test/java/org/springframework/cloud/config/client/ConfigServerConfigDataWithoutMicrometerTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026-present 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.cloud.config.client; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.bootstrap.BootstrapContext; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.test.ClassPathExclusions; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Luccas Asaphe + * + */ +@ClassPathExclusions({ "spring-boot-restclient-*.jar" }) +public class ConfigServerConfigDataWithoutMicrometerTests { + + @Test + void contextStartsWithoutMicrometer() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder(TestConfig.class) + .web(WebApplicationType.NONE) + .addBootstrapRegistryInitializer(registry -> registry.addCloseListener(event -> { + BootstrapContext bootstrapContext = event.getBootstrapContext(); + ConfigurableListableBeanFactory beanFactory = event.getApplicationContext().getBeanFactory(); + + ConfigClientRequestTemplateFactory templateFactory = bootstrapContext + .get(ConfigClientRequestTemplateFactory.class); + beanFactory.registerSingleton("factory", templateFactory); + })) + .run("--spring.config.import=optional:configserver:")) { + assertThat(context).isNotNull(); + + assertThat(context.getBean("factory")).isNotInstanceOf(ObservationConfigClientRequestTemplateFactory.class); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestConfig { + + } + +}