Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sentry-spring-boot-4/api/sentry-spring-boot-4.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()V
}

public class io/sentry/spring/boot4/SentryProfilerAutoConfiguration {
public fun <init> ()V
}
Expand Down
4 changes: 4 additions & 0 deletions sentry-spring-boot-4/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -65,6 +67,7 @@ dependencies {

// tests
testImplementation(projects.sentryLogback)
testImplementation(projects.sentryOkhttp)
testImplementation(projects.sentryApacheHttpClient5)
testImplementation(projects.sentryGraphql)
testImplementation(projects.sentryGraphql22)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing event listener not detected

Medium Severity

wrapEventListener only skips when the factory is the private SentryEventListenerFactory, so clients already configured with SentryOkHttpEventListener via OkHttp’s .eventListener() (the documented manual setup) still get rebuilt and nested. Nested listeners are partly mitigated inside SentryOkHttpEventListener, but the skip path is incomplete for already-instrumented beans.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 12cd936. Configure here.

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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<SentryOkHttpInterceptor>()).hasSize(1)
assertThat(client.interceptors.last()).isInstanceOf(SentryOkHttpInterceptor::class.java)

val call = mock<Call>()
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<SentryOkHttpInterceptor>()).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<SentryOkHttpInterceptor>()).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<SentryOkHttpInterceptor>()).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<SentryOkHttpInterceptor>()).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)
}
}
}
Loading