Spring Boot error monitoring with the Sentry SDK (Boot 3.x + Java 21)
Spring Boot is the largest framework that the urgentry guides library has not covered until today. Boot 3.x runs on Jakarta EE 9+ and Java 17 baseline, with most teams now on Java 21 LTS. The Sentry Java SDK matches that shape with a Jakarta-specific starter, and the wiring is short — but three gotchas around async, WebFlux, and virtual threads quietly eat exceptions if you accept the defaults.
20 seconds. For Spring Boot 3.x, add io.sentry:sentry-spring-boot-starter-jakarta and a single sentry.dsn in application.yml. Unhandled controller exceptions, WebFlux reactive errors, and Logback ERROR-level logs are captured automatically. To switch to a self-hosted backend, change the DSN host. No code change.
60 seconds. The Spring Boot starter wires four integrations at once: a servlet filter that captures unhandled MVC exceptions, a WebFlux filter for reactive controllers, a Logback appender that turns ERROR logs into events and INFO logs into breadcrumbs, and a Spring AOP advice for @SentryCaptureExceptions. The Jakarta-namespaced starter is mandatory for Boot 3.x; the older non-jakarta artifact targets Boot 2.x and fails fast at startup if mixed. The three gotchas: @Async tasks lose Sentry scope unless you register SentryTaskDecorator on the executor; WebFlux reactive chains lose context across operators unless you propagate through contextWrite; and Java 21 virtual threads work but require explicit scope discipline because each task often gets a fresh thread.
This guide covers the install, the Boot 3.x configuration, what gets captured by default, the Logback appender behaviour, WebFlux and virtual-thread context propagation, the three Java gotchas worth knowing, and how to point the same code at urgentry instead of Sentry SaaS.
Where Spring Boot 3.x errors actually come from
Spring Boot is a big framework, and exceptions surface in more than one place. A complete instrumentation has to cover at least five paths.
- Spring MVC controllers. Exceptions thrown out of a
@RestControllermethod travel throughDispatcherServletto aHandlerExceptionResolverchain. Without an@ExceptionHandleror@ControllerAdvicematch, they reach the servlet container and become a 500 response. The Sentry servlet filter sees them on the way out. - Spring WebFlux endpoints. Reactive errors propagate down a
MonoorFluxchain. They are not thrown synchronously; they are signalled.SentryWebFilterhooks into the reactor pipeline and captures terminal error signals. - Async tasks. Methods annotated
@Async, and@Scheduledtasks, run on aTaskExecutor. Exceptions in them do not bubble up to the calling thread. Spring logs them throughAsyncUncaughtExceptionHandler; without explicit capture, that is the only trace they leave. - Background consumers. Kafka listeners (
@KafkaListener), RabbitMQ consumers, and Spring Cloud Stream bindings have their own error channels. Exceptions in them are routed to aDefaultErrorHandleror a dead-letter topic. Capture depends on listener configuration. - Logback ERROR statements. Catch-and-log is the dominant style in mature Spring codebases. Hundreds of
log.error("payment processor returned bad status", e)lines silently swallow exceptions if nothing else is wired in. The Sentry Logback appender turns those into events.
The Sentry Spring Boot starter covers the first three with its auto-configuration; the fourth needs per-listener wiring; the fifth is the appender. Most of what follows is making sure all five paths reach the same backend.
Install: pick the right starter for your Boot version
The single most common mistake teams make is grabbing the wrong starter artifact. The Sentry Java SDK ships two parallel Spring Boot starters:
io.sentry:sentry-spring-boot-starter— targets Spring Boot 2.7 and earlier, which used thejavax.servletnamespace.io.sentry:sentry-spring-boot-starter-jakarta— targets Spring Boot 3.x, which moved tojakarta.servletas part of the Jakarta EE 9 namespace transition.
Mixing them is the failure mode: a Boot 3.x app with the non-jakarta starter throws NoClassDefFoundError: javax/servlet/Filter at startup, or in some Gradle builds simply fails to register the filter and silently drops controller exceptions. Pick the jakarta variant for any Boot 3.x project.
Maven (Boot 3.x):
<dependency>
<groupId>io.sentry</groupId>
<artifactId>sentry-spring-boot-starter-jakarta</artifactId>
<version>8.3.0</version>
</dependency>
Gradle (Boot 3.x, Kotlin DSL):
dependencies {
implementation("io.sentry:sentry-spring-boot-starter-jakarta:8.3.0")
implementation("io.sentry:sentry-logback:8.3.0")
}
The sentry-logback dependency is technically optional, but it is the part that turns log lines into events. Skip it only if you are already routing errors through a different appender.
Wire it up: application.yml
The starter picks up its configuration from application.yml. The minimum is one line; a sensible default is six.
sentry:
dsn: ${SENTRY_DSN:}
environment: ${SPRING_PROFILES_ACTIVE:default}
release: ${APP_VERSION:unknown}
traces-sample-rate: 0.1
send-default-pii: false
logging:
minimum-event-level: error
minimum-breadcrumb-level: info
Three notes about this block:
- Empty DSN means disabled. The
${SENTRY_DSN:}form leaves the DSN blank when the environment variable is missing. The SDK treats an empty DSN as "no-op": it captures nothing and logs a warning at startup. That is the right default for local development. - send-default-pii defaults to false. Leave it false until you know exactly what your servlet filter and serializer chain are sending. The setting controls whether the SDK attaches the request body, headers, and user IP to each event. PII scrubbing is covered in detail in the PII scrubbing guide.
- traces-sample-rate caps the cost of tracing. 0.1 (10%) is the floor most teams settle on in production. Setting it to 1.0 (100%) is fine in staging; in production at any meaningful traffic level it produces a span volume that dominates the bill before it produces a meaningful insight.
What gets captured automatically
Out of the box, with no further configuration, the jakarta starter captures:
- Any exception thrown by a
@RestControlleror@Controllermethod that is not handled by an@ExceptionHandleror@ControllerAdvice. The servlet filter wraps the request, intercepts the unhandled exception, attaches request metadata (method, path, headers honoring the PII setting), and forwards it to Sentry before the container returns 500. - Any exception that terminates a WebFlux
MonoorFluxreturned from a reactive controller. The reactive web filter subscribes to terminal signals. - Any Logback event at ERROR level or above, regardless of where in the stack it originated. The
SentryAppenderauto-registers when thesentry-logbackdependency is on the classpath. Throwables passed tolog.error(message, throwable)become the exception payload; the message becomes the event title. - Any Logback event at INFO level or above as a breadcrumb, attached to whatever event is captured next. The 100 most recent breadcrumbs ride along with each event.
- Spring Security authentication and access-denied exceptions, but only when the security filter chain rethrows them; if you have a
@ExceptionHandler(AccessDeniedException.class), the rethrow does not happen and they are not captured by default.
What is not captured by default:
- Exceptions caught and discarded silently (
try { ... } catch (Exception e) { /* ignore */ }). Nothing tells Sentry these happened. - Exceptions caught and logged at WARN or below. They are below the appender threshold.
- Exceptions inside
@Asyncor@Scheduledtasks if noSentryTaskDecoratoris registered; the exception itself is captured but without the request scope or MDC of the originating thread. - Exceptions inside
@KafkaListeneror other consumer methods if the configured error handler swallows them before they reach the Logback appender. Default Spring Kafka error handlers do log at ERROR, so the appender usually catches these, but custom handlers can short-circuit the path.
Logback appender behaviour: read the threshold knobs
The SentryAppender is doing more work than its single config line suggests. The two thresholds — minimum-event-level and minimum-breadcrumb-level — control two different streams:
sentry:
logging:
minimum-event-level: error # Captures as a full event
minimum-breadcrumb-level: info # Captures as breadcrumb only
The defaults capture too little in development and roughly the right amount in production. Three knobs to know about:
- Lower minimum-breadcrumb-level to debug in dev. Breadcrumbs are how you reconstruct what happened in the seconds before the exception. ERROR-only breadcrumbs miss the request path, the user ID lookup, the cache miss, the retry. DEBUG gives you the whole sequence.
- Raise minimum-event-level to fatal when ERROR is loud. Some codebases use
log.errorfor expected-but-unhappy paths: a third-party API returning a known retryable status, a queue depth crossing a soft threshold. Those produce hundreds of events per hour and dwarf the actual incidents. Either fix the log levels (the correct answer) or raise the event threshold (the pragmatic answer). - The appender does not deduplicate across loggers. If two appenders catch the same throwable — for example, a global handler logs the exception and a controller advice re-throws and the servlet filter catches it again — you can get two events for one failure. Sentry's server-side grouping merges them into one issue, but the event count line on your bill still reflects both.
WebFlux: context propagation across reactive operators
WebFlux is the place teams get bitten worst. Reactor's execution model means a chain of map, flatMap, and onErrorResume operators can hop across threads, and a ThreadLocal-based scope does not survive those hops.
Sentry 7.x and later use Reactor's Context for propagation, which fixes the basic case. But if your reactive pipeline drops to Mono.fromCallable on a custom Scheduler, or you switch to publishOn(Schedulers.boundedElastic()) mid-chain, you still have to opt in:
@GetMapping("/api/things")
public Mono<ThingResponse> getThings(ServerWebExchange exchange) {
return thingService.find(exchange.getRequest())
.contextWrite(SentryReactorUtils.withSentryScope())
.onErrorMap(this::translateError);
}
The contextWrite call pins the Sentry scope to this Reactor chain, which means breadcrumbs added in thingService.find ride along into the error report. Without it, the error is captured but the breadcrumb trail looks empty.
One common gotcha: onErrorResume(e -> Mono.empty()) silences the error from the framework's point of view. The Sentry filter never sees the terminal error signal, because the chain succeeded. If you want to recover from an error but still record it, capture explicitly:
.onErrorResume(e -> {
Sentry.captureException(e);
return Mono.just(fallbackResponse());
})
@Async, @Scheduled, and Java 21 virtual threads
Spring's @Async dispatches work to a TaskExecutor. The default executor in Boot 3.x is a ThreadPoolTaskExecutor, and threads from that pool do not inherit the Sentry scope of the calling thread unless you wire it explicitly.
Register a task decorator on the executor bean:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(100);
executor.setTaskDecorator(new SentryTaskDecorator());
executor.initialize();
return executor;
}
}
With the decorator in place, exceptions in @Async methods arrive with the same user, request ID, and breadcrumb history they would have had on the calling thread.
Java 21 virtual threads change the shape of the problem rather than removing it. Spring Boot 3.2+ ships spring.threads.virtual.enabled=true as a configuration switch that backs Tomcat, the scheduling executor, and the async executor with virtual threads. Sentry's ThreadLocal-based scope still works — virtual threads are java.lang.Thread instances, and ThreadLocal binds to whichever thread you are on — but two assumptions break in practice.
First, virtual threads are typically not pooled. Each task gets a fresh virtual thread. Anything you set on a ThreadLocal as a one-time setup step at thread creation does not happen, because there is no creation step you control. Use Sentry.withScope at the start of each task instead:
@Async
public CompletableFuture<Result> processOrder(String orderId) {
return Sentry.withScope(scope -> {
scope.setTag("order.id", orderId);
return CompletableFuture.completedFuture(orderService.process(orderId));
});
}
Second, virtual threads make it cheap to spawn many concurrent tasks, which surfaces races in any code that was implicitly serialized by an old thread-pool ceiling. Code that ran fine on an 8-thread executor can deadlock or duplicate work on 10,000 virtual threads. The exceptions that result are real exceptions worth capturing, not Sentry SDK bugs.
The three Spring Boot gotchas that swallow errors
- @ControllerAdvice that returns a response without re-throwing. A typical advice converts an exception to a 4xx body and never calls
Sentry.captureException. The servlet filter sees a successful response and never reports the underlying error. If you want the report, either rethrow inside the advice or callSentry.captureException(ex)explicitly before returning. - Caught-and-logged-at-WARN exceptions. The Logback appender threshold defaults to ERROR. Anything caught and logged at
log.warn("retrying", e)never reaches Sentry. Fine if those really are warnings; problematic if a category of real failures has been quietly downgraded over the years. Audit your WARN-with-throwable call sites at least once. - CompletableFuture chains that lose the cause. When a
CompletableFuturecompletes exceptionally and you await it with.join(), the actual exception is wrapped in aCompletionException. The Sentry SDK groups by exception type and stack, which means every unrelated async failure can collapse into oneCompletionExceptionissue. Unwrap before capturing:Sentry.captureException(ex.getCause() != null ? ex.getCause() : ex);
Pointing at urgentry instead of Sentry SaaS
The Spring Boot starter knows nothing about which backend is on the other end of the DSN. It speaks the Sentry envelope format and POSTs to the host inside the DSN. Change the host, point at urgentry, restart the service:
sentry:
dsn: https://a1b2c3d4e5f6@errors.yourdomain.com/3
environment: production
release: ${APP_VERSION}
traces-sample-rate: 0.1
send-default-pii: false
The same Maven dependency, the same starter, the same @SentryCaptureExceptions annotations, the same Logback appender configuration. The only change is the value of sentry.dsn. The switch proof walks through verifying that an event arrives end-to-end.
Frequently asked questions
Which Sentry starter do I use for Spring Boot 3.x?
Use io.sentry:sentry-spring-boot-starter-jakarta for Spring Boot 3.x and Spring 6, which run on Jakarta EE 9+. The non-jakarta starter (sentry-spring-boot-starter) is for Spring Boot 2.7 and earlier, which used the javax namespace. Mixing them produces a ClassNotFoundException on jakarta.servlet at startup.
Does the Sentry SDK capture exceptions thrown inside @Async methods?
Yes, but only the exception itself. The MDC, the request scope, and the Sentry scope from the calling thread are not automatically copied unless you wire SentryTaskDecorator into the TaskExecutor. Without that, @Async exceptions land in Sentry without the user, request ID, or breadcrumb context the synchronous path would have included.
Do Java 21 virtual threads break the Sentry SDK?
No. Virtual threads are still java.lang.Thread instances, so Sentry's ThreadLocal-based scope works on them. The trap is that virtual threads are routinely created per task rather than reused from a pool, so context you set on one carrier thread does not survive to the next. Use Sentry.withScope for any context you want pinned to a single virtual-thread task.
Does sentry-spring-boot-starter capture logs from Logback automatically?
It registers a Logback appender that forwards events at ERROR and above as Sentry events, and INFO and above as breadcrumbs. The defaults are reasonable but conservative. Lower the breadcrumb threshold to DEBUG in development if you need tighter visibility, and raise the event threshold if your code paths use ERROR for expected-but-loud failures.
Can I point sentry-spring-boot-starter at urgentry instead of Sentry SaaS?
Yes. The starter reads sentry.dsn from application.yml or the SENTRY_DSN environment variable. Set that value to a DSN issued by your urgentry deployment and the SDK sends the same envelope payload to your backend. No code changes; no Spring Boot configuration changes beyond the DSN.
Sources
- Sentry Java SDK — Spring Boot guide — canonical install, configuration, and integration reference for both the jakarta and non-jakarta starters.
- getsentry/sentry-java on GitHub — the SDK source, including
SentryTaskDecorator,SentryWebFilter, and the Logback appender implementations referenced above. - Spring Boot reference — virtual threads — the
spring.threads.virtual.enabledflag and its scope across Tomcat, the scheduling executor, and the async executor. - JEP 444: Virtual Threads — the Java 21 feature that produced the virtual-thread context-propagation discussion above.
- Jakarta EE 9 specification — the
javaxtojakartanamespace transition that produced the two parallel starters. - urgentry compatibility matrix — the audited list of Sentry REST API operations urgentry implements, including the ingest endpoints the Spring Boot starter posts to.
One DSN swap. Full Sentry SDK compatibility.
urgentry accepts the Sentry SDK envelope format on a $5 VPS. 218 API operations covered. SQLite by default, Postgres optional. Change one environment variable and your Spring Boot events start arriving.