Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.3.0] - 2026-05-06

### Added
- **Log4j2 appender (optional)** — `JObsLog4j2Appender` mirrors `JObsLogAppender` (Logback) and captures log events into the J-Obs log repository when applications use Log4j2 instead of Logback. Extracts `traceId`/`spanId` from MDC with fallback to the current OpenTelemetry `SpanContext`, applies `LogSanitizer` to message/MDC/stack trace, and skips J-Obs internal loggers to prevent circular logging.
- **Auto-configuration `Log4j2Configuration`** — registers the Log4j2 appender bean and attaches it to the Log4j2 root logger. Activates when `org.apache.logging.log4j.core.appender.AbstractAppender` is on the classpath and Logback (`ch.qos.logback.classic.Logger`) is not. When Log4j2 is bridged to SLF4J, the bean is still created but the root-logger attach step is skipped (defensive `LoggerContext` instance check), so the appender doesn't crash on hybrid setups.
- **Tests** — `JObsLogAutoConfigurationTest` now verifies that Logback wins when both frameworks are present, and that the Log4j2 appender activates when Logback is filtered out via `FilteredClassLoader`.

### Changed
- `j-obs-spring-boot-starter/pom.xml` declares `org.apache.logging.log4j:log4j-core` as an `<optional>true</optional>` dependency (version inherited from `spring-boot-dependencies` BOM, no transitive impact for users on Logback).

## [1.2.0] - 2026-04-17

### Added
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ sequenceDiagram
<dependency>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-spring-boot-starter</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</dependency>
```

Expand All @@ -166,28 +166,28 @@ J-Obs offers **modular starters** so you can include only the features you need,
<dependency>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-starter-tracing</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</dependency>

<!-- Metrics dashboard only (Micrometer + Actuator) -->
<dependency>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-starter-metrics</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</dependency>

<!-- Real-time log streaming only (Logback + WebSocket) -->
<dependency>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-starter-logging</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</dependency>

<!-- CPU/Memory profiling only (JVM MXBeans) -->
<dependency>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-starter-profiling</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</dependency>
```

Expand All @@ -198,23 +198,23 @@ You can combine multiple starters:
<dependency>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-starter-tracing</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-starter-metrics</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</dependency>
```

### Starter Comparison

| Starter | Dependencies Added | Features Enabled |
|---|---|---|
| `j-obs-spring-boot-starter` | All (OpenTelemetry, Micrometer, Logback, WebSocket, Actuator) | Everything |
| `j-obs-spring-boot-starter` | All (OpenTelemetry, Micrometer, Logback or Log4j2, WebSocket, Actuator) | Everything |
| `j-obs-starter-tracing` | OpenTelemetry API + SDK | Traces, waterfall timeline, span details, SQL analyzer, anomaly detection, service map |
| `j-obs-starter-metrics` | Micrometer + Actuator | Metrics dashboard, latency percentiles, JVM stats, health monitoring, SLO/SLI |
| `j-obs-starter-logging` | Logback + WebSocket | Real-time log streaming, level/logger filters, trace correlation |
| `j-obs-starter-logging` | Logback or Log4j2 + WebSocket | Real-time log streaming, level/logger filters, trace correlation |
| `j-obs-starter-profiling` | None (uses JVM MXBeans) | CPU sampling, heap snapshots, thread dumps |

> **Note:** All starters include the base dashboard UI. Features auto-activate based on classpath — only the sections for your chosen starters appear in the dashboard.
Expand Down
2 changes: 1 addition & 1 deletion j-obs-benchmarks/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<parent>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-parent</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</parent>

<artifactId>j-obs-benchmarks</artifactId>
Expand Down
4 changes: 2 additions & 2 deletions j-obs-bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-bom</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
<packaging>pom</packaging>

<name>J-Obs BOM</name>
Expand Down Expand Up @@ -43,7 +43,7 @@
</scm>

<properties>
<j-obs.version>1.2.0</j-obs.version>
<j-obs.version>1.3.0</j-obs.version>
</properties>

<dependencyManagement>
Expand Down
2 changes: 1 addition & 1 deletion j-obs-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<parent>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-parent</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</parent>

<artifactId>j-obs-core</artifactId>
Expand Down
2 changes: 1 addition & 1 deletion j-obs-sample/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<parent>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-parent</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
9 changes: 8 additions & 1 deletion j-obs-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<parent>
<groupId>io.github.johnpitter</groupId>
<artifactId>j-obs-parent</artifactId>
<version>1.2.0</version>
<version>1.3.0</version>
</parent>

<artifactId>j-obs-spring-boot-starter</artifactId>
Expand Down 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();
}
}
Loading
Loading