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
294 changes: 294 additions & 0 deletions docs/docs/administration/debug-mode.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent doc thanks

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ nav:
- Introduction: administration/introduction.md
- Enterprise edition: administration/enterprise.md
- Multi-tenancy: administration/multi-tenancy.md
- Debug mode: administration/debug-mode.md
- Platform settings:
- Parameters: administration/parameters.md
- Security:
Expand Down
15 changes: 15 additions & 0 deletions openaev-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
<opentelemetry.version>1.63.0</opentelemetry.version>
<elasticsearch.version>8.19.17</elasticsearch.version>
<opentelemetry-semconv.version>1.42.0</opentelemetry-semconv.version>
<!-- Not managed by the Spring Boot BOM; pinned here. Apache-2.0. -->
<datasource-proxy.version>1.10</datasource-proxy.version>
</properties>

<profiles>
Expand Down Expand Up @@ -292,6 +294,19 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Debug mode: correlation ids in the MDC via Micrometer Tracing (Brave bridge, from the
Spring Boot BOM). Aligning on the OpenTelemetry bridge is deferred (ADR 0002): the
metrics-only OpenTelemetry bean blocks it, a metrics-owner change. Apache-2.0. -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<!-- Debug mode: SQL statement logging with timing and masked parameters. Apache-2.0. -->
<dependency>
<groupId>net.ttddyy</groupId>
<artifactId>datasource-proxy</artifactId>
<version>${datasource-proxy.version}</version>
</dependency>
<!-- Must match the version Spring Data JPA uses (declared provided/optional there),
otherwise its native-query handling breaks at startup. -->
<dependency>
Expand Down
18 changes: 16 additions & 2 deletions openaev-api/src/main/java/io/openaev/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import io.openaev.config.OpenAEVConfig;
import io.openaev.database.model.Setting;
import io.openaev.database.repository.SettingRepository;
import io.openaev.debug.DebugLogCorrelationListener;
import io.openaev.debug.DebugTracingContextInitializer;
import io.openaev.tools.FlywayMigrationValidator;
import jakarta.annotation.PostConstruct;
import java.sql.Timestamp;
Expand All @@ -15,8 +17,8 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;

