diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index b26632f7f65..f0ed81b44db 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -73,6 +73,9 @@ jobs: - sample: "sentry-samples-spring-boot-4" agent: "false" agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-4-log4j2" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-spring-boot-4-webflux" agent: "false" agent-auto-init: "true" diff --git a/CHANGELOG.md b/CHANGELOG.md index a6600fda921..fb4d450f4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,33 @@ import ``` + - Sentry can now configure Log4j2 automatically for Spring Boot 4 when `sentry-log4j2` is on the classpath and Log4j2 Core is the active logging backend ([#5403](https://github.com/getsentry/sentry-java/pull/5403)) + - Enable automatic appender registration with: + ```properties + sentry.logging.enabled=true + ``` + Automatic registration is disabled by default for now and will be enabled by default in the next major release. + - The appender is attached to the root logger by default. Configure one or more logger names with: + ```properties + sentry.logging.loggers[0]=ROOT + sentry.logging.loggers[1]=com.example + ``` + - Configure the minimum level for creating breadcrumbs. The default is `INFO`: + ```properties + sentry.logging.minimum-breadcrumb-level=INFO + ``` + - Configure the minimum level for creating Sentry error events. The default is `ERROR`: + ```properties + sentry.logging.minimum-event-level=ERROR + ``` + - Configure the minimum level for sending Sentry structured logs. The default is `INFO`: + ```properties + sentry.logging.minimum-level=INFO + ``` + Structured logs must also be enabled: + ```properties + sentry.logs.enabled=true + ``` ### Fixes diff --git a/build.gradle.kts b/build.gradle.kts index 55b5a71a1e5..e99dba65350 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,6 +71,7 @@ apiValidation { "sentry-samples-spring-boot-webflux", "sentry-samples-spring-boot-webflux-jakarta", "sentry-samples-spring-boot-4", + "sentry-samples-spring-boot-4-log4j2", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", "sentry-samples-spring-boot-4-otlp", diff --git a/sentry-log4j2/api/sentry-log4j2.api b/sentry-log4j2/api/sentry-log4j2.api index 2a5d4bf7895..7eebea7136e 100644 --- a/sentry-log4j2/api/sentry-log4j2.api +++ b/sentry-log4j2/api/sentry-log4j2.api @@ -12,6 +12,9 @@ public class io/sentry/log4j2/SentryAppender : org/apache/logging/log4j/core/app public static fun createAppender (Ljava/lang/String;Lorg/apache/logging/log4j/Level;Lorg/apache/logging/log4j/Level;Lorg/apache/logging/log4j/Level;Ljava/lang/String;Ljava/lang/Boolean;Lorg/apache/logging/log4j/core/Filter;Ljava/lang/String;)Lio/sentry/log4j2/SentryAppender; protected fun createBreadcrumb (Lorg/apache/logging/log4j/core/LogEvent;)Lio/sentry/Breadcrumb; protected fun createEvent (Lorg/apache/logging/log4j/core/LogEvent;)Lio/sentry/SentryEvent; + public fun getMinimumBreadcrumbLevel ()Lorg/apache/logging/log4j/Level; + public fun getMinimumEventLevel ()Lorg/apache/logging/log4j/Level; + public fun getMinimumLevel ()Lorg/apache/logging/log4j/Level; public fun start ()V } diff --git a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java index df0f9eeb2d2..9bc8e90bfe0 100644 --- a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java +++ b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java @@ -167,6 +167,18 @@ public void start() { start(getOptionsConfiguration(null)); } + public @NotNull Level getMinimumBreadcrumbLevel() { + return minimumBreadcrumbLevel; + } + + public @NotNull Level getMinimumEventLevel() { + return minimumEventLevel; + } + + public @NotNull Level getMinimumLevel() { + return minimumLevel; + } + @NotNull Sentry.OptionsConfiguration getOptionsConfiguration( final @Nullable Sentry.OptionsConfiguration additionalOptionsConfiguration) { diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/README.md b/sentry-samples/sentry-samples-spring-boot-4-log4j2/README.md new file mode 100644 index 00000000000..dfe080e91df --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/README.md @@ -0,0 +1,122 @@ +# Sentry Sample Spring Boot 4 with Log4j2 + +Sample application showing how to use Sentry with [Spring Boot 4](https://spring.io/projects/spring-boot) and [Log4j2](https://logging.apache.org/log4j/2.x/). + +## How to run? + +To see events triggered in this sample application in your Sentry dashboard, go to `src/main/resources/application.properties` and replace the test DSN with your own DSN. + +Then, execute a command from the module directory: + +``` +../../gradlew bootRun +``` + +Make an HTTP request that will trigger events: + +``` +curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' +``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` + +### Project + +``` +query ProjectQuery($slug: ID!) { + project(slug: $slug) { + slug + name + repositoryUrl + status + } +} +``` +variables: +``` +{ + "slug": "statuscrash" +} +``` + +### Mutation + +``` +mutation AddProjectMutation($slug: ID!) { + addProject(slug: $slug) +} +``` +variables: +``` +{ + "slug": "nocrash", + "name": "nocrash" +} +``` + +### Subscription + +``` +subscription SubscriptionNotifyNewTask($slug: ID!) { + notifyNewTask(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` + +### Data loader + +``` +query TasksAndAssigneesQuery($slug: ID!) { + tasks(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts new file mode 100644 index 00000000000..7686edade78 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/build.gradle.kts @@ -0,0 +1,113 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.springboot4) + alias(libs.plugins.spring.dependency.management) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + id("io.sentry.systemtest") +} + +group = "io.sentry.sample.spring-boot-4-log4j2" + +version = "0.0.1-SNAPSHOT" + +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + kotlin { + explicitApi() + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + } +} + +dependencies { + implementation(libs.springboot4.starter) + implementation(libs.springboot4.starter.actuator) + implementation(libs.springboot4.starter.aspectj) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.quartz) + implementation(libs.springboot4.starter.security) + implementation(libs.springboot4.starter.web) + implementation(libs.springboot4.starter.webflux) + implementation(libs.springboot4.starter.websocket) + implementation(libs.springboot4.starter.restclient) + implementation(libs.springboot4.starter.webclient) + implementation(Config.Libs.aspectj) + implementation(Config.Libs.kotlinReflect) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryGraphql22) + implementation(projects.sentryQuartz) + implementation(projects.sentryAsyncProfiler) + + implementation(projects.sentryLog4j2) + implementation("org.springframework.boot:spring-boot-starter-log4j2") + modules { + module("org.springframework.boot:spring-boot-starter-logging") { + replacedBy( + "org.springframework.boot:spring-boot-starter-log4j2", + "Use Log4j2 instead of Logback", + ) + } + } + + // cache tracing + implementation(libs.springboot4.starter.cache) + implementation(libs.caffeine) + + // kafka + implementation(libs.springboot4.starter.kafka) + implementation(projects.sentryKafka) + + // database query tracing + implementation(projects.sentryJdbc) + runtimeOnly(libs.hsqldb) + + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentry) + testImplementation(projects.sentrySystemTestSupport) + testImplementation(libs.apollo3.kotlin) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.slf4j2.api) + testImplementation(libs.springboot4.starter.test) { + exclude(group = "org.junit.vintage", module = "junit-vintage-engine") + } +} + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + + val test = project.extensions.getByType()["test"] + testClassesDirs = test.output.classesDirs + classpath = test.runtimeClasspath + + maxParallelForks = 1 + + // Cap JVM args per test + minHeapSize = "128m" + maxHeapSize = "1g" + + filter { includeTestsMatching("io.sentry.systemtest*") } +} + +tasks.named("test").configure { + require(this is Test) + + filter { excludeTestsMatching("io.sentry.systemtest.*") } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CacheController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CacheController.java new file mode 100644 index 00000000000..3c2e66442de --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/cache/") +public class CacheController { + private final TodoService todoService; + + public CacheController(TodoService todoService) { + this.todoService = todoService; + } + + @GetMapping("{id}") + Todo get(@PathVariable Long id) { + return todoService.get(id); + } + + @PostMapping + Todo save(@RequestBody Todo todo) { + return todoService.save(todo); + } + + @DeleteMapping("{id}") + void delete(@PathVariable Long id) { + todoService.delete(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java new file mode 100644 index 00000000000..723d9683d31 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.protocol.SentryRuntime; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.SpringBootVersion; +import org.springframework.stereotype.Component; + +/** + * Custom {@link EventProcessor} implementation lets modifying {@link SentryEvent}s before they are + * sent to Sentry. + */ +@Component +public class CustomEventProcessor implements EventProcessor { + private final String springBootVersion; + + public CustomEventProcessor(String springBootVersion) { + this.springBootVersion = springBootVersion; + } + + public CustomEventProcessor() { + this(SpringBootVersion.getVersion()); + } + + @Override + public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + final SentryRuntime runtime = new SentryRuntime(); + runtime.setVersion(springBootVersion); + runtime.setName("Spring Boot"); + event.getContexts().setRuntime(runtime); + return event; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java new file mode 100644 index 00000000000..b96d2a6c431 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.spring7.checkin.SentryCheckIn; +import io.sentry.spring7.tracing.SentryTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * {@link SentryTransaction} added on the class level, creates transaction around each method + * execution of every method of the annotated class. + */ +@Component +@SentryTransaction(operation = "scheduled") +public class CustomJob { + + private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); + + @SentryCheckIn("monitor_slug_1") + // @Scheduled(fixedRate = 3 * 60 * 1000L) + void execute() throws InterruptedException { + LOGGER.info("Executing scheduled job"); + Thread.sleep(2000L); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java new file mode 100644 index 00000000000..9018e4c2184 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot4; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestClient restClient; + + public DistributedTracingController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restClient + .post() + .uri("http://localhost:8080/person/") + .body(person) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/MetricController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/MetricController.java new file mode 100644 index 00000000000..be75f5e3002 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/MetricController.java @@ -0,0 +1,37 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.setAttribute("user.type", "admin"); + Sentry.setAttribute("feature.version", 2); + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/Person.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/Person.java new file mode 100644 index 00000000000..a12881fb346 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot4; + +public class Person { + private final String firstName; + private final String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/PersonController.java new file mode 100644 index 00000000000..489dc629d28 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -0,0 +1,55 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/person/") +public class PersonController { + private final PersonService personService; + private static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + public PersonController(PersonService personService) { + this.personService = personService; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("transaction-feature-flag", true); + ISpan currentSpan = Sentry.getSpan(); + ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); + try { + Sentry.setAttribute("user.type", "admin"); + Sentry.setAttribute("feature.version", 2); + Sentry.setAttribute("debug.enabled", true); + + Sentry.logger().warn("warn Sentry logging"); + Sentry.logger().error("error Sentry logging"); + Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); + LOGGER.error("Trying person with id={}", id, new RuntimeException("error while loading")); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } finally { + sentrySpan.finish(); + } + } + + @PostMapping + Person create(@RequestBody Person person) { + ISpan currentSpan = Sentry.getSpan(); + ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); + try { + return personService.create(person); + } finally { + sentrySpan.finish(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/PersonService.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/PersonService.java new file mode 100644 index 00000000000..de2e684c920 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/PersonService.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring7.tracing.SentrySpan; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * {@link SentrySpan} can be added either on the class or the method to create spans around method + * executions. + */ +@Service +@SentrySpan +public class PersonService { + private static final Logger LOGGER = LoggerFactory.getLogger(PersonService.class); + + private final JdbcTemplate jdbcTemplate; + private int createCount = 0; + + public PersonService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + Person create(Person person) { + createCount++; + final ISpan span = Sentry.getSpan(); + if (span != null) { + span.setMeasurement("create_count", createCount); + } + + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.getFirstName(), + person.getLastName()); + + return person; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java new file mode 100644 index 00000000000..d12a40fb51d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4; + +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfiguration { + + // this API is meant to be consumed by non-browser clients thus the CSRF protection is not needed. + @SuppressWarnings({"lgtm[java/spring-disabled-csrf-protection]", "removal"}) + @Bean + public SecurityFilterChain filterChain(final @NotNull HttpSecurity http) throws Exception { + return http.csrf((csrf) -> csrf.disable()) + .authorizeHttpRequests((r) -> r.anyRequest().authenticated()) + .httpBasic((h) -> {}) + .build(); + } + + @Bean + public @NotNull InMemoryUserDetailsManager userDetailsService() { + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + final UserDetails user = + User.builder() + .passwordEncoder(encoder::encode) + .username("user") + .password("password") + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java new file mode 100644 index 00000000000..13d97fa8442 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java @@ -0,0 +1,73 @@ +package io.sentry.samples.spring.boot4; + +import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; + +import io.sentry.samples.spring.boot4.quartz.SampleJob; +import java.util.Collections; +import org.quartz.JobDetail; +import org.quartz.SimpleTrigger; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.restclient.RestTemplateBuilder; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +@EnableCaching +@EnableScheduling +public class SentryDemoApplication { + public static void main(String[] args) { + SpringApplication.run(SentryDemoApplication.class, args); + } + + @Bean + RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } + + @Bean + WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } + + @Bean + RestClient restClient(RestClient.Builder builder) { + return builder.build(); + } + + @Bean + public JobDetailFactoryBean jobDetail() { + JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); + jobDetailFactory.setJobClass(SampleJob.class); + jobDetailFactory.setDurability(true); + jobDetailFactory.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_job_detail")); + return jobDetailFactory; + } + + @Bean + public SimpleTriggerFactoryBean trigger(JobDetail job) { + SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setRepeatInterval(2 * 60 * 1000); // every two minutes + trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY); + trigger.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_simple_trigger")); + return trigger; + } + + @Bean + public CronTriggerFactoryBean cronTrigger(JobDetail job) { + CronTriggerFactoryBean trigger = new CronTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setCronExpression("0 0/5 * ? * *"); // every five minutes + return trigger; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/Todo.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/Todo.java new file mode 100644 index 00000000000..ae3d128d6b9 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4; + +public class Todo { + private final Long id; + private final String title; + private final boolean completed; + + public Todo(Long id, String title, boolean completed) { + this.id = id; + this.title = title; + this.completed = completed; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public boolean isCompleted() { + return completed; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/TodoController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/TodoController.java new file mode 100644 index 00000000000..0f71cca0419 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/TodoController.java @@ -0,0 +1,57 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.reactor.SentryReactorUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@RestController +public class TodoController { + private final RestTemplate restTemplate; + private final WebClient webClient; + private final RestClient restClient; + + public TodoController(RestTemplate restTemplate, WebClient webClient, RestClient restClient) { + this.restTemplate = restTemplate; + this.webClient = webClient; + this.restClient = restClient; + } + + @GetMapping("/todo/{id}") + Todo todo(@PathVariable Long id) { + return restTemplate.getForObject( + "https://jsonplaceholder.typicode.com/todos/{id}", Todo.class, id); + } + + @GetMapping("/todo-webclient/{id}") + Todo todoWebClient(@PathVariable Long id) { + Hooks.enableAutomaticContextPropagation(); + return SentryReactorUtils.withSentry( + Mono.just(true) + .publishOn(Schedulers.boundedElastic()) + .flatMap( + x -> + webClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .bodyToMono(Todo.class) + .map(response -> response))) + .block(); + } + + @GetMapping("/todo-restclient/{id}") + Todo todoRestClient(@PathVariable Long id) { + return restClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .body(Todo.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/TodoService.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/TodoService.java new file mode 100644 index 00000000000..c837ab8398a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot4; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +public class TodoService { + private final Map store = new ConcurrentHashMap<>(); + + @Cacheable(value = "todos", key = "#id") + public Todo get(Long id) { + return store.get(id); + } + + @CachePut(value = "todos", key = "#todo.id") + public Todo save(Todo todo) { + store.put(todo.getId(), todo); + return todo; + } + + @CacheEvict(value = "todos", key = "#id") + public void delete(Long id) { + store.remove(id); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java new file mode 100644 index 00000000000..d43cde143d1 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class AssigneeController { + + @BatchMapping(typeName = "Task", field = "assignee") + public Mono> assignee( + final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = + new HashMap<>(); + for (final @NotNull ProjectController.Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading assignee"); + } + if (task.assigneeId != null) { + map.put( + task, new ProjectController.Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + } + + return map; + }); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java new file mode 100644 index 00000000000..4770a75a255 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot4.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + @QueryMapping + public String greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + throw new RuntimeException("causing an error for " + name); + } + return "Hello " + name + "!"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java new file mode 100644 index 00000000000..2e1725ea644 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.MutationMapping; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Flux; + +@Controller +public class ProjectController { + + @QueryMapping + public Project project(final @Argument String slug) throws Exception { + if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project error for " + slug); + } + if ("notfound".equalsIgnoreCase(slug)) { + throw new IllegalStateException("not found"); + } + if ("nofile".equals(slug)) { + throw new NoSuchFileException("no such file"); + } + Project project = new Project(); + project.slug = slug; + return project; + } + + @SchemaMapping(typeName = "Project", field = "status") + public ProjectStatus projectStatus(final Project project) { + if ("crash".equalsIgnoreCase(project.slug) || "statuscrash".equalsIgnoreCase(project.slug)) { + throw new RuntimeException("causing a project status error for " + project.slug); + } + return ProjectStatus.COMMUNITY; + } + + @MutationMapping + public String addProject(@Argument String slug) { + if ("crash".equalsIgnoreCase(slug) || "addprojectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project add error for " + slug); + } + return UUID.randomUUID().toString(); + } + + @QueryMapping + public List tasks(final @Argument String projectSlug) { + List tasks = new ArrayList<>(); + tasks.add(new Task("T1", "Create a new API", "A3", "C3")); + tasks.add(new Task("T2", "Update dependencies", "A1", "C1")); + tasks.add(new Task("T3", "Document API", "A1", "C1")); + tasks.add(new Task("T4", "Merge community PRs", "A2", "C2")); + tasks.add(new Task("T5", "Plan more work", null, null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); + } + return tasks; + } + + @SubscriptionMapping + public Flux notifyNewTask(@Argument String projectSlug) { + if ("crash".equalsIgnoreCase(projectSlug)) { + throw new RuntimeException("causing error for subscription"); + } + if ("fluxerror".equalsIgnoreCase(projectSlug)) { + return Flux.error(new RuntimeException("causing flux error for subscription")); + } + final String assigneeId = "assigneecrash".equalsIgnoreCase(projectSlug) ? "Acrash" : "A1"; + final String creatorId = "creatorcrash".equalsIgnoreCase(projectSlug) ? "Ccrash" : "C1"; + final @NotNull AtomicInteger counter = new AtomicInteger(1000); + return Flux.interval(Duration.ofSeconds(1)) + .map( + num -> { + int i = counter.incrementAndGet(); + if ("produceerror".equalsIgnoreCase(projectSlug) && i % 2 == 0) { + throw new RuntimeException("causing produce error for subscription"); + } + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); + }); + } + + public static class Task { + public String id; + public String name; + public String assigneeId; + public String creatorId; + + public Task( + final String id, final String name, final String assigneeId, final String creatorId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + this.creatorId = creatorId; + } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } + } + + public static class Assignee { + public String id; + public String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Creator { + public String id; + public String name; + + public Creator(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Project { + public String slug; + } + + public enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java new file mode 100644 index 00000000000..9ee0ef2c7a4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java @@ -0,0 +1,50 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +class TaskCreatorController { + + public TaskCreatorController(final BatchLoaderRegistry batchLoaderRegistry) { + // using mapped BatchLoader to not have to deal with correct ordering of items + batchLoaderRegistry + .forTypePair(String.class, ProjectController.Creator.class) + .withOptions((builder) -> builder.setBatchingEnabled(true)) + .registerMappedBatchLoader( + (Set keys, BatchLoaderEnvironment env) -> { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (String key : keys) { + if ("Ccrash".equalsIgnoreCase(key)) { + throw new RuntimeException("Causing an error while loading creator"); + } + map.put(key, new ProjectController.Creator(key, "Name" + key)); + } + + return map; + }); + }); + } + + @SchemaMapping(typeName = "Task") + public @Nullable CompletableFuture creator( + final ProjectController.Task task, + final DataLoader dataLoader) { + if (task.creatorId == null) { + return null; + } + return dataLoader.load(task.creatorId); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java new file mode 100644 index 00000000000..c3b4ffd422f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.quartz; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.stereotype.Component; + +@Component +public class SampleJob implements Job { + + public void execute(JobExecutionContext context) throws JobExecutionException { + System.out.println("running job"); + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..0c3bea3b757 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.queues.kafka; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@Profile("kafka") +public class KafkaConsumer { + + private static final Logger logger = LoggerFactory.getLogger(KafkaConsumer.class); + + @KafkaListener(topics = "sentry-topic", groupId = "sentry-sample-group") + public void listen(String message) { + logger.info("Received message: {}", message); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..8c7b166fd33 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/java/io/sentry/samples/spring/boot4/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot4.queues.kafka; + +import org.springframework.context.annotation.Profile; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Profile("kafka") +@RequestMapping("/kafka") +public class KafkaController { + + private final KafkaTemplate kafkaTemplate; + + public KafkaController(KafkaTemplate kafkaTemplate) { + this.kafkaTemplate = kafkaTemplate; + } + + @GetMapping("/produce") + String produce(@RequestParam(defaultValue = "hello from sentry!") String message) { + kafkaTemplate.send("sentry-topic", message); + return "Message sent: " + message; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..eaaa62af13b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/application-kafka.properties @@ -0,0 +1,10 @@ +# Kafka — activate with: --spring.profiles.active=kafka +sentry.enable-queue-tracing=true + +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=sentry-sample-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/application.properties new file mode 100644 index 00000000000..96ec03757ce --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/application.properties @@ -0,0 +1,40 @@ +# NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard +sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 +sentry.send-default-pii=true +sentry.max-request-body-size=medium +# Sentry Spring Boot integration allows more fine-grained SentryOptions configuration +sentry.max-breadcrumbs=150 +# Log4j2 integration configuration options +sentry.logging.enabled=true +sentry.logging.minimum-event-level=info +sentry.logging.minimum-breadcrumb-level=debug +# Performance configuration +sentry.traces-sample-rate=1.0 +sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 +sentry.debug=true +sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR +sentry.enable-backpressure-handling=true +sentry.enable-spotlight=true +sentry.enablePrettySerializationOutput=false +sentry.in-app-includes="io.sentry.samples" +sentry.logs.enabled=true +sentry.profile-session-sample-rate=1.0 +sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces +sentry.profile-lifecycle=TRACE +sentry.enable-cache-tracing=true +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s + +# Uncomment and set to true to enable aot compatibility +# This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) +# to successfully compile to GraalVM +# sentry.enable-aot-compatibility=false + +# Database configuration +spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb +spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver +spring.datasource.username=sa +spring.datasource.password= +spring.graphql.graphiql.enabled=true +spring.graphql.websocket.path=/graphql +spring.quartz.job-store-type=memory diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..aeea62357bd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,68 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/quartz.properties b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/quartz.properties new file mode 100644 index 00000000000..6e302ce765a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/quartz.properties @@ -0,0 +1 @@ +org.quartz.jobStore.class=org.quartz.simpl.RAMJobStore diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/schema.sql b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/main/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/DummyTest.kt @@ -0,0 +1,12 @@ +package io.sentry + +import kotlin.test.Test +import kotlin.test.assertTrue + +class DummyTest { + @Test + fun `the only test`() { + // only needed to have more than 0 tests and not fail the build + assertTrue(true) + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt new file mode 100644 index 00000000000..b45e9c10853 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt @@ -0,0 +1,51 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class CacheSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `cache put and get produce spans`() { + val restClient = testHelper.restClient + + // Save a todo (triggers @CachePut -> cache.put span) + val todo = Todo(1L, "test-todo", false) + restClient.saveCachedTodo(todo) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") + } + + testHelper.reset() + + // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) + restClient.getCachedTodo(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") + } + } + + @Test + fun `cache evict produces span`() { + val restClient = testHelper.restClient + + restClient.deleteCachedTodo(1L) + + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..3cd16003024 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,197 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import org.junit.Before + +class DistributedTracingSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt new file mode 100644 index 00000000000..76a6024decc --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -0,0 +1,46 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import org.junit.Before + +class GraphqlGreetingSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `greeting works`() { + val response = testHelper.graphqlClient.greet("world") + + testHelper.ensureNoErrors(response) + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Query.greeting", + ) + } + } + + @Test + fun `greeting error`() { + val response = testHelper.graphqlClient.greet("crash") + + testHelper.ensureErrorCount(response, 1) + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Query.greeting", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt new file mode 100644 index 00000000000..fca3956717c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -0,0 +1,66 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.Before + +class GraphqlProjectSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `project query works`() { + val response = testHelper.graphqlClient.project("proj-slug") + + testHelper.ensureNoErrors(response) + assertEquals("proj-slug", response?.data?.project?.slug) + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Query.project", + ) + } + } + + @Test + fun `project mutation works`() { + val response = testHelper.graphqlClient.addProject("proj-slug") + + testHelper.ensureNoErrors(response) + assertNotNull(response?.data?.addProject) + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Mutation.addProject", + ) + } + } + + @Test + fun `project mutation error`() { + val response = testHelper.graphqlClient.addProject("addprojectcrash") + + testHelper.ensureErrorCount(response, 1) + assertNull(response?.data?.addProject) + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Mutation.addProject", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt new file mode 100644 index 00000000000..940709c0778 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -0,0 +1,50 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class GraphqlTaskSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `tasks and assignees query works`() { + val response = testHelper.graphqlClient.tasksAndAssignees("project-slug") + + testHelper.ensureNoErrors(response) + + assertEquals(5, response?.data?.tasks?.size) + + val firstTask = response?.data?.tasks?.firstOrNull() ?: throw RuntimeException("no task") + assertEquals("T1", firstTask.id) + assertEquals("A3", firstTask.assigneeId) + assertEquals("A3", firstTask.assignee?.id) + assertEquals("C3", firstTask.creatorId) + assertEquals("C3", firstTask.creator?.id) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Query.tasks", + ) && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Task.assignee", + ) && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Task.creator", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt new file mode 100644 index 00000000000..43781cf2c56 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt @@ -0,0 +1,117 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +/** + * System tests for Kafka queue instrumentation. + * + * Requires: + * - The sample app running with `--spring.profiles.active=kafka` + * - A Kafka broker at localhost:9092 + * - The mock Sentry server at localhost:8000 + */ +class KafkaQueueSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `producer endpoint creates queue publish span`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("test-message") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") + } + } + + @Test + fun `consumer creates queue process transaction`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("test-consumer-message") + assertEquals(200, restClient.lastKnownStatusCode) + + // The consumer runs asynchronously, so wait for the queue.process transaction + testHelper.ensureTransactionReceived { transaction, _ -> + testHelper.doesTransactionHaveOp(transaction, "queue.process") + } + } + + @Test + fun `producer and consumer share same trace`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("trace-test-message") + assertEquals(200, restClient.lastKnownStatusCode) + + // Capture the trace ID from the producer transaction (has queue.publish span) + var producerTraceId: String? = null + testHelper.ensureTransactionReceived { transaction, _ -> + if (testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish")) { + producerTraceId = transaction.contexts.trace?.traceId?.toString() + true + } else { + false + } + } + + // Verify the consumer transaction has the same trace ID + // Use retryCount=3 since the consumer may take a moment to process + testHelper.ensureEnvelopeReceived(retryCount = 3) { envelopeString -> + val envelope = + testHelper.jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + ?: return@ensureEnvelopeReceived false + val txItem = + envelope.items.firstOrNull { it.header.type == io.sentry.SentryItemType.Transaction } + ?: return@ensureEnvelopeReceived false + val tx = + txItem.getTransaction(testHelper.jsonSerializer) ?: return@ensureEnvelopeReceived false + + tx.contexts.trace?.operation == "queue.process" && + tx.contexts.trace?.traceId?.toString() == producerTraceId + } + } + + @Test + fun `queue publish span has messaging attributes`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("attrs-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + val span = transaction.spans.firstOrNull { it.op == "queue.publish" } + if (span == null) return@ensureTransactionReceived false + + val data = span.data ?: return@ensureTransactionReceived false + data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" + } + } + + @Test + fun `queue process transaction has messaging attributes`() { + val restClient = testHelper.restClient + + restClient.produceKafkaMessage("process-attrs-test") + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, _ -> + if (!testHelper.doesTransactionHaveOp(transaction, "queue.process")) { + return@ensureTransactionReceived false + } + + val data = transaction.contexts.trace?.data ?: return@ensureTransactionReceived false + data["messaging.system"] == "kafka" && data["messaging.destination.name"] == "sentry-topic" + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..039d9d640c7 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,51 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) && + testHelper.doesMetricHaveAttribute(event, "countMetric", "user.type", "admin") && + testHelper.doesMetricHaveAttribute(event, "countMetric", "feature.version", 2) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt new file mode 100644 index 00000000000..715c994bee8 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -0,0 +1,110 @@ +package io.sentry.systemtest + +import io.sentry.protocol.FeatureFlag +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class PersonSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person fails`() { + val restClient = testHelper.restClient + restClient.getPerson(1L) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + event.sdk?.integrationSet?.contains("Log4j") == true && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughSentryApi", + featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + } + + Thread.sleep(10000) + + testHelper.ensureLogsReceived { logs, envelopeHeader -> + testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && + testHelper.doesContainLogWithBody(logs, "error Sentry logging") && + testHelper.doesContainLogWithBody(logs, "hello there world!") && + testHelper.doesLogWithBodyHaveAttribute( + logs, + "warn Sentry logging", + "user.type", + "admin", + ) && + testHelper.doesLogWithBodyHaveAttribute( + logs, + "warn Sentry logging", + "feature.version", + 2, + ) && + testHelper.doesLogWithBodyHaveAttribute(logs, "warn Sentry logging", "debug.enabled", true) + } + } + + @Test + fun `create person works`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } + + @Test + fun `create person starts a profile linked to the transaction`() { + var profilerId: SentryId? = null + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + profilerId = transaction.contexts.profile?.profilerId + transaction.transaction == "POST /person/" + } + testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> + profileChunk.profilerId == profilerId + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt new file mode 100644 index 00000000000..d34485e1388 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -0,0 +1,61 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class TodoSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get todo works`() { + val restClient = testHelper.restClient + restClient.getTodo(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo webclient works`() { + val restClient = testHelper.restClient + restClient.getTodoWebclient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo restclient works`() { + val restClient = testHelper.restClient + restClient.getTodoRestClient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/resources/log4j2-test.xml b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/resources/log4j2-test.xml new file mode 100644 index 00000000000..915b9614ef7 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-log4j2/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/sentry-spring-boot-4/api/sentry-spring-boot-4.api b/sentry-spring-boot-4/api/sentry-spring-boot-4.api index 4c8be990b85..f3a16d45f57 100644 --- a/sentry-spring-boot-4/api/sentry-spring-boot-4.api +++ b/sentry-spring-boot-4/api/sentry-spring-boot-4.api @@ -13,6 +13,17 @@ public class io/sentry/spring/boot4/SentryAutoConfiguration { public fun ()V } +public class io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration { + public fun ()V + public fun sentryLog4j2Initializer (Lio/sentry/spring/boot4/SentryProperties;)Lio/sentry/spring/boot4/SentryLog4j2Initializer; +} + +public class io/sentry/spring/boot4/SentryLog4j2Initializer : org/springframework/context/event/GenericApplicationListener { + public fun (Lio/sentry/spring/boot4/SentryProperties;)V + public fun onApplicationEvent (Lorg/springframework/context/ApplicationEvent;)V + public fun supportsEventType (Lorg/springframework/core/ResolvableType;)Z +} + public class io/sentry/spring/boot4/SentryLogbackAppenderAutoConfiguration { public fun ()V public fun sentryLogbackInitializer (Lio/sentry/spring/boot4/SentryProperties;)Lio/sentry/spring/boot4/SentryLogbackInitializer; diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 2a6634b257f..cc5de913f96 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -30,7 +30,10 @@ dependencies { api(projects.sentry) api(projects.sentrySpring7) compileOnly(projects.sentryLogback) + compileOnly(projects.sentryLog4j2) compileOnly(projects.sentryApacheHttpClient5) + compileOnly(libs.log4j.api) + compileOnly(libs.log4j.core) compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) compileOnly(projects.sentryGraphql) compileOnly(projects.sentryGraphql22) @@ -65,7 +68,10 @@ dependencies { // tests testImplementation(projects.sentryLogback) + testImplementation(projects.sentryLog4j2) testImplementation(projects.sentryApacheHttpClient5) + testImplementation(libs.log4j.api) + testImplementation(libs.log4j.core) testImplementation(projects.sentryGraphql) testImplementation(projects.sentryGraphql22) testImplementation(projects.sentryKafka) diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration.java new file mode 100644 index 00000000000..75605a35479 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration.java @@ -0,0 +1,29 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.log4j2.SentryAppender; +import org.apache.logging.log4j.core.LoggerContext; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Auto-configures {@link SentryAppender}. */ +@Configuration(proxyBeanMethods = false) +@Open +@ConditionalOnClass({LoggerContext.class, SentryAppender.class}) +@ConditionalOnProperty( + name = "sentry.logging.enabled", + havingValue = "true", + matchIfMissing = false) +@ConditionalOnBean(SentryProperties.class) +public class SentryLog4j2AppenderAutoConfiguration { + + @Bean + public @NotNull SentryLog4j2Initializer sentryLog4j2Initializer( + final @NotNull SentryProperties sentryProperties) { + return new SentryLog4j2Initializer(sentryProperties); + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java new file mode 100644 index 00000000000..efdf9dc255a --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java @@ -0,0 +1,134 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.ScopesAdapter; +import io.sentry.log4j2.SentryAppender; +import io.sentry.util.Objects; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.GenericApplicationListener; +import org.springframework.core.ResolvableType; + +/** Registers {@link SentryAppender} after Spring context gets refreshed. */ +@Open +public class SentryLog4j2Initializer implements GenericApplicationListener { + private static final Logger logger = LoggerFactory.getLogger(SentryLog4j2Initializer.class); + private static final String SENTRY_APPENDER_NAME = "SENTRY_APPENDER"; + + private final @NotNull SentryProperties sentryProperties; + private final @NotNull List loggers; + @Nullable private SentryAppender sentryAppender; + + public SentryLog4j2Initializer(final @NotNull SentryProperties sentryProperties) { + this.sentryProperties = Objects.requireNonNull(sentryProperties, "properties are required"); + loggers = sentryProperties.getLogging().getLoggers(); + } + + @Override + public boolean supportsEventType(final @NotNull ResolvableType eventType) { + return eventType.getRawClass() != null + && ContextRefreshedEvent.class.isAssignableFrom(eventType.getRawClass()); + } + + @Override + public void onApplicationEvent(final @NotNull ApplicationEvent event) { + final Object context = LogManager.getContext(false); + if (!(context instanceof LoggerContext)) { + logger.info( + "Sentry Log4j2 appender was not configured because Log4j2 Core is not the active logging backend. Log4j2 API calls may be routed through SLF4J."); + return; + } + + final LoggerContext loggerContext = (LoggerContext) context; + final Configuration configuration = loggerContext.getConfiguration(); + + boolean changed = false; + for (final String loggerName : normalizeLoggerNames(loggers)) { + final LoggerConfig loggerConfig = getOrCreateLoggerConfig(configuration, loggerName); + if (!isSentryAppenderRegistered(loggerConfig)) { + loggerConfig.addAppender(getSentryAppender(configuration), null, null); + changed = true; + } + } + + if (changed) { + loggerContext.updateLoggers(configuration); + } + } + + private @NotNull LoggerConfig getOrCreateLoggerConfig( + final @NotNull Configuration configuration, final @NotNull String loggerName) { + if (LogManager.ROOT_LOGGER_NAME.equals(loggerName)) { + return configuration.getRootLogger(); + } + + final LoggerConfig loggerConfig = configuration.getLoggerConfig(loggerName); + if (loggerName.equals(loggerConfig.getName())) { + return loggerConfig; + } + + final LoggerConfig newLoggerConfig = new LoggerConfig(loggerName, null, true); + newLoggerConfig.setParent(loggerConfig); + configuration.addLogger(loggerName, newLoggerConfig); + return newLoggerConfig; + } + + private @NotNull SentryAppender getSentryAppender(final @NotNull Configuration configuration) { + if (sentryAppender == null) { + sentryAppender = + new SentryAppender( + SENTRY_APPENDER_NAME, + null, + null, + toLog4jLevel(sentryProperties.getLogging().getMinimumBreadcrumbLevel()), + toLog4jLevel(sentryProperties.getLogging().getMinimumEventLevel()), + toLog4jLevel(sentryProperties.getLogging().getMinimumLevel()), + null, + null, + ScopesAdapter.getInstance(), + null); + sentryAppender.start(); + configuration.addAppender(sentryAppender); + } + return sentryAppender; + } + + private @NotNull Set normalizeLoggerNames(final @NotNull List loggerNames) { + final Set normalized = new LinkedHashSet<>(); + for (final String loggerName : loggerNames) { + if (loggerName == null || loggerName.trim().isEmpty()) { + continue; + } + normalized.add(normalizeLoggerName(loggerName.trim())); + } + return normalized; + } + + private boolean isSentryAppenderRegistered(final @NotNull LoggerConfig loggerConfig) { + return loggerConfig.getAppenders().values().stream() + .anyMatch(appender -> appender.getClass().equals(SentryAppender.class)); + } + + private @NotNull String normalizeLoggerName(final @NotNull String loggerName) { + if (org.slf4j.Logger.ROOT_LOGGER_NAME.equals(loggerName)) { + return LogManager.ROOT_LOGGER_NAME; + } + return loggerName; + } + + private @Nullable Level toLog4jLevel(final @Nullable org.slf4j.event.Level slf4jLevel) { + return slf4jLevel == null ? null : Level.getLevel(slf4jLevel.name()); + } +} diff --git a/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index a108fa2ca10..e3e7c2e467b 100644 --- a/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ io.sentry.spring.boot4.SentryAutoConfiguration io.sentry.spring.boot4.SentryProfilerAutoConfiguration io.sentry.spring.boot4.SentryLogbackAppenderAutoConfiguration +io.sentry.spring.boot4.SentryLog4j2AppenderAutoConfiguration io.sentry.spring.boot4.SentryWebfluxAutoConfiguration diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt new file mode 100644 index 00000000000..b9bcee11d38 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt @@ -0,0 +1,289 @@ +package io.sentry.spring.boot4 + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import io.sentry.ITransportFactory +import io.sentry.NoOpTransportFactory +import io.sentry.ScopesAdapter +import io.sentry.log4j2.SentryAppender +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.apache.logging.log4j.Level +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.core.Appender +import org.apache.logging.log4j.core.LoggerContext +import org.apache.logging.log4j.core.config.DefaultConfiguration +import org.apache.logging.log4j.core.config.LoggerConfig +import org.assertj.core.api.Assertions.assertThat +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +class SentryLog4j2AppenderAutoConfigurationTest { + + private val baseContextRunner = + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SentryLog4j2AppenderAutoConfiguration::class.java, + SentryAutoConfiguration::class.java, + ) + ) + .withPropertyValues( + "sentry.shutdownTimeoutMillis=0", + "sentry.sessionFlushTimeoutMillis=0", + "sentry.flushTimeoutMillis=0", + "sentry.readTimeoutMillis=50", + "sentry.connectionTimeoutMillis=50", + "sentry.send-modules=false", + "sentry.attach-stacktrace=false", + "sentry.attach-threads=false", + "sentry.enable-backpressure-handling=false", + "sentry.enable-spotlight=false", + "sentry.debug=false", + "sentry.max-breadcrumbs=0", + ) + + private val contextRunner = + baseContextRunner + .withLog4j2CoreProvider() + .withPropertyValues("sentry.logging.enabled=true") + .withUserConfiguration(NoOpTransportConfiguration::class.java) + + private val dsnOnlyRunner = + baseContextRunner + .withLog4j2CoreProvider() + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(NoOpTransportConfiguration::class.java) + + private val dsnEnabledRunner = dsnOnlyRunner.withPropertyValues("sentry.logging.enabled=true") + + // Hide the Log4j2 Core provider so LogManager uses the Log4j-to-SLF4J bridge. + private val log4j2BridgeDsnEnabledRunner = + baseContextRunner + .withClassLoader(FilteredClassLoader("org.apache.logging.log4j.core.impl.Log4jProvider")) + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.logging.enabled=true") + .withUserConfiguration(NoOpTransportConfiguration::class.java) + + private val originalLogManagerFactory = LogManager.getFactory() + + private val loggerContext: LoggerContext + get() = LogManager.getContext(false) as LoggerContext + + private val configuration + get() = loggerContext.configuration + + private val rootLogger + get() = configuration.rootLogger + + @BeforeTest + fun `reset Log4j2 context`() { + useLog4j2Core() + resetLog4j2Context() + } + + @AfterTest + fun `restore Log4j2 context`() { + useLog4j2Core() + resetLog4j2Context() + LogManager.setFactory(originalLogManagerFactory) + } + + @Test + fun `does not configure SentryAppender when auto-configuration dsn is not set`() { + contextRunner.run { assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() } + } + + @Test + fun `does not configure SentryAppender when logging is not enabled`() { + dsnOnlyRunner.run { assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() } + } + + @Test + fun `configures SentryAppender`() { + dsnEnabledRunner.run { + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + } + } + + @Test + fun `configures SentryAppender for configured loggers`() { + dsnEnabledRunner + .withPropertyValues("sentry.logging.loggers[0]=foo.bar", "sentry.logging.loggers[1]=baz") + .run { + val fooBarLogger = configuration.getLoggerConfig("foo.bar") + val bazLogger = configuration.getLoggerConfig("baz") + + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(0) + assertThat(fooBarLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + assertThat(bazLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + } + } + + @Test + fun `configures SentryAppender for descendant logger with additivity disabled`() { + val loggerConfig = LoggerConfig("com.example", null, false) + configuration.addLogger("com.example", loggerConfig) + loggerContext.updateLoggers(configuration) + + dsnEnabledRunner + .withPropertyValues("sentry.logging.loggers[0]=ROOT", "sentry.logging.loggers[1]=com.example") + .run { + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + assertThat( + configuration.getLoggerConfig("com.example").getAppenders(SentryAppender::class.java) + ) + .hasSize(1) + } + } + + @Test + fun `configures SentryAppender for none of the loggers if so configured`() { + dsnEnabledRunner.withPropertyValues("sentry.logging.loggers=").run { + val fooBarLogger = configuration.getLoggerConfig("foo.bar") + val bazLogger = configuration.getLoggerConfig("baz") + + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(0) + assertThat(fooBarLogger.getAppenders(SentryAppender::class.java)).hasSize(0) + assertThat(bazLogger.getAppenders(SentryAppender::class.java)).hasSize(0) + } + } + + @Test + fun `does not overwrite Spring Boot Sentry options`() { + dsnEnabledRunner.withPropertyValues("sentry.environment=boot-env").run { + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + assertThat(ScopesAdapter.getInstance().options.environment).isEqualTo("boot-env") + } + } + + @Test + fun `sets SentryAppender properties`() { + dsnEnabledRunner + .withPropertyValues( + "sentry.logging.minimum-event-level=info", + "sentry.logging.minimum-breadcrumb-level=debug", + "sentry.logging.minimum-level=error", + ) + .run { + val appenders = rootLogger.getAppenders(SentryAppender::class.java) + assertThat(appenders).hasSize(1) + val sentryAppender = appenders[0] as SentryAppender + + assertThat(sentryAppender.minimumBreadcrumbLevel).isEqualTo(Level.DEBUG) + assertThat(sentryAppender.minimumEventLevel).isEqualTo(Level.INFO) + assertThat(sentryAppender.minimumLevel).isEqualTo(Level.ERROR) + } + } + + @Test + fun `does not configure SentryAppender when logging is disabled`() { + dsnEnabledRunner.withPropertyValues("sentry.logging.enabled=false").run { + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() + } + } + + @Test + fun `does not configure SentryAppender when appender is already configured`() { + val sentryAppender = + SentryAppender( + "customAppender", + null, + null, + null, + null, + null, + null, + null, + io.sentry.ScopesAdapter.getInstance(), + null, + ) + sentryAppender.start() + configuration.addAppender(sentryAppender) + rootLogger.addAppender(sentryAppender, null, null) + loggerContext.updateLoggers() + + dsnEnabledRunner.run { + val appenders = rootLogger.getAppenders(SentryAppender::class.java) + assertThat(appenders).hasSize(1) + assertThat(appenders.first().name).isEqualTo("customAppender") + } + } + + @Test + fun `does not configure SentryAppender when active Log4j2 context is not Log4j2 Core`() { + val logbackLogger = LoggerFactory.getLogger(SentryLog4j2Initializer::class.java) as Logger + val listAppender = ListAppender() + listAppender.start() + logbackLogger.addAppender(listAppender) + + try { + useSlf4jBridge() + log4j2BridgeDsnEnabledRunner.run { + assertThat(listAppender.list.map { it.formattedMessage }).anyMatch { + it.contains("Sentry Log4j2 appender was not configured") + } + } + } finally { + logbackLogger.detachAppender(listAppender) + listAppender.stop() + } + } + + @Test + fun `does not configure SentryAppender when log4j2 is not on the classpath`() { + baseContextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.logging.enabled=true") + .withClassLoader(FilteredClassLoader(LoggerContext::class.java)) + .run { assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() } + } + + @Test + fun `does not configure SentryAppender when sentry-log4j2 module is not on the classpath`() { + baseContextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.logging.enabled=true") + .withClassLoader(FilteredClassLoader(SentryAppender::class.java)) + .run { assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() } + } + + @Configuration(proxyBeanMethods = false) + open class NoOpTransportConfiguration { + + @Bean + open fun noOpTransportFactory(): ITransportFactory { + return NoOpTransportFactory.getInstance() + } + } +} + +fun org.apache.logging.log4j.core.config.LoggerConfig.getAppenders( + clazz: Class +): List { + return this.appenders.values.filter { it.javaClass == clazz } +} + +private fun ApplicationContextRunner.withLog4j2CoreProvider(): ApplicationContextRunner = + // Hide the Log4j-to-SLF4J provider so LogManager uses Log4j2 Core in these tests. + withClassLoader(FilteredClassLoader("org.apache.logging.slf4j")) + +private fun useLog4j2Core() { + LogManager.setFactory(org.apache.logging.log4j.core.impl.Log4jContextFactory()) +} + +private fun useSlf4jBridge() { + val factory = + Class.forName("org.apache.logging.slf4j.SLF4JLoggerContextFactory") + .getDeclaredConstructor() + .newInstance() as org.apache.logging.log4j.spi.LoggerContextFactory + LogManager.setFactory(factory) +} + +private fun resetLog4j2Context() { + val loggerContext = LogManager.getContext(false) as? LoggerContext ?: return + loggerContext.reconfigure(DefaultConfiguration()) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7fb5c627912..66001b3908f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -119,6 +119,7 @@ include( "sentry-samples:sentry-samples-spring-boot-webflux", "sentry-samples:sentry-samples-spring-boot-webflux-jakarta", "sentry-samples:sentry-samples-spring-boot-4", + "sentry-samples:sentry-samples-spring-boot-4-log4j2", "sentry-samples:sentry-samples-spring-boot-4-opentelemetry", "sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent", "sentry-samples:sentry-samples-spring-boot-4-otlp", diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 7dd7530c8bd..5cd493cfab4 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -77,6 +77,7 @@ "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-4", + "sentry-samples-spring-boot-4-log4j2", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", } @@ -88,6 +89,7 @@ "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-4", + "sentry-samples-spring-boot-4-log4j2", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", } @@ -861,6 +863,7 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-spring-boot-4-webflux", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4", "false", "true", "false"), + ModuleConfig("sentry-samples-spring-boot-4-log4j2", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "false", "false"),