feat(observability): add selectable tracing modes

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 11:06:13 +08:00
parent 893c0d49ca
commit f805076c5c
14 changed files with 426 additions and 5 deletions

View file

@ -26,6 +26,19 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
<dependency>
<groupId>org.apache.skywalking</groupId>
<artifactId>apm-toolkit-logback-1.x</artifactId>
<version>9.6.0</version>
</dependency>
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>

View file

@ -3,8 +3,10 @@ package com.iflytek.skillhub.observability.logging;
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.fasterxml.jackson.core.JsonGenerator;
import net.logstash.logback.composite.AbstractJsonProvider;
import org.apache.skywalking.apm.toolkit.log.logback.v1.x.mdc.LogbackMDCPatternConverter;
import java.io.IOException;
import java.util.List;
import java.util.Map;
/**
@ -17,22 +19,51 @@ final class CorrelationJsonProvider extends AbstractJsonProvider<ILoggingEvent>
private static final String SPAN_ID_KEY = "spanId";
private static final String EXTERNAL_TRACE_ID_KEY = "tid";
private final LogbackMDCPatternConverter externalTraceIdConverter;
CorrelationJsonProvider(boolean externalTraceIdEnabled) {
if (externalTraceIdEnabled) {
externalTraceIdConverter = new LogbackMDCPatternConverter();
externalTraceIdConverter.setOptionList(List.of(EXTERNAL_TRACE_ID_KEY));
externalTraceIdConverter.start();
} else {
externalTraceIdConverter = null;
}
}
@Override
public void writeTo(JsonGenerator generator, ILoggingEvent event) throws IOException {
Map<String, String> mdc = event.getMDCPropertyMap();
if (mdc == null || mdc.isEmpty()) {
return;
}
mdc = mdc == null ? Map.of() : mdc;
writeIfPresent(generator, "request.id", mdc.get(REQUEST_ID_KEY));
writeIfPresent(
generator,
"trace.id",
firstPresent(mdc.get(TRACE_ID_KEY), mdc.get(EXTERNAL_TRACE_ID_KEY))
firstPresent(mdc.get(TRACE_ID_KEY), externalTraceId(event, mdc))
);
writeIfPresent(generator, "span.id", mdc.get(SPAN_ID_KEY));
}
private String externalTraceId(ILoggingEvent event, Map<String, String> mdc) {
String traceId = mdc.get(EXTERNAL_TRACE_ID_KEY);
if (!isPresent(traceId) && externalTraceIdConverter != null) {
traceId = externalTraceIdConverter.convert(event);
}
return normalizeExternalTraceId(traceId);
}
private String normalizeExternalTraceId(String traceId) {
if (!isPresent(traceId)) {
return null;
}
String normalized = traceId.trim();
if (normalized.regionMatches(true, 0, "TID:", 0, 4)) {
normalized = normalized.substring(4).trim();
}
return "N/A".equalsIgnoreCase(normalized) ? null : normalized;
}
private String firstPresent(String preferred, String fallback) {
return isPresent(preferred) ? preferred : fallback;
}

View file

@ -25,6 +25,7 @@ public class SkillHubEcsEncoder extends LoggingEventCompositeJsonEncoder {
private String serviceName = "skillhub";
private String serviceVersion = "unknown";
private String serviceEnvironment = "local";
private boolean externalTraceIdEnabled;
@Override
public void start() {
@ -48,6 +49,10 @@ public class SkillHubEcsEncoder extends LoggingEventCompositeJsonEncoder {
this.serviceEnvironment = serviceEnvironment;
}
public void setTracingMode(String tracingMode) {
this.externalTraceIdEnabled = "external-agent".equalsIgnoreCase(tracingMode);
}
private LoggingEventJsonProviders createProviders() {
LoggingEventJsonProviders providers = new LoggingEventJsonProviders();
@ -74,7 +79,7 @@ public class SkillHubEcsEncoder extends LoggingEventCompositeJsonEncoder {
providers.addThreadName(thread);
providers.addGlobalCustomFields(serviceFields());
providers.addProvider(new CorrelationJsonProvider());
providers.addProvider(new CorrelationJsonProvider(externalTraceIdEnabled));
ThrowableClassNameJsonProvider errorType = new ThrowableClassNameJsonProvider();
errorType.setFieldName("error.type");

View file

@ -0,0 +1,20 @@
package com.iflytek.skillhub.observability.tracing;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Startup-time observability choices owned by SkillHub.
*/
@ConfigurationProperties(prefix = "skillhub.observability")
public class SkillHubObservabilityProperties {
private TracingMode tracingMode = TracingMode.NONE;
public TracingMode getTracingMode() {
return tracingMode;
}
public void setTracingMode(TracingMode tracingMode) {
this.tracingMode = tracingMode;
}
}

View file

