Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement ThreadLocalAccessor for propagating Sentry hub with reactor / WebFlux #2570

Merged
merged 10 commits into from
Mar 1, 2023
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
- If set to `false` performance is disabled, regardless of `tracesSampleRate` and `tracesSampler` options.
- Detect dependencies by listing MANIFEST.MF files at runtime ([#2538](https://github.com/getsentry/sentry-java/pull/2538))
- Report integrations in use, report packages in use more consistently ([#2179](https://github.com/getsentry/sentry-java/pull/2179))
- Implement `ThreadLocalAccessor` for propagating Sentry hub with reactor / WebFlux ([#2570](https://github.com/getsentry/sentry-java/pull/2570))
- Requires `io.micrometer:context-propagation:1.0.2+` as well as Spring Boot 3.0.3+
- Enable the feature by setting `sentry.reactive.thread-local-accessor-enabled=true`
- This is still considered experimental. Once we have enough feedback we may turn this on by default.
- Checkout the sample here: https://github.com/getsentry/sentry-java/tree/main/sentry-samples/sentry-samples-spring-boot-webflux-jakarta

### Fixes

Expand Down
5 changes: 3 additions & 2 deletions buildSrc/src/main/java/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ object Config {
val kotlinStdLib = "stdlib-jdk8"

val springBootVersion = "2.7.5"
val springBoot3Version = "3.0.0"
val springBoot3Version = "3.0.3"
val kotlinCompatibleLanguageVersion = "1.4"

val composeVersion = "1.1.1"
Expand Down Expand Up @@ -107,7 +107,8 @@ object Config {

val fragment = "androidx.fragment:fragment-ktx:1.3.5"

val reactorCore = "io.projectreactor:reactor-core:3.4.6"
val reactorCore = "io.projectreactor:reactor-core:3.5.3"
val contextPropagation = "io.micrometer:context-propagation:1.0.2"

private val feignVersion = "11.6"
val feignCore = "io.github.openfeign:feign-core:$feignVersion"
Expand Down
2 changes: 2 additions & 0 deletions sentry-spring-boot-starter-jakarta/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ dependencies {
compileOnly(Config.Libs.springBoot3StarterAop)
compileOnly(Config.Libs.springBoot3StarterSecurity)
compileOnly(Config.Libs.reactorCore)
compileOnly(Config.Libs.contextPropagation)
compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryCore)

annotationProcessor(Config.AnnotationProcessors.springBootAutoConfigure)
Expand All @@ -79,6 +80,7 @@ dependencies {
testImplementation(Config.Libs.springBoot3StarterSecurity)
testImplementation(Config.Libs.springBoot3StarterAop)
testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryCore)
testImplementation(Config.Libs.contextPropagation)
}

configure<SourceSetContainer> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public class SentryProperties extends SentryOptions {
/** Logging framework integration properties. */
private @NotNull Logging logging = new Logging();

/** Reactive framework (e.g. WebFlux) integration properties */
private @NotNull Reactive reactive = new Reactive();

public boolean isUseGitCommitIdAsRelease() {
return useGitCommitIdAsRelease;
}
Expand Down Expand Up @@ -72,6 +75,14 @@ public void setLogging(@NotNull Logging logging) {
this.logging = logging;
}

public @NotNull Reactive getReactive() {
return reactive;
}

public void setReactive(@NotNull Reactive reactive) {
this.reactive = reactive;
}

@Open
public static class Logging {
/** Enable/Disable logging auto-configuration. */
Expand Down Expand Up @@ -107,4 +118,18 @@ public void setMinimumEventLevel(@Nullable Level minimumEventLevel) {
this.minimumEventLevel = minimumEventLevel;
}
}

@Open
public static class Reactive {
/** Enable/Disable usage of {@link io.micrometer.context.ThreadLocalAccessor} for Hub propagation */
private boolean threadLocalAccessorEnabled = true;

public boolean isThreadLocalAccessorEnabled() {
return threadLocalAccessorEnabled;
}

public void setThreadLocalAccessorEnabled(boolean threadLocalAccessorEnabled) {
this.threadLocalAccessorEnabled = threadLocalAccessorEnabled;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
package io.sentry.spring.boot.jakarta;

import com.jakewharton.nopen.annotation.Open;

import io.sentry.IHub;
import io.sentry.spring.jakarta.webflux.SentryScheduleHook;
import io.sentry.spring.jakarta.webflux.SentryWebExceptionHandler;
import io.sentry.spring.jakarta.webflux.SentryWebFilter;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.AllNestedConditions;
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;

import io.sentry.spring.jakarta.webflux.SentryWebFilterWithThreadLocalAccessor;
import reactor.core.publisher.Hooks;
import reactor.core.scheduler.Schedulers;

/** Configures Sentry integration for Spring Webflux and Project Reactor. */
Expand All @@ -24,23 +33,77 @@
@ApiStatus.Experimental
public class SentryWebfluxAutoConfiguration {

/** Configures hook that sets correct hub on the executing thread. */
@Bean
public @NotNull ApplicationRunner sentryScheduleHookApplicationRunner() {
return args -> {
Schedulers.onScheduleHook("sentry", new SentryScheduleHook());
};
@Configuration(proxyBeanMethods = false)
@Conditional(SentryThreadLocalAccessorCondition.class)
@Open
static class SentryWebfluxFilterThreadLocalAccessorConfiguration {

/**
* Configures a filter that sets up Sentry {@link io.sentry.Scope} for each request.
*
* Makes use of newer reactor-core and context-propagation library feature ThreadLocalAccessor
* to propagate the Sentry hub.
*/
@Bean
public @NotNull SentryWebFilterWithThreadLocalAccessor sentryWebFilterWithContextPropagation(final @NotNull IHub hub) {
Hooks.enableAutomaticContextPropagation();
return new SentryWebFilterWithThreadLocalAccessor(hub);
}
}

/** Configures a filter that sets up Sentry {@link io.sentry.Scope} for each request. */
@Bean
public @NotNull SentryWebFilter sentryWebFilter(final @NotNull IHub hub) {
return new SentryWebFilter(hub);
@Configuration(proxyBeanMethods = false)
@Conditional(SentryLegacyFilterConfigurationCondition.class)
@Open
static class SentryWebfluxFilterConfiguration {

/** Configures hook that sets correct hub on the executing thread. */
@Bean
public @NotNull ApplicationRunner sentryScheduleHookApplicationRunner() {
return args -> {
Schedulers.onScheduleHook("sentry", new SentryScheduleHook());
};
}

/** Configures a filter that sets up Sentry {@link io.sentry.Scope} for each request. */
@Bean
public @NotNull SentryWebFilter sentryWebFilter(final @NotNull IHub hub) {
return new SentryWebFilter(hub);
}
}

/** Configures exception handler that handles unhandled exceptions and sends them to Sentry. */
@Bean
public @NotNull SentryWebExceptionHandler sentryWebExceptionHandler(final @NotNull IHub hub) {
return new SentryWebExceptionHandler(hub);
}

static final class SentryLegacyFilterConfigurationCondition extends AnyNestedCondition {

public SentryLegacyFilterConfigurationCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}

@ConditionalOnProperty(name = "sentry.reactive.thread-local-accessor-enabled", havingValue = "false", matchIfMissing = true)
@SuppressWarnings("UnusedNestedClass")
private static class SentryDisableThreadLocalAccessorCondition {}

@ConditionalOnMissingClass("io.micrometer.context.ThreadLocalAccessor")
@SuppressWarnings("UnusedNestedClass")
private static class ThreadLocalAccessorClassCondition {}
}

static final class SentryThreadLocalAccessorCondition extends AllNestedConditions {

public SentryThreadLocalAccessorCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}

@ConditionalOnProperty(name = "sentry.reactive.thread-local-accessor-enabled", havingValue = "true")
@SuppressWarnings("UnusedNestedClass")
private static class SentryEnableThreadLocalAccessorCondition {}

@ConditionalOnClass(io.micrometer.context.ThreadLocalAccessor.class)
@SuppressWarnings("UnusedNestedClass")
private static class ThreadLocalAccessorClassCondition {}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package io.sentry.spring.boot.jakarta

import io.micrometer.context.ThreadLocalAccessor
import io.sentry.spring.jakarta.webflux.SentryWebExceptionHandler
import io.sentry.spring.jakarta.webflux.SentryWebFilter
import io.sentry.spring.jakarta.webflux.SentryWebFilterWithThreadLocalAccessor
import org.assertj.core.api.Assertions.assertThat
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration
Expand All @@ -20,6 +22,7 @@ class SentryWebfluxAutoConfigurationTest {
contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj")
.run {
assertThat(it).hasSingleBean(SentryWebFilter::class.java)
assertThat(it).doesNotHaveBean(SentryWebFilterWithThreadLocalAccessor::class.java)
}
}

Expand Down Expand Up @@ -50,4 +53,35 @@ class SentryWebfluxAutoConfigurationTest {
assertThat(it).doesNotHaveBean(SentryWebFilter::class.java)
}
}

@Test
fun `configures web filter with ThreadLocalAccessor support if available and enabled`() {
contextRunner
.withPropertyValues("sentry.dsn=http://key@localhost/proj",
"sentry.reactive.thread-local-accessor-enabled=true")
.run {
assertThat(it).hasSingleBean(SentryWebFilterWithThreadLocalAccessor::class.java)
}
}

@Test
fun `does not configure web filter with ThreadLocalAccessor support if disabled`() {
contextRunner
.withPropertyValues("sentry.dsn=http://key@localhost/proj",
"sentry.reactive.thread-local-accessor-enabled=false")
.run {
assertThat(it).doesNotHaveBean(SentryWebFilterWithThreadLocalAccessor::class.java)
}
}

@Test
fun `does not configure web filter with ThreadLocalAccessor support if not available`() {
contextRunner
.withPropertyValues("sentry.dsn=http://key@localhost/proj",
"sentry.reactive.thread-local-accessor-enabled=true")
.withClassLoader(FilteredClassLoader(ThreadLocalAccessor::class.java))
.run {
assertThat(it).doesNotHaveBean(SentryWebFilterWithThreadLocalAccessor::class.java)
}
}
}
1 change: 1 addition & 0 deletions sentry-spring-jakarta/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies {
compileOnly(Config.Libs.aspectj)
compileOnly(Config.Libs.servletApiJakarta)
compileOnly(Config.Libs.slf4jApi)
compileOnly(Config.Libs.contextPropagation)

compileOnly(Config.Libs.springWebflux)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.sentry.spring.jakarta.webflux;

import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;

import io.sentry.IHub;
import io.sentry.Sentry;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.context.Context;

@ApiStatus.Experimental
public final class ReactorUtils {

/**
* Writes the Sentry {@link IHub} to the {@link Context} and uses {@link io.micrometer.context.ThreadLocalAccessor} to propagate it.
*
* This requires
* - reactor.core.publisher.Hooks#enableAutomaticContextPropagation() to be enabled
* - having `io.micrometer:context-propagation:1.0.2` or newer as dependency
* - having `io.projectreactor:reactor-core:3.5.3` or newer as dependency
*/
@ApiStatus.Experimental
public static <T> Mono<T> withSentry(Mono<T> mono) {
final @NotNull IHub oldHub = Sentry.getCurrentHub();
final @NotNull IHub clonedHub = oldHub.clone();

/**
* WARNING: Cannot set the clonedHub as current hub.
* It would be used by others to clone again causing shared hubs and scopes and thus
* leading to issues like unrelated breadcrumbs showing up in events.
*/
// Sentry.setCurrentHub(clonedHub);

return Mono.deferContextual(ctx -> mono).contextWrite(Context.of(SentryReactorThreadLocalAccessor.KEY, clonedHub));
}

/**
* Writes the Sentry {@link IHub} to the {@link Context} and uses {@link io.micrometer.context.ThreadLocalAccessor} to propagate it.
*
* This requires
* - reactor.core.publisher.Hooks#enableAutomaticContextPropagation() to be enabled
* - having `io.micrometer:context-propagation:1.0.2` or newer as dependency
* - having `io.projectreactor:reactor-core:3.5.3` or newer as dependency
*/
@ApiStatus.Experimental
public static <T> Flux<T> withSentry(Flux<T> flux) {
final @NotNull IHub oldHub = Sentry.getCurrentHub();
final @NotNull IHub clonedHub = oldHub.clone();

/**
* WARNING: Cannot set the clonedHub as current hub.
* It would be used by others to clone again causing shared hubs and scopes and thus
* leading to issues like unrelated breadcrumbs showing up in events.
*/
// Sentry.setCurrentHub(clonedHub);

return Flux.deferContextual(ctx -> flux).contextWrite(Context.of(SentryReactorThreadLocalAccessor.KEY, clonedHub));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package io.sentry.spring.jakarta.webflux;

import org.jetbrains.annotations.ApiStatus;

import io.micrometer.context.ThreadLocalAccessor;
import io.sentry.IHub;
import io.sentry.NoOpHub;
import io.sentry.Sentry;

@ApiStatus.Experimental
public final class SentryReactorThreadLocalAccessor implements ThreadLocalAccessor<IHub> {

public static final String KEY = "sentry-hub";

@Override
public Object key() {
return KEY;
}

@Override
public IHub getValue() {
return Sentry.getCurrentHub();
}

@Override
public void setValue(IHub value) {
Sentry.setCurrentHub(value);
}

@Override
public void reset() {
Sentry.setCurrentHub(NoOpHub.getInstance());
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package io.sentry.spring.jakarta.webflux;

import com.jakewharton.nopen.annotation.Open;

import io.sentry.Sentry;
import static io.sentry.TypeCheckHint.WEBFLUX_FILTER_REQUEST;
import static io.sentry.TypeCheckHint.WEBFLUX_FILTER_RESPONSE;

Expand All @@ -18,7 +21,8 @@

/** Manages {@link io.sentry.Scope} in Webflux request processing. */
@ApiStatus.Experimental
public final class SentryWebFilter implements WebFilter {
@Open
public class SentryWebFilter implements WebFilter {
private final @NotNull IHub hub;
private final @NotNull SentryRequestResolver sentryRequestResolver;

Expand Down
Loading