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/logging/CorrelationJsonProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java
new file mode 100644
index 00000000..814f33ce
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java
@@ -0,0 +1,50 @@
+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 java.io.IOException;
+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";
+
+ @Override
+ public void writeTo(JsonGenerator generator, ILoggingEvent event) throws IOException {
+ Map mdc = event.getMDCPropertyMap();
+ if (mdc == null || mdc.isEmpty()) {
+ return;
+ }
+
+ writeIfPresent(generator, "request.id", mdc.get(REQUEST_ID_KEY));
+ writeIfPresent(
+ generator,
+ "trace.id",
+ firstPresent(mdc.get(TRACE_ID_KEY), mdc.get(EXTERNAL_TRACE_ID_KEY))
+ );
+ writeIfPresent(generator, "span.id", mdc.get(SPAN_ID_KEY));
+ }
+
+ 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..7761b49d
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoder.java
@@ -0,0 +1,108 @@
+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";
+
+ @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;
+ }
+
+ 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());
+
+ 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/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java
index 2c930aa6..36ea746e 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
@@ -4,11 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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 8f5de8d2..50c82768 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
@@ -3,11 +3,11 @@ package com.iflytek.skillhub.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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/application.yml b/server/skillhub-app/src/main/resources/application.yml
index 421e27f6..9983d5e8 100644
--- a/server/skillhub-app/src/main/resources/application.yml
+++ b/server/skillhub-app/src/main/resources/application.yml
@@ -94,6 +94,11 @@ spring:
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false}
skillhub:
+ observability:
+ 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:
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..30152e8e
--- /dev/null
+++ b/server/skillhub-app/src/main/resources/logback-spring.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ ${CONSOLE_LOG_CHARSET}
+ ${CONSOLE_LOG_PATTERN}
+
+
+
+
+
+ ${serviceName}
+ ${serviceVersion}
+ ${serviceEnvironment}
+
+
+
+
+ ${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/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 d8c4ac64..6aae4cb0 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
@@ -7,6 +7,7 @@ import static org.mockito.Mockito.when;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
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;
@@ -40,11 +41,18 @@ class GlobalExceptionHandlerTest {
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out");
+ 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/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..9d0df479
--- /dev/null
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoderTest.java
@@ -0,0 +1,116 @@
+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 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/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java
index db8982b7..00ca5e8a 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/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()
);
}
From f805076c5ccafdcd379e34e676f221c17f44704a Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Fri, 31 Jul 2026 11:06:13 +0800
Subject: [PATCH 2/4] feat(observability): add selectable tracing modes
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
server/skillhub-app/pom.xml | 13 ++
.../logging/CorrelationJsonProvider.java | 39 +++++-
.../logging/SkillHubEcsEncoder.java | 7 +-
.../SkillHubObservabilityProperties.java | 20 +++
.../tracing/SkillHubTracingConfiguration.java | 54 ++++++++
.../observability/tracing/TracingMode.java | 10 ++
...cingModeAutoConfigurationImportFilter.java | 51 +++++++
.../observability/tracing/package-info.java | 4 +
.../main/resources/META-INF/spring.factories | 2 +
.../src/main/resources/application.yml | 12 ++
.../src/main/resources/logback-spring.xml | 4 +
.../logging/SkillHubEcsEncoderTest.java | 26 ++++
.../SkillHubTracingConfigurationTest.java | 124 ++++++++++++++++++
...ModeAutoConfigurationImportFilterTest.java | 65 +++++++++
14 files changed, 426 insertions(+), 5 deletions(-)
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/SkillHubObservabilityProperties.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/SkillHubTracingConfiguration.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/TracingMode.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/TracingModeAutoConfigurationImportFilter.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/tracing/package-info.java
create mode 100644 server/skillhub-app/src/main/resources/META-INF/spring.factories
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/SkillHubTracingConfigurationTest.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/TracingModeAutoConfigurationImportFilterTest.java
diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml
index 8914e00e..413e7eaf 100644
--- a/server/skillhub-app/pom.xml
+++ b/server/skillhub-app/pom.xml
@@ -26,6 +26,19 @@
io.micrometer
micrometer-registry-prometheus