@ -0,0 +1,54 @@
package com.iflytek.skillhub.observability.tracing;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
/**
* Validates tracing mode combinations that SkillHub can determine at startup.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(SkillHubObservabilityProperties.class)
public class SkillHubTracingConfiguration {
private static final Logger log = LoggerFactory.getLogger(SkillHubTracingConfiguration.class);
@Bean
TracingModeGuard tracingModeGuard(
SkillHubObservabilityProperties properties,
Environment environment
) {
TracingMode mode = properties.getTracingMode();
String otlpEndpoint = environment.getProperty("management.otlp.tracing.endpoint");
if (mode != TracingMode.OTEL_SDK && StringUtils.hasText(otlpEndpoint)) {
throw new IllegalStateException(
"management.otlp.tracing.endpoint requires "
+ "skillhub.observability.tracing-mode=otel-sdk"
);
}
if (mode == TracingMode.OTEL_SDK
&& Boolean.FALSE.equals(environment.getProperty(
"management.tracing.enabled",
Boolean.class
))) {
throw new IllegalStateException(
"management.tracing.enabled=false conflicts with "
+ "skillhub.observability.tracing-mode=otel-sdk"
);
}
if (mode == TracingMode.EXTERNAL_AGENT) {
log.warn(
"External tracing agent mode selected. SkillHub cannot verify the agent "
+ "identity; deployment must provide exactly one tracing agent"
);
}
return new TracingModeGuard(mode);
}
record TracingModeGuard(TracingMode mode) {
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.observability.tracing;
/**
* Selects the single tracing implementation that may be active in the application process.
*/
public enum TracingMode {
NONE,
OTEL_SDK,
EXTERNAL_AGENT
}

View file

@ -0,0 +1,51 @@
package com.iflytek.skillhub.observability.tracing;
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import java.util.Set;
/**
* Keeps the OpenTelemetry SDK outside the application context unless the deployment explicitly
* selects {@code otel-sdk}. The normal Spring Boot NOOP tracer remains available in the other
* modes.
*/
public final class TracingModeAutoConfigurationImportFilter
implements AutoConfigurationImportFilter, EnvironmentAware {
static final String TRACING_MODE_PROPERTY = "skillhub.observability.tracing-mode";
private static final Set<String> OTEL_AUTO_CONFIGURATIONS = Set.of(
"org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration",
"org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration",
"org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration"
);
private Environment environment;
@Override
public boolean[] match(
String[] autoConfigurationClasses,
AutoConfigurationMetadata autoConfigurationMetadata
) {
boolean otelSdkEnabled = environment != null
&& "otel-sdk".equalsIgnoreCase(
environment.getProperty(TRACING_MODE_PROPERTY, "none")
);
boolean[] matches = new boolean[autoConfigurationClasses.length];
for (int index = 0; index < autoConfigurationClasses.length; index++) {
String autoConfigurationClass = autoConfigurationClasses[index];
matches[index] = autoConfigurationClass != null
&& (otelSdkEnabled
|| !OTEL_AUTO_CONFIGURATIONS.contains(autoConfigurationClass));
}
return matches;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
}

View file

@ -0,0 +1,4 @@
/**
* Startup tracing mode selection and auto-configuration boundaries.
*/
package com.iflytek.skillhub.observability.tracing;

View file

@ -0,0 +1,2 @@
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
com.iflytek.skillhub.observability.tracing.TracingModeAutoConfigurationImportFilter

View file

@ -95,6 +95,7 @@ spring:
skillhub:
observability:
tracing-mode: ${SKILLHUB_TRACING_MODE:none}
log-format: ${SKILLHUB_LOG_FORMAT:text}
log-async-queue-size: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:1024}
service-version: ${SKILLHUB_SERVICE_VERSION:unknown}
@ -214,6 +215,17 @@ skillhub:
email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local}
management:
tracing:
sampling:
probability: ${SKILLHUB_TRACING_SAMPLING_PROBABILITY:0.1}
baggage:
enabled: false
propagation:
type: W3C
otlp:
tracing:
timeout: ${SKILLHUB_OTLP_TIMEOUT:5s}
compression: ${SKILLHUB_OTLP_COMPRESSION:gzip}
health:
mail:
enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false}

View file

@ -5,6 +5,9 @@
<springProperty name="activeAppender"
source="skillhub.observability.log-format"
defaultValue="text"/>
<springProperty name="tracingMode"
source="skillhub.observability.tracing-mode"
defaultValue="none"/>
<springProperty name="asyncQueueSize"
source="skillhub.observability.log-async-queue-size"
defaultValue="1024"/>
@ -30,6 +33,7 @@
<serviceName>${serviceName}</serviceName>
<serviceVersion>${serviceVersion}</serviceVersion>
<serviceEnvironment>${serviceEnvironment}</serviceEnvironment>
<tracingMode>${tracingMode}</tracingMode>
</encoder>
</appender>

View file

