Easy-to-Miss Details When Integrating Sentry with Spring Boot

Spring Boot

Language :

This code is based on Spring Boot 3.2.5.

Following my previous post on simply applying Sentry to Spring Boot, I am documenting the custom configurations I applied afterward.

When integrating Sentry, I decided that there was no need to collect simple errors occurring during API calls (such as validation errors, login failures, etc.). Therefore, I configured it to report only 500 errors. Additionally, I performed extra work to ensure that errors occurring in schedulers and asynchronous tasks are reported without being missed.

1. Sentry Setup

Documentation: Sentry Docs

Add the dependencies required to use Sentry in Spring Boot.

build.gradle

Plain Text

plugins {
    id "io.sentry.jvm.gradle" version "3.12.0"
}

dependencies {
    implementation 'io.sentry:sentry-spring-boot-starter-jakarta:6.30.0'  
    implementation 'org.jetbrains:annotations:26.0.2' // For Nullable checks in BeforeSendCallback
}

src/main/resources/application.yml

Plain Text

sentry:  
  dsn: {sentry_url}
  send-default-pii: true
  traces-sample-rate: 1.0
  logging:  
    enabled: false

dsn: Sets the location where events will be sent.

sentry.logging.enabled: Whether to automatically record @Slf4j logs.

  • Adding the sentry-spring-boot-starter dependency automatically runs SentryLogbackInitializer at application startup, registering SentryAppender to the ROOT logger.
  • It is enabled (true) by default, automatically recording log.error, etc., to Sentry.
  • Here, it is set to false to control error reporting directly in the GlobalExceptionHandler.

send-default-pii: Records personally identifiable information, such as the requester's IP.

traces-sample-rate: Controls the volume of transactions sent to Sentry upon error (0.0 ~ 1.0).

  • Example: Setting it to 0.2 means approximately 20% of transactions are recorded and sent.

2. Global Exception Handler (ControllerAdvice)

This handles errors occurring during API calls by selectively filtering them.

GlobalExceptionHandler.java

Java

