diff --git a/j-obs-spring-boot-starter/pom.xml b/j-obs-spring-boot-starter/pom.xml index 572124f..889eb92 100644 --- a/j-obs-spring-boot-starter/pom.xml +++ b/j-obs-spring-boot-starter/pom.xml @@ -119,6 +119,13 @@ true + + + org.apache.logging.log4j + log4j-core + true + + diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsLogAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsLogAutoConfiguration.java index 5dfa43b..34daeff 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsLogAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsLogAutoConfiguration.java @@ -4,16 +4,19 @@ import ch.qos.logback.classic.LoggerContext; import io.github.jobs.application.LogRepository; import io.github.jobs.infrastructure.InMemoryLogRepository; +import io.github.jobs.spring.log.JObsLog4j2Appender; import io.github.jobs.spring.log.JObsLogAppender; import io.github.jobs.spring.log.LogEntryFactory; import io.github.jobs.spring.web.LogApiController; import io.github.jobs.spring.web.LogController; import io.github.jobs.spring.web.template.TemplateService; import io.github.jobs.spring.websocket.LogWebSocketHandler; +import org.apache.logging.log4j.LogManager; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -97,8 +100,8 @@ public LogEntryFactory logEntryFactory() { */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(name = { - "org.springframework.web.socket.config.annotation.WebSocketConfigurer", - "jakarta.websocket.server.ServerContainer" + "org.springframework.web.socket.config.annotation.WebSocketConfigurer", + "jakarta.websocket.server.ServerContainer" }) @EnableWebSocket static class WebSocketConfiguration implements WebSocketConfigurer { @@ -148,6 +151,43 @@ public JObsLogAppender jObsLogAppender(LogRepository logRepository, LogEntryFact Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); rootLogger.addAppender(appender); + return appender; + } + } + /** + * Configuration for Log4j2 integration (optional). + * Only loaded when Log4j2 Core is on the classpath AND Logback is NOT present. + * When both are on the classpath, Logback takes priority (Spring Boot default). + * + * Uses @ConditionalOnMissingClass instead of @ConditionalOnMissingBean because + * @ConditionalOnMissingBean on a @Configuration class is evaluated before any beans + * are created, making it unreliable for inter-configuration ordering. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "org.apache.logging.log4j.core.appender.AbstractAppender") + @ConditionalOnMissingClass("ch.qos.logback.classic.Logger") + static class Log4j2Configuration { + + @Bean + @ConditionalOnMissingBean + public JObsLog4j2Appender jObsLog4j2Appender(LogRepository logRepository, LogEntryFactory logEntryFactory) { + JObsLog4j2Appender appender = new JObsLog4j2Appender("J-OBS"); + appender.setLogRepository(logRepository); + appender.setLogEntryFactory(logEntryFactory); + appender.start(); + + // Attach to root logger only when Log4j2 is the real logging backend. + // When Log4j2 is bridged to SLF4J (log4j-to-slf4j), getContext() returns + // an SLF4J-backed context that is not a Log4j2 LoggerContext — safe to skip. + try { + org.apache.logging.log4j.spi.LoggerContext ctx = LogManager.getContext(false); + if (ctx instanceof org.apache.logging.log4j.core.LoggerContext log4j2Ctx) { + log4j2Ctx.getRootLogger().addAppender(appender); + } + } catch (Exception | Error ignored) { + // Log4j2 context unavailable or bridged — appender bean is still valid + } + return appender; } } diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/log/JObsLog4j2Appender.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/log/JObsLog4j2Appender.java new file mode 100644 index 0000000..739f3cb --- /dev/null +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/log/JObsLog4j2Appender.java @@ -0,0 +1,160 @@ +package io.github.jobs.spring.log; + +import io.github.jobs.application.LogRepository; +import io.github.jobs.domain.log.LogEntry; +import io.github.jobs.domain.log.LogLevel; +import io.github.jobs.spring.security.LogSanitizer; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; + +import java.time.Instant; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Log4j2 appender that captures log events and stores them in the LogRepository. + * Mirror of JObsLogAppender for Log4j2 support. + */ +public class JObsLog4j2Appender extends AbstractAppender { + + private volatile LogRepository logRepository; + private volatile LogEntryFactory logEntryFactory; + private volatile LogSanitizer logSanitizer; + + public JObsLog4j2Appender(String name) { + super(name, null, null, true, Property.EMPTY_ARRAY); + } + + public void setLogRepository(LogRepository logRepository) { + this.logRepository = logRepository; + } + + public void setLogEntryFactory(LogEntryFactory logEntryFactory) { + this.logEntryFactory = logEntryFactory; + } + + public void setLogSanitizer(LogSanitizer logSanitizer) { + this.logSanitizer = logSanitizer; + } + + @Override + public void append(LogEvent event) { + LogRepository repo = this.logRepository; + if (repo == null) { + return; + } + + // Skip logs from J-Obs library itself to avoid circular logging + String loggerName = event.getLoggerName(); + if (loggerName.startsWith("io.github.jobs.spring") || + loggerName.startsWith("io.github.jobs.application") || + loggerName.startsWith("io.github.jobs.domain") || + loggerName.startsWith("io.github.jobs.infrastructure")) { + return; + } + + LogEntryFactory factory = this.logEntryFactory; + if (factory == null) { + synchronized (this) { + factory = this.logEntryFactory; + if (factory == null) { + factory = new LogEntryFactory(); + this.logEntryFactory = factory; + } + } + } + + LogSanitizer sanitizer = this.logSanitizer; + if (sanitizer == null) { + synchronized (this) { + sanitizer = this.logSanitizer; + if (sanitizer == null) { + sanitizer = new LogSanitizer(); + this.logSanitizer = sanitizer; + } + } + } + + String message = event.getMessage() != null ? event.getMessage().getFormattedMessage() : ""; + String sanitizedMessage = sanitizer.sanitize(message); + String sanitizedStackTrace = sanitizer.sanitizeStackTrace(formatThrowable(event)); + Map mdcMap = event.getContextData() != null + ? event.getContextData().toMap().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> String.valueOf(e.getValue()))) + : Map.of(); + Map sanitizedMdc = sanitizer.sanitizeMdc(mdcMap); + + LogEntry entry = factory.create( + Instant.ofEpochMilli(event.getTimeMillis()), + convertLevel(event.getLevel()), + loggerName, + sanitizedMessage, + event.getThreadName(), + extractTraceId(mdcMap), + extractSpanId(mdcMap), + sanitizedStackTrace, + sanitizedMdc != null ? Map.copyOf(sanitizedMdc) : Map.of() + ); + + repo.add(entry); + } + + private LogLevel convertLevel(Level level) { + if (level == null) { + return LogLevel.INFO; + } + if (level.isMoreSpecificThan(Level.ERROR)) return LogLevel.ERROR; + if (level.isMoreSpecificThan(Level.WARN)) return LogLevel.WARN; + if (level.isMoreSpecificThan(Level.INFO)) return LogLevel.INFO; + if (level.isMoreSpecificThan(Level.DEBUG)) return LogLevel.DEBUG; + return LogLevel.TRACE; + } + + private String extractTraceId(Map mdc) { + String traceId = mdc.get("traceId"); + if (traceId == null) traceId = mdc.get("trace_id"); + if (traceId == null) traceId = mdc.get("X-B3-TraceId"); + if (traceId == null) { + SpanContext ctx = getOtelSpanContext(); + if (ctx != null) traceId = ctx.getTraceId(); + } + return traceId; + } + + private String extractSpanId(Map mdc) { + String spanId = mdc.get("spanId"); + if (spanId == null) spanId = mdc.get("span_id"); + if (spanId == null) spanId = mdc.get("X-B3-SpanId"); + if (spanId == null) { + SpanContext ctx = getOtelSpanContext(); + if (ctx != null) spanId = ctx.getSpanId(); + } + return spanId; + } + + private SpanContext getOtelSpanContext() { + try { + SpanContext ctx = Span.current().getSpanContext(); + return ctx.isValid() ? ctx : null; + } catch (NoClassDefFoundError | Exception e) { + return null; + } + } + + private String formatThrowable(LogEvent event) { + if (event.getThrown() == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + Throwable t = event.getThrown(); + sb.append(t.toString()); + for (StackTraceElement el : t.getStackTrace()) { + sb.append("\n\tat ").append(el); + } + return sb.toString(); + } +} diff --git a/j-obs-spring-boot-starter/src/test/java/io/github/jobs/spring/autoconfigure/JObsLogAutoConfigurationTest.java b/j-obs-spring-boot-starter/src/test/java/io/github/jobs/spring/autoconfigure/JObsLogAutoConfigurationTest.java index dc88922..1d43dc6 100644 --- a/j-obs-spring-boot-starter/src/test/java/io/github/jobs/spring/autoconfigure/JObsLogAutoConfigurationTest.java +++ b/j-obs-spring-boot-starter/src/test/java/io/github/jobs/spring/autoconfigure/JObsLogAutoConfigurationTest.java @@ -1,6 +1,8 @@ package io.github.jobs.spring.autoconfigure; import io.github.jobs.application.LogRepository; +import io.github.jobs.spring.log.JObsLog4j2Appender; +import io.github.jobs.spring.log.JObsLogAppender; import io.github.jobs.spring.web.LogApiController; import io.github.jobs.spring.web.LogController; import io.github.jobs.spring.websocket.LogWebSocketHandler; @@ -95,4 +97,31 @@ void shouldConfigureMaxEntries() { assertThat(props.getLogs().getMaxEntries()).isEqualTo(5000); }); } + + /** + * When Logback is on the classpath (default Spring Boot setup), Logback takes priority. + * The Log4j2 appender must NOT be created, preventing duplicate log capture. + */ + @Test + void shouldUseLogbackWhenBothLoggingFrameworksPresent() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(JObsLogAppender.class); + assertThat(context).doesNotHaveBean(JObsLog4j2Appender.class); + }); + } + + /** + * When Logback is absent, Log4j2 appender should activate automatically. + * Uses FilteredClassLoader to simulate a Log4j2-only environment. + */ + @Test + void shouldUseLog4j2AppenderWhenLogbackAbsent() { + contextRunner + .withClassLoader(new FilteredClassLoader(ch.qos.logback.classic.Logger.class)) + .run(context -> { + assertThat(context).hasSingleBean(LogRepository.class); + assertThat(context).hasSingleBean(JObsLog4j2Appender.class); + assertThat(context).doesNotHaveBean(JObsLogAppender.class); + }); + } }