From 12cd936650a5eb72e38c2115c1a9f76ca2da0655 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 21 Jul 2026 10:02:09 +0200 Subject: [PATCH] add okhttp autoconfiguration for spring boot 4 --- .../api/sentry-spring-boot-4.api | 4 + sentry-spring-boot-4/build.gradle.kts | 4 + .../boot4/SentryOkHttpAutoConfiguration.java | 28 +++ .../SentryOkHttpClientBeanPostProcessor.java | 84 +++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../SentryOkHttpAutoConfigurationTest.kt | 207 ++++++++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpAutoConfiguration.java create mode 100644 sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpClientBeanPostProcessor.java create mode 100644 sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryOkHttpAutoConfigurationTest.kt diff --git a/sentry-spring-boot-4/api/sentry-spring-boot-4.api b/sentry-spring-boot-4/api/sentry-spring-boot-4.api index 4c8be990b85..723ef6abd14 100644 --- a/sentry-spring-boot-4/api/sentry-spring-boot-4.api +++ b/sentry-spring-boot-4/api/sentry-spring-boot-4.api @@ -24,6 +24,10 @@ public class io/sentry/spring/boot4/SentryLogbackInitializer : org/springframewo public fun supportsEventType (Lorg/springframework/core/ResolvableType;)Z } +public class io/sentry/spring/boot4/SentryOkHttpAutoConfiguration { + public fun ()V +} + public class io/sentry/spring/boot4/SentryProfilerAutoConfiguration { public fun ()V } diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 2a6634b257f..888585c898e 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -30,6 +30,8 @@ dependencies { api(projects.sentry) api(projects.sentrySpring7) compileOnly(projects.sentryLogback) + compileOnly(projects.sentryOkhttp) + compileOnly(libs.okhttp) compileOnly(projects.sentryApacheHttpClient5) compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) compileOnly(projects.sentryGraphql) @@ -65,6 +67,7 @@ dependencies { // tests testImplementation(projects.sentryLogback) + testImplementation(projects.sentryOkhttp) testImplementation(projects.sentryApacheHttpClient5) testImplementation(projects.sentryGraphql) testImplementation(projects.sentryGraphql22) @@ -81,6 +84,7 @@ dependencies { testImplementation(platform(SpringBootPlugin.BOM_COORDINATES)) testImplementation(libs.context.propagation) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.okhttp) testImplementation(libs.okhttp.mockwebserver) diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpAutoConfiguration.java new file mode 100644 index 00000000000..7fdb73af9bb --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpAutoConfiguration.java @@ -0,0 +1,28 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.okhttp.SentryOkHttpEventListener; +import io.sentry.okhttp.SentryOkHttpInterceptor; +import okhttp3.OkHttpClient; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Auto-configures Sentry instrumentation for Spring-managed {@link OkHttpClient} beans. */ +@Configuration(proxyBeanMethods = false) +@Open +@ConditionalOnClass({ + OkHttpClient.class, + SentryOkHttpInterceptor.class, + SentryOkHttpEventListener.class +}) +@ConditionalOnProperty(name = "sentry.dsn") +public class SentryOkHttpAutoConfiguration { + + @Bean + static @NotNull SentryOkHttpClientBeanPostProcessor sentryOkHttpClientBeanPostProcessor() { + return new SentryOkHttpClientBeanPostProcessor(); + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpClientBeanPostProcessor.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpClientBeanPostProcessor.java new file mode 100644 index 00000000000..9b0e465c906 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryOkHttpClientBeanPostProcessor.java @@ -0,0 +1,84 @@ +package io.sentry.spring.boot4; + +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.okhttp.SentryOkHttpEventListener; +import io.sentry.okhttp.SentryOkHttpInterceptor; +import okhttp3.Call; +import okhttp3.EventListener; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; + +final class SentryOkHttpClientBeanPostProcessor implements BeanPostProcessor, PriorityOrdered { + + @Override + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (!(bean instanceof OkHttpClient)) { + return bean; + } + + final @NotNull OkHttpClient client = (OkHttpClient) bean; + if (client.getClass() != OkHttpClient.class) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Sentry OkHttp auto-instrumentation skipped for bean '%s' (%s) because replacing " + + "an OkHttpClient subclass would not preserve its type. Configure Sentry " + + "instrumentation manually for this client.", + beanName, + client.getClass().getName()); + return client; + } + + final boolean addInterceptor = !hasSentryInterceptor(client); + final boolean wrapEventListener = + !(client.eventListenerFactory() instanceof SentryEventListenerFactory); + if (!addInterceptor && !wrapEventListener) { + return client; + } + + final @NotNull OkHttpClient.Builder builder = client.newBuilder(); + if (addInterceptor) { + builder.addInterceptor(new SentryOkHttpInterceptor()); + } + if (wrapEventListener) { + builder.eventListenerFactory(new SentryEventListenerFactory(client.eventListenerFactory())); + } + return builder.build(); + } + + private static boolean hasSentryInterceptor(final @NotNull OkHttpClient client) { + for (final @NotNull Interceptor interceptor : client.interceptors()) { + if (interceptor instanceof SentryOkHttpInterceptor) { + return true; + } + } + return false; + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + private static final class SentryEventListenerFactory implements EventListener.Factory { + private final @NotNull EventListener.Factory delegate; + + private SentryEventListenerFactory(final @NotNull EventListener.Factory delegate) { + this.delegate = delegate; + } + + @Override + public @NotNull EventListener create(final @NotNull Call call) { + return new SentryOkHttpEventListener(ScopesAdapter.getInstance(), delegate); + } + } +} diff --git a/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index a108fa2ca10..0fe658c8a18 100644 --- a/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ io.sentry.spring.boot4.SentryAutoConfiguration io.sentry.spring.boot4.SentryProfilerAutoConfiguration io.sentry.spring.boot4.SentryLogbackAppenderAutoConfiguration +io.sentry.spring.boot4.SentryOkHttpAutoConfiguration io.sentry.spring.boot4.SentryWebfluxAutoConfiguration diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryOkHttpAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryOkHttpAutoConfigurationTest.kt new file mode 100644 index 00000000000..536ce6dbe21 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryOkHttpAutoConfigurationTest.kt @@ -0,0 +1,207 @@ +package io.sentry.spring.boot4 + +import com.google.common.truth.Truth.assertThat +import io.sentry.ITransportFactory +import io.sentry.NoOpTransportFactory +import io.sentry.okhttp.SentryOkHttpEventListener +import io.sentry.okhttp.SentryOkHttpInterceptor +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Test +import okhttp3.Call +import okhttp3.EventListener +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +class SentryOkHttpAutoConfigurationTest { + + private val contextRunner = + ApplicationContextRunner() + .withUserConfiguration(TestApplication::class.java, NoOpTransportConfiguration::class.java) + .withPropertyValues( + "sentry.shutdownTimeoutMillis=0", + "sentry.sessionFlushTimeoutMillis=0", + "sentry.flushTimeoutMillis=0", + "sentry.send-modules=false", + "sentry.enable-backpressure-handling=false", + "sentry.enable-spotlight=false", + ) + + @Test + fun `instruments a Spring managed OkHttpClient`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(OkHttpClientConfiguration::class.java) + .run { context -> + val client = context.getBean(OkHttpClient::class.java) + val existingInterceptor = context.getBean("existingInterceptor", Interceptor::class.java) + + assertThat(client.connectTimeoutMillis).isEqualTo(1234) + assertThat(client.interceptors).contains(existingInterceptor) + assertThat(client.interceptors.filterIsInstance()).hasSize(1) + assertThat(client.interceptors.last()).isInstanceOf(SentryOkHttpInterceptor::class.java) + + val call = mock() + whenever(call.request()).thenReturn(Request.Builder().url("https://example.com").build()) + val listener = client.eventListenerFactory.create(call) + assertThat(listener).isInstanceOf(SentryOkHttpEventListener::class.java) + + listener.callStart(call) + assertThat(context.getBean(RecordingEventListener::class.java).callStarted.get()).isTrue() + listener.callEnd(call) + } + } + + @Test + fun `instruments every Spring managed OkHttpClient`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(MultipleOkHttpClientsConfiguration::class.java) + .run { context -> + val clients = context.getBeansOfType(OkHttpClient::class.java) + + assertThat(clients).hasSize(2) + clients.values.forEach { client -> + assertThat(client.interceptors.filterIsInstance()).hasSize(1) + assertThat(client.interceptors.last()).isInstanceOf(SentryOkHttpInterceptor::class.java) + } + } + } + + @Test + fun `does not duplicate an existing Sentry interceptor`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(ManuallyInstrumentedOkHttpClientConfiguration::class.java) + .run { context -> + val client = context.getBean(OkHttpClient::class.java) + + assertThat(client.interceptors.filterIsInstance()).hasSize(1) + assertThat(client.eventListenerFactory.create(mock())) + .isInstanceOf(SentryOkHttpEventListener::class.java) + } + } + + @Test + fun `post processor is idempotent`() { + val processor = SentryOkHttpClientBeanPostProcessor() + val firstResult = + processor.postProcessAfterInitialization(OkHttpClient(), "okHttpClient") as OkHttpClient + val secondResult = + processor.postProcessAfterInitialization(firstResult, "okHttpClient") as OkHttpClient + + assertThat(secondResult).isSameInstanceAs(firstResult) + assertThat(secondResult.interceptors.filterIsInstance()).hasSize(1) + } + + @Test + fun `does not replace unrelated beans`() { + val processor = SentryOkHttpClientBeanPostProcessor() + val bean = Any() + + assertThat(processor.postProcessAfterInitialization(bean, "bean")).isSameInstanceAs(bean) + } + + @Test + fun `does not replace OkHttpClient subclasses`() { + val processor = SentryOkHttpClientBeanPostProcessor() + val client = CustomOkHttpClient() + + assertThat(processor.postProcessAfterInitialization(client, "okHttpClient")) + .isSameInstanceAs(client) + assertThat(client.interceptors).isEmpty() + } + + @Test + fun `does not instrument OkHttpClient without a dsn`() { + contextRunner.withUserConfiguration(OkHttpClientConfiguration::class.java).run { context -> + val client = context.getBean(OkHttpClient::class.java) + + assertThat(client.interceptors.filterIsInstance()).isEmpty() + assertThat(client.eventListenerFactory.create(mock())) + .isNotInstanceOf(SentryOkHttpEventListener::class.java) + } + } + + @Test + fun `does not create a default OkHttpClient`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { context -> + assertThat(context.getBeansOfType(OkHttpClient::class.java)).isEmpty() + } + } + + @Test + fun `does not instrument when sentry-okhttp is not on the classpath`() { + contextRunner + .withClassLoader(FilteredClassLoader(SentryOkHttpInterceptor::class.java)) + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(OkHttpClientConfiguration::class.java) + .run { context -> + val client = context.getBean(OkHttpClient::class.java) + assertThat(client.interceptors).hasSize(1) + assertThat(client.interceptors.first().javaClass.name) + .isEqualTo(OkHttpClientConfiguration.ExistingInterceptor::class.java.name) + } + } + + @Configuration(proxyBeanMethods = false) @EnableAutoConfiguration open class TestApplication + + @Configuration(proxyBeanMethods = false) + open class NoOpTransportConfiguration { + @Bean open fun noOpTransportFactory(): ITransportFactory = NoOpTransportFactory.getInstance() + } + + @Configuration(proxyBeanMethods = false) + open class OkHttpClientConfiguration { + @Bean open fun existingInterceptor(): Interceptor = ExistingInterceptor() + + @Bean open fun recordingEventListener(): RecordingEventListener = RecordingEventListener() + + @Bean + open fun okHttpClient( + existingInterceptor: Interceptor, + recordingEventListener: RecordingEventListener, + ): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(1234, TimeUnit.MILLISECONDS) + .addInterceptor(existingInterceptor) + .eventListener(recordingEventListener) + .build() + + class ExistingInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain) = chain.proceed(chain.request()) + } + } + + @Configuration(proxyBeanMethods = false) + open class MultipleOkHttpClientsConfiguration { + @Bean open fun firstOkHttpClient(): OkHttpClient = OkHttpClient() + + @Bean open fun secondOkHttpClient(): OkHttpClient = OkHttpClient() + } + + @Configuration(proxyBeanMethods = false) + open class ManuallyInstrumentedOkHttpClientConfiguration { + @Bean + open fun okHttpClient(): OkHttpClient = + OkHttpClient.Builder().addInterceptor(SentryOkHttpInterceptor()).build() + } + + class CustomOkHttpClient : OkHttpClient() + + class RecordingEventListener : EventListener() { + val callStarted = AtomicBoolean(false) + + override fun callStart(call: Call) { + callStarted.set(true) + } + } +}