import io.sentry.Sentry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * Errors occurring in Spring Security are not sent to Sentry.
     */
    @ExceptionHandler({AccessDeniedException.class, AuthenticationException.class})
    public ResponseEntity<ErrorResponseDto> handleSecurityException(Exception e) {
        log.warn("Security Exception: {}", e.getMessage());
        HttpStatus status = (e instanceof AccessDeniedException) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED;
        return ResponseEntity.status(status)
                .body(new ErrorResponseDto("SECURITY_ERROR", e.getMessage()));
    }

    /**
     * Consolidated handling for all exceptions.
     * - Dynamically determines whether it is a 4xx or 5xx error to decide on Sentry transmission.
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponseDto> handleUnexpectedException(Exception e) {
        // Check for Spring standard web exceptions (Implementations of ErrorResponse)
        // (e.g., NoResourceFoundException, HttpRequestMethodNotSupportedException, ResponseStatusException, etc.)
        if (e instanceof ErrorResponse errorResponse) {
            HttpStatus status = HttpStatus.valueOf(errorResponse.getStatusCode().value());
            
            // If it is a 4xx Client Error: Leave only a WARN log and do NOT send to Sentry.
            if (status.is4xxClientError()) {
			    if (errorResponse instanceof MethodArgumentNotValidException m) {  
			        if (m.getBindingResult().getFieldError() != null && StringUtils.hasText(m.getBindingResult().getFieldError().getDefaultMessage())) {  
			            return ResponseEntity.status(status)
                        .body(new ErrorResponseDto("BAD_REQUEST", m.getBindingResult().getFieldError().getDefaultMessage());  
			        }  
			    }
                log.warn("Client Error ({}): {}", status, e.getMessage());
                return ResponseEntity.status(status)
                        .body(new ErrorResponseDto("BAD_REQUEST", e.getMessage()));
            }
        }

        // Real 500 Server Error (All other exceptions)
        log.error("Unexpected Error", e);
        
        // Send to Sentry
        Sentry.captureException(e);

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponseDto("SERVER_ERROR", "Internal Server Error occurred."));
    }

    public record ErrorResponseDto(String code, String message) {}
}

3. BeforeSendCallback

Intercepts events right before they are sent to Sentry to modify or filter them.

SentryBeforeSendCallback.java

Java

import io.sentry.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Component;
import java.util.List; // 추가됨

@Component
public class SentryBeforeSendCallback implements SentryOptions.BeforeSendCallback {
    @Override
    public @Nullable SentryEvent execute(@NotNull SentryEvent event, @NotNull Hint hint) {
        // 1. Do not send specific exceptions at all.
        if (event.getThrowable() instanceof IllegalArgumentException) {
            return null; 
        }

        // 2. Mask sensitive information (e.g., credit card numbers).
        // Alternatively, you can enable 'Data Scrubber' in the Sentry server settings.
        
        // 3. Custom Fingerprinting (Grouping similar errors).
        // Useful when you want to group DB connection errors as the same issue regardless of the specific query.
        if (event.getThrowable() instanceof java.sql.SQLException) {
            event.setFingerprints(List.of("database-connection-error"));
        }

        return event;
    }
}

4. Scheduler Error Handling

Errors occurring in schedulers (@Scheduled) are not caught by the global exception handler, so a separate configuration is required.

ScheduledConfig.java

Java

import io.sentry.Sentry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

@Configuration
@EnableScheduling
public class ScheduledConfig implements SchedulingConfigurer {  
  
    @Bean  
    public ThreadPoolTaskScheduler taskScheduler() {  
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();  
        scheduler.setPoolSize(5);  
        scheduler.setAwaitTerminationSeconds(60);  
        scheduler.setWaitForTasksToCompleteOnShutdown(true);  
        scheduler.setThreadNamePrefix("scheduler-thread-task-");  
        
        // Register Sentry capture in the error handler
        scheduler.setErrorHandler(Sentry::captureException);  
        
        scheduler.initialize();  
        return scheduler;  
    }  
  
    @Override  
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {  
        taskRegistrar.setTaskScheduler(taskScheduler());  
    }  
}

5. Message Channel Error Handling

This covers error handling for asynchronous tasks using message channels, such as Spring Integration.

MyJobEndpoint.java

Java

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.integration.annotation.MessageEndpoint;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHandler;
import org.springframework.messaging.MessagingException;
import org.springframework.messaging.support.ErrorMessage;

@Slf4j
@MessageEndpoint
@RequiredArgsConstructor
public class MyJobEndpoint {

    private final MessageChannel errorChannel;

    @Bean
    @ServiceActivator(inputChannel = "executorChannel", async = "true")
    public MessageHandler messageHandler() {
        return new MessageHandler() {
            @Override
            public void handleMessage(Message<?> message) throws MessagingException {
                try {
                    // Processing logic
                } catch (Exception e) {
                    var errMsg = new ErrorMessage(e);
                    errorChannel.send(errMsg);
                }
            }
        };
    }
}

AsyncJobConfig.java

Plain Text

import org.springframework.integration.annotation.ServiceActivator;

@Configuration  
@EnableIntegration  
public class AsyncJobConfig {

    @ServiceActivator(inputChannel = "errorChannel")  
    public void handleError(Message<?> message) {  
        Object payload = message.getPayload();  
        if (payload instanceof MessagingException ex) {  
            Sentry.captureException(ex);  
        } else if (payload instanceof Throwable throwable) {  
            Sentry.captureException(throwable);  
        }  
    }  
}

Conclusion

With these settings, you can ignore 4xx client errors while ensuring that 5xx server errors and asynchronous errors are monitored without fail.

Applying these configurations allows you to build a robust monitoring environment that reduces unnecessary Alert Fatigue while not missing critical server outages and failures in asynchronous tasks.

민갤

Back-End Developer

백엔드 개발자입니다.