Skip to content

Commit 940aff9

Browse files
authored
feat(debug): global debug mode (correlation ids, SQL detail, JFR profiling) (#6378) (#6380)
1 parent 67f4102 commit 940aff9

50 files changed

Lines changed: 4395 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/docs/administration/debug-mode.md

Lines changed: 294 additions & 0 deletions
Large diffs are not rendered by default.

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ nav:
207207
- Introduction: administration/introduction.md
208208
- Enterprise edition: administration/enterprise.md
209209
- Multi-tenancy: administration/multi-tenancy.md
210+
- Debug mode: administration/debug-mode.md
210211
- Platform settings:
211212
- Parameters: administration/parameters.md
212213
- Security:

openaev-api/pom.xml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
<opentelemetry.version>1.63.0</opentelemetry.version>
2525
<elasticsearch.version>8.19.17</elasticsearch.version>
2626
<opentelemetry-semconv.version>1.42.0</opentelemetry-semconv.version>
27+
<!-- Not managed by the Spring Boot BOM; pinned here. Apache-2.0. -->
28+
<datasource-proxy.version>1.10</datasource-proxy.version>
2729
</properties>
2830

2931
<profiles>
@@ -292,6 +294,19 @@
292294
<groupId>io.micrometer</groupId>
293295
<artifactId>micrometer-registry-prometheus</artifactId>
294296
</dependency>
297+
<!-- Debug mode: correlation ids in the MDC via Micrometer Tracing (Brave bridge, from the
298+
Spring Boot BOM). Aligning on the OpenTelemetry bridge is deferred (ADR 0002): the
299+
metrics-only OpenTelemetry bean blocks it, a metrics-owner change. Apache-2.0. -->
300+
<dependency>
301+
<groupId>io.micrometer</groupId>
302+
<artifactId>micrometer-tracing-bridge-brave</artifactId>
303+
</dependency>
304+
<!-- Debug mode: SQL statement logging with timing and masked parameters. Apache-2.0. -->
305+
<dependency>
306+
<groupId>net.ttddyy</groupId>
307+
<artifactId>datasource-proxy</artifactId>
308+
<version>${datasource-proxy.version}</version>
309+
</dependency>
295310
<!-- Must match the version Spring Data JPA uses (declared provided/optional there),
296311
otherwise its native-query handling breaks at startup. -->
297312
<dependency>

openaev-api/src/main/java/io/openaev/App.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import io.openaev.config.OpenAEVConfig;
77
import io.openaev.database.model.Setting;
88
import io.openaev.database.repository.SettingRepository;
9+
import io.openaev.debug.DebugLogCorrelationListener;
10+
import io.openaev.debug.DebugTracingContextInitializer;
911
import io.openaev.tools.FlywayMigrationValidator;
1012
import jakarta.annotation.PostConstruct;
1113
import java.sql.Timestamp;
@@ -15,8 +17,8 @@
1517
import lombok.RequiredArgsConstructor;
1618
import lombok.extern.slf4j.Slf4j;
1719
import org.apache.logging.log4j.util.Strings;
18-
import org.springframework.boot.SpringApplication;
1920
import org.springframework.boot.autoconfigure.SpringBootApplication;
21+
import org.springframework.boot.builder.SpringApplicationBuilder;
2022

2123
@SpringBootApplication
2224
@Slf4j
@@ -28,7 +30,19 @@ public class App {
2830

2931
public static void main(String[] args) {
3032
FlywayMigrationValidator.validateFlywayMigrationNames();
31-
SpringApplication.run(App.class, args);
33+
application().run(args);
34+
}
35+
36+
/**
37+
* Builds the production application. {@link DebugTracingContextInitializer} excludes the tracing
38+
* auto-configuration unless debug mode is active; it is registered here for production and via
39+
* src/test/resources spring.factories for tests (an EnvironmentPostProcessor /
40+
* context.initializer.classes does not run under {@code @SpringBootTest}).
41+
*/
42+
static SpringApplicationBuilder application() {
43+
return new SpringApplicationBuilder(App.class)
44+
.initializers(new DebugTracingContextInitializer())
45+
.listeners(new DebugLogCorrelationListener());
3246
}
3347

3448
@PostConstruct
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.openaev.debug;
2+
3+
import javax.sql.DataSource;
4+
import net.ttddyy.dsproxy.support.ProxyDataSource;
5+
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
import org.springframework.beans.factory.config.BeanPostProcessor;
9+
10+
/**
11+
* Wraps the auto-configured {@link DataSource} in a datasource-proxy for SQL logging. Created only
12+
* when debug mode is on; when off there is no proxy on the query path at all.
13+
*/
14+
public class DataSourceProxyBeanPostProcessor implements BeanPostProcessor {
15+
16+
private static final Logger log = LoggerFactory.getLogger(DataSourceProxyBeanPostProcessor.class);
17+
18+
private final MaskingSqlLoggingListener listener;
19+
20+
public DataSourceProxyBeanPostProcessor(MaskingSqlLoggingListener listener) {
21+
this.listener = listener;
22+
}
23+
24+
@Override
25+
public Object postProcessAfterInitialization(Object bean, String beanName) {
26+
if (bean instanceof DataSource dataSource && !(bean instanceof ProxyDataSource)) {
27+
log.warn("Debug mode: wrapping datasource bean '{}' with SQL statement logging", beanName);
28+
return ProxyDataSourceBuilder.create(dataSource)
29+
.name("openaev-debug")
30+
.listener(listener)
31+
.build();
32+
}
33+
return bean;
34+
}
35+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package io.openaev.debug;
2+
3+
import jakarta.annotation.PostConstruct;
4+
import org.slf4j.Logger;
5+
import org.slf4j.LoggerFactory;
6+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
7+
import org.springframework.core.env.Environment;
8+
import org.springframework.stereotype.Component;
9+
10+
/**
11+
* Logs the production-barrier outcome. Exists whenever {@code enabled=true} (so a refusal is
12+
* visible), independently of {@link DebugEnabledCondition} which gates the actual debug beans.
13+
*/
14+
@Component
15+
@ConditionalOnProperty(prefix = "openaev.debug", name = "enabled", havingValue = "true")
16+
public class DebugActivationGuard {
17+
18+
private static final Logger log = LoggerFactory.getLogger(DebugActivationGuard.class);
19+
20+
private final Environment environment;
21+
22+
public DebugActivationGuard(Environment environment) {
23+
this.environment = environment;
24+
}
25+
26+
@PostConstruct
27+
public void check() {
28+
boolean production = DebugEnabledCondition.isProduction(environment);
29+
boolean override =
30+
environment.getProperty("openaev.debug.allow-in-production", Boolean.class, false);
31+
32+
if (production && !override) {
33+
log.error(
34+
"Debug mode was requested (openaev.debug.enabled=true) but REFUSED: production environment "
35+
+ "detected (no dev/test/ci profile active). Set openaev.debug.allow-in-production=true "
36+
+ "to override deliberately.");
37+
} else if (production) {
38+
log.warn(
39+
"Debug mode is ACTIVE IN PRODUCTION via openaev.debug.allow-in-production=true. This is a "
40+
+ "deliberate override; do not leave it on.");
41+
}
42+
}
43+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package io.openaev.debug;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.beans.factory.config.BeanDefinition;
5+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Conditional;
8+
import org.springframework.context.annotation.Configuration;
9+
import org.springframework.context.annotation.Role;
10+
11+
/**
12+
* Wires the debug-mode beans, gated by {@link DebugEnabledCondition}. When off (or refused in
13+
* production) the whole configuration backs off: no proxy, no extra per-request cost.
14+
*/
15+
@Configuration(proxyBeanMethods = false)
16+
@Conditional(DebugEnabledCondition.class)
17+
public class DebugConfiguration {
18+
19+
@Bean
20+
public DebugRuntimeState debugRuntimeState() {
21+
return new DebugRuntimeState();
22+
}
23+
24+
@Bean
25+
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
26+
public SensitiveDataMasker sensitiveDataMasker(DebugProperties properties) {
27+
return new SensitiveDataMasker(properties.getMasking());
28+
}
29+
30+
@Bean
31+
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
32+
@ConditionalOnProperty(
33+
prefix = "openaev.debug.sql",
34+
name = "enabled",
35+
havingValue = "true",
36+
matchIfMissing = true)
37+
public MaskingSqlLoggingListener maskingSqlLoggingListener(
38+
SensitiveDataMasker masker, DebugRuntimeState runtimeState, DebugProperties properties) {
39+
return new MaskingSqlLoggingListener(masker, runtimeState, properties.getSql());
40+
}
41+
42+
/** {@code static} so the post-processor is created early enough to wrap the datasource. */
43+
@Bean
44+
@ConditionalOnProperty(
45+
prefix = "openaev.debug.sql",
46+
name = "enabled",
47+
havingValue = "true",
48+
matchIfMissing = true)
49+
public static DataSourceProxyBeanPostProcessor dataSourceProxyBeanPostProcessor(
50+
MaskingSqlLoggingListener listener) {
51+
return new DataSourceProxyBeanPostProcessor(listener);
52+
}
53+
54+
/** ORM summary / N+1 detection; rides on the SQL proxy, gated by {@code sql.enabled}. */
55+
@Bean
56+
@ConditionalOnProperty(
57+
prefix = "openaev.debug.sql",
58+
name = "enabled",
59+
havingValue = "true",
60+
matchIfMissing = true)
61+
public OrmInsightFilter ormInsightFilter(
62+
SensitiveDataMasker masker, DebugRuntimeState runtimeState) {
63+
return new OrmInsightFilter(masker, runtimeState);
64+
}
65+
66+
@Bean
67+
public JfrRecordingManager jfrRecordingManager(DebugProperties properties) {
68+
return new JfrRecordingManager(properties.getOutputDir(), properties.getJfr());
69+
}
70+
71+
/** Routes the verbose SQL log to a rotated file (off the console). Gated like SQL logging. */
72+
@Bean
73+
@ConditionalOnProperty(
74+
prefix = "openaev.debug.sql",
75+
name = "enabled",
76+
havingValue = "true",
77+
matchIfMissing = true)
78+
public DebugSqlLogFileConfigurer debugSqlLogFileConfigurer(DebugProperties properties) {
79+
return new DebugSqlLogFileConfigurer(properties.getOutputDir());
80+
}
81+
82+
@Bean
83+
public DebugTenantSource debugTenantSource() {
84+
return new DebugTenantSource();
85+
}
86+
87+
@Bean
88+
public DebugTenantMdcInterceptor debugTenantMdcInterceptor(DebugTenantSource tenantSource) {
89+
return new DebugTenantMdcInterceptor(tenantSource);
90+
}
91+
92+
@Bean
93+
public DebugWebMvcConfigurer debugWebMvcConfigurer(
94+
DebugTenantMdcInterceptor tenantMdcInterceptor) {
95+
return new DebugWebMvcConfigurer(tenantMdcInterceptor);
96+
}
97+
98+
@Bean
99+
public DebugModeManager debugModeManager(
100+
DebugProperties properties,
101+
JfrRecordingManager jfrRecordingManager,
102+
DebugRuntimeState runtimeState,
103+
@Value("${pyroscope.agent.enabled:false}") boolean pyroscopeEnabled) {
104+
return new DebugModeManager(properties, jfrRecordingManager, runtimeState, pyroscopeEnabled);
105+
}
106+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package io.openaev.debug;
2+
3+
import java.util.Arrays;
4+
import java.util.Set;
5+
import org.springframework.context.annotation.Condition;
6+
import org.springframework.context.annotation.ConditionContext;
7+
import org.springframework.core.env.Environment;
8+
import org.springframework.core.type.AnnotatedTypeMetadata;
9+
10+
/**
11+
* Production barrier: debug mode runs only when {@code enabled=true} AND (a non-production profile
12+
* is active OR {@code allow-in-production=true}). Production = no {@code dev}/{@code test}/{@code
13+
* ci} profile (there is no explicit {@code production} profile).
14+
*/
15+
public class DebugEnabledCondition implements Condition {
16+
17+
static final Set<String> NON_PRODUCTION_PROFILES = Set.of("dev", "test", "ci");
18+
19+
@Override
20+
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
21+
return isDebugActive(context.getEnvironment());
22+
}
23+
24+
/**
25+
* The production barrier, reusable outside a {@link Condition} (e.g. an environment processor).
26+
*/
27+
static boolean isDebugActive(Environment env) {
28+
if (!env.getProperty("openaev.debug.enabled", Boolean.class, false)) {
29+
return false;
30+
}
31+
if (env.getProperty("openaev.debug.allow-in-production", Boolean.class, false)) {
32+
return true;
33+
}
34+
return !isProduction(env);
35+
}
36+
37+
static boolean isProduction(Environment env) {
38+
return Arrays.stream(env.getActiveProfiles()).noneMatch(NON_PRODUCTION_PROFILES::contains);
39+
}
40+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package io.openaev.debug;
2+
3+
import java.util.Map;
4+
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
5+
import org.springframework.context.ApplicationListener;
6+
import org.springframework.core.Ordered;
7+
import org.springframework.core.env.ConfigurableEnvironment;
8+
import org.springframework.core.env.MapPropertySource;
9+
10+
/**
11+
* Removes the log correlation slot ({@code [traceId spanId]}) when debug mode is off. Spring Boot
12+
* adds a default {@code logging.pattern.correlation} as soon as {@code
13+
* io.micrometer.tracing.Tracer} is on the classpath, so without this the logs carry an empty {@code
14+
* [ ]} slot even when nothing is traced. When debug mode is active the pattern is left to Spring
15+
* Boot, so the traceId shows in the logs (the point of debug mode).
16+
*
17+
* <p>It runs on {@link ApplicationEnvironmentPreparedEvent}, before the logging system reads the
18+
* pattern, and ordered ahead of Spring Boot's {@code LoggingApplicationListener}. It is registered
19+
* on the {@code SpringApplicationBuilder} in {@code App} (an environment post-processor would not
20+
* do, as its {@code .imports} registration is not honoured and {@code spring.factories} is
21+
* generated here).
22+
*/
23+
public class DebugLogCorrelationListener
24+
implements ApplicationListener<ApplicationEnvironmentPreparedEvent>, Ordered {
25+
26+
@Override
27+
public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
28+
suppressCorrelationWhenDebugOff(event.getEnvironment());
29+
}
30+
31+
void suppressCorrelationWhenDebugOff(ConfigurableEnvironment env) {
32+
if (DebugEnabledCondition.isDebugActive(env)) {
33+
return; // debug active: keep Spring Boot's correlation pattern so the traceId is logged
34+
}
35+
env.getPropertySources()
36+
.addFirst(
37+
new MapPropertySource(
38+
"openaev-debug-log-correlation", Map.of("logging.pattern.correlation", "")));
39+
}
40+
41+
@Override
42+
public int getOrder() {
43+
// Before LoggingApplicationListener (HIGHEST_PRECEDENCE + 20) so the pattern is set in time.
44+
return Ordered.HIGHEST_PRECEDENCE + 11;
45+
}
46+
}

0 commit comments

Comments
 (0)