@SpringBootApplication
@Slf4j
Expand All @@ -28,7 +30,19 @@ public class App {

public static void main(String[] args) {
FlywayMigrationValidator.validateFlywayMigrationNames();
SpringApplication.run(App.class, args);
application().run(args);
}

/**
* Builds the production application. {@link DebugTracingContextInitializer} excludes the tracing
* auto-configuration unless debug mode is active; it is registered here for production and via
* src/test/resources spring.factories for tests (an EnvironmentPostProcessor /
* context.initializer.classes does not run under {@code @SpringBootTest}).
*/
static SpringApplicationBuilder application() {
return new SpringApplicationBuilder(App.class)
.initializers(new DebugTracingContextInitializer())
.listeners(new DebugLogCorrelationListener());
}

@PostConstruct
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.openaev.debug;

import javax.sql.DataSource;
import net.ttddyy.dsproxy.support.ProxyDataSource;
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.config.BeanPostProcessor;

/**
* Wraps the auto-configured {@link DataSource} in a datasource-proxy for SQL logging. Created only
* when debug mode is on; when off there is no proxy on the query path at all.
*/
public class DataSourceProxyBeanPostProcessor implements BeanPostProcessor {

private static final Logger log = LoggerFactory.getLogger(DataSourceProxyBeanPostProcessor.class);

private final MaskingSqlLoggingListener listener;

public DataSourceProxyBeanPostProcessor(MaskingSqlLoggingListener listener) {
this.listener = listener;
}

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof DataSource dataSource && !(bean instanceof ProxyDataSource)) {
log.warn("Debug mode: wrapping datasource bean '{}' with SQL statement logging", beanName);
return ProxyDataSourceBuilder.create(dataSource)
.name("openaev-debug")
.listener(listener)
.build();
}
return bean;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.openaev.debug;

import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

/**
* Logs the production-barrier outcome. Exists whenever {@code enabled=true} (so a refusal is
* visible), independently of {@link DebugEnabledCondition} which gates the actual debug beans.
*/
@Component
@ConditionalOnProperty(prefix = "openaev.debug", name = "enabled", havingValue = "true")
public class DebugActivationGuard {

private static final Logger log = LoggerFactory.getLogger(DebugActivationGuard.class);

private final Environment environment;

public DebugActivationGuard(Environment environment) {
this.environment = environment;
}

@PostConstruct
public void check() {
boolean production = DebugEnabledCondition.isProduction(environment);
boolean override =
environment.getProperty("openaev.debug.allow-in-production", Boolean.class, false);

if (production && !override) {
log.error(
"Debug mode was requested (openaev.debug.enabled=true) but REFUSED: production environment "
+ "detected (no dev/test/ci profile active). Set openaev.debug.allow-in-production=true "
+ "to override deliberately.");
} else if (production) {
log.warn(
"Debug mode is ACTIVE IN PRODUCTION via openaev.debug.allow-in-production=true. This is a "
+ "deliberate override; do not leave it on.");
}
}
}
106 changes: 106 additions & 0 deletions openaev-api/src/main/java/io/openaev/debug/DebugConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package io.openaev.debug;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;

/**
* Wires the debug-mode beans, gated by {@link DebugEnabledCondition}. When off (or refused in
* production) the whole configuration backs off: no proxy, no extra per-request cost.
*/
@Configuration(proxyBeanMethods = false)
@Conditional(DebugEnabledCondition.class)
public class DebugConfiguration {

@Bean
public DebugRuntimeState debugRuntimeState() {
return new DebugRuntimeState();
}

@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public SensitiveDataMasker sensitiveDataMasker(DebugProperties properties) {
return new SensitiveDataMasker(properties.getMasking());
}

@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@ConditionalOnProperty(
prefix = "openaev.debug.sql",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
public MaskingSqlLoggingListener maskingSqlLoggingListener(
SensitiveDataMasker masker, DebugRuntimeState runtimeState, DebugProperties properties) {
return new MaskingSqlLoggingListener(masker, runtimeState, properties.getSql());
}

/** {@code static} so the post-processor is created early enough to wrap the datasource. */
@Bean
@ConditionalOnProperty(
prefix = "openaev.debug.sql",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
public static DataSourceProxyBeanPostProcessor dataSourceProxyBeanPostProcessor(
MaskingSqlLoggingListener listener) {
return new DataSourceProxyBeanPostProcessor(listener);
}

/** ORM summary / N+1 detection; rides on the SQL proxy, gated by {@code sql.enabled}. */
@Bean
@ConditionalOnProperty(
prefix = "openaev.debug.sql",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
public OrmInsightFilter ormInsightFilter(
SensitiveDataMasker masker, DebugRuntimeState runtimeState) {
return new OrmInsightFilter(masker, runtimeState);
}

@Bean
public JfrRecordingManager jfrRecordingManager(DebugProperties properties) {
return new JfrRecordingManager(properties.getOutputDir(), properties.getJfr());
}

/** Routes the verbose SQL log to a rotated file (off the console). Gated like SQL logging. */
@Bean
@ConditionalOnProperty(
prefix = "openaev.debug.sql",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
public DebugSqlLogFileConfigurer debugSqlLogFileConfigurer(DebugProperties properties) {
return new DebugSqlLogFileConfigurer(properties.getOutputDir());
}

@Bean
public DebugTenantSource debugTenantSource() {
return new DebugTenantSource();
}

@Bean
public DebugTenantMdcInterceptor debugTenantMdcInterceptor(DebugTenantSource tenantSource) {
return new DebugTenantMdcInterceptor(tenantSource);
}

@Bean
public DebugWebMvcConfigurer debugWebMvcConfigurer(
DebugTenantMdcInterceptor tenantMdcInterceptor) {
return new DebugWebMvcConfigurer(tenantMdcInterceptor);
}

@Bean
public DebugModeManager debugModeManager(
DebugProperties properties,
JfrRecordingManager jfrRecordingManager,
DebugRuntimeState runtimeState,
@Value("${pyroscope.agent.enabled:false}") boolean pyroscopeEnabled) {
return new DebugModeManager(properties, jfrRecordingManager, runtimeState, pyroscopeEnabled);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package io.openaev.debug;

import java.util.Arrays;
import java.util.Set;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.env.Environment;
import org.springframework.core.type.AnnotatedTypeMetadata;

/**
* Production barrier: debug mode runs only when {@code enabled=true} AND (a non-production profile
* is active OR {@code allow-in-production=true}). Production = no {@code dev}/{@code test}/{@code
* ci} profile (there is no explicit {@code production} profile).
*/
public class DebugEnabledCondition implements Condition {

static final Set<String> NON_PRODUCTION_PROFILES = Set.of("dev", "test", "ci");

@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return isDebugActive(context.getEnvironment());
}

/**
* The production barrier, reusable outside a {@link Condition} (e.g. an environment processor).
*/
static boolean isDebugActive(Environment env) {
if (!env.getProperty("openaev.debug.enabled", Boolean.class, false)) {
return false;
}
if (env.getProperty("openaev.debug.allow-in-production", Boolean.class, false)) {
return true;
}
return !isProduction(env);
}

static boolean isProduction(Environment env) {
return Arrays.stream(env.getActiveProfiles()).noneMatch(NON_PRODUCTION_PROFILES::contains);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package io.openaev.debug;

import java.util.Map;
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;

/**
* Removes the log correlation slot ({@code [traceId spanId]}) when debug mode is off. Spring Boot
* adds a default {@code logging.pattern.correlation} as soon as {@code
* io.micrometer.tracing.Tracer} is on the classpath, so without this the logs carry an empty {@code
* [ ]} slot even when nothing is traced. When debug mode is active the pattern is left to Spring
* Boot, so the traceId shows in the logs (the point of debug mode).
*
* <p>It runs on {@link ApplicationEnvironmentPreparedEvent}, before the logging system reads the
* pattern, and ordered ahead of Spring Boot's {@code LoggingApplicationListener}. It is registered
* on the {@code SpringApplicationBuilder} in {@code App} (an environment post-processor would not
* do, as its {@code .imports} registration is not honoured and {@code spring.factories} is
* generated here).
*/
public class DebugLogCorrelationListener
implements ApplicationListener<ApplicationEnvironmentPreparedEvent>, Ordered {

@Override
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
suppressCorrelationWhenDebugOff(event.getEnvironment());
}

void suppressCorrelationWhenDebugOff(ConfigurableEnvironment env) {
if (DebugEnabledCondition.isDebugActive(env)) {
return; // debug active: keep Spring Boot's correlation pattern so the traceId is logged
}
env.getPropertySources()
.addFirst(
new MapPropertySource(
"openaev-debug-log-correlation", Map.of("logging.pattern.correlation", "")));
}

@Override
public int getOrder() {
// Before LoggingApplicationListener (HIGHEST_PRECEDENCE + 20) so the pattern is set in time.
return Ordered.HIGHEST_PRECEDENCE + 11;
}
}
Loading
Loading