body = new ApiResponse<>(409, "error.request.duplicate", null,
- Instant.now(clock), null);
+ Instant.now(clock), requestIdAccessor.current());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));
}
+
+ private String resolveRequestId(HttpServletRequest request) {
+ String suppliedRequestId = request.getHeader(REQUEST_ID_HEADER);
+ if (suppliedRequestId == null || suppliedRequestId.isEmpty()) {
+ return null;
+ }
+ return requestIdAccessor.current();
+ }
}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java
index cc5c0932..f5b88bc3 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java
@@ -1,10 +1,10 @@
package com.iflytek.skillhub.filter;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@@ -12,6 +12,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
+import java.util.regex.Pattern;
/**
* Ensures every request has a request identifier for logs, responses, and downstream audit
@@ -22,23 +23,27 @@ import java.util.UUID;
public class RequestIdFilter extends OncePerRequestFilter {
private static final String REQUEST_ID_HEADER = "X-Request-Id";
- private static final String REQUEST_ID_MDC_KEY = "requestId";
+ private static final Pattern VALID_REQUEST_ID =
+ Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$");
+
+ private final RequestIdAccessor requestIdAccessor;
+
+ public RequestIdFilter(RequestIdAccessor requestIdAccessor) {
+ this.requestIdAccessor = requestIdAccessor;
+ }
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestId = request.getHeader(REQUEST_ID_HEADER);
- if (requestId == null || requestId.isBlank()) {
+ if (requestId == null || !VALID_REQUEST_ID.matcher(requestId).matches()) {
requestId = UUID.randomUUID().toString();
}
- MDC.put(REQUEST_ID_MDC_KEY, requestId);
response.setHeader(REQUEST_ID_HEADER, requestId);
- try {
+ try (RequestIdAccessor.Scope ignored = requestIdAccessor.open(requestId)) {
filterChain.doFilter(request, response);
- } finally {
- MDC.remove(REQUEST_ID_MDC_KEY);
}
}
}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdAccessor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdAccessor.java
new file mode 100644
index 00000000..ecdfb803
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdAccessor.java
@@ -0,0 +1,78 @@
+package com.iflytek.skillhub.observability;
+
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * Holds the current SkillHub request identifier independently from the logging implementation.
+ *
+ * The thread-local value is authoritative. MDC is maintained only as a mirror for log
+ * correlation.
+ */
+@Component
+public class RequestIdAccessor {
+
+ public static final String MDC_KEY = "requestId";
+
+ private final ThreadLocal currentRequestId = new ThreadLocal<>();
+
+ /**
+ * Returns the current request identifier, or {@code null} outside a request/task scope.
+ */
+ public String current() {
+ return currentRequestId.get();
+ }
+
+ /**
+ * Opens a nested request identifier scope on the current thread.
+ */
+ public Scope open(String requestId) {
+ Objects.requireNonNull(requestId, "requestId must not be null");
+ if (requestId.isBlank()) {
+ throw new IllegalArgumentException("requestId must not be blank");
+ }
+
+ String previousRequestId = currentRequestId.get();
+ replace(requestId);
+ return new Scope(previousRequestId, requestId);
+ }
+
+ void replace(String requestId) {
+ if (requestId == null) {
+ currentRequestId.remove();
+ MDC.remove(MDC_KEY);
+ return;
+ }
+ currentRequestId.set(requestId);
+ MDC.put(MDC_KEY, requestId);
+ }
+
+ /**
+ * A same-thread, LIFO scope for the request identifier.
+ */
+ public final class Scope implements AutoCloseable {
+
+ private final String previousRequestId;
+ private final String installedRequestId;
+ private boolean closed;
+
+ private Scope(String previousRequestId, String installedRequestId) {
+ this.previousRequestId = previousRequestId;
+ this.installedRequestId = installedRequestId;
+ }
+
+ @Override
+ public void close() {
+ if (closed) {
+ return;
+ }
+ if (!Objects.equals(currentRequestId.get(), installedRequestId)) {
+ throw new IllegalStateException("Request ID scopes must close on the owning thread in LIFO order");
+ }
+ replace(previousRequestId);
+ closed = true;
+ }
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdThreadLocalAccessor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdThreadLocalAccessor.java
new file mode 100644
index 00000000..9d1deffb
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdThreadLocalAccessor.java
@@ -0,0 +1,37 @@
+package com.iflytek.skillhub.observability;
+
+import io.micrometer.context.ThreadLocalAccessor;
+
+/**
+ * Captures and restores the authoritative Request ID scope for asynchronous execution.
+ */
+public final class RequestIdThreadLocalAccessor implements ThreadLocalAccessor {
+
+ public static final String KEY = "skillhub.request-id";
+
+ private final RequestIdAccessor requestIdAccessor;
+
+ public RequestIdThreadLocalAccessor(RequestIdAccessor requestIdAccessor) {
+ this.requestIdAccessor = requestIdAccessor;
+ }
+
+ @Override
+ public Object key() {
+ return KEY;
+ }
+
+ @Override
+ public String getValue() {
+ return requestIdAccessor.current();
+ }
+
+ @Override
+ public void setValue(String value) {
+ requestIdAccessor.replace(value);
+ }
+
+ @Override
+ public void setValue() {
+ requestIdAccessor.replace(null);
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/SkillHubContextPropagationConfiguration.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/SkillHubContextPropagationConfiguration.java
new file mode 100644
index 00000000..e0eaa710
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/SkillHubContextPropagationConfiguration.java
@@ -0,0 +1,51 @@
+package com.iflytek.skillhub.observability;
+
+import com.iflytek.skillhub.observability.tracing.SkillHubObservabilityProperties;
+import com.iflytek.skillhub.observability.tracing.TracingMode;
+import io.micrometer.context.ContextRegistry;
+import io.micrometer.context.ContextSnapshotFactory;
+import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.tracing.Tracer;
+import io.micrometer.tracing.contextpropagation.ObservationAwareSpanThreadLocalAccessor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
+
+/**
+ * Defines the context captured by SkillHub-managed asynchronous executors.
+ */
+@Configuration(proxyBeanMethods = false)
+public class SkillHubContextPropagationConfiguration {
+
+ @Bean
+ ContextRegistry skillHubContextRegistry(
+ RequestIdAccessor requestIdAccessor,
+ SkillHubObservabilityProperties observabilityProperties,
+ ObservationRegistry observationRegistry,
+ Tracer tracer
+ ) {
+ ContextRegistry registry = new ContextRegistry()
+ .loadContextAccessors()
+ .loadThreadLocalAccessors();
+ registry.registerThreadLocalAccessor(
+ new RequestIdThreadLocalAccessor(requestIdAccessor)
+ );
+ if (observabilityProperties.getTracingMode() == TracingMode.OTEL_SDK) {
+ registry.registerThreadLocalAccessor(
+ new ObservationAwareSpanThreadLocalAccessor(observationRegistry, tracer)
+ );
+ }
+ return registry;
+ }
+
+ @Bean
+ ContextPropagatingTaskDecorator skillHubContextPropagatingTaskDecorator(
+ ContextRegistry skillHubContextRegistry
+ ) {
+ ContextSnapshotFactory snapshotFactory = ContextSnapshotFactory.builder()
+ .contextRegistry(skillHubContextRegistry)
+ .clearMissing(true)
+ .build();
+ return new ContextPropagatingTaskDecorator(snapshotFactory);
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java
new file mode 100644
index 00000000..82c87fd5
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java
@@ -0,0 +1,81 @@
+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;
+
+/**
+ * Writes only the approved correlation fields from MDC.
+ */
+final class CorrelationJsonProvider extends AbstractJsonProvider {
+
+ private static final String REQUEST_ID_KEY = "requestId";
+ private static final String TRACE_ID_KEY = "traceId";
+ 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 mdc = event.getMDCPropertyMap();
+ 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), externalTraceId(event, mdc))
+ );
+ writeIfPresent(generator, "span.id", mdc.get(SPAN_ID_KEY));
+ }
+
+ private String externalTraceId(ILoggingEvent event, Map 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;
+ }
+
+ private void writeIfPresent(JsonGenerator generator, String fieldName, String value)
+ throws IOException {
+ if (isPresent(value)) {
+ generator.writeStringField(fieldName, value);
+ }
+ }
+
+ private boolean isPresent(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoder.java
new file mode 100644
index 00000000..8dc728bb
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoder.java
@@ -0,0 +1,113 @@
+package com.iflytek.skillhub.observability.logging;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import net.logstash.logback.composite.GlobalCustomFieldsJsonProvider;
+import net.logstash.logback.composite.loggingevent.LogLevelJsonProvider;
+import net.logstash.logback.composite.loggingevent.LoggerNameJsonProvider;
+import net.logstash.logback.composite.loggingevent.LoggingEventFormattedTimestampJsonProvider;
+import net.logstash.logback.composite.loggingevent.LoggingEventJsonProviders;
+import net.logstash.logback.composite.loggingevent.LoggingEventThreadNameJsonProvider;
+import net.logstash.logback.composite.loggingevent.MessageJsonProvider;
+import net.logstash.logback.composite.loggingevent.StackTraceJsonProvider;
+import net.logstash.logback.composite.loggingevent.ThrowableClassNameJsonProvider;
+import net.logstash.logback.composite.loggingevent.ThrowableMessageJsonProvider;
+import net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder;
+
+/**
+ * ECS-style JSON encoder with an explicit field allowlist.
+ */
+public class SkillHubEcsEncoder extends LoggingEventCompositeJsonEncoder {
+
+ private static final String ECS_VERSION = "1.2.0";
+
+ private String serviceName = "skillhub";
+ private String serviceVersion = "unknown";
+ private String serviceEnvironment = "local";
+ private boolean externalTraceIdEnabled;
+
+ @Override
+ public void start() {
+ if (isStarted()) {
+ return;
+ }
+ setLineSeparator("UNIX");
+ setProviders(createProviders());
+ super.start();
+ }
+
+ public void setServiceName(String serviceName) {
+ this.serviceName = serviceName;
+ }
+
+ public void setServiceVersion(String serviceVersion) {
+ this.serviceVersion = serviceVersion;
+ }
+
+ public void setServiceEnvironment(String serviceEnvironment) {
+ this.serviceEnvironment = serviceEnvironment;
+ }
+
+ public void setTracingMode(String tracingMode) {
+ this.externalTraceIdEnabled = "external-agent".equalsIgnoreCase(tracingMode);
+ }
+
+ private LoggingEventJsonProviders createProviders() {
+ LoggingEventJsonProviders providers = new LoggingEventJsonProviders();
+
+ LoggingEventFormattedTimestampJsonProvider timestamp =
+ new LoggingEventFormattedTimestampJsonProvider();
+ timestamp.setFieldName("@timestamp");
+ timestamp.setTimeZone("UTC");
+ providers.addTimestamp(timestamp);
+
+ LogLevelJsonProvider level = new LogLevelJsonProvider();
+ level.setFieldName("log.level");
+ providers.addLogLevel(level);
+
+ MessageJsonProvider message = new MessageJsonProvider();
+ message.setFieldName("message");
+ providers.addMessage(message);
+
+ LoggerNameJsonProvider logger = new LoggerNameJsonProvider();
+ logger.setFieldName("log.logger");
+ providers.addLoggerName(logger);
+
+ LoggingEventThreadNameJsonProvider thread = new LoggingEventThreadNameJsonProvider();
+ thread.setFieldName("process.thread.name");
+ providers.addThreadName(thread);
+
+ providers.addGlobalCustomFields(serviceFields());
+ providers.addProvider(new CorrelationJsonProvider(externalTraceIdEnabled));
+
+ ThrowableClassNameJsonProvider errorType = new ThrowableClassNameJsonProvider();
+ errorType.setFieldName("error.type");
+ errorType.setUseSimpleClassName(false);
+ providers.addThrowableClassName(errorType);
+
+ ThrowableMessageJsonProvider errorMessage = new ThrowableMessageJsonProvider();
+ errorMessage.setFieldName("error.message");
+ providers.addThrowableMessage(errorMessage);
+
+ StackTraceJsonProvider stackTrace = new StackTraceJsonProvider();
+ stackTrace.setFieldName("error.stack_trace");
+ providers.addStackTrace(stackTrace);
+
+ return providers;
+ }
+
+ private GlobalCustomFieldsJsonProvider serviceFields() {
+ ObjectNode fields = JsonNodeFactory.instance.objectNode();
+ fields.put("ecs.version", ECS_VERSION);
+ fields.put("service.name", serviceName);
+ fields.put("service.version", serviceVersion);
+ fields.put("service.environment", serviceEnvironment);
+ fields.put("event.dataset", serviceName);
+
+ GlobalCustomFieldsJsonProvider provider =
+ new GlobalCustomFieldsJsonProvider<>();
+ provider.setCustomFieldsNode(fields);
+ return provider;
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/package-info.java
new file mode 100644
index 00000000..6877f0ca
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Structured logging adapters for SkillHub correlation fields.
+ */
+package com.iflytek.skillhub.observability.logging;
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/package-info.java
new file mode 100644
index 00000000..2c10c355
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Application-level observability context and integration boundaries.
+ */
+package com.iflytek.skillhub.observability;
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/SkillHubObservabilityProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/SkillHubObservabilityProperties.java
new file mode 100644
index 00000000..c27a3bfc
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/SkillHubObservabilityProperties.java
@@ -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;
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/SkillHubTracingConfiguration.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/SkillHubTracingConfiguration.java
new file mode 100644
index 00000000..ea3872b2
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/SkillHubTracingConfiguration.java
@@ -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) {
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/TracingMode.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/TracingMode.java
new file mode 100644
index 00000000..feaf1171
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/TracingMode.java
@@ -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
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/TracingModeAutoConfigurationImportFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/TracingModeAutoConfigurationImportFilter.java
new file mode 100644
index 00000000..55a33fda
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/TracingModeAutoConfigurationImportFilter.java
@@ -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 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;
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/package-info.java
new file mode 100644
index 00000000..a6043895
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Startup tracing mode selection and auto-configuration boundaries.
+ */
+package com.iflytek.skillhub.observability.tracing;
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java
index 0d498bcb..30f979ef 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java
@@ -5,12 +5,12 @@ import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException;
import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
@@ -26,13 +26,16 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
+ private final RequestIdAccessor requestIdAccessor;
public ApiAccessDeniedHandler(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
- SensitiveLogSanitizer sensitiveLogSanitizer) {
+ SensitiveLogSanitizer sensitiveLogSanitizer,
+ RequestIdAccessor requestIdAccessor) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
+ this.requestIdAccessor = requestIdAccessor;
}
@Override
@@ -45,7 +48,7 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
: null;
logger.info(
"Forbidden API request [requestId={}, method={}, path={}, reason={}, detail={}]",
- MDC.get("requestId"),
+ requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
accessDeniedException.getClass().getSimpleName(),
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java
index 2452db1c..0862919f 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java
@@ -4,12 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.config.IdentityLinkRouteRequestMatcher;
import com.iflytek.skillhub.auth.identity.IdentityLinkFailureCode;
import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
@@ -25,13 +25,16 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
+ private final RequestIdAccessor requestIdAccessor;
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
- SensitiveLogSanitizer sensitiveLogSanitizer) {
+ SensitiveLogSanitizer sensitiveLogSanitizer,
+ RequestIdAccessor requestIdAccessor) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
+ this.requestIdAccessor = requestIdAccessor;
}
@Override
@@ -40,7 +43,7 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
AuthenticationException authException) throws IOException {
logger.info(
"Unauthorized API request [requestId={}, method={}, path={}, reason={}]",
- MDC.get("requestId"),
+ requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
authException.getClass().getSimpleName()
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelAdminAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelAdminAppService.java
index 23bace70..88316c50 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelAdminAppService.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelAdminAppService.java
@@ -11,9 +11,9 @@ import com.iflytek.skillhub.dto.AdminLabelUpdateRequest;
import com.iflytek.skillhub.dto.LabelDefinitionResponse;
import com.iflytek.skillhub.dto.LabelSortOrderUpdateRequest;
import com.iflytek.skillhub.dto.LabelTranslationResponse;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.List;
import java.util.Set;
-import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
@@ -27,17 +27,20 @@ public class LabelAdminAppService {
private final AuditLogService auditLogService;
private final RbacService rbacService;
private final LabelSearchSyncService labelSearchSyncService;
+ private final RequestIdAccessor requestIdAccessor;
public LabelAdminAppService(LabelDefinitionService labelDefinitionService,
SkillLabelService skillLabelService,
AuditLogService auditLogService,
RbacService rbacService,
- LabelSearchSyncService labelSearchSyncService) {
+ LabelSearchSyncService labelSearchSyncService,
+ RequestIdAccessor requestIdAccessor) {
this.labelDefinitionService = labelDefinitionService;
this.skillLabelService = skillLabelService;
this.auditLogService = auditLogService;
this.rbacService = rbacService;
this.labelSearchSyncService = labelSearchSyncService;
+ this.requestIdAccessor = requestIdAccessor;
}
public List listAll() {
@@ -153,7 +156,7 @@ public class LabelAdminAppService {
action,
"LABEL",
targetId,
- MDC.get("requestId"),
+ requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java
index aab28382..1c7f6b44 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java
@@ -12,11 +12,11 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.PromotionResponseDto;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
-import org.slf4j.MDC;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
@@ -32,17 +32,20 @@ public class PromotionPortalAppService {
private final GovernanceQueryRepository governanceQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
+ private final RequestIdAccessor requestIdAccessor;
public PromotionPortalAppService(PromotionService promotionService,
PromotionRequestRepository promotionRequestRepository,
GovernanceQueryRepository governanceQueryRepository,
RbacService rbacService,
- AuditLogService auditLogService) {
+ AuditLogService auditLogService,
+ RequestIdAccessor requestIdAccessor) {
this.promotionService = promotionService;
this.promotionRequestRepository = promotionRequestRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
+ this.requestIdAccessor = requestIdAccessor;
}
public PromotionResponseDto submitPromotion(Long sourceSkillId,
@@ -235,7 +238,7 @@ public class PromotionPortalAppService {
action,
"PROMOTION_REQUEST",
targetId,
- MDC.get("requestId"),
+ requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java
index 47d8eed5..96663c9e 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java
@@ -13,11 +13,11 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.util.List;
import java.util.Map;
import java.util.Set;
-import org.slf4j.MDC;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
@@ -34,19 +34,22 @@ public class ReviewPortalAppService {
private final GovernanceQueryRepository governanceQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
+ private final RequestIdAccessor requestIdAccessor;
public ReviewPortalAppService(ReviewService reviewService,
ReviewTaskRepository reviewTaskRepository,
NamespaceRepository namespaceRepository,
GovernanceQueryRepository governanceQueryRepository,
RbacService rbacService,
- AuditLogService auditLogService) {
+ AuditLogService auditLogService,
+ RequestIdAccessor requestIdAccessor) {
this.reviewService = reviewService;
this.reviewTaskRepository = reviewTaskRepository;
this.namespaceRepository = namespaceRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
+ this.requestIdAccessor = requestIdAccessor;
}
public ReviewTaskResponse submitReview(Long skillVersionId,
@@ -256,7 +259,7 @@ public class ReviewPortalAppService {
action,
"REVIEW_TASK",
targetId,
- MDC.get("requestId"),
+ requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java
index 25b92081..5947934e 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java
@@ -18,12 +18,12 @@ import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
-import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
@@ -42,6 +42,7 @@ public class SkillLabelAppService {
private final AuditLogService auditLogService;
private final LabelSearchSyncService labelSearchSyncService;
private final SkillSlugResolutionService skillSlugResolutionService;
+ private final RequestIdAccessor requestIdAccessor;
public SkillLabelAppService(NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
@@ -52,7 +53,8 @@ public class SkillLabelAppService {
RbacService rbacService,
AuditLogService auditLogService,
LabelSearchSyncService labelSearchSyncService,
- SkillSlugResolutionService skillSlugResolutionService) {
+ SkillSlugResolutionService skillSlugResolutionService,
+ RequestIdAccessor requestIdAccessor) {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.visibilityChecker = visibilityChecker;
@@ -63,6 +65,7 @@ public class SkillLabelAppService {
this.auditLogService = auditLogService;
this.labelSearchSyncService = labelSearchSyncService;
this.skillSlugResolutionService = skillSlugResolutionService;
+ this.requestIdAccessor = requestIdAccessor;
}
public List listSkillLabels(String namespaceSlug,
@@ -188,7 +191,7 @@ public class SkillLabelAppService {
action,
"SKILL",
targetId,
- MDC.get("requestId"),
+ requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson
diff --git a/server/skillhub-app/src/main/resources/META-INF/spring.factories b/server/skillhub-app/src/main/resources/META-INF/spring.factories
new file mode 100644
index 00000000..e8a70f68
--- /dev/null
+++ b/server/skillhub-app/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,2 @@
+org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
+com.iflytek.skillhub.observability.tracing.TracingModeAutoConfigurationImportFilter
diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml
index 3cccb5dc..eacd31f3 100644
--- a/server/skillhub-app/src/main/resources/application.yml
+++ b/server/skillhub-app/src/main/resources/application.yml
@@ -94,6 +94,12 @@ spring:
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false}
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}
+ service-environment: ${SKILLHUB_SERVICE_ENVIRONMENT:local}
builtin-skills:
enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true}
redis:
@@ -215,6 +221,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}
diff --git a/server/skillhub-app/src/main/resources/logback-spring.xml b/server/skillhub-app/src/main/resources/logback-spring.xml
new file mode 100644
index 00000000..c9372168
--- /dev/null
+++ b/server/skillhub-app/src/main/resources/logback-spring.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${CONSOLE_LOG_CHARSET}
+ ${CONSOLE_LOG_PATTERN}
+
+
+
+
+
+ ${serviceName}
+ ${serviceVersion}
+ ${serviceEnvironment}
+ ${tracingMode}
+
+
+
+
+ ${asyncQueueSize}
+ 0
+ true
+ false
+
+
+
+
+
+
+
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java
index c5921f59..f5eac2d1 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java
@@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.util.Map;
import java.util.Optional;
@@ -40,7 +41,8 @@ class ClawHubCompatAppServiceTest {
multipartPackageExtractor,
auditLogService,
compatSkillLookupService,
- skillStarService
+ skillStarService,
+ new RequestIdAccessor()
);
@Test
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java
index 9c8f4305..67d7d61d 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java
@@ -2,10 +2,14 @@ package com.iflytek.skillhub.config;
import static org.assertj.core.api.Assertions.assertThat;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
+import com.iflytek.skillhub.observability.RequestIdThreadLocalAccessor;
+import io.micrometer.context.ContextRegistry;
+import io.micrometer.context.ContextSnapshotFactory;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
-import org.slf4j.MDC;
+import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@@ -19,23 +23,35 @@ class AsyncConfigTest {
}
@Test
- void skillhubEventExecutor_propagatesAndClearsMdc() throws Exception {
+ void skillhubEventExecutor_propagatesAndClearsRequestIdContext() throws Exception {
+ RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
+ ContextRegistry contextRegistry = new ContextRegistry()
+ .registerThreadLocalAccessor(
+ new RequestIdThreadLocalAccessor(requestIdAccessor)
+ );
+ ContextSnapshotFactory snapshotFactory = ContextSnapshotFactory.builder()
+ .contextRegistry(contextRegistry)
+ .clearMissing(true)
+ .build();
ThreadPoolTaskExecutor executor =
- (ThreadPoolTaskExecutor) new AsyncConfig().skillhubEventExecutor();
+ (ThreadPoolTaskExecutor) new AsyncConfig().skillhubEventExecutor(
+ new ContextPropagatingTaskDecorator(snapshotFactory)
+ );
try {
- MDC.put("requestId", "req-597");
CompletableFuture propagatedRequestId = new CompletableFuture<>();
- executor.execute(() -> propagatedRequestId.complete(MDC.get("requestId")));
- MDC.clear();
+ try (RequestIdAccessor.Scope ignored = requestIdAccessor.open("req-597")) {
+ executor.execute(
+ () -> propagatedRequestId.complete(requestIdAccessor.current())
+ );
+ }
assertThat(propagatedRequestId.get(5, TimeUnit.SECONDS)).isEqualTo("req-597");
CompletableFuture nextRequestId = new CompletableFuture<>();
- executor.execute(() -> nextRequestId.complete(MDC.get("requestId")));
+ executor.execute(() -> nextRequestId.complete(requestIdAccessor.current()));
assertThat(nextRequestId.get(5, TimeUnit.SECONDS)).isNull();
} finally {
- MDC.clear();
executor.shutdown();
}
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerUnitTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerUnitTest.java
index 43b35ff1..d3e11795 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerUnitTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerUnitTest.java
@@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserProfileService;
import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -54,9 +55,11 @@ class UserProfileControllerUnitTest {
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("response.success.read", Locale.getDefault(), "response.success.read");
+ RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
- Clock.fixed(Instant.parse("2026-03-19T08:00:00Z"), ZoneOffset.UTC)
+ Clock.fixed(Instant.parse("2026-03-19T08:00:00Z"), ZoneOffset.UTC),
+ requestIdAccessor
);
controller = new UserProfileController(
responseFactory,
@@ -64,7 +67,8 @@ class UserProfileControllerUnitTest {
userAccountRepository,
changeRequestRepository,
platformSessionService,
- fieldPolicyConfig
+ fieldPolicyConfig,
+ requestIdAccessor
);
given(fieldPolicyConfig.fieldPolicies()).willReturn(Map.of(
"displayName", new ProfileFieldPolicyConfig.FieldPolicy(true, false),
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java
index 2f344f2c..f44d2941 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java
@@ -12,6 +12,7 @@ import com.iflytek.skillhub.notification.domain.Notification;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationService;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
@@ -42,7 +43,8 @@ class NotificationControllerTest {
messageSource.addMessage("response.success.read", java.util.Locale.getDefault(), "ok");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
- Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC)
+ Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC),
+ new RequestIdAccessor()
);
controller = new NotificationController(notificationService, sseEmitterManager, new ObjectMapper(), responseFactory);
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceControllerTest.java
index c89520c3..a160323f 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceControllerTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceControllerTest.java
@@ -15,6 +15,7 @@ import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.domain.NotificationChannel;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService.PreferenceView;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
@@ -41,7 +42,8 @@ class NotificationPreferenceControllerTest {
messageSource.addMessage("response.success.updated", java.util.Locale.getDefault(), "ok");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
- Clock.fixed(Instant.parse("2026-03-23T00:00:00Z"), ZoneOffset.UTC)
+ Clock.fixed(Instant.parse("2026-03-23T00:00:00Z"), ZoneOffset.UTC),
+ new RequestIdAccessor()
);
controller = new NotificationPreferenceController(preferenceService, responseFactory);
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java
index 58ac25c9..9f5bbc09 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java
@@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.IdentityLinkErrorResponse;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
@@ -46,11 +47,18 @@ class GlobalExceptionHandlerTest {
"error.auth.local.invalidCredentials",
java.util.Locale.getDefault(),
"Invalid username or password");
+ RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
- Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC)
+ Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC),
+ requestIdAccessor
+ );
+ handler = new GlobalExceptionHandler(
+ responseFactory,
+ sensitiveLogSanitizer,
+ metrics,
+ requestIdAccessor
);
- handler = new GlobalExceptionHandler(responseFactory, sensitiveLogSanitizer, metrics);
}
@Test
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java
index 6bf56337..f3d2d117 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java
@@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpSession;
import java.time.Clock;
@@ -47,7 +48,8 @@ class AuthContextFilterTest {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.auth.local.accountDisabled", Locale.ENGLISH, "This account has been disabled");
Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC);
- ApiResponseFactory apiResponseFactory = new ApiResponseFactory(messageSource, clock);
+ ApiResponseFactory apiResponseFactory =
+ new ApiResponseFactory(messageSource, clock, new RequestIdAccessor());
filter = new AuthContextFilter(
namespaceMemberRepository,
userAccountRepository,
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java
index 485534fb..c7e8be1f 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository;
import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
@@ -38,6 +39,8 @@ class IdempotencyInterceptorTest {
@Mock
private ValueOperations valueOperations;
+ @Mock
+ private RequestIdAccessor requestIdAccessor;
@Mock
private HttpServletRequest request;
@@ -53,13 +56,20 @@ class IdempotencyInterceptorTest {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC);
- interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper, clock);
+ interceptor = new IdempotencyInterceptor(
+ redisTemplate,
+ idempotencyRecordRepository,
+ objectMapper,
+ clock,
+ requestIdAccessor
+ );
}
@Test
void testNewRequestPassesThrough() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-123");
+ when(requestIdAccessor.current()).thenReturn("req-123");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("idempotency:req-123")).thenReturn(null);
when(idempotencyRecordRepository.findByRequestId("req-123")).thenReturn(Optional.empty());
@@ -70,10 +80,28 @@ class IdempotencyInterceptorTest {
verify(idempotencyRecordRepository).save(any(IdempotencyRecord.class));
}
+ @Test
+ void testProvidedInvalidHeaderUsesEffectiveRequestContext() throws Exception {
+ when(request.getMethod()).thenReturn("POST");
+ when(request.getHeader("X-Request-Id")).thenReturn("invalid request id");
+ when(requestIdAccessor.current()).thenReturn("generated-valid-id");
+ when(redisTemplate.opsForValue()).thenReturn(valueOperations);
+ when(valueOperations.get("idempotency:generated-valid-id")).thenReturn(null);
+ when(idempotencyRecordRepository.findByRequestId("generated-valid-id"))
+ .thenReturn(Optional.empty());
+
+ boolean result = interceptor.preHandle(request, response, new Object());
+
+ assertTrue(result);
+ verify(idempotencyRecordRepository).findByRequestId("generated-valid-id");
+ verify(idempotencyRecordRepository, never()).findByRequestId("invalid request id");
+ }
+
@Test
void testDuplicateRequestReturnsCachedResponse() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-456");
+ when(requestIdAccessor.current()).thenReturn("req-456");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("idempotency:req-456")).thenReturn("COMPLETED");
@@ -114,6 +142,7 @@ class IdempotencyInterceptorTest {
void testAfterCompletionUpdatesRecord() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-789");
+ when(requestIdAccessor.current()).thenReturn("req-789");
when(response.getStatus()).thenReturn(200);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java
index 74ac9647..9386e39c 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java
@@ -1,14 +1,19 @@
package com.iflytek.skillhub.filter;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@@ -23,7 +28,8 @@ class RequestIdFilterTest {
void shouldGenerateRequestIdWhenNotProvided() throws Exception {
mockMvc.perform(get("/api/v1/health"))
.andExpect(status().isOk())
- .andExpect(header().exists("X-Request-Id"));
+ .andExpect(header().exists("X-Request-Id"))
+ .andExpect(jsonPath("$.requestId").isNotEmpty());
}
@Test
@@ -32,6 +38,50 @@ class RequestIdFilterTest {
mockMvc.perform(get("/api/v1/health")
.header("X-Request-Id", requestId))
.andExpect(status().isOk())
- .andExpect(header().string("X-Request-Id", requestId));
+ .andExpect(header().string("X-Request-Id", requestId))
+ .andExpect(jsonPath("$.requestId").value(requestId));
+ }
+
+ @Test
+ void shouldPreserveRequestIdAtMaximumLength() throws Exception {
+ String requestId = "a".repeat(64);
+
+ mockMvc.perform(get("/api/v1/health")
+ .header("X-Request-Id", requestId))
+ .andExpect(status().isOk())
+ .andExpect(header().string("X-Request-Id", requestId))
+ .andExpect(jsonPath("$.requestId").value(requestId));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "",
+ "-starts-with-symbol",
+ "contains space",
+ "contains/slash",
+ "包含中文"
+ })
+ void shouldReplaceInvalidRequestId(String requestId) throws Exception {
+ assertInvalidRequestIdIsReplaced(requestId);
+ }
+
+ @Test
+ void shouldReplaceRequestIdLongerThanMaximumLength() throws Exception {
+ assertInvalidRequestIdIsReplaced("a".repeat(65));
+ }
+
+ private void assertInvalidRequestIdIsReplaced(String invalidRequestId) throws Exception {
+ MvcResult result = mockMvc.perform(get("/api/v1/health")
+ .header("X-Request-Id", invalidRequestId))
+ .andExpect(status().isOk())
+ .andExpect(header().exists("X-Request-Id"))
+ .andExpect(jsonPath("$.requestId").isNotEmpty())
+ .andReturn();
+
+ String effectiveRequestId = result.getResponse().getHeader("X-Request-Id");
+ assertThat(effectiveRequestId)
+ .isNotEqualTo(invalidRequestId)
+ .matches("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$");
+ assertThat(result.getResponse().getContentAsString()).contains(effectiveRequestId);
}
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/ContextPropagationConfigurationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/ContextPropagationConfigurationTest.java
new file mode 100644
index 00000000..ceb1b351
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/ContextPropagationConfigurationTest.java
@@ -0,0 +1,320 @@
+package com.iflytek.skillhub.observability;
+
+import com.iflytek.skillhub.config.AsyncConfig;
+import com.iflytek.skillhub.observability.tracing.SkillHubTracingConfiguration;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.tracing.Span;
+import io.micrometer.tracing.Tracer;
+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 org.springframework.core.task.TaskDecorator;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ContextPropagationConfigurationTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(TestApplication.class)
+ .withPropertyValues(
+ "spring.flyway.enabled=false",
+ "spring.jpa.hibernate.ddl-auto=none"
+ );
+
+ @Test
+ void requestIdShouldPropagateRestoreNestedScopeAndNotLeakOnThreadReuse() {
+ contextRunner
+ .withPropertyValues("skillhub.observability.tracing-mode=none")
+ .run(context -> {
+ RequestIdAccessor requestIdAccessor =
+ context.getBean(RequestIdAccessor.class);
+ TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
+ ExecutorService worker = Executors.newSingleThreadExecutor();
+ try {
+ ContextValues propagated;
+ try (RequestIdAccessor.Scope ignored =
+ requestIdAccessor.open("request-one")) {
+ propagated = execute(worker, taskDecorator, () -> {
+ assertThat(requestIdAccessor.current())
+ .isEqualTo("request-one");
+ try (RequestIdAccessor.Scope nested =
+ requestIdAccessor.open("nested")) {
+ assertThat(requestIdAccessor.current())
+ .isEqualTo("nested");
+ }
+ return currentValues(requestIdAccessor, null);
+ });
+ }
+
+ assertThat(propagated.requestId()).isEqualTo("request-one");
+ assertThat(propagated.mdcRequestId()).isEqualTo("request-one");
+
+ try (RequestIdAccessor.Scope ignored =
+ requestIdAccessor.open("request-failure")) {
+ assertThatThrownBy(() -> execute(
+ worker,
+ taskDecorator,
+ () -> {
+ throw new IllegalStateException("expected failure");
+ }
+ )).hasCauseInstanceOf(IllegalStateException.class);
+ }
+
+ ContextValues clean = execute(
+ worker,
+ taskDecorator,
+ () -> currentValues(requestIdAccessor, null)
+ );
+ assertThat(clean.requestId()).isNull();
+ assertThat(clean.mdcRequestId()).isNull();
+ } finally {
+ worker.shutdownNow();
+ MDC.clear();
+ }
+ });
+ }
+
+ @Test
+ void configuredEventExecutorShouldPropagateRequestId() {
+ contextRunner
+ .withPropertyValues("skillhub.observability.tracing-mode=none")
+ .run(context -> {
+ RequestIdAccessor requestIdAccessor =
+ context.getBean(RequestIdAccessor.class);
+ Executor executor = context.getBean("skillhubEventExecutor", Executor.class);
+ try (RequestIdAccessor.Scope ignored =
+ requestIdAccessor.open("configured-executor")) {
+ FutureTask task = new FutureTask<>(
+ () -> currentValues(requestIdAccessor, null)
+ );
+ executor.execute(task);
+
+ assertThat(task.get(5, TimeUnit.SECONDS).requestId())
+ .isEqualTo("configured-executor");
+ } finally {
+ MDC.clear();
+ }
+ });
+ }
+
+ @Test
+ void callerRunsPolicyShouldRestoreCallerScopeAndLeaveWorkerClean() {
+ contextRunner
+ .withPropertyValues("skillhub.observability.tracing-mode=none")
+ .run(context -> {
+ RequestIdAccessor requestIdAccessor =
+ context.getBean(RequestIdAccessor.class);
+ TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
+ ThreadPoolTaskExecutor executor = callerRunsExecutor(taskDecorator);
+ CountDownLatch workerStarted = new CountDownLatch(1);
+ CountDownLatch releaseWorker = new CountDownLatch(1);
+ FutureTask blockingTask = new FutureTask<>(() -> {
+ workerStarted.countDown();
+ releaseWorker.await(5, TimeUnit.SECONDS);
+ return null;
+ });
+ try {
+ executor.execute(blockingTask);
+ assertThat(workerStarted.await(5, TimeUnit.SECONDS)).isTrue();
+
+ AtomicReference callerRunValues =
+ new AtomicReference<>();
+ String callerThread = Thread.currentThread().getName();
+ try (RequestIdAccessor.Scope ignored =
+ requestIdAccessor.open("caller-request")) {
+ executor.execute(() -> {
+ assertThat(Thread.currentThread().getName())
+ .isEqualTo(callerThread);
+ callerRunValues.set(currentValues(
+ requestIdAccessor,
+ null
+ ));
+ });
+ assertThat(requestIdAccessor.current())
+ .isEqualTo("caller-request");
+ assertThat(MDC.get(RequestIdAccessor.MDC_KEY))
+ .isEqualTo("caller-request");
+ }
+
+ assertThat(callerRunValues.get().requestId())
+ .isEqualTo("caller-request");
+ releaseWorker.countDown();
+ blockingTask.get(5, TimeUnit.SECONDS);
+
+ FutureTask cleanTask = new FutureTask<>(
+ () -> currentValues(requestIdAccessor, null)
+ );
+ executor.execute(cleanTask);
+ ContextValues clean = cleanTask.get(5, TimeUnit.SECONDS);
+ assertThat(clean.requestId()).isNull();
+ assertThat(clean.mdcRequestId()).isNull();
+ } finally {
+ releaseWorker.countDown();
+ executor.shutdown();
+ MDC.clear();
+ }
+ });
+ }
+
+ @Test
+ void otelSpanShouldPropagateAndBeClearedAfterTask() {
+ contextRunner
+ .withPropertyValues(
+ "skillhub.observability.tracing-mode=otel-sdk",
+ "management.tracing.sampling.probability=1.0"
+ )
+ .run(context -> {
+ RequestIdAccessor requestIdAccessor =
+ context.getBean(RequestIdAccessor.class);
+ TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
+ Tracer tracer = context.getBean(Tracer.class);
+ ExecutorService worker = Executors.newSingleThreadExecutor();
+ Span span = tracer.nextSpan().name("parent").start();
+ try {
+ ContextValues propagated;
+ try (Tracer.SpanInScope ignored = tracer.withSpan(span)) {
+ propagated = execute(
+ worker,
+ taskDecorator,
+ () -> currentValues(requestIdAccessor, tracer)
+ );
+ }
+
+ assertThat(propagated.traceId())
+ .isEqualTo(span.context().traceId());
+ assertThat(propagated.mdcTraceId())
+ .isEqualTo(span.context().traceId());
+
+ ContextValues clean = execute(
+ worker,
+ taskDecorator,
+ () -> currentValues(requestIdAccessor, tracer)
+ );
+ assertThat(clean.traceId()).isNull();
+ assertThat(clean.mdcTraceId()).isNull();
+ } finally {
+ span.end();
+ worker.shutdownNow();
+ MDC.clear();
+ }
+ });
+ }
+
+ @Test
+ void otelObservationShouldPropagateItsTraceAndRestoreWorker() {
+ contextRunner
+ .withPropertyValues(
+ "skillhub.observability.tracing-mode=otel-sdk",
+ "management.tracing.sampling.probability=1.0"
+ )
+ .run(context -> {
+ RequestIdAccessor requestIdAccessor =
+ context.getBean(RequestIdAccessor.class);
+ TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
+ Tracer tracer = context.getBean(Tracer.class);
+ ObservationRegistry observationRegistry =
+ context.getBean(ObservationRegistry.class);
+ ExecutorService worker = Executors.newSingleThreadExecutor();
+ Observation observation = Observation
+ .createNotStarted("parent-observation", observationRegistry)
+ .start();
+ try {
+ ContextValues propagated;
+ String parentTraceId;
+ try (Observation.Scope ignored = observation.openScope()) {
+ assertThat(tracer.currentSpan()).isNotNull();
+ parentTraceId = tracer.currentSpan().context().traceId();
+ propagated = execute(
+ worker,
+ taskDecorator,
+ () -> currentValues(requestIdAccessor, tracer)
+ );
+ }
+
+ assertThat(propagated.traceId()).isEqualTo(parentTraceId);
+ assertThat(propagated.mdcTraceId()).isEqualTo(parentTraceId);
+
+ ContextValues clean = execute(
+ worker,
+ taskDecorator,
+ () -> currentValues(requestIdAccessor, tracer)
+ );
+ assertThat(clean.traceId()).isNull();
+ assertThat(clean.mdcTraceId()).isNull();
+ } finally {
+ observation.stop();
+ worker.shutdownNow();
+ MDC.clear();
+ }
+ });
+ }
+
+ private ContextValues currentValues(
+ RequestIdAccessor requestIdAccessor,
+ Tracer tracer
+ ) {
+ Span currentSpan = tracer == null ? null : tracer.currentSpan();
+ return new ContextValues(
+ requestIdAccessor.current(),
+ MDC.get(RequestIdAccessor.MDC_KEY),
+ currentSpan == null ? null : currentSpan.context().traceId(),
+ MDC.get("traceId")
+ );
+ }
+
+ private T execute(
+ Executor executor,
+ TaskDecorator taskDecorator,
+ Callable action
+ ) throws Exception {
+ FutureTask task = new FutureTask<>(action);
+ executor.execute(taskDecorator.decorate(task));
+ return task.get(5, TimeUnit.SECONDS);
+ }
+
+ private ThreadPoolTaskExecutor callerRunsExecutor(TaskDecorator taskDecorator) {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(1);
+ executor.setMaxPoolSize(1);
+ executor.setQueueCapacity(0);
+ executor.setTaskDecorator(taskDecorator);
+ executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+ executor.initialize();
+ return executor;
+ }
+
+ private record ContextValues(
+ String requestId,
+ String mdcRequestId,
+ String traceId,
+ String mdcTraceId
+ ) {
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableAutoConfiguration
+ @Import({
+ SkillHubTracingConfiguration.class,
+ SkillHubContextPropagationConfiguration.class,
+ RequestIdAccessor.class,
+ AsyncConfig.class
+ })
+ static class TestApplication {
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/RequestIdAccessorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/RequestIdAccessorTest.java
new file mode 100644
index 00000000..e2c49d7f
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/RequestIdAccessorTest.java
@@ -0,0 +1,61 @@
+package com.iflytek.skillhub.observability;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.MDC;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+class RequestIdAccessorTest {
+
+ private final RequestIdAccessor accessor = new RequestIdAccessor();
+
+ @AfterEach
+ void tearDown() {
+ MDC.clear();
+ }
+
+ @Test
+ void shouldMirrorRequestIdToMdcAndClearItWhenScopeCloses() {
+ assertThat(accessor.current()).isNull();
+ assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull();
+
+ try (RequestIdAccessor.Scope ignored = accessor.open("req-123")) {
+ assertThat(accessor.current()).isEqualTo("req-123");
+ assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isEqualTo("req-123");
+ }
+
+ assertThat(accessor.current()).isNull();
+ assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull();
+ }
+
+ @Test
+ void shouldRestoreOuterScope() {
+ try (RequestIdAccessor.Scope ignored = accessor.open("outer")) {
+ try (RequestIdAccessor.Scope nested = accessor.open("inner")) {
+ assertThat(accessor.current()).isEqualTo("inner");
+ }
+ assertThat(accessor.current()).isEqualTo("outer");
+ assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isEqualTo("outer");
+ }
+ }
+
+ @Test
+ void shouldUseThreadLocalAsAuthorityWhenMdcIsChangedExternally() {
+ try (RequestIdAccessor.Scope ignored = accessor.open("authoritative")) {
+ MDC.put(RequestIdAccessor.MDC_KEY, "logging-only");
+
+ assertThat(accessor.current()).isEqualTo("authoritative");
+ }
+
+ assertThat(accessor.current()).isNull();
+ assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull();
+ }
+
+ @Test
+ void shouldRejectBlankRequestId() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> accessor.open(" "));
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoderTest.java
new file mode 100644
index 00000000..e417d97e
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoderTest.java
@@ -0,0 +1,142 @@
+package com.iflytek.skillhub.observability.logging;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.LoggingEvent;
+import ch.qos.logback.classic.spi.ThrowableProxy;
+import ch.qos.logback.classic.util.LogbackMDCAdapter;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SkillHubEcsEncoderTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final LoggerContext loggerContext = new LoggerContext();
+ private final SkillHubEcsEncoder encoder = new SkillHubEcsEncoder();
+
+ @BeforeEach
+ void setUp() {
+ loggerContext.setMDCAdapter(new LogbackMDCAdapter());
+ encoder.setContext(loggerContext);
+ encoder.setServiceName("skillhub");
+ encoder.setServiceVersion("test-sha");
+ encoder.setServiceEnvironment("test");
+ encoder.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ encoder.stop();
+ loggerContext.stop();
+ }
+
+ @Test
+ void shouldWriteEcsFieldsAndOnlyApprovedMdcValues() throws Exception {
+ LoggingEvent event = event("hello");
+ event.setMDCPropertyMap(Map.of(
+ "requestId", "req-123",
+ "traceId", "trace-123",
+ "spanId", "span-123",
+ "authorization", "must-not-leak",
+ "userEmail", "must-not-leak"
+ ));
+
+ JsonNode json = encode(event);
+
+ assertThat(json.path("log.level").asText()).isEqualTo("INFO");
+ assertThat(json.path("log.logger").asText()).isEqualTo("test.logger");
+ assertThat(json.path("message").asText()).isEqualTo("hello");
+ assertThat(json.path("service.name").asText()).isEqualTo("skillhub");
+ assertThat(json.path("service.version").asText()).isEqualTo("test-sha");
+ assertThat(json.path("service.environment").asText()).isEqualTo("test");
+ assertThat(json.path("request.id").asText()).isEqualTo("req-123");
+ assertThat(json.path("trace.id").asText()).isEqualTo("trace-123");
+ assertThat(json.path("span.id").asText()).isEqualTo("span-123");
+ assertThat(json.has("authorization")).isFalse();
+ assertThat(json.has("userEmail")).isFalse();
+ }
+
+ @Test
+ void shouldPreferMicrometerTraceIdOverExternalAgentFallback() throws Exception {
+ LoggingEvent event = event("trace precedence");
+ event.setMDCPropertyMap(Map.of(
+ "traceId", "micrometer-trace",
+ "tid", "external-agent-trace"
+ ));
+
+ JsonNode json = encode(event);
+
+ assertThat(json.path("trace.id").asText()).isEqualTo("micrometer-trace");
+ assertThat(json.fieldNames()).toIterable()
+ .filteredOn("trace.id"::equals)
+ .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");
+ event.setThrowableProxy(new ThrowableProxy(new IllegalStateException("boom")));
+
+ JsonNode json = encode(event);
+
+ assertThat(json.path("error.type").asText())
+ .isEqualTo(IllegalStateException.class.getName());
+ assertThat(json.path("error.message").asText()).isEqualTo("boom");
+ assertThat(json.path("error.stack_trace").asText())
+ .contains("IllegalStateException: boom");
+ }
+
+ private LoggingEvent event(String message) {
+ Logger logger = loggerContext.getLogger("test.logger");
+ LoggingEvent event = new LoggingEvent(
+ getClass().getName(),
+ logger,
+ Level.INFO,
+ message,
+ null,
+ null
+ );
+ event.setThreadName("test-thread");
+ event.setTimeStamp(1_785_465_600_000L);
+ return event;
+ }
+
+ private JsonNode encode(LoggingEvent event) throws Exception {
+ return objectMapper.readTree(new String(encoder.encode(event), StandardCharsets.UTF_8));
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/HttpTracePropagationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/HttpTracePropagationTest.java
new file mode 100644
index 00000000..6fea90f0
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/HttpTracePropagationTest.java
@@ -0,0 +1,178 @@
+package com.iflytek.skillhub.observability.tracing;
+
+import com.iflytek.skillhub.auth.identity.ProviderAuthenticationResult;
+import com.iflytek.skillhub.auth.oauth.GitLabClaimsExtractor;
+import com.iflytek.skillhub.config.SkillScannerConfig;
+import com.iflytek.skillhub.config.SkillScannerProperties;
+import com.iflytek.skillhub.infra.http.HttpClient;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import io.micrometer.tracing.Span;
+import io.micrometer.tracing.Tracer;
+import org.junit.jupiter.api.Test;
+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 org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HttpTracePropagationTest {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(TestApplication.class)
+ .withPropertyValues(
+ "spring.flyway.enabled=false",
+ "spring.jpa.hibernate.ddl-auto=none",
+ "skillhub.observability.tracing-mode=otel-sdk",
+ "management.tracing.sampling.probability=1.0",
+ "skillhub.security.scanner.enabled=true"
+ );
+
+ @Test
+ void shouldPropagateW3cContextToScannerButNotExternalGitLab() throws Exception {
+ try (HeaderCaptureServer scannerServer =
+ new HeaderCaptureServer("text/plain", "scanner-ok");
+ HeaderCaptureServer gitLabServer =
+ new HeaderCaptureServer(
+ "application/json",
+ """
+ [
+ {
+ "email": "alice@gitlab.example",
+ "confirmed_at": "2026-04-16T08:00:00Z"
+ }
+ ]
+ """
+ )) {
+ contextRunner.run(context -> {
+ Tracer tracer = context.getBean(Tracer.class);
+ HttpClient scannerClient =
+ context.getBean("scannerHttpClient", HttpClient.class);
+ GitLabClaimsExtractor gitLabClaimsExtractor =
+ context.getBean(GitLabClaimsExtractor.class);
+ Span span = tracer.nextSpan().name("outbound-boundary").start();
+ try (Tracer.SpanInScope ignored = tracer.withSpan(span)) {
+ assertThat(scannerClient.get(
+ scannerServer.url("/health"),
+ String.class
+ )).isEqualTo("scanner-ok");
+
+ ProviderAuthenticationResult claims = gitLabClaimsExtractor.extract(
+ gitLabRequest(gitLabServer.url("/api/v4/user")),
+ new DefaultOAuth2User(
+ List.of(),
+ Map.of(
+ "id", 42,
+ "username", "alice",
+ "email", "alice+pending@gitlab.example"
+ ),
+ "username"
+ )
+ );
+ assertThat(claims.attributes().get("email").getFirst().value())
+ .isEqualTo("alice@gitlab.example");
+ } finally {
+ span.end();
+ }
+
+ String scannerTraceparent = scannerServer.traceparent();
+ assertThat(scannerTraceparent)
+ .matches("^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$");
+ assertThat(scannerTraceparent.substring(3, 35))
+ .isEqualTo(span.context().traceId());
+ assertThat(gitLabServer.traceparent()).isNull();
+ });
+ }
+ }
+
+ private OAuth2UserRequest gitLabRequest(String userInfoUri) {
+ ClientRegistration registration = ClientRegistration
+ .withRegistrationId("gitlab")
+ .clientId("client-id")
+ .clientSecret("client-secret")
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+ .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
+ .scope("read_user", "email")
+ .authorizationUri("https://gitlab.example/oauth/authorize")
+ .tokenUri("https://gitlab.example/oauth/token")
+ .userInfoUri(userInfoUri)
+ .userNameAttributeName("username")
+ .clientName("GitLab")
+ .build();
+ OAuth2AccessToken accessToken = new OAuth2AccessToken(
+ OAuth2AccessToken.TokenType.BEARER,
+ "test-token",
+ Instant.now(),
+ Instant.now().plusSeconds(3600)
+ );
+ return new OAuth2UserRequest(registration, accessToken);
+ }
+
+ private static final class HeaderCaptureServer implements AutoCloseable {
+
+ private final HttpServer server;
+ private final AtomicReference traceparent = new AtomicReference<>();
+
+ private HeaderCaptureServer(String contentType, String body) throws IOException {
+ byte[] response = body.getBytes(StandardCharsets.UTF_8);
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> respond(
+ exchange,
+ contentType,
+ response
+ ));
+ server.start();
+ }
+
+ private void respond(
+ HttpExchange exchange,
+ String contentType,
+ byte[] response
+ ) throws IOException {
+ traceparent.set(exchange.getRequestHeaders().getFirst("traceparent"));
+ exchange.getResponseHeaders().set("Content-Type", contentType);
+ exchange.sendResponseHeaders(200, response.length);
+ try (var responseBody = exchange.getResponseBody()) {
+ responseBody.write(response);
+ }
+ }
+
+ private String url(String path) {
+ return "http://127.0.0.1:" + server.getAddress().getPort() + path;
+ }
+
+ private String traceparent() {
+ return traceparent.get();
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableAutoConfiguration
+ @Import({
+ SkillHubTracingConfiguration.class,
+ SkillScannerConfig.class,
+ SkillScannerProperties.class,
+ GitLabClaimsExtractor.class
+ })
+ static class TestApplication {
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/SkillHubTracingConfigurationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/SkillHubTracingConfigurationTest.java
new file mode 100644
index 00000000..1c5913ce
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/SkillHubTracingConfigurationTest.java
@@ -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 {
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/TracingModeAutoConfigurationImportFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/TracingModeAutoConfigurationImportFilterTest.java
new file mode 100644
index 00000000..6cfbb7d3
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/TracingModeAutoConfigurationImportFilterTest.java
@@ -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)
+ );
+ }
+}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java
index cf72bfb8..fd8c57ad 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java
@@ -9,6 +9,7 @@ import com.iflytek.skillhub.auth.token.ApiTokenScopeService;
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import java.time.Clock;
import java.time.Instant;
@@ -19,7 +20,6 @@ import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.slf4j.MDC;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.mock.web.MockHttpServletRequest;
@@ -33,28 +33,32 @@ class ApiAccessDeniedHandlerTest {
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private ApiAccessDeniedHandler handler;
+ private RequestIdAccessor.Scope requestIdScope;
@BeforeEach
void setUp() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
+ RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
- Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)
+ Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC),
+ requestIdAccessor
);
handler = new ApiAccessDeniedHandler(
objectMapper,
responseFactory,
- new SensitiveLogSanitizer()
+ new SensitiveLogSanitizer(),
+ requestIdAccessor
);
- MDC.put("requestId", "req-610");
+ requestIdScope = requestIdAccessor.open("req-610");
LocaleContextHolder.setLocale(Locale.ENGLISH);
}
@AfterEach
void tearDown() {
- MDC.clear();
+ requestIdScope.close();
LocaleContextHolder.resetLocaleContext();
SecurityContextHolder.clearContext();
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPointTest.java
index 87e27a85..bfae96d3 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPointTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPointTest.java
@@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
@@ -30,6 +31,7 @@ class ApiAuthenticationEntryPointTest {
new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
+ RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
entryPoint = new ApiAuthenticationEntryPoint(
objectMapper,
new ApiResponseFactory(
@@ -37,8 +39,10 @@ class ApiAuthenticationEntryPointTest {
Clock.fixed(
Instant.parse(
"2026-07-31T00:00:00Z"),
- ZoneOffset.UTC)),
- new SensitiveLogSanitizer());
+ ZoneOffset.UTC),
+ requestIdAccessor),
+ new SensitiveLogSanitizer(),
+ requestIdAccessor);
LocaleContextHolder.setLocale(Locale.ENGLISH);
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelAdminAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelAdminAppServiceTest.java
index d6092f65..60a527d3 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelAdminAppServiceTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelAdminAppServiceTest.java
@@ -14,6 +14,7 @@ import com.iflytek.skillhub.dto.LabelDefinitionResponse;
import com.iflytek.skillhub.dto.LabelSortOrderItemRequest;
import com.iflytek.skillhub.dto.LabelSortOrderUpdateRequest;
import com.iflytek.skillhub.dto.LabelTranslationItemRequest;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Instant;
import java.util.List;
import java.util.Set;
@@ -40,7 +41,8 @@ class LabelAdminAppServiceTest {
skillLabelService,
auditLogService,
rbacService,
- labelSearchSyncService
+ labelSearchSyncService,
+ new RequestIdAccessor()
);
@Test
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java
index bc824b89..9d8a5acc 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java
@@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.PromotionService;
import com.iflytek.skillhub.dto.PromotionResponseDto;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.lang.reflect.Field;
import java.util.Set;
@@ -47,7 +48,8 @@ class PromotionPortalAppServiceTest {
promotionRequestRepository,
governanceQueryRepository,
rbacService,
- auditLogService
+ auditLogService,
+ new RequestIdAccessor()
);
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java
index e51b4e9a..4d552196 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java
@@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
@@ -70,7 +71,8 @@ class SkillLabelAppServiceTest {
rbacService,
auditLogService,
labelSearchSyncService,
- skillSlugResolutionService
+ skillSlugResolutionService,
+ new RequestIdAccessor()
);
}
diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java
index cc1a4e45..48ba0f36 100644
--- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java
+++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java
@@ -34,6 +34,14 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
private final RestClient restClient;
+ /**
+ * Uses an external-service client that is intentionally not customized with application
+ * tracing. Trace context must not be propagated to a user-configured GitLab host.
+ */
+ public GitLabClaimsExtractor() {
+ this(RestClient.builder());
+ }
+
public GitLabClaimsExtractor(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)