From a8d09168187e9d3f80373e5c2410c7c823723171 Mon Sep 17 00:00:00 2001 From: Carlosho Date: Wed, 8 Apr 2026 14:37:28 -0300 Subject: [PATCH 1/2] refactor: respect server.servlet.context-path for all UI URLs The J-Obs UI dashboard previously constructed URLs using only its own base path (`j-obs.path`, defaulting to `/j-obs`), which caused all links and asset requests to fail with a 404 when the application was deployed under a `server.servlet.context-path`. This commit refactors `TemplateService` to be aware of the application's context path. - `TemplateService` now injects `Environment` to read `server.servlet.context-path`. - A new `fullPath()` method combines the application's context path with the J-Obs path (e.g., `/my-app/j-obs`). - All controllers and template rendering logic now use `templateService.fullPath()` to generate correct, context-aware URLs for `{{BASE_PATH}}`. - The API Docs page (`api-docs.html`) is updated to use a new `{{CONTEXT_PATH}}` variable for Swagger UI and OpenAPI spec links, ensuring they are also correctly prefixed. This change ensures that the J-Obs UI is fully functional when hosted within a Spring Boot application that has a global context path configured. --- .../JObsAlertAutoConfiguration.java | 5 +++-- ...JObsAnomalyDetectionAutoConfiguration.java | 5 +++-- .../autoconfigure/JObsAutoConfiguration.java | 18 +++++++++------ .../JObsHealthAutoConfiguration.java | 5 +++-- .../JObsLogAutoConfiguration.java | 5 +++-- .../JObsMetricAutoConfiguration.java | 5 +++-- .../JObsServiceMapAutoConfiguration.java | 5 +++-- .../JObsSqlAnalyzerAutoConfiguration.java | 5 +++-- .../JObsToolsAutoConfiguration.java | 6 ----- .../JObsTraceAutoConfiguration.java | 6 ++--- .../JObsWebFluxAutoConfiguration.java | 5 +++-- .../jobs/spring/web/AlertController.java | 7 ++++-- .../jobs/spring/web/AnomalyController.java | 9 +++++--- .../jobs/spring/web/HealthController.java | 7 ++++-- .../jobs/spring/web/JObsController.java | 16 +++++++++----- .../github/jobs/spring/web/LogController.java | 8 ++++--- .../jobs/spring/web/MetricController.java | 7 ++++-- .../jobs/spring/web/ServiceMapController.java | 9 +++++--- .../spring/web/SqlAnalyzerController.java | 7 ++++-- .../jobs/spring/web/ToolsController.java | 3 +-- .../jobs/spring/web/TraceController.java | 13 ++++++----- .../spring/web/template/TemplateService.java | 22 ++++++++++++++----- .../static/j-obs/templates/api-docs.html | 18 +++++++-------- 23 files changed, 118 insertions(+), 78 deletions(-) diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAlertAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAlertAutoConfiguration.java index 28ad5d6..d51be4b 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAlertAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAlertAutoConfiguration.java @@ -4,6 +4,7 @@ import io.github.jobs.infrastructure.InMemoryAlertRepository; import io.github.jobs.spring.web.AlertApiController; import io.github.jobs.spring.web.AlertController; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -34,8 +35,8 @@ public AlertRepository alertRepository() { @Bean @ConditionalOnMissingBean - public AlertController alertController() { - return new AlertController(properties); + public AlertController alertController(TemplateService templateService) { + return new AlertController(properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAnomalyDetectionAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAnomalyDetectionAutoConfiguration.java index 0d09639..ccb1142 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAnomalyDetectionAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAnomalyDetectionAutoConfiguration.java @@ -6,6 +6,7 @@ import io.github.jobs.spring.anomaly.DefaultAnomalyDetector; import io.github.jobs.spring.web.AnomalyApiController; import io.github.jobs.spring.web.AnomalyController; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -53,8 +54,8 @@ public AnomalyDetector anomalyDetector(TraceRepository traceRepository, AnomalyD @Bean @ConditionalOnMissingBean - public AnomalyController anomalyController(JObsProperties properties) { - return new AnomalyController(properties); + public AnomalyController anomalyController(JObsProperties properties, TemplateService templateService) { + return new AnomalyController(properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAutoConfiguration.java index 6508115..3374216 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsAutoConfiguration.java @@ -2,11 +2,8 @@ import io.github.jobs.application.DependencyChecker; import io.github.jobs.infrastructure.ClasspathDependencyChecker; -import io.github.jobs.spring.web.CapabilitiesController; -import io.github.jobs.spring.web.JObsApiController; -import io.github.jobs.spring.web.JObsController; -import io.github.jobs.spring.web.RateLimiter; -import io.github.jobs.spring.web.RateLimitInterceptor; +import io.github.jobs.spring.web.*; +import io.github.jobs.spring.web.template.TemplateService; import jakarta.annotation.PreDestroy; import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.ObjectProvider; @@ -16,6 +13,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -67,6 +65,12 @@ public void destroy() { } } + @Bean + @ConditionalOnMissingBean + public TemplateService templateService(Environment environment) { + return new TemplateService(properties, environment); + } + @Bean @ConditionalOnMissingBean public JObsPropertiesValidator jObsPropertiesValidator() { @@ -81,8 +85,8 @@ public DependencyChecker dependencyChecker() { @Bean @ConditionalOnMissingBean - public JObsController jObsController(DependencyChecker dependencyChecker) { - return new JObsController(dependencyChecker, properties); + public JObsController jObsController(DependencyChecker dependencyChecker, TemplateService templateService) { + return new JObsController(dependencyChecker, properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsHealthAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsHealthAutoConfiguration.java index 2666de2..7e7dfb5 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsHealthAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsHealthAutoConfiguration.java @@ -4,6 +4,7 @@ import io.github.jobs.spring.health.ActuatorHealthRepository; import io.github.jobs.spring.web.HealthApiController; import io.github.jobs.spring.web.HealthController; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration; import org.springframework.boot.actuate.health.HealthEndpoint; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -37,7 +38,7 @@ public HealthApiController healthApiController(HealthRepository healthRepository @Bean @ConditionalOnBean(HealthRepository.class) - public HealthController healthController(HealthRepository healthRepository, JObsProperties properties) { - return new HealthController(healthRepository, properties); + public HealthController healthController(HealthRepository healthRepository, JObsProperties properties, TemplateService templateService) { + return new HealthController(healthRepository, properties, templateService); } } 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 d725e20..5dfa43b 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 @@ -8,6 +8,7 @@ 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.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -70,8 +71,8 @@ public LogRepository logRepository() { @Bean @ConditionalOnMissingBean - public LogController logController(LogRepository logRepository) { - return new LogController(logRepository, properties); + public LogController logController(LogRepository logRepository, TemplateService templateService) { + return new LogController(logRepository, properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsMetricAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsMetricAutoConfiguration.java index a309e30..8e75a4f 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsMetricAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsMetricAutoConfiguration.java @@ -5,6 +5,7 @@ import io.github.jobs.spring.metric.MicrometerMetricRepository; import io.github.jobs.spring.web.MetricApiController; import io.github.jobs.spring.web.MetricController; +import io.github.jobs.spring.web.template.TemplateService; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -46,8 +47,8 @@ public MetricApiController metricApiController(MetricRepository metricRepository @Bean @ConditionalOnBean(MetricRepository.class) - public MetricController metricController(MetricRepository metricRepository, JObsProperties properties) { - return new MetricController(metricRepository, properties); + public MetricController metricController(MetricRepository metricRepository, JObsProperties properties, TemplateService templateService) { + return new MetricController(metricRepository, properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsServiceMapAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsServiceMapAutoConfiguration.java index f8be46b..ffeed97 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsServiceMapAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsServiceMapAutoConfiguration.java @@ -5,6 +5,7 @@ import io.github.jobs.spring.servicemap.DefaultServiceMapBuilder; import io.github.jobs.spring.web.ServiceMapApiController; import io.github.jobs.spring.web.ServiceMapController; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -37,8 +38,8 @@ public ServiceMapBuilder serviceMapBuilder(TraceRepository traceRepository, JObs @Bean @ConditionalOnMissingBean @ConditionalOnBean(ServiceMapBuilder.class) - public ServiceMapController serviceMapController(JObsProperties properties) { - return new ServiceMapController(properties); + public ServiceMapController serviceMapController(JObsProperties properties, TemplateService templateService) { + return new ServiceMapController(properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsSqlAnalyzerAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsSqlAnalyzerAutoConfiguration.java index 1bb6ecb..48d302f 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsSqlAnalyzerAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsSqlAnalyzerAutoConfiguration.java @@ -6,6 +6,7 @@ import io.github.jobs.spring.sql.SqlAnalyzerConfig; import io.github.jobs.spring.web.SqlAnalyzerApiController; import io.github.jobs.spring.web.SqlAnalyzerController; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -46,8 +47,8 @@ public SqlAnalyzer sqlAnalyzer(TraceRepository traceRepository, SqlAnalyzerConfi @Bean @ConditionalOnMissingBean - public SqlAnalyzerController sqlAnalyzerController(SqlAnalyzer sqlAnalyzer, JObsProperties properties) { - return new SqlAnalyzerController(sqlAnalyzer, properties); + public SqlAnalyzerController sqlAnalyzerController(SqlAnalyzer sqlAnalyzer, JObsProperties properties, TemplateService templateService) { + return new SqlAnalyzerController(sqlAnalyzer, properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsToolsAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsToolsAutoConfiguration.java index aac7f07..c5a3f96 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsToolsAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsToolsAutoConfiguration.java @@ -22,12 +22,6 @@ @EnableConfigurationProperties(JObsProperties.class) public class JObsToolsAutoConfiguration { - @Bean - @ConditionalOnMissingBean - public TemplateService templateService(JObsProperties properties) { - return new TemplateService(properties); - } - @Bean @ConditionalOnMissingBean @ConditionalOnBean(TemplateService.class) diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsTraceAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsTraceAutoConfiguration.java index 67455a1..9dc1dfe 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsTraceAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsTraceAutoConfiguration.java @@ -7,6 +7,7 @@ import io.github.jobs.spring.trace.TraceSampler; import io.github.jobs.spring.web.TraceApiController; import io.github.jobs.spring.web.TraceController; +import io.github.jobs.spring.web.template.TemplateService; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.common.AttributeKey; @@ -16,7 +17,6 @@ import io.opentelemetry.sdk.resources.Resource; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; -import io.opentelemetry.sdk.trace.SpanProcessor; import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; import io.opentelemetry.sdk.trace.export.SpanExporter; import org.slf4j.Logger; @@ -107,8 +107,8 @@ public TraceRepository traceRepository(InMemoryTraceRepository inMemoryTraceRepo @Bean @ConditionalOnMissingBean - public TraceController traceController(TraceRepository traceRepository, JObsProperties properties) { - return new TraceController(traceRepository, properties); + public TraceController traceController(TraceRepository traceRepository, JObsProperties properties, TemplateService templateService) { + return new TraceController(traceRepository, properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsWebFluxAutoConfiguration.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsWebFluxAutoConfiguration.java index eb72545..a2ddde0 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsWebFluxAutoConfiguration.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/autoconfigure/JObsWebFluxAutoConfiguration.java @@ -5,6 +5,7 @@ import io.github.jobs.spring.web.JObsApiController; import io.github.jobs.spring.web.JObsController; import io.github.jobs.spring.web.RateLimiter; +import io.github.jobs.spring.web.template.TemplateService; import io.github.jobs.spring.webflux.ReactiveRateLimitFilter; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -51,8 +52,8 @@ public DependencyChecker dependencyChecker() { @Bean @ConditionalOnMissingBean - public JObsController jObsController(DependencyChecker dependencyChecker) { - return new JObsController(dependencyChecker, properties); + public JObsController jObsController(DependencyChecker dependencyChecker, TemplateService templateService) { + return new JObsController(dependencyChecker, properties, templateService); } @Bean diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/AlertController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/AlertController.java index 026bb49..776e232 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/AlertController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/AlertController.java @@ -1,6 +1,7 @@ package io.github.jobs.spring.web; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -17,17 +18,19 @@ public class AlertController { private final JObsProperties properties; + private final TemplateService templateService; private final String alertsHtml; - public AlertController(JObsProperties properties) { + public AlertController(JObsProperties properties, TemplateService templateService) { this.properties = properties; + this.templateService = templateService; this.alertsHtml = loadResource("/static/j-obs/templates/alerts.html"); } @GetMapping(value = "${j-obs.path:/j-obs}/alerts", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String alertsPage() { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); return alertsHtml.replace("{{BASE_PATH}}", basePath); } diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/AnomalyController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/AnomalyController.java index 04a7879..723101b 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/AnomalyController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/AnomalyController.java @@ -1,6 +1,7 @@ package io.github.jobs.spring.web; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -19,17 +20,19 @@ public class AnomalyController { private final JObsProperties properties; + private final TemplateService templateService; private final String anomaliesHtml; - public AnomalyController(JObsProperties properties) { + public AnomalyController(JObsProperties properties, TemplateService templateService) { this.properties = properties; + this.templateService = templateService; this.anomaliesHtml = loadResource("/templates/anomalies.html"); } @GetMapping(value = "/anomalies", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String anomaliesPage() { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); return anomaliesHtml .replace("th:href=\"${basePath}\"", "href=\"" + basePath + "\"") .replace("th:href=\"${basePath + ", "href=\"" + basePath) @@ -66,6 +69,6 @@ private String getDefaultHtml() { - """, properties.getPath()); + """, templateService.fullPath()); } } diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/HealthController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/HealthController.java index 63eb24f..876fee4 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/HealthController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/HealthController.java @@ -3,6 +3,7 @@ import io.github.jobs.application.HealthRepository; import io.github.jobs.domain.health.HealthCheckResult; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -22,18 +23,20 @@ public class HealthController { private final HealthRepository healthRepository; private final JObsProperties properties; + private final TemplateService templateService; private final String healthHtml; - public HealthController(HealthRepository healthRepository, JObsProperties properties) { + public HealthController(HealthRepository healthRepository, JObsProperties properties, TemplateService templateService) { this.healthRepository = healthRepository; this.properties = properties; + this.templateService = templateService; this.healthHtml = loadResource("/static/j-obs/templates/health.html"); } @GetMapping(produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String healthPage() { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); HealthCheckResult health = healthRepository.getHealth(); return healthHtml diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/JObsController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/JObsController.java index 38c2440..8bf630d 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/JObsController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/JObsController.java @@ -3,8 +3,8 @@ import io.github.jobs.application.DependencyChecker; import io.github.jobs.domain.DependencyCheckResult; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; @@ -21,14 +21,16 @@ public class JObsController { private final DependencyChecker dependencyChecker; private final JObsProperties properties; + private final TemplateService templateService; private final String dashboardHtml; private final String requirementsHtml; private final String loginHtml; private final String apiDocsHtml; - public JObsController(DependencyChecker dependencyChecker, JObsProperties properties) { + public JObsController(DependencyChecker dependencyChecker, JObsProperties properties, TemplateService templateService) { this.dependencyChecker = dependencyChecker; this.properties = properties; + this.templateService = templateService; this.dashboardHtml = loadResource("/static/j-obs/index.html"); this.requirementsHtml = loadResource("/static/j-obs/requirements.html"); this.loginHtml = loadResource("/static/j-obs/login.html"); @@ -74,11 +76,13 @@ public String apiDocs() { } private String renderApiDocs() { - return apiDocsHtml.replace("{{BASE_PATH}}", escapeHtml(properties.getPath())); + return apiDocsHtml + .replace("{{BASE_PATH}}", escapeHtml(templateService.fullPath())) + .replace("{{CONTEXT_PATH}}", escapeHtml(templateService.getContextPath())); } private String renderLogin() { - return loginHtml.replace("{{BASE_PATH}}", escapeHtml(properties.getPath())); + return loginHtml.replace("{{BASE_PATH}}", escapeHtml(templateService.fullPath())); } private String renderDashboard(DependencyCheckResult result) { @@ -87,7 +91,7 @@ private String renderDashboard(DependencyCheckResult result) { .replace("{{FOUND_COUNT}}", String.valueOf(result.foundCount())) .replace("{{TOTAL_COUNT}}", String.valueOf(result.totalCount())) .replace("{{MISSING_OPTIONAL}}", String.valueOf(result.missingOptionalCount())) - .replace("{{BASE_PATH}}", escapeHtml(properties.getPath())); + .replace("{{BASE_PATH}}", escapeHtml(templateService.fullPath())); } private String renderRequirements(DependencyCheckResult result) { @@ -154,7 +158,7 @@ private String renderRequirements(DependencyCheckResult result) { .replace("{{MISSING_OPTIONAL_COUNT}}", String.valueOf(result.missingOptionalCount())) .replace("{{STATUS_CLASS}}", result.isComplete() ? "text-green-500" : "text-red-500") .replace("{{STATUS_TEXT}}", result.isComplete() ? "All requirements met" : result.missingRequiredCount() + " required dependencies missing") - .replace("{{BASE_PATH}}", escapeHtml(properties.getPath())); + .replace("{{BASE_PATH}}", escapeHtml(templateService.fullPath())); } private String loadResource(String path) { diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/LogController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/LogController.java index 96d121c..b7989e3 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/LogController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/LogController.java @@ -1,8 +1,8 @@ package io.github.jobs.spring.web; import io.github.jobs.application.LogRepository; -import io.github.jobs.domain.log.LogLevel; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -22,18 +22,20 @@ public class LogController { private final LogRepository logRepository; private final JObsProperties properties; + private final TemplateService templateService; private final String logsHtml; - public LogController(LogRepository logRepository, JObsProperties properties) { + public LogController(LogRepository logRepository, JObsProperties properties, TemplateService templateService) { this.logRepository = logRepository; this.properties = properties; + this.templateService = templateService; this.logsHtml = loadResource("/static/j-obs/templates/logs.html"); } @GetMapping(produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String logsPage() { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); var stats = logRepository.stats(); return logsHtml diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/MetricController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/MetricController.java index 8ce3bc9..e8a6a32 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/MetricController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/MetricController.java @@ -2,6 +2,7 @@ import io.github.jobs.application.MetricRepository; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -21,18 +22,20 @@ public class MetricController { private final MetricRepository metricRepository; private final JObsProperties properties; + private final TemplateService templateService; private final String metricsHtml; - public MetricController(MetricRepository metricRepository, JObsProperties properties) { + public MetricController(MetricRepository metricRepository, JObsProperties properties, TemplateService templateService) { this.metricRepository = metricRepository; this.properties = properties; + this.templateService = templateService; this.metricsHtml = loadResource("/static/j-obs/templates/metrics.html"); } @GetMapping(produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String metricsPage() { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); var stats = metricRepository.stats(); return metricsHtml diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/ServiceMapController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/ServiceMapController.java index 24b6636..c19c89d 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/ServiceMapController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/ServiceMapController.java @@ -1,6 +1,7 @@ package io.github.jobs.spring.web; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -19,10 +20,12 @@ public class ServiceMapController { private final JObsProperties properties; + private final TemplateService templateService; private final String serviceMapHtml; - public ServiceMapController(JObsProperties properties) { + public ServiceMapController(JObsProperties properties, TemplateService templateService) { this.properties = properties; + this.templateService = templateService; this.serviceMapHtml = loadResource("/templates/service-map.html"); } @@ -32,7 +35,7 @@ public ServiceMapController(JObsProperties properties) { @GetMapping(value = "/service-map", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String serviceMap() { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); return serviceMapHtml .replace("th:href=\"@{/j-obs}\"", "href=\"" + basePath + "\"") .replace("th:href=\"@{/j-obs/", "href=\"" + basePath + "/") @@ -67,6 +70,6 @@ private String getDefaultHtml() { - """, properties.getPath()); + """, templateService.fullPath()); } } diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/SqlAnalyzerController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/SqlAnalyzerController.java index 16d2335..f01b6b3 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/SqlAnalyzerController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/SqlAnalyzerController.java @@ -2,6 +2,7 @@ import io.github.jobs.application.SqlAnalyzer; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -19,18 +20,20 @@ public class SqlAnalyzerController { private final SqlAnalyzer sqlAnalyzer; private final JObsProperties properties; + private final TemplateService templateService; private final String sqlAnalyzerHtml; - public SqlAnalyzerController(SqlAnalyzer sqlAnalyzer, JObsProperties properties) { + public SqlAnalyzerController(SqlAnalyzer sqlAnalyzer, JObsProperties properties, TemplateService templateService) { this.sqlAnalyzer = sqlAnalyzer; this.properties = properties; + this.templateService = templateService; this.sqlAnalyzerHtml = loadResource("/static/j-obs/templates/sql-analyzer.html"); } @GetMapping(value = "${j-obs.path:/j-obs}/sql", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String sqlAnalyzerPage() { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); return sqlAnalyzerHtml.replace("{{BASE_PATH}}", basePath); } diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/ToolsController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/ToolsController.java index 44fd6b2..72e12bc 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/ToolsController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/ToolsController.java @@ -1,7 +1,6 @@ package io.github.jobs.spring.web; import io.github.jobs.spring.autoconfigure.JObsProperties; -import io.github.jobs.spring.web.template.TemplateContext; import io.github.jobs.spring.web.template.TemplateService; import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; @@ -37,7 +36,7 @@ public ToolsController(JObsProperties properties, TemplateService templateServic @GetMapping(produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String toolsPage() { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); String css = templateService.getSharedCss(); String content = toolsContent.replace("{{BASE_PATH}}", basePath); diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/TraceController.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/TraceController.java index 8346509..aa25991 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/TraceController.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/TraceController.java @@ -2,8 +2,8 @@ import io.github.jobs.application.TraceRepository; import io.github.jobs.domain.trace.Trace; -import io.github.jobs.domain.trace.TraceQuery; import io.github.jobs.spring.autoconfigure.JObsProperties; +import io.github.jobs.spring.web.template.TemplateService; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -12,7 +12,6 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.util.List; /** * Controller for trace HTML pages. @@ -22,13 +21,15 @@ public class TraceController { private final TraceRepository traceRepository; private final JObsProperties properties; + private final TemplateService templateService; private final String tracesListHtml; private final String traceDetailHtml; private final String traceNotFoundHtml; - public TraceController(TraceRepository traceRepository, JObsProperties properties) { + public TraceController(TraceRepository traceRepository, JObsProperties properties, TemplateService templateService) { this.traceRepository = traceRepository; this.properties = properties; + this.templateService = templateService; this.tracesListHtml = loadResource("/static/j-obs/templates/traces-list.html"); this.traceDetailHtml = loadResource("/static/j-obs/templates/trace-detail.html"); this.traceNotFoundHtml = loadResource("/static/j-obs/templates/trace-not-found.html"); @@ -37,7 +38,7 @@ public TraceController(TraceRepository traceRepository, JObsProperties propertie @GetMapping("${j-obs.path:/j-obs}/traces") @ResponseBody public String tracesPage() { - return tracesListHtml.replace("{{BASE_PATH}}", properties.getPath()); + return tracesListHtml.replace("{{BASE_PATH}}", templateService.fullPath()); } private String loadResource(String path) { @@ -80,7 +81,7 @@ public String traceDetailPage(@PathVariable String traceId) { } private String renderTraceDetail(Trace trace) { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); return traceDetailHtml .replace("{{BASE_PATH}}", basePath) .replace("{{TRACE_ID}}", trace.traceId()) @@ -89,7 +90,7 @@ private String renderTraceDetail(Trace trace) { } private String renderNotFound(String traceId) { - String basePath = properties.getPath(); + String basePath = templateService.fullPath(); String safeTraceId = InputSanitizer.sanitizeTraceId(traceId); if (safeTraceId == null) { safeTraceId = "(invalid)"; diff --git a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/template/TemplateService.java b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/template/TemplateService.java index ab6648e..c465a9b 100644 --- a/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/template/TemplateService.java +++ b/j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/web/template/TemplateService.java @@ -3,12 +3,12 @@ import io.github.jobs.spring.autoconfigure.JObsProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.env.Environment; import org.springframework.stereotype.Service; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -22,13 +22,23 @@ public class TemplateService { private static final Logger log = LoggerFactory.getLogger(TemplateService.class); private static final String TEMPLATES_PATH = "/static/j-obs/templates/"; private static final String CSS_PATH = "/static/j-obs/css/"; - + private final String contextPath; private final JObsProperties properties; private final Map templateCache = new ConcurrentHashMap<>(); private final Map cssCache = new ConcurrentHashMap<>(); - public TemplateService(JObsProperties properties) { + public TemplateService(JObsProperties properties, Environment environment) { this.properties = properties; + String cp = environment.getProperty("server.servlet.context-path", ""); + this.contextPath = cp.endsWith("/") ? cp.substring(0, cp.length() - 1) : cp; + } + + public String fullPath() { + return contextPath + properties.getPath(); + } + + public String getContextPath() { + return contextPath; } /** @@ -36,7 +46,7 @@ public TemplateService(JObsProperties properties) { */ public TemplateContext.Builder context() { return TemplateContext.builder() - .put("basePath", properties.getPath()); + .put("basePath", fullPath()); } /** @@ -84,7 +94,7 @@ public String wrapInLayout(String content, String title, String activeNav, Templ .replace("{{ACTIVE_NAV}}", activeNav) .replace("{{CONTENT}}", content) .replace("{{SHARED_CSS}}", getSharedCss()) - .replace("{{BASE_PATH}}", properties.getPath()); + .replace("{{BASE_PATH}}", fullPath()); } private String loadTemplate(String templateName) { @@ -125,7 +135,7 @@ private String processTemplate(String template, TemplateContext context) { } // Always replace basePath - result = result.replace("{{BASE_PATH}}", properties.getPath()); + result = result.replace("{{BASE_PATH}}", fullPath()); return result; } diff --git a/j-obs-spring-boot-starter/src/main/resources/static/j-obs/templates/api-docs.html b/j-obs-spring-boot-starter/src/main/resources/static/j-obs/templates/api-docs.html index f05ff54..8407c37 100644 --- a/j-obs-spring-boot-starter/src/main/resources/static/j-obs/templates/api-docs.html +++ b/j-obs-spring-boot-starter/src/main/resources/static/j-obs/templates/api-docs.html @@ -188,7 +188,7 @@

API Documentation

API Documentation
@@ -220,7 +220,7 @@

API Documentation

- +
@@ -229,11 +229,11 @@

API Documentation

J-Obs OpenAPI Spec

-

/v3/api-docs/j-obs

+

{{CONTEXT_PATH}}/v3/api-docs/j-obs

- +
@@ -242,11 +242,11 @@

Application OpenAPI Spec

-

/v3/api-docs/application

+

{{CONTEXT_PATH}}/v3/api-docs/application

- +
@@ -255,7 +255,7 @@

Combined OpenAPI Spec

-

/v3/api-docs

+

{{CONTEXT_PATH}}/v3/api-docs

From 9c844782c4f9a22e71d1a77dd2b341ca526809b8 Mon Sep 17 00:00:00 2001 From: Carlosho Date: Wed, 8 Apr 2026 14:53:18 -0300 Subject: [PATCH 2/2] feat: add Log4j2 appender for log collection Adds support for capturing logs from Log4j2, mirroring the existing Logback integration. This allows applications using Log4j2 as their logging framework to have their logs collected and displayed within the J-Obs UI. - **`JObsLog4j2Appender`**: A new Log4j2 appender that captures log events, sanitizes them, and forwards them to the `LogRepository`. - It extracts `traceId` and `spanId` from the MDC or falls back to the current OpenTelemetry `SpanContext`. - It avoids circular logging by ignoring log events originating from the `io.github.jobs` packages. - Gracefully handles the absence of the OpenTelemetry API on the classpath. - **`JObsLogAutoConfiguration`**: Updated to include a new `Log4j2Configuration` inner class. - This configuration automatically registers the `JObsLog4j2Appender` bean and attaches it to the Log4j2 root logger. - It is conditionally enabled only when Log4j2 is on the classpath and the Logback appender is not present, ensuring no conflicts. --- j-obs-spring-boot-starter/pom.xml | 7 + .../JObsLogAutoConfiguration.java | 31 +++- .../jobs/spring/log/JObsLog4j2Appender.java | 160 ++++++++++++++++++ 3 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 j-obs-spring-boot-starter/src/main/java/io/github/jobs/spring/log/JObsLog4j2Appender.java diff --git a/j-obs-spring-boot-starter/pom.xml b/j-obs-spring-boot-starter/pom.xml index 95bd3ff..3e83053 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(); + } +}