Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions j-obs-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@
<optional>true</optional>
</dependency>

<!--Log4j (optional) -->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<optional>true</optional>
</dependency>

<!-- Note: Jaeger exporter was removed (deprecated).
Use OTLP exporter instead - Jaeger natively supports OTLP protocol.
Configure: j-obs.traces.export.otlp.endpoint=http://jaeger:4317 -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> mdcMap = event.getContextData() != null
? event.getContextData().toMap().entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> String.valueOf(e.getValue())))
: Map.of();
Map<String, String> 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<String, String> 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<String, String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
});
}
}
Loading