From 4e03272ff8b8190e27efa148b6ec81b120cb88c1 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Mon, 11 May 2026 17:17:10 +0200 Subject: [PATCH 1/4] initial log4j appender auto config --- .../api/sentry-spring-boot-4.api | 11 + sentry-spring-boot-4/build.gradle.kts | 6 + ...SentryLog4j2AppenderAutoConfiguration.java | 26 ++ .../spring/boot4/SentryLog4j2Initializer.java | 205 +++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...ntryLog4j2AppenderAutoConfigurationTest.kt | 285 ++++++++++++++++++ 6 files changed, 534 insertions(+) create mode 100644 sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration.java create mode 100644 sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java create mode 100644 sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt diff --git a/sentry-spring-boot-4/api/sentry-spring-boot-4.api b/sentry-spring-boot-4/api/sentry-spring-boot-4.api index 4c8be990b8..f3a16d45f5 100644 --- a/sentry-spring-boot-4/api/sentry-spring-boot-4.api +++ b/sentry-spring-boot-4/api/sentry-spring-boot-4.api @@ -13,6 +13,17 @@ public class io/sentry/spring/boot4/SentryAutoConfiguration { public fun ()V } +public class io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration { + public fun ()V + public fun sentryLog4j2Initializer (Lio/sentry/spring/boot4/SentryProperties;)Lio/sentry/spring/boot4/SentryLog4j2Initializer; +} + +public class io/sentry/spring/boot4/SentryLog4j2Initializer : org/springframework/context/event/GenericApplicationListener { + public fun (Lio/sentry/spring/boot4/SentryProperties;)V + public fun onApplicationEvent (Lorg/springframework/context/ApplicationEvent;)V + public fun supportsEventType (Lorg/springframework/core/ResolvableType;)Z +} + public class io/sentry/spring/boot4/SentryLogbackAppenderAutoConfiguration { public fun ()V public fun sentryLogbackInitializer (Lio/sentry/spring/boot4/SentryProperties;)Lio/sentry/spring/boot4/SentryLogbackInitializer; diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 69a40f7b64..d92f60d8ff 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -31,7 +31,10 @@ dependencies { api(projects.sentry) api(projects.sentrySpring7) compileOnly(projects.sentryLogback) + compileOnly(projects.sentryLog4j2) compileOnly(projects.sentryApacheHttpClient5) + compileOnly(libs.log4j.api) + compileOnly(libs.log4j.core) compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) compileOnly(projects.sentryGraphql) compileOnly(projects.sentryGraphql22) @@ -65,7 +68,10 @@ dependencies { // tests testImplementation(projects.sentryLogback) + testImplementation(projects.sentryLog4j2) testImplementation(projects.sentryApacheHttpClient5) + testImplementation(libs.log4j.api) + testImplementation(libs.log4j.core) testImplementation(projects.sentryGraphql) testImplementation(projects.sentryGraphql22) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryCore) diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration.java new file mode 100644 index 0000000000..d7ebf82b22 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfiguration.java @@ -0,0 +1,26 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.log4j2.SentryAppender; +import org.apache.logging.log4j.core.LoggerContext; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Auto-configures {@link SentryAppender}. */ +@Configuration(proxyBeanMethods = false) +@Open +@ConditionalOnClass({LoggerContext.class, SentryAppender.class}) +@ConditionalOnProperty(name = "sentry.logging.enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnBean(SentryProperties.class) +public class SentryLog4j2AppenderAutoConfiguration { + + @Bean + public @NotNull SentryLog4j2Initializer sentryLog4j2Initializer( + final @NotNull SentryProperties sentryProperties) { + return new SentryLog4j2Initializer(sentryProperties); + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java new file mode 100644 index 0000000000..674f90be73 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java @@ -0,0 +1,205 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.ScopesAdapter; +import io.sentry.log4j2.SentryAppender; +import io.sentry.util.Objects; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.GenericApplicationListener; +import org.springframework.core.ResolvableType; + +/** Registers {@link SentryAppender} after Spring context gets refreshed. */ +@Open +public class SentryLog4j2Initializer implements GenericApplicationListener { + private static final Logger logger = LoggerFactory.getLogger(SentryLog4j2Initializer.class); + private static final String SENTRY_APPENDER_NAME = "SENTRY_APPENDER"; + + private final @NotNull SentryProperties sentryProperties; + private final @NotNull List loggers; + @Nullable private SentryAppender sentryAppender; + + public SentryLog4j2Initializer(final @NotNull SentryProperties sentryProperties) { + this.sentryProperties = Objects.requireNonNull(sentryProperties, "properties are required"); + loggers = sentryProperties.getLogging().getLoggers(); + } + + @Override + public boolean supportsEventType(final @NotNull ResolvableType eventType) { + return eventType.getRawClass() != null + && ContextRefreshedEvent.class.isAssignableFrom(eventType.getRawClass()); + } + + @Override + public void onApplicationEvent(final @NotNull ApplicationEvent event) { + final Object context = LogManager.getContext(false); + if (!(context instanceof LoggerContext)) { + logger.info( + "Sentry Log4j2 appender was not configured because Log4j2 Core is not the active logging backend. Log4j2 API calls may be routed through SLF4J."); + return; + } + + final LoggerContext loggerContext = (LoggerContext) context; + final Configuration configuration = loggerContext.getConfiguration(); + + final Set loggerNames = normalizeLoggerNames(loggers); + boolean changed = false; + for (final String loggerName : loggerNames) { + if (isCoveredByAncestorLogger(configuration, loggerName, loggerNames) + || hasSentryAppenderRegisteredOnAncestor(configuration, loggerName)) { + continue; + } + + final LoggerConfig loggerConfig = getOrCreateLoggerConfig(configuration, loggerName); + if (!isSentryAppenderRegistered(loggerConfig)) { + final SentryAppender sentryAppender = getSentryAppender(configuration); + loggerConfig.addAppender(sentryAppender, 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 normalizedLoggerNames = new LinkedHashSet<>(); + for (final String loggerName : loggerNames) { + if (loggerName == null || loggerName.trim().isEmpty()) { + continue; + } + normalizedLoggerNames.add(normalizeLoggerName(loggerName.trim())); + } + return normalizedLoggerNames; + } + + private boolean isCoveredByAncestorLogger( + final @NotNull Configuration configuration, + final @NotNull String loggerName, + final @NotNull Set loggerNames) { + return loggerNames.stream() + .anyMatch( + candidate -> + isAncestorLogger(candidate, loggerName) + && isAdditivePathToAncestor(configuration, loggerName, candidate)); + } + + private boolean hasSentryAppenderRegisteredOnAncestor( + final @NotNull Configuration configuration, final @NotNull String loggerName) { + if (LogManager.ROOT_LOGGER_NAME.equals(loggerName)) { + return false; + } + + @Nullable String parentLoggerName = getParentLoggerName(loggerName); + while (parentLoggerName != null) { + final LoggerConfig parentLoggerConfig = configuration.getLoggerConfig(parentLoggerName); + if (parentLoggerName.equals(parentLoggerConfig.getName()) + && isSentryAppenderRegistered(parentLoggerConfig) + && isAdditivePathToAncestor(configuration, loggerName, parentLoggerName)) { + return true; + } + parentLoggerName = getParentLoggerName(parentLoggerName); + } + + return isSentryAppenderRegistered(configuration.getRootLogger()) + && isAdditivePathToAncestor(configuration, loggerName, LogManager.ROOT_LOGGER_NAME); + } + + private boolean isAdditivePathToAncestor( + final @NotNull Configuration configuration, + final @NotNull String loggerName, + final @NotNull String ancestorLoggerName) { + String currentLoggerName = loggerName; + while (!ancestorLoggerName.equals(currentLoggerName)) { + final LoggerConfig loggerConfig = configuration.getLoggerConfig(currentLoggerName); + if (currentLoggerName.equals(loggerConfig.getName()) && !loggerConfig.isAdditive()) { + return false; + } + currentLoggerName = getParentLoggerName(currentLoggerName); + if (currentLoggerName == null) { + currentLoggerName = LogManager.ROOT_LOGGER_NAME; + } + } + return true; + } + + private @Nullable String getParentLoggerName(final @NotNull String loggerName) { + final int separator = loggerName.lastIndexOf('.'); + return separator > -1 ? loggerName.substring(0, separator) : null; + } + + private boolean isAncestorLogger( + final @NotNull String candidateLoggerName, final @NotNull String loggerName) { + if (candidateLoggerName.equals(loggerName)) { + return false; + } + return LogManager.ROOT_LOGGER_NAME.equals(candidateLoggerName) + || loggerName.startsWith(candidateLoggerName + "."); + } + + private boolean isSentryAppenderRegistered(final @NotNull LoggerConfig loggerConfig) { + return loggerConfig.getAppenders().values().stream() + .anyMatch(appender -> appender.getClass().equals(SentryAppender.class)); + } + + private @NotNull String normalizeLoggerName(final @NotNull String loggerName) { + if (org.slf4j.Logger.ROOT_LOGGER_NAME.equals(loggerName)) { + return LogManager.ROOT_LOGGER_NAME; + } + return loggerName; + } + + private @Nullable Level toLog4jLevel(final @Nullable org.slf4j.event.Level slf4jLevel) { + return slf4jLevel == null ? null : Level.getLevel(slf4jLevel.name()); + } +} diff --git a/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index a108fa2ca1..e3e7c2e467 100644 --- a/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ io.sentry.spring.boot4.SentryAutoConfiguration io.sentry.spring.boot4.SentryProfilerAutoConfiguration io.sentry.spring.boot4.SentryLogbackAppenderAutoConfiguration +io.sentry.spring.boot4.SentryLog4j2AppenderAutoConfiguration io.sentry.spring.boot4.SentryWebfluxAutoConfiguration diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt new file mode 100644 index 0000000000..f24c9d95d6 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt @@ -0,0 +1,285 @@ +package io.sentry.spring.boot4 + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.read.ListAppender +import io.sentry.ITransportFactory +import io.sentry.NoOpTransportFactory +import io.sentry.ScopesAdapter +import io.sentry.log4j2.SentryAppender +import kotlin.test.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() + .withUserConfiguration(NoOpTransportConfiguration::class.java) + + private val dsnEnabledRunner = + baseContextRunner + .withLog4j2CoreProvider() + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(NoOpTransportConfiguration::class.java) + + private val log4j2BridgeDsnEnabledRunner = + baseContextRunner + .withClassLoader(FilteredClassLoader("org.apache.logging.log4j.core.impl.Log4jProvider")) + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(NoOpTransportConfiguration::class.java) + + 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() + } + + @Test + fun `does not configure SentryAppender when auto-configuration dsn is not set`() { + contextRunner.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 `does not configure SentryAppender for descendant logger covered by ancestor logger`() { + dsnEnabledRunner + .withPropertyValues("sentry.logging.loggers[0]=ROOT", "sentry.logging.loggers[1]=com.example") + .run { + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + assertThat(configuration.loggers).doesNotContainKey("com.example") + } + } + + @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.getLevel("minimumBreadcrumbLevel")).isEqualTo(Level.DEBUG) + assertThat(sentryAppender.getLevel("minimumEventLevel")).isEqualTo(Level.INFO) + assertThat(sentryAppender.getLevel("minimumLevel")).isEqualTo(Level.ERROR) + } + } + + @Test + fun `does not configure SentryAppender when logging is disabled`() { + contextRunner.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") + .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") + .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 = + 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()) +} + +private fun SentryAppender.getLevel(fieldName: String): Level { + val field = SentryAppender::class.java.getDeclaredField(fieldName) + field.isAccessible = true + return field.get(this) as Level +} From 4536c325f891127933ff55ac17681168633595de Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Mon, 11 May 2026 17:21:06 +0200 Subject: [PATCH 2/4] Improve Log4j2 appender auto-configuration tests --- sentry-log4j2/api/sentry-log4j2.api | 3 +++ .../java/io/sentry/log4j2/SentryAppender.java | 12 ++++++++++ ...ntryLog4j2AppenderAutoConfigurationTest.kt | 24 ++++++++++++------- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/sentry-log4j2/api/sentry-log4j2.api b/sentry-log4j2/api/sentry-log4j2.api index 2a5d4bf789..7eebea7136 100644 --- a/sentry-log4j2/api/sentry-log4j2.api +++ b/sentry-log4j2/api/sentry-log4j2.api @@ -12,6 +12,9 @@ public class io/sentry/log4j2/SentryAppender : org/apache/logging/log4j/core/app public static fun createAppender (Ljava/lang/String;Lorg/apache/logging/log4j/Level;Lorg/apache/logging/log4j/Level;Lorg/apache/logging/log4j/Level;Ljava/lang/String;Ljava/lang/Boolean;Lorg/apache/logging/log4j/core/Filter;Ljava/lang/String;)Lio/sentry/log4j2/SentryAppender; protected fun createBreadcrumb (Lorg/apache/logging/log4j/core/LogEvent;)Lio/sentry/Breadcrumb; protected fun createEvent (Lorg/apache/logging/log4j/core/LogEvent;)Lio/sentry/SentryEvent; + public fun getMinimumBreadcrumbLevel ()Lorg/apache/logging/log4j/Level; + public fun getMinimumEventLevel ()Lorg/apache/logging/log4j/Level; + public fun getMinimumLevel ()Lorg/apache/logging/log4j/Level; public fun start ()V } diff --git a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java index df0f9eeb2d..9bc8e90bfe 100644 --- a/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java +++ b/sentry-log4j2/src/main/java/io/sentry/log4j2/SentryAppender.java @@ -167,6 +167,18 @@ public void start() { start(getOptionsConfiguration(null)); } + public @NotNull Level getMinimumBreadcrumbLevel() { + return minimumBreadcrumbLevel; + } + + public @NotNull Level getMinimumEventLevel() { + return minimumEventLevel; + } + + public @NotNull Level getMinimumLevel() { + return minimumLevel; + } + @NotNull Sentry.OptionsConfiguration getOptionsConfiguration( final @Nullable Sentry.OptionsConfiguration additionalOptionsConfiguration) { diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt index f24c9d95d6..54af51ad98 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt @@ -7,6 +7,7 @@ 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 @@ -59,12 +60,15 @@ class SentryLog4j2AppenderAutoConfigurationTest { .withPropertyValues("sentry.dsn=http://key@localhost/proj") .withUserConfiguration(NoOpTransportConfiguration::class.java) + // 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") .withUserConfiguration(NoOpTransportConfiguration::class.java) + private val originalLogManagerFactory = LogManager.getFactory() + private val loggerContext: LoggerContext get() = LogManager.getContext(false) as LoggerContext @@ -80,6 +84,13 @@ class SentryLog4j2AppenderAutoConfigurationTest { 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() } @@ -166,9 +177,9 @@ class SentryLog4j2AppenderAutoConfigurationTest { assertThat(appenders).hasSize(1) val sentryAppender = appenders[0] as SentryAppender - assertThat(sentryAppender.getLevel("minimumBreadcrumbLevel")).isEqualTo(Level.DEBUG) - assertThat(sentryAppender.getLevel("minimumEventLevel")).isEqualTo(Level.INFO) - assertThat(sentryAppender.getLevel("minimumLevel")).isEqualTo(Level.ERROR) + assertThat(sentryAppender.minimumBreadcrumbLevel).isEqualTo(Level.DEBUG) + assertThat(sentryAppender.minimumEventLevel).isEqualTo(Level.INFO) + assertThat(sentryAppender.minimumLevel).isEqualTo(Level.ERROR) } } @@ -259,6 +270,7 @@ fun org.apache.logging.log4j.core.config.LoggerConfig.getAppenders( } 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() { @@ -277,9 +289,3 @@ private fun resetLog4j2Context() { val loggerContext = LogManager.getContext(false) as? LoggerContext ?: return loggerContext.reconfigure(DefaultConfiguration()) } - -private fun SentryAppender.getLevel(fieldName: String): Level { - val field = SentryAppender::class.java.getDeclaredField(fieldName) - field.isAccessible = true - return field.get(this) as Level -} From d896d435874be85f26285f7ae94bf2201f579385 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 12 May 2026 14:39:16 +0200 Subject: [PATCH 3/4] remove ancestor safeguard to match logback, as the user defines which loggers to attach to --- .../build.gradle.kts | 13 +++ .../spring/boot4/SentryLog4j2Initializer.java | 81 ++----------------- ...ntryLog4j2AppenderAutoConfigurationTest.kt | 10 --- 3 files changed, 18 insertions(+), 86 deletions(-) diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts index cdb33ecc67..3c6c9f5eaa 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -57,6 +57,19 @@ dependencies { implementation(projects.sentryQuartz) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryLog4j2) + implementation(libs.log4j.api) + implementation(libs.log4j.core) + + // Enable Log4j2 in Spring Boot + // implementation("org.springframework.boot:spring-boot-starter-log4j2") + // modules { + // module("org.springframework.boot:spring-boot-starter-logging") { + // replacedBy("org.springframework.boot:spring-boot-starter-log4j2", "Use Log4j2 instead of + // Logback") + // } + // } + // cache tracing implementation(libs.springboot4.starter.cache) implementation(libs.caffeine) diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java index 674f90be73..efdf9dc255 100644 --- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLog4j2Initializer.java @@ -54,18 +54,11 @@ public void onApplicationEvent(final @NotNull ApplicationEvent event) { final LoggerContext loggerContext = (LoggerContext) context; final Configuration configuration = loggerContext.getConfiguration(); - final Set loggerNames = normalizeLoggerNames(loggers); boolean changed = false; - for (final String loggerName : loggerNames) { - if (isCoveredByAncestorLogger(configuration, loggerName, loggerNames) - || hasSentryAppenderRegisteredOnAncestor(configuration, loggerName)) { - continue; - } - + for (final String loggerName : normalizeLoggerNames(loggers)) { final LoggerConfig loggerConfig = getOrCreateLoggerConfig(configuration, loggerName); if (!isSentryAppenderRegistered(loggerConfig)) { - final SentryAppender sentryAppender = getSentryAppender(configuration); - loggerConfig.addAppender(sentryAppender, null, null); + loggerConfig.addAppender(getSentryAppender(configuration), null, null); changed = true; } } @@ -113,78 +106,14 @@ public void onApplicationEvent(final @NotNull ApplicationEvent event) { } private @NotNull Set normalizeLoggerNames(final @NotNull List loggerNames) { - final Set normalizedLoggerNames = new LinkedHashSet<>(); + final Set normalized = new LinkedHashSet<>(); for (final String loggerName : loggerNames) { if (loggerName == null || loggerName.trim().isEmpty()) { continue; } - normalizedLoggerNames.add(normalizeLoggerName(loggerName.trim())); - } - return normalizedLoggerNames; - } - - private boolean isCoveredByAncestorLogger( - final @NotNull Configuration configuration, - final @NotNull String loggerName, - final @NotNull Set loggerNames) { - return loggerNames.stream() - .anyMatch( - candidate -> - isAncestorLogger(candidate, loggerName) - && isAdditivePathToAncestor(configuration, loggerName, candidate)); - } - - private boolean hasSentryAppenderRegisteredOnAncestor( - final @NotNull Configuration configuration, final @NotNull String loggerName) { - if (LogManager.ROOT_LOGGER_NAME.equals(loggerName)) { - return false; - } - - @Nullable String parentLoggerName = getParentLoggerName(loggerName); - while (parentLoggerName != null) { - final LoggerConfig parentLoggerConfig = configuration.getLoggerConfig(parentLoggerName); - if (parentLoggerName.equals(parentLoggerConfig.getName()) - && isSentryAppenderRegistered(parentLoggerConfig) - && isAdditivePathToAncestor(configuration, loggerName, parentLoggerName)) { - return true; - } - parentLoggerName = getParentLoggerName(parentLoggerName); - } - - return isSentryAppenderRegistered(configuration.getRootLogger()) - && isAdditivePathToAncestor(configuration, loggerName, LogManager.ROOT_LOGGER_NAME); - } - - private boolean isAdditivePathToAncestor( - final @NotNull Configuration configuration, - final @NotNull String loggerName, - final @NotNull String ancestorLoggerName) { - String currentLoggerName = loggerName; - while (!ancestorLoggerName.equals(currentLoggerName)) { - final LoggerConfig loggerConfig = configuration.getLoggerConfig(currentLoggerName); - if (currentLoggerName.equals(loggerConfig.getName()) && !loggerConfig.isAdditive()) { - return false; - } - currentLoggerName = getParentLoggerName(currentLoggerName); - if (currentLoggerName == null) { - currentLoggerName = LogManager.ROOT_LOGGER_NAME; - } - } - return true; - } - - private @Nullable String getParentLoggerName(final @NotNull String loggerName) { - final int separator = loggerName.lastIndexOf('.'); - return separator > -1 ? loggerName.substring(0, separator) : null; - } - - private boolean isAncestorLogger( - final @NotNull String candidateLoggerName, final @NotNull String loggerName) { - if (candidateLoggerName.equals(loggerName)) { - return false; + normalized.add(normalizeLoggerName(loggerName.trim())); } - return LogManager.ROOT_LOGGER_NAME.equals(candidateLoggerName) - || loggerName.startsWith(candidateLoggerName + "."); + return normalized; } private boolean isSentryAppenderRegistered(final @NotNull LoggerConfig loggerConfig) { diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt index 54af51ad98..6fa961f25d 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt @@ -117,16 +117,6 @@ class SentryLog4j2AppenderAutoConfigurationTest { } } - @Test - fun `does not configure SentryAppender for descendant logger covered by ancestor logger`() { - dsnEnabledRunner - .withPropertyValues("sentry.logging.loggers[0]=ROOT", "sentry.logging.loggers[1]=com.example") - .run { - assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(1) - assertThat(configuration.loggers).doesNotContainKey("com.example") - } - } - @Test fun `configures SentryAppender for descendant logger with additivity disabled`() { val loggerConfig = LoggerConfig("com.example", null, false) From 7d76aa598ac7b07408e199a41f086eedabcd59d9 Mon Sep 17 00:00:00 2001 From: Lukas Bloder Date: Tue, 12 May 2026 14:40:40 +0200 Subject: [PATCH 4/4] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceda85d8b9..6a9e03fc0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Add support to configure reporting historical ANRs via `AndroidManifest.xml` using the `io.sentry.anr.report-historical` attribute ([#5387](https://github.com/getsentry/sentry-java/pull/5387)) +- Add log4j2 spring boot 4 auto configuration ([#5403](https://github.com/getsentry/sentry-java/pull/5403)) ## 8.41.0