From d7528feec545b98ccbee9f29d6b56e52b8b6e361 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Fri, 28 Aug 2026 10:43:21 +0200 Subject: [PATCH 1/4] add log5j autoinstrumentation for spring-boot-3 --- CHANGELOG.md | 2 +- .../api/sentry-spring-boot-jakarta.api | 11 + sentry-spring-boot-jakarta/build.gradle.kts | 6 + ...SentryLog4j2AppenderAutoConfiguration.java | 29 ++ .../boot/jakarta/SentryLog4j2Initializer.java | 134 ++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...ntryLog4j2AppenderAutoConfigurationTest.kt | 289 ++++++++++++++++++ 7 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfiguration.java create mode 100644 sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2Initializer.java create mode 100644 sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index af3a4255365..bd37b866c9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- 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)) +- Sentry can now configure Log4j2 automatically for Spring Boot 3 and 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 diff --git a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api index 197bdbeef72..52633965cda 100644 --- a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api +++ b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api @@ -13,6 +13,17 @@ public class io/sentry/spring/boot/jakarta/SentryAutoConfiguration { public fun ()V } +public class io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfiguration { + public fun ()V + public fun sentryLog4j2Initializer (Lio/sentry/spring/boot/jakarta/SentryProperties;)Lio/sentry/spring/boot/jakarta/SentryLog4j2Initializer; +} + +public class io/sentry/spring/boot/jakarta/SentryLog4j2Initializer : org/springframework/context/event/GenericApplicationListener { + public fun (Lio/sentry/spring/boot/jakarta/SentryProperties;)V + public fun onApplicationEvent (Lorg/springframework/context/ApplicationEvent;)V + public fun supportsEventType (Lorg/springframework/core/ResolvableType;)Z +} + public class io/sentry/spring/boot/jakarta/SentryLogbackAppenderAutoConfiguration { public fun ()V public fun sentryLogbackInitializer (Lio/sentry/spring/boot/jakarta/SentryProperties;)Lio/sentry/spring/boot/jakarta/SentryLogbackInitializer; diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 1ed9373f4bf..6cb88afc7d7 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -34,7 +34,10 @@ dependencies { api(projects.sentry) api(projects.sentrySpringJakarta) 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) @@ -67,7 +70,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-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfiguration.java new file mode 100644 index 00000000000..464bb7e1dcc --- /dev/null +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfiguration.java @@ -0,0 +1,29 @@ +package io.sentry.spring.boot.jakarta; + +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-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2Initializer.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2Initializer.java new file mode 100644 index 00000000000..141f6a8ba00 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryLog4j2Initializer.java @@ -0,0 +1,134 @@ +package io.sentry.spring.boot.jakarta; + +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-jakarta/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sentry-spring-boot-jakarta/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 1a812670017..0a6bca3525d 100644 --- a/sentry-spring-boot-jakarta/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/sentry-spring-boot-jakarta/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ io.sentry.spring.boot.jakarta.SentryAutoConfiguration io.sentry.spring.boot.jakarta.SentryProfilerAutoConfiguration io.sentry.spring.boot.jakarta.SentryLogbackAppenderAutoConfiguration +io.sentry.spring.boot.jakarta.SentryLog4j2AppenderAutoConfiguration io.sentry.spring.boot.jakarta.SentryWebfluxAutoConfiguration diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt new file mode 100644 index 00000000000..08eb6ac45c7 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt @@ -0,0 +1,289 @@ +package io.sentry.spring.boot.jakarta + +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()) +} From 30b2384e61b10706a960f502b9c259a14376aa71 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 8 Sep 2026 11:17:53 +0200 Subject: [PATCH 2/4] add changelog, add log4j sample for jakarta and add system tests --- .github/workflows/system-tests-backend.yml | 3 + CHANGELOG.md | 3 +- build.gradle.kts | 1 + .../README.md | 122 ++++++++++ .../build.gradle.kts | 120 ++++++++++ .../sentry-jakarta-text-master.properties | 2 + .../sentry-package-rename.properties | 2 + .../spring/boot/jakarta/CacheController.java | 34 +++ .../boot/jakarta/CustomEventProcessor.java | 35 +++ .../spring/boot/jakarta/CustomJob.java | 25 ++ .../jakarta/DistributedTracingController.java | 49 ++++ .../boot/jakarta/FeatureFlagController.java | 68 ++++++ .../spring/boot/jakarta/MetricController.java | 37 +++ .../boot/jakarta/OpenFeatureConfig.java | 226 ++++++++++++++++++ .../samples/spring/boot/jakarta/Person.java | 24 ++ .../spring/boot/jakarta/PersonController.java | 54 +++++ .../spring/boot/jakarta/PersonService.java | 41 ++++ .../boot/jakarta/SecurityConfiguration.java | 40 ++++ .../boot/jakarta/SentryDemoApplication.java | 73 ++++++ .../samples/spring/boot/jakarta/Todo.java | 25 ++ .../spring/boot/jakarta/TodoController.java | 57 +++++ .../spring/boot/jakarta/TodoService.java | 29 +++ .../jakarta/graphql/AssigneeController.java | 34 +++ .../jakarta/graphql/GreetingController.java | 17 ++ .../jakarta/graphql/ProjectController.java | 140 +++++++++++ .../graphql/TaskCreatorController.java | 50 ++++ .../spring/boot/jakarta/quartz/SampleJob.java | 19 ++ .../jakarta/queues/kafka/KafkaConsumer.java | 19 ++ .../jakarta/queues/kafka/KafkaController.java | 26 ++ .../resources/application-kafka.properties | 10 + .../src/main/resources/application.properties | 43 ++++ .../main/resources/graphql/schema.graphqls | 68 ++++++ .../src/main/resources/quartz.properties | 1 + .../src/main/resources/schema.sql | 5 + .../src/test/kotlin/io/sentry/DummyTest.kt | 12 + .../io/sentry/systemtest/CacheSystemTest.kt | 51 ++++ .../DistributedTracingSystemTest.kt | 197 +++++++++++++++ .../systemtest/FeatureFlagSystemTest.kt | 102 ++++++++ .../systemtest/GraphqlGreetingSystemTest.kt | 46 ++++ .../systemtest/GraphqlProjectSystemTest.kt | 66 +++++ .../systemtest/GraphqlTaskSystemTest.kt | 50 ++++ .../sentry/systemtest/KafkaQueueSystemTest.kt | 117 +++++++++ .../io/sentry/systemtest/MetricsSystemTest.kt | 51 ++++ .../io/sentry/systemtest/PersonSystemTest.kt | 110 +++++++++ .../io/sentry/systemtest/TodoSystemTest.kt | 61 +++++ .../src/test/resources/log4j2-test.xml | 13 + settings.gradle.kts | 1 + test/system-test-runner.py | 3 + 48 files changed, 2381 insertions(+), 1 deletion(-) create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/README.md create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-jakarta-text-master.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-package-rename.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomEventProcessor.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/FeatureFlagController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/OpenFeatureConfig.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/Person.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonService.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/SecurityConfiguration.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/Todo.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/application-kafka.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/application.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/graphql/schema.graphqls create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/quartz.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/schema.sql create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/DummyTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/resources/log4j2-test.xml diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 2d1c5a3170a..a067f82ac29 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -46,6 +46,9 @@ jobs: - sample: "sentry-samples-spring-boot-webflux" agent: "false" agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-jakarta-log4j2" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-spring-boot-jakarta-opentelemetry-noagent" agent: "false" agent-auto-init: "true" diff --git a/CHANGELOG.md b/CHANGELOG.md index d007d26af19..48f718edcae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Sentry can now configure Log4j2 automatically for Spring Boot 3 and 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)) +- 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 @@ -31,6 +31,7 @@ ```properties sentry.logs.enabled=true ``` +- Sentry can now configure Log4j2 automatically for Spring Boot 3 when `sentry-log4j2` is on the classpath and Log4j2 Core is the active logging backend ([#6072](https://github.com/getsentry/sentry-java/pull/6072)) ### Fixes diff --git a/build.gradle.kts b/build.gradle.kts index cc8a6b8ca35..4f7cdb7cbb2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,6 +66,7 @@ apiValidation { "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples-spring-boot-jakarta", + "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-webflux", diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/README.md b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/README.md new file mode 100644 index 00000000000..4c5632b5eed --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/README.md @@ -0,0 +1,122 @@ +# Sentry Sample Spring Boot 3.0+ with Log4j2 + +Sample application showing how to use Sentry with [Spring Boot](https://spring.io/projects/spring-boot) from version `3.0` onwards 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-jakarta-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts new file mode 100644 index 00000000000..e10eeb167f7 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/build.gradle.kts @@ -0,0 +1,120 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.springboot3) + 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-jakarta-log4j2" + +version = "0.0.1-SNAPSHOT" + +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${libs.versions.springboot3.get()}") + } +} + +// Apollo 4.x requires coroutines 1.9.0+, override Spring Boot's managed version +extra["kotlin-coroutines.version"] = "1.9.0" + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { compilerOptions.jvmTarget = JvmTarget.JVM_17 } + +tasks.withType().configureEach { + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(libs.springboot3.starter) + implementation(libs.springboot3.starter.actuator) + implementation(libs.springboot3.starter.aop) + implementation(libs.springboot3.starter.graphql) + implementation(libs.springboot3.starter.jdbc) + implementation(libs.springboot3.starter.quartz) + implementation(libs.springboot3.starter.security) + implementation(libs.springboot3.starter.web) + implementation(libs.springboot3.starter.webflux) + implementation(libs.springboot3.starter.websocket) + implementation(Config.Libs.aspectj) + implementation(Config.Libs.kotlinReflect) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpringBootStarterJakarta) + 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", + ) + } + } + implementation(projects.sentryGraphql22) + implementation(projects.sentryQuartz) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryOpenfeature) + + // cache tracing + implementation(libs.springboot3.starter.cache) + implementation(libs.caffeine) + + // kafka + implementation(libs.spring.kafka3) + implementation(projects.sentryKafka) + + // OpenFeature SDK + implementation(libs.openfeature) + + // database query tracing + implementation(projects.sentryJdbc) + runtimeOnly(libs.hsqldb) + + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentry) + testImplementation(projects.sentrySystemTestSupport) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.slf4j2.api) + testImplementation(libs.springboot3.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-jakarta-log4j2/sentry-jakarta-text-master.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-jakarta-text-master.properties new file mode 100644 index 00000000000..3f287fd5252 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-jakarta-text-master.properties @@ -0,0 +1,2 @@ +*.kt=jakarta-renames.properties,sentry-package-rename.properties +*.java=sentry-package-rename.properties diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-package-rename.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-package-rename.properties new file mode 100644 index 00000000000..1437fa40928 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-package-rename.properties @@ -0,0 +1,2 @@ +package\ io.sentry.samples.spring.boot=package\ io.sentry.samples.spring.boot.jakarta +import\ io.sentry.spring=import\ io.sentry.spring.jakarta diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java new file mode 100644 index 00000000000..1327d5e6e29 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CacheController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomEventProcessor.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomEventProcessor.java new file mode 100644 index 00000000000..51451a5d77e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java new file mode 100644 index 00000000000..4cd609d67ce --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.spring.jakarta.checkin.SentryCheckIn; +import io.sentry.spring.jakarta.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java new file mode 100644 index 00000000000..d67059abb68 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/DistributedTracingController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/FeatureFlagController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/FeatureFlagController.java new file mode 100644 index 00000000000..2393c06158b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/FeatureFlagController.java @@ -0,0 +1,68 @@ +package io.sentry.samples.spring.boot.jakarta; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +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("/feature-flag/") +public class FeatureFlagController { + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureFlagController.class); + private final OpenFeatureAPI openFeatureAPI; + + public FeatureFlagController(OpenFeatureAPI openFeatureAPI) { + this.openFeatureAPI = openFeatureAPI; + } + + @GetMapping("check/{flagKey}") + public FeatureFlagResponse checkFlag(@PathVariable String flagKey) { + Client client = openFeatureAPI.getClient(); + + // Evaluate boolean feature flag + // This will trigger the SentryOpenFeatureHook which tracks the evaluation + boolean flagValue = + client.getBooleanValue(flagKey, false, new ImmutableContext("example-context-key")); + + LOGGER.info("Feature flag '{}' evaluated to: {}", flagKey, flagValue); + + return new FeatureFlagResponse(flagKey, flagValue); + } + + @GetMapping("error/{flagKey}") + public String errorWithFeatureFlag(@PathVariable String flagKey) { + Client client = openFeatureAPI.getClient(); + + // Evaluate feature flag before throwing error + // The feature flag will be included in the Sentry event + boolean flagValue = + client.getBooleanValue(flagKey, false, new ImmutableContext("example-context-key")); + + LOGGER.info("Feature flag '{}' evaluated to: {} before error", flagKey, flagValue); + + throw new RuntimeException("Error occurred with feature flag: " + flagKey + " = " + flagValue); + } + + public static class FeatureFlagResponse { + private final String flagKey; + private final boolean value; + + public FeatureFlagResponse(String flagKey, boolean value) { + this.flagKey = flagKey; + this.value = value; + } + + public String getFlagKey() { + return flagKey; + } + + public boolean isValue() { + return value; + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java new file mode 100644 index 00000000000..6b28e59d6a3 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/MetricController.java @@ -0,0 +1,37 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/OpenFeatureConfig.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/OpenFeatureConfig.java new file mode 100644 index 00000000000..2ada5e18b52 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/OpenFeatureConfig.java @@ -0,0 +1,226 @@ +package io.sentry.samples.spring.boot.jakarta; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.Metadata; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ProviderEvaluation; +import dev.openfeature.sdk.Reason; +import dev.openfeature.sdk.Value; +import io.sentry.openfeature.SentryOpenFeatureHook; +import java.util.HashMap; +import java.util.Map; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenFeatureConfig { + + @Bean + public OpenFeatureAPI openFeatureAPI() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + + // Use simple in-memory provider for demo purposes + // In production, you would use a real provider like LaunchDarkly, Flagsmith, etc. + FeatureProvider provider = new InMemoryProvider(createFeatureFlags()); + api.setProvider(provider); + + // Register Sentry hook to track feature flag evaluations + api.addHooks(new SentryOpenFeatureHook()); + + return api; + } + + private Map createFeatureFlags() { + Map flags = new HashMap<>(); + + // Boolean flags + flags.put("new-checkout-flow", true); + flags.put("experimental-feature", false); + flags.put("enable-caching", true); + flags.put("dark-mode", false); + flags.put("beta-features", true); + + // String flags + flags.put("theme-color", "blue"); + flags.put("api-version", "v2"); + flags.put("default-language", "en"); + flags.put("welcome-message", "Welcome to Sentry!"); + + // Integer flags + flags.put("max-retries", 3); + flags.put("page-size", 20); + flags.put("timeout-seconds", 30); + flags.put("max-connections", 100); + + // Double flags + flags.put("discount-percentage", 0.15); + flags.put("tax-rate", 0.08); + flags.put("conversion-rate", 0.025); + + // Object/Structure flags (stored as Map) + Map configMap = new HashMap<>(); + configMap.put("enabled", true); + configMap.put("threshold", 100); + configMap.put("mode", "production"); + flags.put("advanced-config", configMap); + + Map uiConfig = new HashMap<>(); + uiConfig.put("layout", "grid"); + uiConfig.put("itemsPerPage", 12); + flags.put("ui-settings", uiConfig); + + return flags; + } + + /** + * Simple in-memory provider for demo purposes. In production, use a real provider like + * LaunchDarkly, Flagsmith, etc. + */ + private static class InMemoryProvider implements FeatureProvider { + private final Map flags; + + public InMemoryProvider(Map flags) { + this.flags = flags; + } + + @Override + public Metadata getMetadata() { + return new Metadata() { + @Override + public String getName() { + return "InMemoryProvider"; + } + }; + } + + @Override + public void initialize(EvaluationContext evaluationContext) { + // No initialization needed for in-memory provider + } + + @Override + public void shutdown() { + // No cleanup needed for in-memory provider + } + + @Override + public ProviderEvaluation getBooleanEvaluation( + String key, Boolean defaultValue, EvaluationContext ctx) { + Object value = flags.get(key); + if (value == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + if (value instanceof Boolean) { + return ProviderEvaluation.builder() + .value((Boolean) value) + .reason(Reason.STATIC.name()) + .build(); + } + // Type mismatch - return default + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + + @Override + public ProviderEvaluation getStringEvaluation( + String key, String defaultValue, EvaluationContext ctx) { + Object value = flags.get(key); + if (value == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + if (value instanceof String) { + return ProviderEvaluation.builder() + .value((String) value) + .reason(Reason.STATIC.name()) + .build(); + } + // Type mismatch - return default + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + + @Override + public ProviderEvaluation getIntegerEvaluation( + String key, Integer defaultValue, EvaluationContext ctx) { + Object value = flags.get(key); + if (value == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + if (value instanceof Integer) { + return ProviderEvaluation.builder() + .value((Integer) value) + .reason(Reason.STATIC.name()) + .build(); + } + // Type mismatch - return default + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + + @Override + public ProviderEvaluation getDoubleEvaluation( + String key, Double defaultValue, EvaluationContext ctx) { + Object value = flags.get(key); + if (value == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + if (value instanceof Double) { + return ProviderEvaluation.builder() + .value((Double) value) + .reason(Reason.STATIC.name()) + .build(); + } + // Type mismatch - return default + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + + @Override + public ProviderEvaluation getObjectEvaluation( + String key, Value defaultValue, EvaluationContext ctx) { + Object value = flags.get(key); + if (value == null) { + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + if (value instanceof Map) { + // Convert Map to Value object + @SuppressWarnings("unchecked") + Map mapValue = (Map) value; + Value valueObj = Value.objectToValue(mapValue); + return ProviderEvaluation.builder() + .value(valueObj) + .reason(Reason.STATIC.name()) + .build(); + } + // Type mismatch - return default + return ProviderEvaluation.builder() + .value(defaultValue) + .reason(Reason.DEFAULT.name()) + .build(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/Person.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/Person.java new file mode 100644 index 00000000000..2c8bdc33c62 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java new file mode 100644 index 00000000000..f3c42b26081 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -0,0 +1,54 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonService.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonService.java new file mode 100644 index 00000000000..50cdc9dd4e7 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonService.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring.jakarta.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/SecurityConfiguration.java new file mode 100644 index 00000000000..e5987c8f4a2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/SecurityConfiguration.java @@ -0,0 +1,40 @@ +package io.sentry.samples.spring.boot.jakarta; + +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 { + http.csrf().disable().authorizeHttpRequests().anyRequest().authenticated().and().httpBasic(); + + return http.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java new file mode 100644 index 00000000000..e818cbe42ff --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -0,0 +1,73 @@ +package io.sentry.samples.spring.boot.jakarta; + +import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; + +import io.sentry.samples.spring.boot.jakarta.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.web.client.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/Todo.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/Todo.java new file mode 100644 index 00000000000..5fc4164d1b0 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoController.java new file mode 100644 index 00000000000..987d516936b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoController.java @@ -0,0 +1,57 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java new file mode 100644 index 00000000000..70a145558c2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoService.java @@ -0,0 +1,29 @@ +package io.sentry.samples.spring.boot.jakarta; + +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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java new file mode 100644 index 00000000000..6fdf96506c8 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.jakarta.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java new file mode 100644 index 00000000000..bfc383c9122 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot.jakarta.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java new file mode 100644 index 00000000000..63790bca628 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot.jakarta.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java new file mode 100644 index 00000000000..434a4822084 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java @@ -0,0 +1,50 @@ +package io.sentry.samples.spring.boot.jakarta.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java new file mode 100644 index 00000000000..d0f0973c864 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.jakarta.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java new file mode 100644 index 00000000000..5931efa3a3b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaConsumer.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.jakarta.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-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java new file mode 100644 index 00000000000..b17d231951d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/java/io/sentry/samples/spring/boot/jakarta/queues/kafka/KafkaController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot.jakarta.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-jakarta-log4j2/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/application-kafka.properties new file mode 100644 index 00000000000..eaaa62af13b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-log4j2/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/application.properties new file mode 100644 index 00000000000..d413cded851 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/application.properties @@ -0,0 +1,43 @@ +# 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=false +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 + +# 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 + +# Cache tracing +sentry.enable-cache-tracing=true + +spring.cache.cache-names=todos +spring.cache.caffeine.spec=maximumSize=500,expireAfterAccess=600s diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..aeea62357bd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-log4j2/src/main/resources/quartz.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/quartz.properties new file mode 100644 index 00000000000..6e302ce765a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-log4j2/src/main/resources/schema.sql b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/main/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt new file mode 100644 index 00000000000..10233e579e5 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/FeatureFlagSystemTest.kt @@ -0,0 +1,102 @@ +package io.sentry.systemtest + +import io.sentry.protocol.FeatureFlag +import io.sentry.systemtest.util.FeatureFlagResponse +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.junit.Before + +class FeatureFlagSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `check feature flag includes flag in transaction`() { + val restClient = testHelper.restClient + val response: FeatureFlagResponse? = restClient.checkFeatureFlag("new-checkout-flow") + + assertEquals(200, restClient.lastKnownStatusCode) + assertNotNull(response) + assertEquals("new-checkout-flow", response!!.flagKey) + assertEquals(true, response.value) + + // Verify feature flag is included in the transaction + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /feature-flag/check/{flagKey}" && + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.new-checkout-flow", true), + ) + } + } + + @Test + fun `error with feature flag includes flag in error event and transaction`() { + val restClient = testHelper.restClient + restClient.errorWithFeatureFlag("beta-features") + assertEquals(500, restClient.lastKnownStatusCode) + + // Verify feature flag is included in the error event + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage( + event, + "Error occurred with feature flag: beta-features = true", + ) && testHelper.doesEventHaveFlag(event, "beta-features", true) + } + + // Verify feature flag is also included in the transaction + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /feature-flag/error/{flagKey}" && + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.beta-features", true), + ) + } + } + + @Test + fun `check non-existent feature flag returns default value`() { + val restClient = testHelper.restClient + val response: FeatureFlagResponse? = restClient.checkFeatureFlag("non-existent-flag") + + assertEquals(200, restClient.lastKnownStatusCode) + assertNotNull(response) + assertEquals("non-existent-flag", response!!.flagKey) + assertEquals(false, response.value) // Default value + + // Verify transaction is still created + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /feature-flag/check/{flagKey}" + } + } + + @Test + fun `check feature flag with false value`() { + val restClient = testHelper.restClient + val response: FeatureFlagResponse? = restClient.checkFeatureFlag("experimental-feature") + + assertEquals(200, restClient.lastKnownStatusCode) + assertNotNull(response) + assertEquals("experimental-feature", response!!.flagKey) + assertEquals(false, response.value) + + // Verify feature flag with false value is included in transaction + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /feature-flag/check/{flagKey}" && + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.experimental-feature", false), + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/KafkaQueueSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-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-jakarta-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-jakarta-log4j2/src/test/resources/log4j2-test.xml b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/resources/log4j2-test.xml new file mode 100644 index 00000000000..915b9614ef7 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/src/test/resources/log4j2-test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 10a95c9902b..334ddadf89a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -115,6 +115,7 @@ include( "sentry-samples:sentry-samples-spring-boot-opentelemetry", "sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples:sentry-samples-spring-boot-jakarta", + "sentry-samples:sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples:sentry-samples-spring-boot-webflux", diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 5cd493cfab4..ed3fda97dcd 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -74,6 +74,7 @@ "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples-spring-boot-jakarta", + "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-4", @@ -86,6 +87,7 @@ "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", "sentry-samples-spring-boot-jakarta", + "sentry-samples-spring-boot-jakarta-log4j2", "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-4", @@ -858,6 +860,7 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-webflux-jakarta", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-webflux", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta", "false", "true", "false"), + ModuleConfig("sentry-samples-spring-boot-jakarta-log4j2", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "false", "false"), From 4aa9502ade9ef53bfdc68c6d9cc9b2b6313a2472 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 8 Sep 2026 14:37:18 +0200 Subject: [PATCH 3/4] remove stale rename files --- .../sentry-jakarta-text-master.properties | 2 -- .../sentry-package-rename.properties | 2 -- 2 files changed, 4 deletions(-) delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-jakarta-text-master.properties delete mode 100644 sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-package-rename.properties diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-jakarta-text-master.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-jakarta-text-master.properties deleted file mode 100644 index 3f287fd5252..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-jakarta-text-master.properties +++ /dev/null @@ -1,2 +0,0 @@ -*.kt=jakarta-renames.properties,sentry-package-rename.properties -*.java=sentry-package-rename.properties diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-package-rename.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-package-rename.properties deleted file mode 100644 index 1437fa40928..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-log4j2/sentry-package-rename.properties +++ /dev/null @@ -1,2 +0,0 @@ -package\ io.sentry.samples.spring.boot=package\ io.sentry.samples.spring.boot.jakarta -import\ io.sentry.spring=import\ io.sentry.spring.jakarta From e0e8d3aec07cb4eedf2059fb1871b36c644721fd Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 8 Sep 2026 14:47:44 +0200 Subject: [PATCH 4/4] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f718edcae..7bee5835d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ sentry.logs.enabled=true ``` - Sentry can now configure Log4j2 automatically for Spring Boot 3 when `sentry-log4j2` is on the classpath and Log4j2 Core is the active logging backend ([#6072](https://github.com/getsentry/sentry-java/pull/6072)) + - Disabled by default for now; enable it and configure levels the same way as described in the Spring Boot 4 entry above (`sentry.logging.enabled=true`) ### Fixes