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..9b70917 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,12 +4,14 @@ 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; @@ -97,8 +99,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 +150,31 @@ 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 is on the classpath and Logback is NOT present. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(name = "org.apache.logging.log4j.core.appender.AbstractAppender") + @ConditionalOnMissingBean(type = "io.github.jobs.spring.log.JObsLogAppender") + 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 + org.apache.logging.log4j.core.Logger rootLogger = + (org.apache.logging.log4j.core.Logger) LogManager.getRootLogger(); + rootLogger.addAppender(appender); + 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(); + } +}