diff --git a/.env.example b/.env.example
index b698b29f..97d677e9 100644
--- a/.env.example
+++ b/.env.example
@@ -50,6 +50,7 @@ GLITCHTIP_SECRET_KEY=changeme-generate-a-real-secret
# Error reporting DSNs — leave empty to disable the SDK (safe default).
# SENTRY_DSN: backend (Spring Boot) — used by the GlitchTip/Sentry Java SDK
SENTRY_DSN=
+SENTRY_TRACES_SAMPLE_RATE=
# VITE_SENTRY_DSN: frontend (SvelteKit) — injected at build time via Vite
VITE_SENTRY_DSN=
# Sentry/GlitchTip auth token for source map upload at build time (optional)
diff --git a/backend/pom.xml b/backend/pom.xml
index 7ac0a6a5..74d513cd 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -224,6 +224,15 @@
+
+
+
+ io.sentry
+ sentry-spring-boot-4
+ 8.41.0
+
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
index e99acfdd..aa33094d 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.exception;
import java.util.stream.Collectors;
+import io.sentry.Sentry;
import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -63,6 +64,7 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity handleGeneric(Exception ex) {
+ Sentry.captureException(ex);
log.error("Unhandled exception", ex);
return ResponseEntity.internalServerError()
.body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred"));
diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml
index 6198f3d9..ead3d9e8 100644
--- a/backend/src/main/resources/application.yaml
+++ b/backend/src/main/resources/application.yaml
@@ -118,3 +118,12 @@ ocr:
sender-model:
activation-threshold: 100
retrain-delta: 50
+
+sentry:
+ dsn: ${SENTRY_DSN:}
+ environment: ${SPRING_PROFILES_ACTIVE:dev}
+ traces-sample-rate: ${SENTRY_TRACES_SAMPLE_RATE:1.0}
+ send-default-pii: false
+ enable-tracing: true
+ ignored-exceptions-for-type:
+ - org.raddatz.familienarchiv.exception.DomainException
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java b/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java
index 899c526c..f7eaa7b9 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/ApplicationContextTest.java
@@ -1,14 +1,18 @@
package org.raddatz.familienarchiv;
import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.containers.PostgreSQLContainer;
import software.amazon.awssdk.services.s3.S3Client;
+import static org.assertj.core.api.Assertions.assertThat;
+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@@ -17,9 +21,18 @@ class ApplicationContextTest {
@MockitoBean
S3Client s3Client;
+ @Autowired
+ ApplicationContext ctx;
+
@Test
void contextLoads() {
// verifies that the Spring context starts successfully with all beans wired,
// Flyway migrations applied, and no configuration errors
}
+
+ @Test
+ void sentry_is_disabled_when_no_dsn_is_configured() {
+ // application-test.yaml has no sentry.dsn — SDK must stay inactive so tests are clean
+ assertThat(io.sentry.Sentry.isEnabled()).isFalse();
+ }
}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceIntegrationTest.java
index b8401d18..16db59be 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceIntegrationTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceIntegrationTest.java
@@ -1,11 +1,11 @@
package org.raddatz.familienarchiv.audit;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
-import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
@@ -18,7 +18,6 @@ import static org.awaitility.Awaitility.await;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
-@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class AuditServiceIntegrationTest {
@MockitoBean S3Client s3Client;
@@ -26,6 +25,11 @@ class AuditServiceIntegrationTest {
@Autowired AuditLogRepository auditLogRepository;
@Autowired TransactionTemplate transactionTemplate;
+ @BeforeEach
+ void resetAuditLog() {
+ auditLogRepository.deleteAll();
+ }
+
@Test
void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() {
transactionTemplate.execute(status -> {
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java
index bdf9f810..0ba61da3 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchPagedIntegrationTest.java
@@ -12,9 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
-import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
@@ -33,7 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
-@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+@Transactional
class DocumentSearchPagedIntegrationTest {
private static final int FIXTURE_SIZE = 120;
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java
new file mode 100644
index 00000000..a12933b8
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java
@@ -0,0 +1,33 @@
+package org.raddatz.familienarchiv.exception;
+
+import io.sentry.Sentry;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mockStatic;
+
+@ExtendWith(MockitoExtension.class)
+class GlobalExceptionHandlerTest {
+
+ @InjectMocks
+ private GlobalExceptionHandler handler;
+
+ @Test
+ void handleGeneric_captures_exception_in_sentry_and_returns_500() {
+ RuntimeException ex = new RuntimeException("unexpected failure");
+
+ try (MockedStatic sentryMock = mockStatic(Sentry.class)) {
+ ResponseEntity response = handler.handleGeneric(ex);
+
+ sentryMock.verify(() -> Sentry.captureException(ex));
+ assertThat(response.getStatusCode().value()).isEqualTo(500);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
+ }
+ }
+}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java
index 55eaaa4c..31c73af1 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java
@@ -19,9 +19,9 @@ import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
@@ -32,7 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
-@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+@Transactional
class GeschichteServiceIntegrationTest {
@MockitoBean
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java
index 02cc7aa4..e8d5ed97 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java
@@ -8,9 +8,9 @@ import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
-import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
-@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+@Transactional
class PersonServiceIntegrationTest {
@MockitoBean S3Client s3Client;
diff --git a/docker-compose.yml b/docker-compose.yml
index 3e42c981..2a3b7407 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -147,6 +147,8 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
APP_OCR_BASE_URL: http://ocr-service:8000
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
+ SENTRY_DSN: ${SENTRY_DSN:-}
+ SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
# Tempo is defined in docker-compose.observability.yml (future issue).
# OTLP failures are non-fatal — backend starts cleanly without the observability stack.