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();
+ }
+}