Skip to content

Commit f64d1f2

Browse files
aesyadinauer
andauthored
Add support for async dispatch requests (#3983)
* Add support for async dispatch requests Keeps the transaction open until the response is committed. * fix distributed tracing for TwP * add option to opt into async handling * Copy changes to Spring Boot 2 * add tests; fix comments * additional comment * changelog --------- Co-authored-by: Alexander Dinauer <[email protected]> Co-authored-by: Alexander Dinauer <[email protected]>
1 parent 3b6cfdf commit f64d1f2

File tree

13 files changed

+458
-56
lines changed

13 files changed

+458
-56
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
- Added `enableTraceIdGeneration` to the AndroidOptions. This allows Hybrid SDKs to "freeze" and control the trace and connect errors on different layers of the application ([4188](https://github.com/getsentry/sentry-java/pull/4188))
1515
- Move to a single NetworkCallback listener to reduce number of IPC calls on Android ([#4164](https://github.com/getsentry/sentry-java/pull/4164))
1616
- Add GraphQL Apollo Kotlin 4 integration ([#4166](https://github.com/getsentry/sentry-java/pull/4166))
17+
- Add support for async dispatch requests to Spring Boot 2 and 3 ([#3983](https://github.com/getsentry/sentry-java/pull/3983))
18+
- To enable it, please set `sentry.keep-transactions-open-for-async-responses=true` in `application.properties` or `sentry.keepTransactionsOpenForAsyncResponses: true` in `application.yml`
1719
- Add constructor to JUL `SentryHandler` for disabling external config ([#4208](https://github.com/getsentry/sentry-java/pull/4208))
1820

1921
### Fixes

sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ public class io/sentry/spring/boot/jakarta/SentryProperties : io/sentry/SentryOp
3232
public fun getReactive ()Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive;
3333
public fun getUserFilterOrder ()Ljava/lang/Integer;
3434
public fun isEnableAotCompatibility ()Z
35+
public fun isKeepTransactionsOpenForAsyncResponses ()Z
3536
public fun isUseGitCommitIdAsRelease ()Z
3637
public fun setEnableAotCompatibility (Z)V
3738
public fun setExceptionResolverOrder (I)V
3839
public fun setGraphql (Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql;)V
40+
public fun setKeepTransactionsOpenForAsyncResponses (Z)V
3941
public fun setLogging (Lio/sentry/spring/boot/jakarta/SentryProperties$Logging;)V
4042
public fun setReactive (Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive;)V
4143
public fun setUseGitCommitIdAsRelease (Z)V

sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,9 +312,14 @@ static class SentrySecurityConfiguration {
312312
@ConditionalOnMissingBean(name = "sentryTracingFilter")
313313
public FilterRegistrationBean<SentryTracingFilter> sentryTracingFilter(
314314
final @NotNull IScopes scopes,
315-
final @NotNull TransactionNameProvider transactionNameProvider) {
315+
final @NotNull TransactionNameProvider transactionNameProvider,
316+
final @NotNull SentryProperties sentryProperties) {
316317
FilterRegistrationBean<SentryTracingFilter> filter =
317-
new FilterRegistrationBean<>(new SentryTracingFilter(scopes, transactionNameProvider));
318+
new FilterRegistrationBean<>(
319+
new SentryTracingFilter(
320+
scopes,
321+
transactionNameProvider,
322+
sentryProperties.isKeepTransactionsOpenForAsyncResponses()));
318323
filter.setOrder(SENTRY_SPRING_FILTER_PRECEDENCE + 1); // must run after SentrySpringFilter
319324
return filter;
320325
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.ArrayList;
66
import java.util.Arrays;
77
import java.util.List;
8+
import org.jetbrains.annotations.ApiStatus;
89
import org.jetbrains.annotations.NotNull;
910
import org.jetbrains.annotations.Nullable;
1011
import org.slf4j.event.Level;
@@ -28,6 +29,8 @@ public class SentryProperties extends SentryOptions {
2829
*/
2930
private @Nullable Integer userFilterOrder;
3031

32+
@ApiStatus.Experimental private boolean keepTransactionsOpenForAsyncResponses = false;
33+
3134
/** Logging framework integration properties. */
3235
private @NotNull Logging logging = new Logging();
3336

@@ -104,6 +107,15 @@ public void setEnableAotCompatibility(boolean enableAotCompatibility) {
104107
this.enableAotCompatibility = enableAotCompatibility;
105108
}
106109

110+
public boolean isKeepTransactionsOpenForAsyncResponses() {
111+
return keepTransactionsOpenForAsyncResponses;
112+
}
113+
114+
public void setKeepTransactionsOpenForAsyncResponses(
115+
boolean keepTransactionsOpenForAsyncResponses) {
116+
this.keepTransactionsOpenForAsyncResponses = keepTransactionsOpenForAsyncResponses;
117+
}
118+
107119
public @NotNull Graphql getGraphql() {
108120
return graphql;
109121
}

sentry-spring-boot/api/sentry-spring-boot.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ public class io/sentry/spring/boot/SentryProperties : io/sentry/SentryOptions {
3030
public fun getGraphql ()Lio/sentry/spring/boot/SentryProperties$Graphql;
3131
public fun getLogging ()Lio/sentry/spring/boot/SentryProperties$Logging;
3232
public fun getUserFilterOrder ()Ljava/lang/Integer;
33+
public fun isKeepTransactionsOpenForAsyncResponses ()Z
3334
public fun isUseGitCommitIdAsRelease ()Z
3435
public fun setExceptionResolverOrder (I)V
3536
public fun setGraphql (Lio/sentry/spring/boot/SentryProperties$Graphql;)V
37+
public fun setKeepTransactionsOpenForAsyncResponses (Z)V
3638
public fun setLogging (Lio/sentry/spring/boot/SentryProperties$Logging;)V
3739
public fun setUseGitCommitIdAsRelease (Z)V
3840
public fun setUserFilterOrder (Ljava/lang/Integer;)V

sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,14 @@ static class SentrySecurityConfiguration {
297297
@ConditionalOnMissingBean(name = "sentryTracingFilter")
298298
public FilterRegistrationBean<SentryTracingFilter> sentryTracingFilter(
299299
final @NotNull IScopes scopes,
300-
final @NotNull TransactionNameProvider transactionNameProvider) {
300+
final @NotNull TransactionNameProvider transactionNameProvider,
301+
final @NotNull SentryProperties sentryProperties) {
301302
FilterRegistrationBean<SentryTracingFilter> filter =
302-
new FilterRegistrationBean<>(new SentryTracingFilter(scopes, transactionNameProvider));
303+
new FilterRegistrationBean<>(
304+
new SentryTracingFilter(
305+
scopes,
306+
transactionNameProvider,
307+
sentryProperties.isKeepTransactionsOpenForAsyncResponses()));
303308
filter.setOrder(SENTRY_SPRING_FILTER_PRECEDENCE + 1); // must run after SentrySpringFilter
304309
return filter;
305310
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.ArrayList;
66
import java.util.Arrays;
77
import java.util.List;
8+
import org.jetbrains.annotations.ApiStatus;
89
import org.jetbrains.annotations.NotNull;
910
import org.jetbrains.annotations.Nullable;
1011
import org.slf4j.event.Level;
@@ -28,6 +29,8 @@ public class SentryProperties extends SentryOptions {
2829
*/
2930
private @Nullable Integer userFilterOrder;
3031

32+
@ApiStatus.Experimental private boolean keepTransactionsOpenForAsyncResponses = false;
33+
3134
/** Logging framework integration properties. */
3235
private @NotNull Logging logging = new Logging();
3336

@@ -70,6 +73,15 @@ public void setUserFilterOrder(final @Nullable Integer userFilterOrder) {
7073
this.userFilterOrder = userFilterOrder;
7174
}
7275

76+
public boolean isKeepTransactionsOpenForAsyncResponses() {
77+
return keepTransactionsOpenForAsyncResponses;
78+
}
79+
80+
public void setKeepTransactionsOpenForAsyncResponses(
81+
boolean keepTransactionsOpenForAsyncResponses) {
82+
this.keepTransactionsOpenForAsyncResponses = keepTransactionsOpenForAsyncResponses;
83+
}
84+
7385
public @NotNull Logging getLogging() {
7486
return logging;
7587
}

sentry-spring-jakarta/api/sentry-spring-jakarta.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,9 @@ public class io/sentry/spring/jakarta/tracing/SentryTracingFilter : org/springfr
264264
public fun <init> ()V
265265
public fun <init> (Lio/sentry/IScopes;)V
266266
public fun <init> (Lio/sentry/IScopes;Lio/sentry/spring/jakarta/tracing/TransactionNameProvider;)V
267+
public fun <init> (Lio/sentry/IScopes;Lio/sentry/spring/jakarta/tracing/TransactionNameProvider;Z)V
267268
protected fun doFilterInternal (Ljakarta/servlet/http/HttpServletRequest;Ljakarta/servlet/http/HttpServletResponse;Ljakarta/servlet/FilterChain;)V
269+
protected fun shouldNotFilterAsyncDispatch ()Z
268270
}
269271

270272
public abstract interface annotation class io/sentry/spring/jakarta/tracing/SentryTransaction : java/lang/annotation/Annotation {

sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java

Lines changed: 100 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ public class SentryTracingFilter extends OncePerRequestFilter {
3737
private static final String TRANSACTION_OP = "http.server";
3838

3939
private static final String TRACE_ORIGIN = "auto.http.spring_jakarta.webmvc";
40+
private static final String TRANSACTION_ATTR = "sentry.transaction";
4041

4142
private final @NotNull TransactionNameProvider transactionNameProvider;
4243
private final @NotNull IScopes scopes;
44+
private final boolean isAsyncSupportEnabled;
4345

4446
/**
4547
* Creates filter that resolves transaction name using {@link SpringMvcTransactionNameProvider}.
@@ -63,28 +65,52 @@ public SentryTracingFilter() {
6365
public SentryTracingFilter(
6466
final @NotNull IScopes scopes,
6567
final @NotNull TransactionNameProvider transactionNameProvider) {
68+
this(scopes, transactionNameProvider, false);
69+
}
70+
71+
/**
72+
* Creates filter that resolves transaction name using transaction name provider given by
73+
* parameter.
74+
*
75+
* @param scopes - the scopes
76+
* @param transactionNameProvider - transaction name provider.
77+
* @param isAsyncSupportEnabled - whether transactions should be kept open until async handling is
78+
* done
79+
*/
80+
public SentryTracingFilter(
81+
final @NotNull IScopes scopes,
82+
final @NotNull TransactionNameProvider transactionNameProvider,
83+
final boolean isAsyncSupportEnabled) {
6684
this.scopes = Objects.requireNonNull(scopes, "Scopes are required");
6785
this.transactionNameProvider =
6886
Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required");
87+
this.isAsyncSupportEnabled = isAsyncSupportEnabled;
6988
}
7089

7190
public SentryTracingFilter(final @NotNull IScopes scopes) {
7291
this(scopes, new SpringMvcTransactionNameProvider());
7392
}
7493

94+
@Override
95+
protected boolean shouldNotFilterAsyncDispatch() {
96+
return !isAsyncSupportEnabled;
97+
}
98+
7599
@Override
76100
protected void doFilterInternal(
77101
final @NotNull HttpServletRequest httpRequest,
78102
final @NotNull HttpServletResponse httpResponse,
79103
final @NotNull FilterChain filterChain)
80104
throws ServletException, IOException {
81105
if (scopes.isEnabled() && !isIgnored()) {
82-
final @Nullable String sentryTraceHeader =
83-
httpRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER);
84-
final @Nullable List<String> baggageHeader =
85-
Collections.list(httpRequest.getHeaders(BaggageHeader.BAGGAGE_HEADER));
86-
final @Nullable TransactionContext transactionContext =
87-
scopes.continueTrace(sentryTraceHeader, baggageHeader);
106+
@Nullable TransactionContext transactionContext = null;
107+
if (shouldContinueTrace(httpRequest)) {
108+
final @Nullable String sentryTraceHeader =
109+
httpRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER);
110+
final @Nullable List<String> baggageHeader =
111+
Collections.list(httpRequest.getHeaders(BaggageHeader.BAGGAGE_HEADER));
112+
transactionContext = scopes.continueTrace(sentryTraceHeader, baggageHeader);
113+
}
88114
if (scopes.getOptions().isTracingEnabled() && shouldTraceRequest(httpRequest)) {
89115
doFilterWithTransaction(httpRequest, httpResponse, filterChain, transactionContext);
90116
} else {
@@ -105,35 +131,85 @@ private void doFilterWithTransaction(
105131
FilterChain filterChain,
106132
final @Nullable TransactionContext transactionContext)
107133
throws IOException, ServletException {
108-
// at this stage we are not able to get real transaction name
109-
final ITransaction transaction = startTransaction(httpRequest, transactionContext);
134+
final @Nullable ITransaction transaction =
135+
getOrStartTransaction(httpRequest, transactionContext);
110136

111137
try {
112138
filterChain.doFilter(httpRequest, httpResponse);
113139
} catch (Throwable e) {
114-
// exceptions that are not handled by Spring
115-
transaction.setStatus(SpanStatus.INTERNAL_ERROR);
140+
if (transaction != null) {
141+
// exceptions that are not handled by Spring
142+
transaction.setStatus(SpanStatus.INTERNAL_ERROR);
143+
}
116144
throw e;
117145
} finally {
118-
// after all filters run, templated path pattern is available in request attribute
119-
final String transactionName = transactionNameProvider.provideTransactionName(httpRequest);
120-
final TransactionNameSource transactionNameSource =
121-
transactionNameProvider.provideTransactionSource();
122-
// if transaction name is not resolved, the request has not been processed by a controller
123-
// and we should not report it to Sentry
124-
if (transactionName != null) {
125-
transaction.setName(transactionName, transactionNameSource);
126-
transaction.setOperation(TRANSACTION_OP);
127-
// if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and
128-
// httpResponse.getStatus() returns 200.
129-
if (transaction.getStatus() == null) {
130-
transaction.setStatus(SpanStatus.fromHttpStatusCode(httpResponse.getStatus()));
146+
if (shouldFinishTransaction(httpRequest) && transaction != null) {
147+
// after all filters run, templated path pattern is available in request attribute
148+
final String transactionName = transactionNameProvider.provideTransactionName(httpRequest);
149+
final TransactionNameSource transactionNameSource =
150+
transactionNameProvider.provideTransactionSource();
151+
// if transaction name is not resolved, the request has not been processed by a controller
152+
// and we should not report it to Sentry
153+
if (transactionName != null) {
154+
transaction.setName(transactionName, transactionNameSource);
155+
transaction.setOperation(TRANSACTION_OP);
156+
// if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and
157+
// httpResponse.getStatus() returns 200.
158+
if (transaction.getStatus() == null) {
159+
transaction.setStatus(SpanStatus.fromHttpStatusCode(httpResponse.getStatus()));
160+
}
161+
transaction.finish();
131162
}
132-
transaction.finish();
133163
}
134164
}
135165
}
136166

167+
private ITransaction getOrStartTransaction(
168+
final @NotNull HttpServletRequest httpRequest,
169+
final @Nullable TransactionContext transactionContext) {
170+
if (isAsyncDispatch(httpRequest)) {
171+
// second invocation of this filter for the same async request already has the transaction
172+
// in the attributes
173+
return (ITransaction) httpRequest.getAttribute(TRANSACTION_ATTR);
174+
} else {
175+
// at this stage we are not able to get real transaction name
176+
final @NotNull ITransaction transaction = startTransaction(httpRequest, transactionContext);
177+
if (shouldStoreTransactionForAsyncProcessing()) {
178+
httpRequest.setAttribute(TRANSACTION_ATTR, transaction);
179+
}
180+
return transaction;
181+
}
182+
}
183+
184+
/**
185+
* Returns false if an async request is being dispatched (second invocation of the filter for the
186+
* same async request).
187+
*
188+
* <p>Returns true if not an async request or this is the first invocation of the filter for the
189+
* same async request
190+
*/
191+
private boolean shouldContinueTrace(HttpServletRequest httpRequest) {
192+
return !isAsyncSupportEnabled || !isAsyncDispatch(httpRequest);
193+
}
194+
195+
private boolean shouldStoreTransactionForAsyncProcessing() {
196+
return isAsyncSupportEnabled;
197+
}
198+
199+
/**
200+
* Returns false if async request handling has only been started but not yet finished (first
201+
* invocation of this filter for the same async request).
202+
*
203+
* <p>Returns true if not an async request or async request handling has finished (second
204+
* invocation of this filter for the same async request)
205+
*
206+
* <p>Note: isAsyncStarted changes its return value after filterChain.doFilter() of the first
207+
* async invocation
208+
*/
209+
private boolean shouldFinishTransaction(HttpServletRequest httpRequest) {
210+
return !isAsyncSupportEnabled || !isAsyncStarted(httpRequest);
211+
}
212+
137213
private boolean shouldTraceRequest(final @NotNull HttpServletRequest request) {
138214
return scopes.getOptions().isTraceOptionsRequests()
139215
|| !HttpMethod.OPTIONS.name().equals(request.getMethod());

0 commit comments

Comments
 (0)