Skip to content

Commit 12cd936

Browse files
committed
add okhttp autoconfiguration for spring boot 4
1 parent 98106dd commit 12cd936

6 files changed

Lines changed: 328 additions & 0 deletions

File tree

‎sentry-spring-boot-4/api/sentry-spring-boot-4.api‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ public class io/sentry/spring/boot4/SentryLogbackInitializer : org/springframewo
2424
public fun supportsEventType (Lorg/springframework/core/ResolvableType;)Z
2525
}
2626

27+
public class io/sentry/spring/boot4/SentryOkHttpAutoConfiguration {
28+
public fun <init> ()V
29+
}
30+
2731
public class io/sentry/spring/boot4/SentryProfilerAutoConfiguration {
2832
public fun <init> ()V
2933
}

‎sentry-spring-boot-4/build.gradle.kts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ dependencies {
3030
api(projects.sentry)
3131
api(projects.sentrySpring7)
3232
compileOnly(projects.sentryLogback)
33+
compileOnly(projects.sentryOkhttp)
34+
compileOnly(libs.okhttp)
3335
compileOnly(projects.sentryApacheHttpClient5)
3436
compileOnly(platform(SpringBootPlugin.BOM_COORDINATES))
3537
compileOnly(projects.sentryGraphql)
@@ -65,6 +67,7 @@ dependencies {
6567

6668
// tests
6769
testImplementation(projects.sentryLogback)
70+
testImplementation(projects.sentryOkhttp)
6871
testImplementation(projects.sentryApacheHttpClient5)
6972
testImplementation(projects.sentryGraphql)
7073
testImplementation(projects.sentryGraphql22)
@@ -81,6 +84,7 @@ dependencies {
8184
testImplementation(platform(SpringBootPlugin.BOM_COORDINATES))
8285
testImplementation(libs.context.propagation)
8386
testImplementation(libs.kotlin.test.junit)
87+
testImplementation(libs.google.truth)
8488
testImplementation(libs.mockito.kotlin)
8589
testImplementation(libs.okhttp)
8690
testImplementation(libs.okhttp.mockwebserver)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package io.sentry.spring.boot4;
2+
3+
import com.jakewharton.nopen.annotation.Open;
4+
import io.sentry.okhttp.SentryOkHttpEventListener;
5+
import io.sentry.okhttp.SentryOkHttpInterceptor;
6+
import okhttp3.OkHttpClient;
7+
import org.jetbrains.annotations.NotNull;
8+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
9+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
10+
import org.springframework.context.annotation.Bean;
11+
import org.springframework.context.annotation.Configuration;
12+
13+
/** Auto-configures Sentry instrumentation for Spring-managed {@link OkHttpClient} beans. */
14+
@Configuration(proxyBeanMethods = false)
15+
@Open
16+
@ConditionalOnClass({
17+
OkHttpClient.class,
18+
SentryOkHttpInterceptor.class,
19+
SentryOkHttpEventListener.class
20+
})
21+
@ConditionalOnProperty(name = "sentry.dsn")
22+
public class SentryOkHttpAutoConfiguration {
23+
24+
@Bean
25+
static @NotNull SentryOkHttpClientBeanPostProcessor sentryOkHttpClientBeanPostProcessor() {
26+
return new SentryOkHttpClientBeanPostProcessor();
27+
}
28+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package io.sentry.spring.boot4;
2+
3+
import io.sentry.ScopesAdapter;
4+
import io.sentry.SentryLevel;
5+
import io.sentry.okhttp.SentryOkHttpEventListener;
6+
import io.sentry.okhttp.SentryOkHttpInterceptor;
7+
import okhttp3.Call;
8+
import okhttp3.EventListener;
9+
import okhttp3.Interceptor;
10+
import okhttp3.OkHttpClient;
11+
import org.jetbrains.annotations.NotNull;
12+
import org.springframework.beans.BeansException;
13+
import org.springframework.beans.factory.config.BeanPostProcessor;
14+
import org.springframework.core.Ordered;
15+
import org.springframework.core.PriorityOrdered;
16+
17+
final class SentryOkHttpClientBeanPostProcessor implements BeanPostProcessor, PriorityOrdered {
18+
19+
@Override
20+
public @NotNull Object postProcessAfterInitialization(
21+
final @NotNull Object bean, final @NotNull String beanName) throws BeansException {
22+
if (!(bean instanceof OkHttpClient)) {
23+
return bean;
24+
}
25+
26+
final @NotNull OkHttpClient client = (OkHttpClient) bean;
27+
if (client.getClass() != OkHttpClient.class) {
28+
ScopesAdapter.getInstance()
29+
.getOptions()
30+
.getLogger()
31+
.log(
32+
SentryLevel.WARNING,
33+
"Sentry OkHttp auto-instrumentation skipped for bean '%s' (%s) because replacing "
34+
+ "an OkHttpClient subclass would not preserve its type. Configure Sentry "
35+
+ "instrumentation manually for this client.",
36+
beanName,
37+
client.getClass().getName());
38+
return client;
39+
}
40+
41+
final boolean addInterceptor = !hasSentryInterceptor(client);
42+
final boolean wrapEventListener =
43+
!(client.eventListenerFactory() instanceof SentryEventListenerFactory);
44+
if (!addInterceptor && !wrapEventListener) {
45+
return client;
46+
}
47+
48+
final @NotNull OkHttpClient.Builder builder = client.newBuilder();
49+
if (addInterceptor) {
50+
builder.addInterceptor(new SentryOkHttpInterceptor());
51+
}
52+
if (wrapEventListener) {
53+
builder.eventListenerFactory(new SentryEventListenerFactory(client.eventListenerFactory()));
54+
}
55+
return builder.build();
56+
}
57+
58+
private static boolean hasSentryInterceptor(final @NotNull OkHttpClient client) {
59+
for (final @NotNull Interceptor interceptor : client.interceptors()) {
60+
if (interceptor instanceof SentryOkHttpInterceptor) {
61+
return true;
62+
}
63+
}
64+
return false;
65+
}
66+
67+
@Override
68+
public int getOrder() {
69+
return Ordered.LOWEST_PRECEDENCE;
70+
}
71+
72+
private static final class SentryEventListenerFactory implements EventListener.Factory {
73+
private final @NotNull EventListener.Factory delegate;
74+
75+
private SentryEventListenerFactory(final @NotNull EventListener.Factory delegate) {
76+
this.delegate = delegate;
77+
}
78+
79+
@Override
80+
public @NotNull EventListener create(final @NotNull Call call) {
81+
return new SentryOkHttpEventListener(ScopesAdapter.getInstance(), delegate);
82+
}
83+
}
84+
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
io.sentry.spring.boot4.SentryAutoConfiguration
22
io.sentry.spring.boot4.SentryProfilerAutoConfiguration
33
io.sentry.spring.boot4.SentryLogbackAppenderAutoConfiguration
4+
io.sentry.spring.boot4.SentryOkHttpAutoConfiguration
45
io.sentry.spring.boot4.SentryWebfluxAutoConfiguration
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package io.sentry.spring.boot4
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import io.sentry.ITransportFactory
5+
import io.sentry.NoOpTransportFactory
6+
import io.sentry.okhttp.SentryOkHttpEventListener
7+
import io.sentry.okhttp.SentryOkHttpInterceptor
8+
import java.util.concurrent.TimeUnit
9+
import java.util.concurrent.atomic.AtomicBoolean
10+
import kotlin.test.Test
11+
import okhttp3.Call
12+
import okhttp3.EventListener
13+
import okhttp3.Interceptor
14+
import okhttp3.OkHttpClient
15+
import okhttp3.Request
16+
import org.mockito.kotlin.mock
17+
import org.mockito.kotlin.whenever
18+
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
19+
import org.springframework.boot.test.context.FilteredClassLoader
20+
import org.springframework.boot.test.context.runner.ApplicationContextRunner
21+
import org.springframework.context.annotation.Bean
22+
import org.springframework.context.annotation.Configuration
23+
24+
class SentryOkHttpAutoConfigurationTest {
25+
26+
private val contextRunner =
27+
ApplicationContextRunner()
28+
.withUserConfiguration(TestApplication::class.java, NoOpTransportConfiguration::class.java)
29+
.withPropertyValues(
30+
"sentry.shutdownTimeoutMillis=0",
31+
"sentry.sessionFlushTimeoutMillis=0",
32+
"sentry.flushTimeoutMillis=0",
33+
"sentry.send-modules=false",
34+
"sentry.enable-backpressure-handling=false",
35+
"sentry.enable-spotlight=false",
36+
)
37+
38+
@Test
39+
fun `instruments a Spring managed OkHttpClient`() {
40+
contextRunner
41+
.withPropertyValues("sentry.dsn=http://key@localhost/proj")
42+
.withUserConfiguration(OkHttpClientConfiguration::class.java)
43+
.run { context ->
44+
val client = context.getBean(OkHttpClient::class.java)
45+
val existingInterceptor = context.getBean("existingInterceptor", Interceptor::class.java)
46+
47+
assertThat(client.connectTimeoutMillis).isEqualTo(1234)
48+
assertThat(client.interceptors).contains(existingInterceptor)
49+
assertThat(client.interceptors.filterIsInstance<SentryOkHttpInterceptor>()).hasSize(1)
50+
assertThat(client.interceptors.last()).isInstanceOf(SentryOkHttpInterceptor::class.java)
51+
52+
val call = mock<Call>()
53+
whenever(call.request()).thenReturn(Request.Builder().url("https://example.com").build())
54+
val listener = client.eventListenerFactory.create(call)
55+
assertThat(listener).isInstanceOf(SentryOkHttpEventListener::class.java)
56+
57+
listener.callStart(call)
58+
assertThat(context.getBean(RecordingEventListener::class.java).callStarted.get()).isTrue()
59+
listener.callEnd(call)
60+
}
61+
}
62+
63+
@Test
64+
fun `instruments every Spring managed OkHttpClient`() {
65+
contextRunner
66+
.withPropertyValues("sentry.dsn=http://key@localhost/proj")
67+
.withUserConfiguration(MultipleOkHttpClientsConfiguration::class.java)
68+
.run { context ->
69+
val clients = context.getBeansOfType(OkHttpClient::class.java)
70+
71+
assertThat(clients).hasSize(2)
72+
clients.values.forEach { client ->
73+
assertThat(client.interceptors.filterIsInstance<SentryOkHttpInterceptor>()).hasSize(1)
74+
assertThat(client.interceptors.last()).isInstanceOf(SentryOkHttpInterceptor::class.java)
75+
}
76+
}
77+
}
78+
79+
@Test
80+
fun `does not duplicate an existing Sentry interceptor`() {
81+
contextRunner
82+
.withPropertyValues("sentry.dsn=http://key@localhost/proj")
83+
.withUserConfiguration(ManuallyInstrumentedOkHttpClientConfiguration::class.java)
84+
.run { context ->
85+
val client = context.getBean(OkHttpClient::class.java)
86+
87+
assertThat(client.interceptors.filterIsInstance<SentryOkHttpInterceptor>()).hasSize(1)
88+
assertThat(client.eventListenerFactory.create(mock()))
89+
.isInstanceOf(SentryOkHttpEventListener::class.java)
90+
}
91+
}
92+
93+
@Test
94+
fun `post processor is idempotent`() {
95+
val processor = SentryOkHttpClientBeanPostProcessor()
96+
val firstResult =
97+
processor.postProcessAfterInitialization(OkHttpClient(), "okHttpClient") as OkHttpClient
98+
val secondResult =
99+
processor.postProcessAfterInitialization(firstResult, "okHttpClient") as OkHttpClient
100+
101+
assertThat(secondResult).isSameInstanceAs(firstResult)
102+
assertThat(secondResult.interceptors.filterIsInstance<SentryOkHttpInterceptor>()).hasSize(1)
103+
}
104+
105+
@Test
106+
fun `does not replace unrelated beans`() {
107+
val processor = SentryOkHttpClientBeanPostProcessor()
108+
val bean = Any()
109+
110+
assertThat(processor.postProcessAfterInitialization(bean, "bean")).isSameInstanceAs(bean)
111+
}
112+
113+
@Test
114+
fun `does not replace OkHttpClient subclasses`() {
115+
val processor = SentryOkHttpClientBeanPostProcessor()
116+
val client = CustomOkHttpClient()
117+
118+
assertThat(processor.postProcessAfterInitialization(client, "okHttpClient"))
119+
.isSameInstanceAs(client)
120+
assertThat(client.interceptors).isEmpty()
121+
}
122+
123+
@Test
124+
fun `does not instrument OkHttpClient without a dsn`() {
125+
contextRunner.withUserConfiguration(OkHttpClientConfiguration::class.java).run { context ->
126+
val client = context.getBean(OkHttpClient::class.java)
127+
128+
assertThat(client.interceptors.filterIsInstance<SentryOkHttpInterceptor>()).isEmpty()
129+
assertThat(client.eventListenerFactory.create(mock()))
130+
.isNotInstanceOf(SentryOkHttpEventListener::class.java)
131+
}
132+
}
133+
134+
@Test
135+
fun `does not create a default OkHttpClient`() {
136+
contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { context ->
137+
assertThat(context.getBeansOfType(OkHttpClient::class.java)).isEmpty()
138+
}
139+
}
140+
141+
@Test
142+
fun `does not instrument when sentry-okhttp is not on the classpath`() {
143+
contextRunner
144+
.withClassLoader(FilteredClassLoader(SentryOkHttpInterceptor::class.java))
145+
.withPropertyValues("sentry.dsn=http://key@localhost/proj")
146+
.withUserConfiguration(OkHttpClientConfiguration::class.java)
147+
.run { context ->
148+
val client = context.getBean(OkHttpClient::class.java)
149+
assertThat(client.interceptors).hasSize(1)
150+
assertThat(client.interceptors.first().javaClass.name)
151+
.isEqualTo(OkHttpClientConfiguration.ExistingInterceptor::class.java.name)
152+
}
153+
}
154+
155+
@Configuration(proxyBeanMethods = false) @EnableAutoConfiguration open class TestApplication
156+
157+
@Configuration(proxyBeanMethods = false)
158+
open class NoOpTransportConfiguration {
159+
@Bean open fun noOpTransportFactory(): ITransportFactory = NoOpTransportFactory.getInstance()
160+
}
161+
162+
@Configuration(proxyBeanMethods = false)
163+
open class OkHttpClientConfiguration {
164+
@Bean open fun existingInterceptor(): Interceptor = ExistingInterceptor()
165+
166+
@Bean open fun recordingEventListener(): RecordingEventListener = RecordingEventListener()
167+
168+
@Bean
169+
open fun okHttpClient(
170+
existingInterceptor: Interceptor,
171+
recordingEventListener: RecordingEventListener,
172+
): OkHttpClient =
173+
OkHttpClient.Builder()
174+
.connectTimeout(1234, TimeUnit.MILLISECONDS)
175+
.addInterceptor(existingInterceptor)
176+
.eventListener(recordingEventListener)
177+
.build()
178+
179+
class ExistingInterceptor : Interceptor {
180+
override fun intercept(chain: Interceptor.Chain) = chain.proceed(chain.request())
181+
}
182+
}
183+
184+
@Configuration(proxyBeanMethods = false)
185+
open class MultipleOkHttpClientsConfiguration {
186+
@Bean open fun firstOkHttpClient(): OkHttpClient = OkHttpClient()
187+
188+
@Bean open fun secondOkHttpClient(): OkHttpClient = OkHttpClient()
189+
}
190+
191+
@Configuration(proxyBeanMethods = false)
192+
open class ManuallyInstrumentedOkHttpClientConfiguration {
193+
@Bean
194+
open fun okHttpClient(): OkHttpClient =
195+
OkHttpClient.Builder().addInterceptor(SentryOkHttpInterceptor()).build()
196+
}
197+
198+
class CustomOkHttpClient : OkHttpClient()
199+
200+
class RecordingEventListener : EventListener() {
201+
val callStarted = AtomicBoolean(false)
202+
203+
override fun callStart(call: Call) {
204+
callStarted.set(true)
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)