@ -81,6 +81,32 @@ class SkillHubEcsEncoderTest {
.hasSize(1);
}
@Test
void shouldNormalizeExternalAgentTraceId() throws Exception {
encoder.stop();
encoder.setTracingMode("external-agent");
encoder.start();
LoggingEvent event = event("external trace");
event.setMDCPropertyMap(Map.of("tid", "TID: external-agent-trace"));
JsonNode json = encode(event);
assertThat(json.path("trace.id").asText()).isEqualTo("external-agent-trace");
}
@Test
void shouldNotWriteToolkitSentinelAsTraceId() throws Exception {
encoder.stop();
encoder.setTracingMode("external-agent");
encoder.start();
LoggingEvent event = event("no external agent");
event.setMDCPropertyMap(Map.of("tid", "TID: N/A"));
JsonNode json = encode(event);
assertThat(json.has("trace.id")).isFalse();
}
@Test
void shouldWriteStructuredExceptionFields() throws Exception {
LoggingEvent event = event("failed");

View file

@ -0,0 +1,124 @@
package com.iflytek.skillhub.observability.tracing;
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.otel.bridge.OtelTracer;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
class SkillHubTracingConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues(
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none"
);
@Test
void noneModeShouldUseNoopTracerAndNoOtelSdk() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=none")
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(Tracer.class)).isSameAs(Tracer.NOOP);
assertThat(context).doesNotHaveBean(OpenTelemetry.class);
assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class);
});
}
@Test
void externalAgentModeShouldUseNoopTracerAndNoOtelSdk() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=external-agent")
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(Tracer.class)).isSameAs(Tracer.NOOP);
assertThat(context).doesNotHaveBean(OpenTelemetry.class);
assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class);
});
}
@Test
void otelSdkModeWithoutEndpointShouldCreateInProcessTracerOnly() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0",
"management.tracing.baggage.enabled=false",
"management.tracing.propagation.type=W3C"
)
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(Tracer.class)).isInstanceOf(OtelTracer.class);
assertThat(context).hasSingleBean(OpenTelemetry.class);
assertThat(context).doesNotHaveBean(OtlpHttpSpanExporter.class);
});
}
@Test
void otelSdkModeShouldCreateExporterOnlyWhenEndpointIsConfigured() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.otlp.tracing.endpoint=http://127.0.0.1:4318/v1/traces"
)
.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(OtlpHttpSpanExporter.class);
});
}
@Test
void nonOtelModeShouldRejectConfiguredOtlpEndpoint() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=none",
"management.otlp.tracing.endpoint=http://127.0.0.1:4318/v1/traces"
)
.run(context -> assertThat(context).hasFailed());
}
@Test
void otelSdkModeShouldRejectDisabledTracing() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.enabled=false"
)
.run(context -> assertThat(context).hasFailed());
}
@Test
void otelSdkScopeShouldPublishTraceCorrelationToMdc() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0"
)
.run(context -> {
Tracer tracer = context.getBean(Tracer.class);
io.micrometer.tracing.Span span = tracer.nextSpan().name("test-span").start();
try (Tracer.SpanInScope ignored = tracer.withSpan(span)) {
assertThat(MDC.get("traceId")).hasSize(32);
assertThat(MDC.get("spanId")).hasSize(16);
} finally {
span.end();
MDC.clear();
}
});
}
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import(SkillHubTracingConfiguration.class)
static class TestApplication {
}
}

View file

@ -0,0 +1,65 @@
package com.iflytek.skillhub.observability.tracing;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurationMetadata;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
class TracingModeAutoConfigurationImportFilterTest {
private static final String CORE_OTEL_AUTO_CONFIGURATION =
"org.springframework.boot.actuate.autoconfigure.opentelemetry.OpenTelemetryAutoConfiguration";
private static final String TRACING_OTEL_AUTO_CONFIGURATION =
"org.springframework.boot.actuate.autoconfigure.tracing.OpenTelemetryAutoConfiguration";
private static final String OTLP_AUTO_CONFIGURATION =
"org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration";
private static final String NOOP_AUTO_CONFIGURATION =
"org.springframework.boot.actuate.autoconfigure.tracing.NoopTracerAutoConfiguration";
private final TracingModeAutoConfigurationImportFilter filter =
new TracingModeAutoConfigurationImportFilter();
@Test
void shouldExcludeApplicationOtelForDefaultNoneMode() {
filter.setEnvironment(new MockEnvironment());
assertThat(matches()).containsExactly(false, false, false, true, false);
}
@Test
void shouldExcludeApplicationOtelForExternalAgentMode() {
filter.setEnvironment(new MockEnvironment()
.withProperty(
TracingModeAutoConfigurationImportFilter.TRACING_MODE_PROPERTY,
"external-agent"
));
assertThat(matches()).containsExactly(false, false, false, true, false);
}
@Test
void shouldEnableApplicationOtelOnlyForOtelSdkMode() {
filter.setEnvironment(new MockEnvironment()
.withProperty(
TracingModeAutoConfigurationImportFilter.TRACING_MODE_PROPERTY,
"otel-sdk"
));
assertThat(matches()).containsExactly(true, true, true, true, false);
}
private boolean[] matches() {
return filter.match(
new String[]{
CORE_OTEL_AUTO_CONFIGURATION,
TRACING_OTEL_AUTO_CONFIGURATION,
OTLP_AUTO_CONFIGURATION,
NOOP_AUTO_CONFIGURATION,
null
},
mock(AutoConfigurationMetadata.class)
);
}
}