Skip to content

Commit e5bbb00

Browse files
authored
Implement ThreadLocalAccessor for propagating Sentry hub with reactor / WebFlux (#2570)
1 parent d691d8f commit e5bbb00

File tree

26 files changed

+666
-39
lines changed

26 files changed

+666
-39
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,18 @@
1111
- If set to `false` performance is disabled, regardless of `tracesSampleRate` and `tracesSampler` options.
1212
- Detect dependencies by listing MANIFEST.MF files at runtime ([#2538](https://github.com/getsentry/sentry-java/pull/2538))
1313
- Report integrations in use, report packages in use more consistently ([#2179](https://github.com/getsentry/sentry-java/pull/2179))
14+
- Implement `ThreadLocalAccessor` for propagating Sentry hub with reactor / WebFlux ([#2570](https://github.com/getsentry/sentry-java/pull/2570))
15+
- Requires `io.micrometer:context-propagation:1.0.2+` as well as Spring Boot 3.0.3+
16+
- Enable the feature by setting `sentry.reactive.thread-local-accessor-enabled=true`
17+
- This is still considered experimental. Once we have enough feedback we may turn this on by default.
18+
- Checkout the sample here: https://github.com/getsentry/sentry-java/tree/main/sentry-samples/sentry-samples-spring-boot-webflux-jakarta
19+
- A new hub is now cloned from the main hub for every request
1420

1521
### Fixes
1622

1723
- Leave `inApp` flag for stack frames undecided in SDK if unsure and let ingestion decide instead ([#2547](https://github.com/getsentry/sentry-java/pull/2547))
1824
- Allow `0.0` error sample rate ([#2573](https://github.com/getsentry/sentry-java/pull/2573))
25+
- Use the same hub in WebFlux exception handler as we do in WebFilter ([#2566](https://github.com/getsentry/sentry-java/pull/2566))
1926

2027
## 6.14.0
2128

buildSrc/src/main/java/Config.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ object Config {
66
val kotlinStdLib = "stdlib-jdk8"
77

88
val springBootVersion = "2.7.5"
9-
val springBoot3Version = "3.0.0"
9+
val springBoot3Version = "3.0.3"
1010
val kotlinCompatibleLanguageVersion = "1.4"
1111

1212
val composeVersion = "1.1.1"
@@ -107,7 +107,8 @@ object Config {
107107

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

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

112113
private val feignVersion = "11.6"
113114
val feignCore = "io.github.openfeign:feign-core:$feignVersion"

sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoController.java

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package io.sentry.samples.spring.boot.jakarta;
22

3+
import io.sentry.spring.jakarta.webflux.ReactorUtils;
34
import org.springframework.web.bind.annotation.GetMapping;
45
import org.springframework.web.bind.annotation.PathVariable;
56
import org.springframework.web.bind.annotation.RestController;
67
import org.springframework.web.client.RestTemplate;
78
import org.springframework.web.reactive.function.client.WebClient;
9+
import reactor.core.publisher.Hooks;
10+
import reactor.core.publisher.Mono;
11+
import reactor.core.scheduler.Schedulers;
812

913
@RestController
1014
public class TodoController {
@@ -24,11 +28,18 @@ Todo todo(@PathVariable Long id) {
2428

2529
@GetMapping("/todo-webclient/{id}")
2630
Todo todoWebClient(@PathVariable Long id) {
27-
return webClient
28-
.get()
29-
.uri("https://jsonplaceholder.typicode.com/todos/{id}", id)
30-
.retrieve()
31-
.bodyToMono(Todo.class)
31+
Hooks.enableAutomaticContextPropagation();
32+
return ReactorUtils.withSentry(
33+
Mono.just(true)
34+
.publishOn(Schedulers.boundedElastic())
35+
.flatMap(
36+
x ->
37+
webClient
38+
.get()
39+
.uri("https://jsonplaceholder.typicode.com/todos/{id}", id)
40+
.retrieve()
41+
.bodyToMono(Todo.class)
42+
.map(response -> response)))
3243
.block();
3344
}
3445
}

sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ repositories {
1919

2020
dependencies {
2121
implementation(Config.Libs.springBoot3StarterWebflux)
22+
implementation(Config.Libs.contextPropagation)
2223
implementation(Config.Libs.kotlinReflect)
2324
implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION))
2425
implementation(projects.sentrySpringBootStarterJakarta)
2526
implementation(projects.sentryLogback)
27+
2628
testImplementation(Config.Libs.springBoot3StarterTest) {
2729
exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
2830
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package io.sentry.samples.spring.boot;
1+
package io.sentry.samples.spring.boot.jakarta;
22

33
public class Person {
44
private final String firstName;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package io.sentry.samples.spring.boot;
1+
package io.sentry.samples.spring.boot.jakarta;
22

33
import org.slf4j.Logger;
44
import org.slf4j.LoggerFactory;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package io.sentry.samples.spring.boot;
1+
package io.sentry.samples.spring.boot.jakarta;
22

33
import io.sentry.Sentry;
44
import java.time.Duration;
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
package io.sentry.samples.spring.boot;
1+
package io.sentry.samples.spring.boot.jakarta;
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.web.reactive.function.client.WebClient;
57

68
@SpringBootApplication
79
public class SentryDemoApplication {
810
public static void main(String[] args) {
911
SpringApplication.run(SentryDemoApplication.class, args);
1012
}
13+
14+
@Bean
15+
WebClient webClient(WebClient.Builder builder) {
16+
return builder.build();
17+
}
1118
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.sentry.samples.spring.boot.jakarta;
2+
3+
public class Todo {
4+
private final Long id;
5+
private final String title;
6+
private final boolean completed;
7+
8+
public Todo(Long id, String title, boolean completed) {
9+
this.id = id;
10+
this.title = title;
11+
this.completed = completed;
12+
}
13+
14+
public Long getId() {
15+
return id;
16+
}
17+
18+
public String getTitle() {
19+
return title;
20+
}
21+
22+
public boolean isCompleted() {
23+
return completed;
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package io.sentry.samples.spring.boot.jakarta;
2+
3+
import org.springframework.web.bind.annotation.GetMapping;
4+
import org.springframework.web.bind.annotation.PathVariable;
5+
import org.springframework.web.bind.annotation.RestController;
6+
import org.springframework.web.reactive.function.client.WebClient;
7+
import reactor.core.publisher.Mono;
8+
9+
@RestController
10+
public class TodoController {
11+
private final WebClient webClient;
12+
13+
public TodoController(WebClient webClient) {
14+
this.webClient = webClient;
15+
}
16+
17+
@GetMapping("/todo-webclient/{id}")
18+
Mono<Todo> todoWebClient(@PathVariable Long id) {
19+
return webClient
20+
.get()
21+
.uri("https://jsonplaceholder.typicode.com/todos/{id}", id)
22+
.retrieve()
23+
.bodyToMono(Todo.class)
24+
.map(response -> response);
25+
}
26+
}

sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/resources/application.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ sentry.max-breadcrumbs=150
77
# Logback integration configuration options
88
sentry.logging.minimum-event-level=info
99
sentry.logging.minimum-breadcrumb-level=debug
10+
sentry.reactive.thread-local-accessor-enabled=true

sentry-spring-boot-starter-jakarta/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ dependencies {
5353
compileOnly(Config.Libs.springBoot3StarterAop)
5454
compileOnly(Config.Libs.springBoot3StarterSecurity)
5555
compileOnly(Config.Libs.reactorCore)
56+
compileOnly(Config.Libs.contextPropagation)
5657
compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryCore)
5758

5859
annotationProcessor(Config.AnnotationProcessors.springBootAutoConfigure)
@@ -79,6 +80,7 @@ dependencies {
7980
testImplementation(Config.Libs.springBoot3StarterSecurity)
8081
testImplementation(Config.Libs.springBoot3StarterAop)
8182
testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryCore)
83+
testImplementation(Config.Libs.contextPropagation)
8284
}
8385

8486
configure<SourceSetContainer> {

sentry-spring-boot-starter-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ public class SentryProperties extends SentryOptions {
2828
/** Logging framework integration properties. */
2929
private @NotNull Logging logging = new Logging();
3030

31+
/** Reactive framework (e.g. WebFlux) integration properties */
32+
private @NotNull Reactive reactive = new Reactive();
33+
3134
public boolean isUseGitCommitIdAsRelease() {
3235
return useGitCommitIdAsRelease;
3336
}
@@ -72,6 +75,14 @@ public void setLogging(@NotNull Logging logging) {
7275
this.logging = logging;
7376
}
7477

78+
public @NotNull Reactive getReactive() {
79+
return reactive;
80+
}
81+
82+
public void setReactive(@NotNull Reactive reactive) {
83+
this.reactive = reactive;
84+
}
85+
7586
@Open
7687
public static class Logging {
7788
/** Enable/Disable logging auto-configuration. */
@@ -107,4 +118,18 @@ public void setMinimumEventLevel(@Nullable Level minimumEventLevel) {
107118
this.minimumEventLevel = minimumEventLevel;
108119
}
109120
}
121+
122+
@Open
123+
public static class Reactive {
124+
/** Enable/Disable usage of {@link io.micrometer.context.ThreadLocalAccessor} for Hub propagation */
125+
private boolean threadLocalAccessorEnabled = true;
126+
127+
public boolean isThreadLocalAccessorEnabled() {
128+
return threadLocalAccessorEnabled;
129+
}
130+
131+
public void setThreadLocalAccessorEnabled(boolean threadLocalAccessorEnabled) {
132+
this.threadLocalAccessorEnabled = threadLocalAccessorEnabled;
133+
}
134+
}
110135
}
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,27 @@
11
package io.sentry.spring.boot.jakarta;
22

33
import com.jakewharton.nopen.annotation.Open;
4+
45
import io.sentry.IHub;
56
import io.sentry.spring.jakarta.webflux.SentryScheduleHook;
67
import io.sentry.spring.jakarta.webflux.SentryWebExceptionHandler;
78
import io.sentry.spring.jakarta.webflux.SentryWebFilter;
89
import org.jetbrains.annotations.ApiStatus;
910
import org.jetbrains.annotations.NotNull;
1011
import org.springframework.boot.ApplicationRunner;
12+
import org.springframework.boot.autoconfigure.condition.AllNestedConditions;
13+
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
1114
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
1215
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
16+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
17+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
1318
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
1419
import org.springframework.context.annotation.Bean;
20+
import org.springframework.context.annotation.Conditional;
1521
import org.springframework.context.annotation.Configuration;
22+
23+
import io.sentry.spring.jakarta.webflux.SentryWebFilterWithThreadLocalAccessor;
24+
import reactor.core.publisher.Hooks;
1625
import reactor.core.scheduler.Schedulers;
1726

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

27-
/** Configures hook that sets correct hub on the executing thread. */
28-
@Bean
29-
public @NotNull ApplicationRunner sentryScheduleHookApplicationRunner() {
30-
return args -> {
31-
Schedulers.onScheduleHook("sentry", new SentryScheduleHook());
32-
};
36+
@Configuration(proxyBeanMethods = false)
37+
@Conditional(SentryThreadLocalAccessorCondition.class)
38+
@Open
39+
static class SentryWebfluxFilterThreadLocalAccessorConfiguration {
40+
41+
/**
42+
* Configures a filter that sets up Sentry {@link io.sentry.Scope} for each request.
43+
*
44+
* Makes use of newer reactor-core and context-propagation library feature ThreadLocalAccessor
45+
* to propagate the Sentry hub.
46+
*/
47+
@Bean
48+
public @NotNull SentryWebFilterWithThreadLocalAccessor sentryWebFilterWithContextPropagation(final @NotNull IHub hub) {
49+
Hooks.enableAutomaticContextPropagation();
50+
return new SentryWebFilterWithThreadLocalAccessor(hub);
51+
}
3352
}
3453

35-
/** Configures a filter that sets up Sentry {@link io.sentry.Scope} for each request. */
36-
@Bean
37-
public @NotNull SentryWebFilter sentryWebFilter(final @NotNull IHub hub) {
38-
return new SentryWebFilter(hub);
54+
@Configuration(proxyBeanMethods = false)
55+
@Conditional(SentryLegacyFilterConfigurationCondition.class)
56+
@Open
57+
static class SentryWebfluxFilterConfiguration {
58+
59+
/** Configures hook that sets correct hub on the executing thread. */
60+
@Bean
61+
public @NotNull ApplicationRunner sentryScheduleHookApplicationRunner() {
62+
return args -> {
63+
Schedulers.onScheduleHook("sentry", new SentryScheduleHook());
64+
};
65+
}
66+
67+
/** Configures a filter that sets up Sentry {@link io.sentry.Scope} for each request. */
68+
@Bean
69+
public @NotNull SentryWebFilter sentryWebFilter(final @NotNull IHub hub) {
70+
return new SentryWebFilter(hub);
71+
}
3972
}
4073

4174
/** Configures exception handler that handles unhandled exceptions and sends them to Sentry. */
4275
@Bean
4376
public @NotNull SentryWebExceptionHandler sentryWebExceptionHandler(final @NotNull IHub hub) {
4477
return new SentryWebExceptionHandler(hub);
4578
}
79+
80+
static final class SentryLegacyFilterConfigurationCondition extends AnyNestedCondition {
81+
82+
public SentryLegacyFilterConfigurationCondition() {
83+
super(ConfigurationPhase.REGISTER_BEAN);
84+
}
85+
86+
@ConditionalOnProperty(name = "sentry.reactive.thread-local-accessor-enabled", havingValue = "false", matchIfMissing = true)
87+
@SuppressWarnings("UnusedNestedClass")
88+
private static class SentryDisableThreadLocalAccessorCondition {}
89+
90+
@ConditionalOnMissingClass("io.micrometer.context.ThreadLocalAccessor")
91+
@SuppressWarnings("UnusedNestedClass")
92+
private static class ThreadLocalAccessorClassCondition {}
93+
}
94+
95+
static final class SentryThreadLocalAccessorCondition extends AllNestedConditions {
96+
97+
public SentryThreadLocalAccessorCondition() {
98+
super(ConfigurationPhase.REGISTER_BEAN);
99+
}
100+
101+
@ConditionalOnProperty(name = "sentry.reactive.thread-local-accessor-enabled", havingValue = "true")
102+
@SuppressWarnings("UnusedNestedClass")
103+
private static class SentryEnableThreadLocalAccessorCondition {}
104+
105+
@ConditionalOnClass(io.micrometer.context.ThreadLocalAccessor.class)
106+
@SuppressWarnings("UnusedNestedClass")
107+
private static class ThreadLocalAccessorClassCondition {}
108+
}
46109
}

0 commit comments

Comments
 (0)