From 893c0d49cadfd324ab2c7b7ddd4d52862a7df472 Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Fri, 31 Jul 2026 10:49:32 +0800
Subject: [PATCH 01/10] feat(observability): establish request correlation
boundary
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
server/skillhub-app/pom.xml | 5 +
.../compat/ClawHubCompatAppService.java | 9 +-
.../controller/DeviceAuthWebController.java | 9 +-
.../controller/UserProfileController.java | 8 +-
.../admin/AdminProfileReviewController.java | 10 +-
.../admin/AdminSearchController.java | 9 +-
.../skillhub/dto/ApiResponseFactory.java | 14 ++-
.../exception/GlobalExceptionHandler.java | 15 ++-
.../filter/IdempotencyInterceptor.java | 24 +++-
.../skillhub/filter/RequestIdFilter.java | 19 +--
.../observability/RequestIdAccessor.java | 78 ++++++++++++
.../logging/CorrelationJsonProvider.java | 50 ++++++++
.../logging/SkillHubEcsEncoder.java | 108 ++++++++++++++++
.../observability/logging/package-info.java | 4 +
.../skillhub/observability/package-info.java | 4 +
.../security/ApiAccessDeniedHandler.java | 9 +-
.../security/ApiAuthenticationEntryPoint.java | 9 +-
.../service/LabelAdminAppService.java | 9 +-
.../service/PromotionPortalAppService.java | 9 +-
.../service/ReviewPortalAppService.java | 9 +-
.../service/SkillLabelAppService.java | 9 +-
.../src/main/resources/application.yml | 5 +
.../src/main/resources/logback-spring.xml | 47 +++++++
.../compat/ClawHubCompatAppServiceTest.java | 4 +-
.../UserProfileControllerUnitTest.java | 8 +-
.../portal/NotificationControllerTest.java | 4 +-
.../NotificationPreferenceControllerTest.java | 4 +-
.../exception/GlobalExceptionHandlerTest.java | 12 +-
.../filter/AuthContextFilterTest.java | 4 +-
.../filter/IdempotencyInterceptorTest.java | 31 ++++-
.../skillhub/filter/RequestIdFilterTest.java | 54 +++++++-
.../observability/RequestIdAccessorTest.java | 61 +++++++++
.../logging/SkillHubEcsEncoderTest.java | 116 ++++++++++++++++++
.../security/ApiAccessDeniedHandlerTest.java | 14 ++-
.../service/LabelAdminAppServiceTest.java | 4 +-
.../PromotionPortalAppServiceTest.java | 4 +-
.../service/SkillLabelAppServiceTest.java | 4 +-
37 files changed, 721 insertions(+), 75 deletions(-)
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdAccessor.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoder.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/package-info.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/package-info.java
create mode 100644 server/skillhub-app/src/main/resources/logback-spring.xml
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/RequestIdAccessorTest.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoderTest.java
diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml
index aeeee7a33..8914e00e7 100644
--- a/server/skillhub-app/pom.xml
+++ b/server/skillhub-app/pom.xml
@@ -26,6 +26,11 @@
io.micrometer
micrometer-registry-prometheus
+
+ net.logstash.logback
+ logstash-logback-encoder
+ 7.4
+
org.springdoc
springdoc-openapi-starter-webmvc-ui
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java
index b6fc5b1f6..c2b583f3c 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java
@@ -21,12 +21,12 @@
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@@ -49,6 +49,7 @@ public class ClawHubCompatAppService {
private final AuditLogService auditLogService;
private final CompatSkillLookupService compatSkillLookupService;
private final SkillStarService skillStarService;
+ private final RequestIdAccessor requestIdAccessor;
public ClawHubCompatAppService(CanonicalSlugMapper mapper,
SkillSearchAppService skillSearchAppService,
@@ -58,7 +59,8 @@ public ClawHubCompatAppService(CanonicalSlugMapper mapper,
MultipartPackageExtractor multipartPackageExtractor,
AuditLogService auditLogService,
CompatSkillLookupService compatSkillLookupService,
- SkillStarService skillStarService) {
+ SkillStarService skillStarService,
+ RequestIdAccessor requestIdAccessor) {
this.mapper = mapper;
this.skillSearchAppService = skillSearchAppService;
this.skillQueryService = skillQueryService;
@@ -68,6 +70,7 @@ public ClawHubCompatAppService(CanonicalSlugMapper mapper,
this.auditLogService = auditLogService;
this.compatSkillLookupService = compatSkillLookupService;
this.skillStarService = skillStarService;
+ this.requestIdAccessor = requestIdAccessor;
}
public ClawHubSearchResponse search(String q,
@@ -430,7 +433,7 @@ private void recordCompatPublishAudit(String userId,
"COMPAT_PUBLISH",
"SKILL_VERSION",
versionId,
- MDC.get("requestId"),
+ requestIdAccessor.current(),
clientIp,
userAgent,
detailJson
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java
index c2f47d4ce..71f27a195 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java
@@ -6,8 +6,8 @@
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MessageResponse;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
-import org.slf4j.MDC;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -24,13 +24,16 @@ public class DeviceAuthWebController extends BaseApiController {
private final DeviceAuthService deviceAuthService;
private final AuditLogService auditLogService;
+ private final RequestIdAccessor requestIdAccessor;
public DeviceAuthWebController(ApiResponseFactory responseFactory,
DeviceAuthService deviceAuthService,
- AuditLogService auditLogService) {
+ AuditLogService auditLogService,
+ RequestIdAccessor requestIdAccessor) {
super(responseFactory);
this.deviceAuthService = deviceAuthService;
this.auditLogService = auditLogService;
+ this.requestIdAccessor = requestIdAccessor;
}
@PostMapping("/authorize")
@@ -45,7 +48,7 @@ public ApiResponse authorizeDevice(
"DEVICE_AUTHORIZE",
"DEVICE_CODE",
null,
- MDC.get("requestId"),
+ requestIdAccessor.current(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
"{\"userCode\":\"" + request.userCode() + "\"}"
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java
index 3265c4b7e..2849455ed 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java
@@ -21,6 +21,7 @@
import com.iflytek.skillhub.dto.UpdateProfileResponse;
import com.iflytek.skillhub.dto.UserProfileResponse;
import com.iflytek.skillhub.exception.UnauthorizedException;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.security.core.Authentication;
@@ -53,19 +54,22 @@ public class UserProfileController extends BaseApiController {
private final ProfileChangeRequestRepository changeRequestRepository;
private final PlatformSessionService platformSessionService;
private final ProfileFieldPolicyConfig fieldPolicyConfig;
+ private final RequestIdAccessor requestIdAccessor;
public UserProfileController(ApiResponseFactory responseFactory,
UserProfileService userProfileService,
UserAccountRepository userAccountRepository,
ProfileChangeRequestRepository changeRequestRepository,
PlatformSessionService platformSessionService,
- ProfileFieldPolicyConfig fieldPolicyConfig) {
+ ProfileFieldPolicyConfig fieldPolicyConfig,
+ RequestIdAccessor requestIdAccessor) {
super(responseFactory);
this.userProfileService = userProfileService;
this.userAccountRepository = userAccountRepository;
this.changeRequestRepository = changeRequestRepository;
this.platformSessionService = platformSessionService;
this.fieldPolicyConfig = fieldPolicyConfig;
+ this.requestIdAccessor = requestIdAccessor;
}
/**
@@ -143,7 +147,7 @@ public ApiResponse updateProfile(
UpdateProfileResult result = userProfileService.updateProfile(
principal.userId(),
changes,
- httpRequest.getHeader("X-Request-Id"),
+ requestIdAccessor.current(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent")
);
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminProfileReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminProfileReviewController.java
index 9e0a87f49..cc2edcc7a 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminProfileReviewController.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminProfileReviewController.java
@@ -9,6 +9,7 @@
import com.iflytek.skillhub.dto.ProfileReviewMutationResponse;
import com.iflytek.skillhub.dto.ProfileReviewRejectRequest;
import com.iflytek.skillhub.dto.ProfileReviewSummaryResponse;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.AdminProfileReviewAppService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@@ -28,13 +29,16 @@ public class AdminProfileReviewController extends BaseApiController {
private final AdminProfileReviewAppService appService;
private final ProfileReviewService reviewService;
+ private final RequestIdAccessor requestIdAccessor;
public AdminProfileReviewController(ApiResponseFactory responseFactory,
AdminProfileReviewAppService appService,
- ProfileReviewService reviewService) {
+ ProfileReviewService reviewService,
+ RequestIdAccessor requestIdAccessor) {
super(responseFactory);
this.appService = appService;
this.reviewService = reviewService;
+ this.requestIdAccessor = requestIdAccessor;
}
/** List profile change requests filtered by status (default: PENDING). */
@@ -58,7 +62,7 @@ public ApiResponse approve(
var result = reviewService.approve(
id,
principal.userId(),
- httpRequest.getHeader("X-Request-Id"),
+ requestIdAccessor.current(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent"));
return ok("response.success.updated",
@@ -77,7 +81,7 @@ public ApiResponse reject(
id,
principal.userId(),
request.comment(),
- httpRequest.getHeader("X-Request-Id"),
+ requestIdAccessor.current(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent"));
return ok("response.success.updated",
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java
index b6dc264a7..87770644d 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java
@@ -5,8 +5,8 @@
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.audit.AuditLogService;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
-import org.slf4j.MDC;
import com.iflytek.skillhub.search.SearchRebuildService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -23,13 +23,16 @@ public class AdminSearchController extends BaseApiController {
private final SearchRebuildService searchRebuildService;
private final AuditLogService auditLogService;
+ private final RequestIdAccessor requestIdAccessor;
public AdminSearchController(ApiResponseFactory responseFactory,
SearchRebuildService searchRebuildService,
- AuditLogService auditLogService) {
+ AuditLogService auditLogService,
+ RequestIdAccessor requestIdAccessor) {
super(responseFactory);
this.searchRebuildService = searchRebuildService;
this.auditLogService = auditLogService;
+ this.requestIdAccessor = requestIdAccessor;
}
@PostMapping("/rebuild")
@@ -42,7 +45,7 @@ public ApiResponse rebuildAll(@AuthenticationPrincipal PlatformPrincipal p
"REBUILD_SEARCH_INDEX",
"SEARCH_INDEX",
null,
- MDC.get("requestId"),
+ requestIdAccessor.current(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
"{\"scope\":\"ALL\"}"
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java
index 720958f42..13e7f701b 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java
@@ -1,8 +1,8 @@
package com.iflytek.skillhub.dto;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Component;
-import org.slf4j.MDC;
import org.springframework.context.i18n.LocaleContextHolder;
import java.time.Clock;
@@ -13,23 +13,27 @@ public class ApiResponseFactory {
private final MessageSource messageSource;
private final Clock clock;
+ private final RequestIdAccessor requestIdAccessor;
- public ApiResponseFactory(MessageSource messageSource, Clock clock) {
+ public ApiResponseFactory(MessageSource messageSource,
+ Clock clock,
+ RequestIdAccessor requestIdAccessor) {
this.messageSource = messageSource;
this.clock = clock;
+ this.requestIdAccessor = requestIdAccessor;
}
public ApiResponse ok(String messageCode, T data, Object... args) {
String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale());
- return new ApiResponse<>(0, msg, data, Instant.now(clock), MDC.get("requestId"));
+ return new ApiResponse<>(0, msg, data, Instant.now(clock), requestIdAccessor.current());
}
public ApiResponse error(int code, String messageCode, Object... args) {
String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale());
- return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId"));
+ return new ApiResponse<>(code, msg, null, Instant.now(clock), requestIdAccessor.current());
}
public ApiResponse errorMessage(int code, String msg) {
- return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId"));
+ return new ApiResponse<>(code, msg, null, Instant.now(clock), requestIdAccessor.current());
}
}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java
index 2d30bd54b..712411862 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java
@@ -7,12 +7,12 @@
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import com.iflytek.skillhub.storage.StorageAccessException;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
@@ -34,13 +34,16 @@ public class GlobalExceptionHandler {
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
private final SkillHubMetrics metrics;
+ private final RequestIdAccessor requestIdAccessor;
public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer,
- SkillHubMetrics metrics) {
+ SkillHubMetrics metrics,
+ RequestIdAccessor requestIdAccessor) {
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
this.metrics = metrics;
+ this.requestIdAccessor = requestIdAccessor;
}
@ExceptionHandler(LocalizedException.class)
@@ -111,7 +114,7 @@ public ResponseEntity> handleStorageAccess(StorageAccessExcept
metrics.incrementStorageAccessFailure(ex.getOperation());
logger.warn(
"Object storage unavailable [requestId={}, method={}, path={}, userId={}, operation={}, key={}]",
- MDC.get("requestId"),
+ requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
resolveUserId(request),
@@ -127,7 +130,7 @@ public ResponseEntity> handleStorageAccess(StorageAccessExcept
public ResponseEntity> handleAsyncRequestTimeout(AsyncRequestTimeoutException ex, HttpServletRequest request) {
String path = request.getRequestURI();
if (path != null && path.endsWith("/sse")) {
- logger.debug("SSE timeout [requestId={}, path={}]", MDC.get("requestId"), path);
+ logger.debug("SSE timeout [requestId={}, path={}]", requestIdAccessor.current(), path);
return ResponseEntity.noContent().build();
}
@@ -140,7 +143,7 @@ public ResponseEntity> handleAsyncRequestTimeout(AsyncRequestTimeoutException
public ResponseEntity> handleGlobalException(Exception ex, HttpServletRequest request) {
logger.error(
"Unhandled API exception [requestId={}, method={}, path={}, userId={}]",
- MDC.get("requestId"),
+ requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
resolveUserId(request),
@@ -153,7 +156,7 @@ public ResponseEntity> handleGlobalException(Exception ex, Htt
private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) {
logger.info(
"API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]",
- MDC.get("requestId"),
+ requestIdAccessor.current(),
status.value(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java
index 9a950d60a..e517f9a8a 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java
@@ -5,6 +5,7 @@
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository;
import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus;
import com.iflytek.skillhub.dto.ApiResponse;
+import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.StringRedisTemplate;
@@ -34,15 +35,18 @@ public class IdempotencyInterceptor implements HandlerInterceptor {
private final IdempotencyRecordRepository idempotencyRecordRepository;
private final ObjectMapper objectMapper;
private final Clock clock;
+ private final RequestIdAccessor requestIdAccessor;
public IdempotencyInterceptor(StringRedisTemplate redisTemplate,
IdempotencyRecordRepository idempotencyRecordRepository,
ObjectMapper objectMapper,
- Clock clock) {
+ Clock clock,
+ RequestIdAccessor requestIdAccessor) {
this.redisTemplate = redisTemplate;
this.idempotencyRecordRepository = idempotencyRecordRepository;
this.objectMapper = objectMapper;
this.clock = clock;
+ this.requestIdAccessor = requestIdAccessor;
}
/**
@@ -56,8 +60,8 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
return true;
}
- String requestId = request.getHeader(REQUEST_ID_HEADER);
- if (requestId == null || requestId.isEmpty()) {
+ String requestId = resolveRequestId(request);
+ if (requestId == null) {
return true;
}
@@ -116,8 +120,8 @@ public void afterCompletion(HttpServletRequest request, HttpServletResponse resp
return;
}
- String requestId = request.getHeader(REQUEST_ID_HEADER);
- if (requestId == null || requestId.isEmpty()) {
+ String requestId = resolveRequestId(request);
+ if (requestId == null) {
return;
}
@@ -139,8 +143,16 @@ public void afterCompletion(HttpServletRequest request, HttpServletResponse resp
private void writeDuplicateResponse(HttpServletResponse response) throws Exception {
ApiResponse 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 cc5c09323..f5b88bc31 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 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 @@
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 000000000..ecdfb8038
--- /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 000000000..814f33cee
--- /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 000000000..7761b49dd
--- /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 000000000..6877f0ca4
--- /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 000000000..2c10c3550
--- /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 2c930aa64..36ea746eb 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.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 void handle(HttpServletRequest request,
: 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 8f5de8d25..50c827685 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 @@
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 void commence(HttpServletRequest request,
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 23bace70c..88316c508 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.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 @@ private void recordAudit(String action,
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 aab28382c..1c7f6b44c 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.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 @@ private void recordAudit(String action,
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 47d8eed5b..96663c9e2 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.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 @@ private void recordAudit(String action,
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 25b92081f..5947934e7 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.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 SkillLabelAppService(NamespaceRepository namespaceRepository,
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 SkillLabelAppService(NamespaceRepository namespaceRepository,
this.auditLogService = auditLogService;
this.labelSearchSyncService = labelSearchSyncService;
this.skillSlugResolutionService = skillSlugResolutionService;
+ this.requestIdAccessor = requestIdAccessor;
}
public List listSkillLabels(String namespaceSlug,
@@ -188,7 +191,7 @@ private void recordAudit(String action,
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 421e27f68..9983d5e8e 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 000000000..30152e8ef
--- /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 c5921f59b..f5eac2d11 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.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 43b35ff18..d3e117952 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.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 @@ void setUp() {
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 2f344f2cd..f44d29414 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.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 @@ void setUp() {
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 c89520c30..a160323f7 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.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 @@ void setUp() {
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 d8c4ac64e..6aae4cb05 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 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 6bf563374..f3d2d117e 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.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 485534fb6..c7e8be1f9 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.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 @@ void setUp() {
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 @@ void testNewRequestPassesThrough() throws Exception {
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 @@ void testGetRequestPassesThrough() throws Exception {
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 74ac9647b..9386e39ca 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 @@ void shouldPreserveProvidedRequestId() throws Exception {
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 000000000..e2c49d7fe
--- /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 000000000..9d0df479a
--- /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 db8982b75..00ca5e8a8 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.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 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 d6092f652..60a527d34 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.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 bc824b89e..9d8a5accb 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.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 @@ void setUp() {
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 e51b4e9a6..4d5521966 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.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 @@ void setUp() {
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 02/10] 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 8914e00e7..413e7eafc 100644
--- a/server/skillhub-app/pom.xml
+++ b/server/skillhub-app/pom.xml
@@ -26,6 +26,19 @@
io.micrometer
micrometer-registry-prometheus
+
+ io.micrometer
+ micrometer-tracing-bridge-otel
+
+
+ io.opentelemetry
+ opentelemetry-exporter-otlp
+
+
+ org.apache.skywalking
+ apm-toolkit-logback-1.x
+ 9.6.0
+
net.logstash.logback
logstash-logback-encoder
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
index 814f33cee..82c87fd55 100644
--- 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
@@ -3,8 +3,10 @@
import ch.qos.logback.classic.spi.ILoggingEvent;
import com.fasterxml.jackson.core.JsonGenerator;
import net.logstash.logback.composite.AbstractJsonProvider;
+import org.apache.skywalking.apm.toolkit.log.logback.v1.x.mdc.LogbackMDCPatternConverter;
import java.io.IOException;
+import java.util.List;
import java.util.Map;
/**
@@ -17,22 +19,51 @@ final class CorrelationJsonProvider extends AbstractJsonProvider
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();
- if (mdc == null || mdc.isEmpty()) {
- return;
- }
+ mdc = mdc == null ? Map.of() : mdc;
writeIfPresent(generator, "request.id", mdc.get(REQUEST_ID_KEY));
writeIfPresent(
generator,
"trace.id",
- firstPresent(mdc.get(TRACE_ID_KEY), mdc.get(EXTERNAL_TRACE_ID_KEY))
+ firstPresent(mdc.get(TRACE_ID_KEY), externalTraceId(event, mdc))
);
writeIfPresent(generator, "span.id", mdc.get(SPAN_ID_KEY));
}
+ private String externalTraceId(ILoggingEvent event, Map 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;
}
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
index 7761b49dd..8dc728bb2 100644
--- 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
@@ -25,6 +25,7 @@ public class SkillHubEcsEncoder extends LoggingEventCompositeJsonEncoder {
private String serviceName = "skillhub";
private String serviceVersion = "unknown";
private String serviceEnvironment = "local";
+ private boolean externalTraceIdEnabled;
@Override
public void start() {
@@ -48,6 +49,10 @@ public 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();
@@ -74,7 +79,7 @@ private LoggingEventJsonProviders createProviders() {
providers.addThreadName(thread);
providers.addGlobalCustomFields(serviceFields());
- providers.addProvider(new CorrelationJsonProvider());
+ providers.addProvider(new CorrelationJsonProvider(externalTraceIdEnabled));
ThrowableClassNameJsonProvider errorType = new ThrowableClassNameJsonProvider();
errorType.setFieldName("error.type");
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 000000000..c27a3bfc2
--- /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 000000000..ea3872b25
--- /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 000000000..feaf11712
--- /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 000000000..55a33fda6
--- /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 000000000..a60438958
--- /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/resources/META-INF/spring.factories b/server/skillhub-app/src/main/resources/META-INF/spring.factories
new file mode 100644
index 000000000..e8a70f68e
--- /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 9983d5e8e..10e9638eb 100644
--- a/server/skillhub-app/src/main/resources/application.yml
+++ b/server/skillhub-app/src/main/resources/application.yml
@@ -95,6 +95,7 @@ spring:
skillhub:
observability:
+ tracing-mode: ${SKILLHUB_TRACING_MODE:none}
log-format: ${SKILLHUB_LOG_FORMAT:text}
log-async-queue-size: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:1024}
service-version: ${SKILLHUB_SERVICE_VERSION:unknown}
@@ -214,6 +215,17 @@ skillhub:
email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local}
management:
+ tracing:
+ sampling:
+ probability: ${SKILLHUB_TRACING_SAMPLING_PROBABILITY:0.1}
+ baggage:
+ enabled: false
+ propagation:
+ type: W3C
+ otlp:
+ tracing:
+ timeout: ${SKILLHUB_OTLP_TIMEOUT:5s}
+ compression: ${SKILLHUB_OTLP_COMPRESSION:gzip}
health:
mail:
enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false}
diff --git a/server/skillhub-app/src/main/resources/logback-spring.xml b/server/skillhub-app/src/main/resources/logback-spring.xml
index 30152e8ef..c9372168c 100644
--- a/server/skillhub-app/src/main/resources/logback-spring.xml
+++ b/server/skillhub-app/src/main/resources/logback-spring.xml
@@ -5,6 +5,9 @@
+
@@ -30,6 +33,7 @@
${serviceName}
${serviceVersion}
${serviceEnvironment}
+ ${tracingMode}
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
index 9d0df479a..e417d97eb 100644
--- 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
@@ -81,6 +81,32 @@ void shouldPreferMicrometerTraceIdOverExternalAgentFallback() throws Exception {
.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");
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 000000000..1c5913ce5
--- /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 000000000..6cfbb7d32
--- /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)
+ );
+ }
+}
From 91fb155ef17ec27dd7e9abb7c6e205da8f3c305a Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Fri, 31 Jul 2026 11:22:12 +0800
Subject: [PATCH 03/10] feat(observability): propagate async trace context
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.../iflytek/skillhub/config/AsyncConfig.java | 6 +-
.../skillhub/config/SkillScannerConfig.java | 7 +-
.../RequestIdThreadLocalAccessor.java | 37 ++
...illHubContextPropagationConfiguration.java | 51 +++
.../ContextPropagationConfigurationTest.java | 320 ++++++++++++++++++
.../tracing/HttpTracePropagationTest.java | 178 ++++++++++
.../auth/oauth/GitLabClaimsExtractor.java | 8 +
7 files changed, 604 insertions(+), 3 deletions(-)
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdThreadLocalAccessor.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/SkillHubContextPropagationConfiguration.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/ContextPropagationConfigurationTest.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/HttpTracePropagationTest.java
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java
index 7feb8955b..10a6f5a5f 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java
@@ -2,6 +2,7 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+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,12 +20,15 @@
public class AsyncConfig {
@Bean(name = "skillhubEventExecutor")
- public Executor skillhubEventExecutor() {
+ public Executor skillhubEventExecutor(
+ ContextPropagatingTaskDecorator contextPropagatingTaskDecorator
+ ) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("skillhub-event-");
+ executor.setTaskDecorator(contextPropagatingTaskDecorator);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerConfig.java
index d6b7a5ec7..9753e982f 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerConfig.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerConfig.java
@@ -26,7 +26,10 @@ public class SkillScannerConfig {
@Bean
@ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true")
- public HttpClient scannerHttpClient(SkillScannerProperties properties) {
+ public HttpClient scannerHttpClient(
+ WebClient.Builder webClientBuilder,
+ SkillScannerProperties properties
+ ) {
int readTimeoutMs = properties.getReadTimeoutMs();
int connectTimeoutMs = properties.getConnectTimeoutMs();
@@ -52,7 +55,7 @@ public HttpClient scannerHttpClient(SkillScannerProperties properties) {
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(100 * 1024 * 1024))
.build();
- WebClient webClient = WebClient.builder()
+ WebClient webClient = webClientBuilder.clone()
.clientConnector(new ReactorClientHttpConnector(reactorClient))
.exchangeStrategies(strategies)
.build();
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 000000000..9d1deffbe
--- /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 000000000..e0eaa710f
--- /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/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 000000000..ceb1b351e
--- /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/tracing/HttpTracePropagationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/tracing/HttpTracePropagationTest.java
new file mode 100644
index 000000000..0e0b4c184
--- /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.oauth.GitLabClaimsExtractor;
+import com.iflytek.skillhub.auth.oauth.OAuthClaims;
+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");
+
+ OAuthClaims 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.email())
+ .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-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 d6c840a27..7a744a30e 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
@@ -27,6 +27,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)
From 8b3ad8b14e6397db731a58dda1a673bf72e30a7c Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Fri, 31 Jul 2026 11:30:42 +0800
Subject: [PATCH 04/10] docs(observability): document tracing deployment modes
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.env.release.example | 13 +
compose.release.yml | 9 +
docs/09-deployment.md | 128 ++++-
...6-07-31-observability-construction-plan.md | 523 ++++++++++++++++++
docs/observability-decision-map.md | 124 +++++
...26-07-31-observability-common-solutions.md | 120 ++++
6 files changed, 915 insertions(+), 2 deletions(-)
create mode 100644 docs/2026-07-31-observability-construction-plan.md
create mode 100644 docs/observability-decision-map.md
create mode 100644 docs/research/2026-07-31-observability-common-solutions.md
diff --git a/.env.release.example b/.env.release.example
index 59387e0f8..1b8e38e69 100644
--- a/.env.release.example
+++ b/.env.release.example
@@ -53,6 +53,19 @@ API_PORT=8080
WEB_PORT=80
SESSION_COOKIE_SECURE=false
+# Observability defaults require no Collector or tracing backend.
+# Use json in container deployments when stdout is collected centrally.
+SKILLHUB_TRACING_MODE=none
+SKILLHUB_LOG_FORMAT=text
+SKILLHUB_LOG_ASYNC_QUEUE_SIZE=1024
+SKILLHUB_SERVICE_VERSION=unknown
+SKILLHUB_SERVICE_ENVIRONMENT=production
+SKILLHUB_TRACING_SAMPLING_PROBABILITY=0.1
+# Set only with SKILLHUB_TRACING_MODE=otel-sdk.
+MANAGEMENT_OTLP_TRACING_ENDPOINT=
+SKILLHUB_OTLP_TIMEOUT=5s
+SKILLHUB_OTLP_COMPRESSION=gzip
+
# Zero-config runtime validation uses local storage.
# Switch to `s3` and fill the fields below before a real production deployment.
SKILLHUB_STORAGE_PROVIDER=local
diff --git a/compose.release.yml b/compose.release.yml
index 733187688..db306bda8 100644
--- a/compose.release.yml
+++ b/compose.release.yml
@@ -88,6 +88,15 @@ services:
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
+ SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none}
+ SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-text}
+ SKILLHUB_LOG_ASYNC_QUEUE_SIZE: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:-1024}
+ SKILLHUB_SERVICE_VERSION: ${SKILLHUB_SERVICE_VERSION:-unknown}
+ SKILLHUB_SERVICE_ENVIRONMENT: ${SKILLHUB_SERVICE_ENVIRONMENT:-production}
+ SKILLHUB_TRACING_SAMPLING_PROBABILITY: ${SKILLHUB_TRACING_SAMPLING_PROBABILITY:-0.1}
+ MANAGEMENT_OTLP_TRACING_ENDPOINT: ${MANAGEMENT_OTLP_TRACING_ENDPOINT:-}
+ SKILLHUB_OTLP_TIMEOUT: ${SKILLHUB_OTLP_TIMEOUT:-5s}
+ SKILLHUB_OTLP_COMPRESSION: ${SKILLHUB_OTLP_COMPRESSION:-gzip}
BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false}
BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin}
BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin}
diff --git a/docs/09-deployment.md b/docs/09-deployment.md
index bcf362894..847391c76 100644
--- a/docs/09-deployment.md
+++ b/docs/09-deployment.md
@@ -320,8 +320,132 @@ override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 `
| 维度 | 方案 |
|------|------|
| 健康检查 | `web/nginx-health`、`server/actuator/health` |
-| 日志 | 容器 stdout / stderr |
-| 指标 | Spring Boot Actuator,后续可接 Prometheus |
+| 请求关联 | 响应头和日志中的 `X-Request-Id` / `request.id` |
+| 日志 | 文本或 ECS 风格 JSON,均输出到容器 stdout / stderr |
+| Trace | `none`、Micrometer + OTel SDK、或外部 Java Agent 三选一 |
+| 指标 | Spring Boot Actuator;Prometheus 是可选后端,不是 Trace 前置条件 |
+
+### 10.1 通用配置
+
+默认配置不要求 Collector、SkyWalking 或 Elasticsearch:
+
+```dotenv
+SKILLHUB_TRACING_MODE=none
+SKILLHUB_LOG_FORMAT=text
+SKILLHUB_SERVICE_VERSION=v0.2.15
+SKILLHUB_SERVICE_ENVIRONMENT=production
+```
+
+部署环境建议将 `SKILLHUB_LOG_FORMAT` 设为 `json`,由 Filebeat、Fluent Bit 或容器平台
+采集 stdout。SkillHub 不直接连接 Elasticsearch。JSON 日志使用以下稳定字段:
+
+- `request.id`:SkillHub 请求、响应和审计关联 ID。
+- `trace.id`、`span.id`:当前存在有效 Trace 时输出。
+- `service.name`、`service.version`、`service.environment`。
+
+`SKILLHUB_LOG_ASYNC_QUEUE_SIZE` 默认是 `1024`。JSON 日志队列是有界且非阻塞的;采集端
+阻塞时允许丢弃日志以保护业务线程,数据库中的 `audit_log` 仍是审计事实来源。
+
+### 10.2 三种 Tracing 模式
+
+三种模式只能选择一种,切换后需要重启:
+
+| 模式 | 适用场景 | 必需配置 |
+|------|----------|----------|
+| `none` | 不部署链路追踪 | `SKILLHUB_TRACING_MODE=none` |
+| `otel-sdk` | 厂商中立 OTLP/Collector | 模式、采样率;需要导出时再配置 endpoint |
+| `external-agent` | 使用 SkyWalking Agent 原生能力 | 模式、唯一的外部 Agent;不得配置 OTLP endpoint |
+
+OTel SDK 模式的最小配置:
+
+```dotenv
+SKILLHUB_TRACING_MODE=otel-sdk
+SKILLHUB_LOG_FORMAT=json
+SKILLHUB_TRACING_SAMPLING_PROBABILITY=0.1
+MANAGEMENT_OTLP_TRACING_ENDPOINT=http://otel-collector:4318/v1/traces
+SKILLHUB_OTLP_TIMEOUT=5s
+SKILLHUB_OTLP_COMPRESSION=gzip
+```
+
+未设置 `MANAGEMENT_OTLP_TRACING_ENDPOINT` 时,`otel-sdk` 仍可建立进程内 Trace,但不会
+创建 OTLP Exporter,也不会尝试连接默认地址。`none` 或 `external-agent` 模式配置
+endpoint 会启动失败。
+
+External Agent 模式的应用侧配置:
+
+```dotenv
+SKILLHUB_TRACING_MODE=external-agent
+SKILLHUB_LOG_FORMAT=json
+```
+
+部署平台还必须通过 JVM 启动参数挂载且只挂载一个 Agent。SkillHub 无法可靠识别任意
+Java Agent,因此上线前应检查实际 `JAVA_TOOL_OPTIONS` 或容器启动命令,确认没有同时启用
+OTel Agent、SkyWalking Agent 和应用内 `otel-sdk`。SkyWalking Agent 模式可以通过官方
+Logback Toolkit 输出 `trace.id`;`span.id` 是否可用取决于 Agent 版本。
+
+### 10.3 OTel Collector 接入 SkyWalking
+
+下面是只转发 Trace 的最小 Collector 配置:
+
+```yaml
+receivers:
+ otlp:
+ protocols:
+ http:
+ endpoint: 0.0.0.0:4318
+
+processors:
+ batch: {}
+
+exporters:
+ otlp/skywalking:
+ endpoint: skywalking-oap:11800
+ tls:
+ insecure: true
+
+service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [otlp/skywalking]
+```
+
+SkyWalking OAP 10.3 还需要启用 OTLP Trace handler、Zipkin receiver 和 Zipkin query:
+
+```dotenv
+SW_OTEL_RECEIVER_ENABLED_HANDLERS=otlp-traces
+SW_RECEIVER_ZIPKIN=default
+SW_QUERY_ZIPKIN=default
+```
+
+应用使用 Collector 的 OTLP/HTTP `4318` 端口,Collector 使用 OAP 的 OTLP/gRPC
+`11800` 端口。生产环境应按网络边界配置 TLS;上例中的 `insecure: true` 只适用于受控的
+容器内部网络。
+
+SkyWalking 10.3 会把 OTLP Trace 转换为 Zipkin Trace,并通过 Zipkin Query/Lens 查询。
+这条路径不提供 SkyWalking Java Agent 的完整原生拓扑、慢 SQL 和 Profiling 能力。需要
+这些能力时使用 `external-agent`,不要同时启用 `otel-sdk`。
+
+### 10.4 日志与 Trace 联查
+
+JSON 日志由采集器写入 Elasticsearch 后,在 Kibana 通过 `trace.id` 查询;同一个
+`trace.id` 可在 SkyWalking 的 Zipkin Query/Lens 或 Agent 原生查询界面中定位调用链。
+`request.id` 始终可以用于 SkillHub 内部日志和审计关联。
+
+当采样率小于 `1.0` 时,日志仍是全量输出,因此部分日志虽有请求关联信息,但在
+SkyWalking 中没有被保留的 Trace。这是头部采样的预期行为。
+
+### 10.5 回滚
+
+遇到观测后端异常时:
+
+1. 将 `SKILLHUB_TRACING_MODE` 改为 `none`。
+2. 删除 `MANAGEMENT_OTLP_TRACING_ENDPOINT`。
+3. 需要进一步降低日志开销时,将 `SKILLHUB_LOG_FORMAT` 改为 `text`。
+4. 滚动重启 Server。
+
+关闭 Trace 和 JSON 日志不会改变请求、数据库或异步任务的业务语义。
## 11 安全扫描服务
diff --git a/docs/2026-07-31-observability-construction-plan.md b/docs/2026-07-31-observability-construction-plan.md
new file mode 100644
index 000000000..688528352
--- /dev/null
+++ b/docs/2026-07-31-observability-construction-plan.md
@@ -0,0 +1,523 @@
+# SkillHub 日志关联与链路追踪建设方案
+
+> 日期:2026-07-31
+>
+> 状态:Accepted(2026-07-31,按本文分阶段实施和验证)
+>
+> 关联:GitHub Issue #597
+> 适用基线:Spring Boot 3.2.3、Java 21、Logback、Micrometer Actuator
+
+## 1. 背景
+
+SkillHub 已经使用 `X-Request-Id` 关联 API 响应、业务日志和审计记录,但目前仍存在以下问题:
+
+- 部分应用服务和 DTO 直接读取 SLF4J MDC,可观测性实现泄漏到了业务代码。
+- `X-Request-Id` 接受任意客户端输入,没有统一的长度和字符约束。
+- `@Async` 线程池没有显式传播请求和 Trace 上下文,异步日志可能丢失关联信息。
+- 当前没有标准分布式 Trace,无法通过一个 ID 串联 SkillHub、Scanner 等服务调用。
+- 日志字段尚未形成适合 Elasticsearch/Kibana 查询的稳定结构。
+
+本方案用最小建设成本建立通用日志关联与链路追踪基础设施。它不负责建设完整的企业
+可观测性平台,也不把日志、Trace 或 Metrics 逻辑写入业务处理器。
+
+Issue #597 中“搜索索引可靠异步交付”应作为独立问题处理,不属于本文范围。
+
+## 2. 建设目标
+
+一期需要实现:
+
+1. 每个 HTTP 请求都有合法的 `request.id`。
+2. 启用 Tracing 时,日志包含标准 `trace.id` 和 `span.id`。
+3. `otel-sdk` 模式使用 W3C `traceparent` / `tracestate` 传播 Trace Context。
+4. 业务代码不直接读写 MDC,也不直接依赖 OpenTelemetry 或 SkyWalking API。
+5. 现有 Spring `@Async` 执行器能够正确传播并清理上下文。
+6. 日志以结构化 JSON 输出到 stdout,可由 Filebeat/Fluent Bit 采集到
+ Elasticsearch/Kibana。
+7. Trace 可以选择通过 OTLP Collector 接入 SkyWalking。
+8. Collector、SkyWalking、Elasticsearch 或日志采集器不可用时,SkillHub 业务继续运行。
+9. 同一进程只能有一个实际生效的 Tracer。
+
+本方案按多个小阶段、小提交实施和验证,全部通过后再统一创建一个替代 PR。
+
+## 3. 非目标
+
+一期不建设:
+
+- 搜索索引可靠队列、重试、死信和重放。
+- 多租户差异化采样和运行时动态采样。
+- Spring Cloud Config、Nacos 或可写 Actuator 配置端点。
+- 应用内 OTLP 熔断器或自定义重试框架。
+- 审计日志归档、物理隔离和 WORM 存储。
+- 通用 PII/DLP 检测平台。
+- Prometheus/Grafana/Kibana 告警模板和容量规划平台。
+- Spring Boot 2.x 或 Java 17 兼容。
+- 在业务类上增加 Trace 注解或要求业务开发者操作 Span。
+
+## 4. 总体架构
+
+```text
+HTTP request
+ │
+ ├─ RequestIdFilter
+ │ └─ request.id
+ │
+ └─ Micrometer Observation / Tracing
+ ├─ MDC correlation
+ │ └─ JSON stdout
+ │ └─ Filebeat / Fluent Bit
+ │ └─ Elasticsearch / Kibana
+ │
+ └─ OpenTelemetry Bridge
+ └─ OTLP
+ └─ OpenTelemetry Collector
+ └─ SkyWalking OAP
+```
+
+稳定边界是:
+
+- 应用内使用 Micrometer Observation/Tracing。
+- `otel-sdk` 模式跨进程使用 W3C Trace Context。
+- Trace 导出使用 OTLP。
+- 日志使用 ECS 风格字段。
+- SkyWalking、Elasticsearch 和 Kibana 都是部署适配器,不进入业务模型。
+
+## 5. 运行模式
+
+通过一个启动期配置选择运行模式:
+
+```yaml
+skillhub:
+ observability:
+ tracing-mode: ${SKILLHUB_TRACING_MODE:none}
+```
+
+允许值和确定行为:
+
+| 模式 | Micrometer Tracer | OTLP Exporter | 外部 Agent | 无 Agent/endpoint 时 |
+|------|-------------------|---------------|------------|---------------------|
+| `none` | NOOP | 无 | 不支持 | 只有 `request.id` |
+| `otel-sdk` | OTel Bridge | 配置 endpoint 时创建 | 不支持 | 仍建立进程内 Trace,但不导出 |
+| `external-agent` | NOOP | 无 | 可选 | 记录警告并退化为只有 `request.id` |
+
+运行模式是启动期不变量,不支持热切换。
+
+必须保证:
+
+- `none` 和 `external-agent` 不创建应用内 OTel Span。
+- `otel-sdk` 不支持同时启用 SkyWalking、OTel 或其他外部 Tracing Agent。
+- `external-agent` 不创建 OTLP Exporter。
+- SkillHub 配置能够识别的冲突应在启动时失败;任意 Java Agent 无法被应用可靠识别,因此
+ 部署检查和原型测试还必须验证实际 JVM 参数中只有一个 Tracer。
+
+一期实现并验证三种模式的互斥边界和日志关联。`external-agent` 只验证 SkyWalking Agent
+接管 Trace 后不会与应用内 OTel Tracer 冲突;SkyWalking 特有高级能力不进入 SkillHub
+核心代码。
+
+## 6. 关联字段契约
+
+### 6.1 对外日志字段
+
+日志输出统一使用:
+
+| 字段 | 必需性 | 含义 |
+|------|--------|------|
+| `request.id` | HTTP 请求或显式任务上下文中存在 | SkillHub API、响应和审计关联 ID |
+| `trace.id` | 当前存在有效 Trace 时 | 分布式 Trace ID |
+| `span.id` | 当前 Tracer 能提供时 | 当前调用节点 ID |
+| `service.name` | 始终存在 | 固定为 `skillhub` |
+| `service.version` | 部署时提供 | 发布版本或镜像对应 Commit |
+| `service.environment` | 部署时提供 | 当前部署环境 |
+
+`request.id` 与 `trace.id` 不能合并:
+
+- `request.id` 属于 SkillHub API 契约,可出现在响应和审计记录中。
+- `trace.id` 属于可选的分布式追踪上下文,可能被采样或关闭。
+
+启动日志以及没有显式任务上下文的后台维护日志允许不包含 `request.id`。
+
+### 6.2 内部字段映射
+
+日志基础设施负责字段映射,业务代码不感知具体 MDC 键:
+
+| 来源 | 内部字段 | 输出字段 |
+|------|----------|----------|
+| SkillHub Request Context | `requestId` | `request.id` |
+| Micrometer OTel Bridge | `traceId` | `trace.id` |
+| Micrometer OTel Bridge | `spanId` | `span.id` |
+| SkyWalking Logback Toolkit 事件转换器 | `tid` | `trace.id` |
+
+SkyWalking Agent 是否能稳定提供独立 `span.id` 以实际原型结果为准。无法稳定提供时允许只
+输出 `trace.id`,不得解析不稳定的内部字符串格式。
+
+External Agent 模式通过 SkyWalking 官方 Logback Toolkit 从当前日志事件读取 `tid`;
+这不是业务代码读取 MDC,也不能假定 `tid` 一定存在于异步日志线程的 MDC 中。日志编码器
+只读取允许的关联字段,不得把整个 MDC Map 自动写入 JSON。
+
+## 7. Request ID
+
+### 7.1 输入规则
+
+客户端可以传入 `X-Request-Id`,但必须同时满足:
+
+- 长度为 1–64 个字符。
+- 首字符是字母或数字。
+- 其余字符只允许字母、数字、`.`、`_`、`:`、`-`。
+
+建议校验表达式:
+
+```regex
+^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$
+```
+
+请求头缺失、为空或不合法时,服务端生成 UUID。响应始终返回最终采用的
+`X-Request-Id`。
+
+### 7.2 代码边界
+
+新增通用 `RequestIdAccessor` 和对应的 Request ID Scope:
+
+- Filter 负责解析、校验、建立和清理 Request ID 上下文。
+- 独立 ThreadLocal Scope 是 Request ID 的进程内权威来源。
+- 为该 Scope 注册 Micrometer `ThreadLocalAccessor`,由
+ `ContextPropagatingTaskDecorator` 捕获、恢复和清理。
+- Scope 同步维护日志所需的 MDC 镜像,但读取方不能把 MDC 当作权威来源。
+- API 响应工厂通过该抽象读取 Request ID。
+- 审计编排通过该抽象或明确参数读取 Request ID。
+- 应用服务、Controller 和 DTO 不再直接调用 `MDC.get()`。
+- MDC 只作为日志适配器,不再作为业务上下文的权威来源。
+
+## 8. Tracing 配置
+
+`skillhub-app` 使用 Spring Boot 3.2.3 管理的依赖版本:
+
+```xml
+
+ io.micrometer
+ micrometer-tracing-bridge-otel
+
+
+ io.opentelemetry
+ opentelemetry-exporter-otlp
+
+```
+
+基础配置:
+
+```yaml
+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}
+```
+
+基础配置不得为 OTLP endpoint 提供默认地址。只有 `otel-sdk` 部署显式设置以下标准
+Spring Boot 配置时才创建 Exporter:
+
+```bash
+MANAGEMENT_OTLP_TRACING_ENDPOINT=http://otel-collector:4318/v1/traces
+```
+
+一期沿用 OpenTelemetry 1.31 的默认 BatchSpanProcessor 有界队列和丢弃策略,不增加应用内
+重试、熔断或自定义队列实现。
+
+## 9. 日志输出
+
+### 9.1 输出模式
+
+- 本地开发默认使用可读的文本日志。
+- `SKILLHUB_LOG_FORMAT=json` 启用 ECS 风格 JSON stdout。
+- JSON 编码器显式输出标准字段和三个关联字段,不启用“输出全部 MDC”。
+- JSON ConsoleAppender 外包一层 Logback AsyncAppender,初始队列容量为 1024,并允许通过
+ `SKILLHUB_LOG_ASYNC_QUEUE_SIZE` 调整。
+- AsyncAppender 使用非阻塞策略;队列耗尽时日志可能丢失,审计事实不依赖该通道。
+- 异常使用 `error.type`、`error.message`、`error.stack_trace`。
+- 队列容量保持可配置,默认值在原型压测后固定,不在设计阶段猜测。
+- 异常和队列丢弃行为必须在测试中验证。
+
+示例:
+
+```json
+{
+ "@timestamp": "2026-07-31T10:10:10.123Z",
+ "log.level": "INFO",
+ "service.name": "skillhub",
+ "service.version": "0.2.15",
+ "service.environment": "test",
+ "request.id": "req-123",
+ "trace.id": "4bf92f3577b34da6a3ce929d0e0e4736",
+ "span.id": "00f067aa0ba902b7",
+ "log.logger": "com.iflytek.skillhub...",
+ "message": "..."
+}
+```
+
+应用只输出 stdout,不直接依赖 Elasticsearch SDK,也不直接写 Elasticsearch。
+
+### 9.2 审计边界
+
+`audit_log` 数据库记录仍是审计事实来源。stdout 日志不能代替审计记录,审计留存和归档
+不在本方案中处理。
+
+## 10. 上下文传播
+
+### 10.1 Spring 异步执行器
+
+为现有 `skillhubEventExecutor` 配置 Spring Framework 6.1 的
+`ContextPropagatingTaskDecorator`:
+
+- 提交任务时捕获 Request ID 和 Trace Context。
+- 执行任务时恢复上下文。
+- 执行完成后在 `finally` 中清理。
+- `CallerRunsPolicy` 触发时也必须保持正确的嵌套作用域。
+
+测试必须重复复用同一工作线程,证明不同请求之间不会串号。
+
+### 10.2 长生命周期后台线程
+
+Redis Stream 消费循环、Reclaimer 和其他长生命周期线程不继承应用启动线程或任意请求的
+MDC。需要追踪具体任务时,由通用任务执行边界建立新的上下文。
+
+一期不把 HTTP Trace Context 写入搜索业务 payload,也不改造可靠任务状态机。
+
+### 10.3 HTTP 出站
+
+一期只管理两类 HTTP Client:
+
+- 内部 Scanner Client:使用 Spring 管理且带 Observation 的 Builder,传播 W3C Trace
+ Context。
+- 其他现有 Client:GitHub、GitLab、内置 Skill 公网下载和 S3 Client 均不在一期新增
+ Trace Context 传播。
+
+后续新增 Client 必须明确选择内部或外部配置,不能依赖全局 Host 正则或在业务代码中手工
+删除 Header。
+
+## 11. SkyWalking 与 Elasticsearch 接入
+
+### 11.1 OTel SDK 模式
+
+推荐链路:
+
+```text
+SkillHub
+ → OTLP/HTTP
+ → OpenTelemetry Collector
+ → OTLP
+ → SkyWalking OAP
+```
+
+Collector 用于协议适配和后端路由,不是 SkillHub 的启动依赖。
+
+SkyWalking 10.3 的 OTLP Trace 会转换为 Zipkin Trace,并通过 Zipkin Query/Lens UI 查询。
+它不等价于 SkyWalking Java Agent 的原生拓扑、慢 SQL 和 Profiling 能力,部署文档必须
+明确该差异。原型报告必须记录实际使用的 Maven 依赖、Collector、OAP 和 Agent 版本及
+查询结果。
+
+### 11.2 External Agent 模式
+
+需要 SkyWalking 原生能力时:
+
+- 使用 `external-agent`。
+- 不配置 SkillHub OTLP endpoint。
+- 由部署环境挂载并启动 SkyWalking Java Agent。
+- 使用 SkyWalking 官方 Logback Toolkit 提供 Trace ID。
+- 日志基础设施将 `tid` 映射为 `trace.id`。
+
+### 11.3 日志链路
+
+```text
+SkillHub JSON stdout
+ → Filebeat / Fluent Bit
+ → Elasticsearch
+ → Kibana
+```
+
+Kibana 使用 `trace.id` 查询日志,SkyWalking 使用同一个 Trace ID 查询调用链。
+
+## 12. 实施步骤
+
+### 阶段一:Request ID 与日志边界
+
+1. 增加 Request ID 校验。
+2. 建立 `RequestIdAccessor`。
+3. 移除应用服务、Controller、DTO 对 MDC 的直接读取。
+4. 增加允许字段明确的结构化日志配置。
+5. 增加 Request ID 和日志字段测试。
+
+可观察结果:
+
+- 非法 Request ID 被替换。
+- API 响应和审计记录仍使用同一 Request ID。
+- 业务类不再 import `org.slf4j.MDC`。
+
+### 阶段二:Micrometer + OTel
+
+1. 增加 Tracing Bridge 和 OTLP Exporter 依赖。
+2. 增加 `none`、`otel-sdk`、`external-agent` 模式。
+3. 设置 W3C、关闭 baggage、配置采样率。
+4. 保证无 endpoint 时不会产生网络连接。
+5. 保证每个模式只存在一个实际 Tracer。
+
+可观察结果:
+
+- `none` 模式只有 `request.id`。
+- `otel-sdk` 模式日志出现标准 Trace 字段。
+- `external-agent` 模式不会产生应用内 OTel Trace。
+
+### 阶段三:传播边界
+
+1. 为 `skillhubEventExecutor` 增加上下文传播。
+2. 验证线程复用、嵌套任务和 `CallerRunsPolicy`。
+3. 让内部 Scanner Client 使用 Spring 管理且可观测的 Client Builder。
+4. 验证外部 HTTP Client 不发送 Trace Context。
+
+### 阶段四:部署示例与远端验证
+
+1. 提供最小 OTel Collector 配置示例。
+2. 补充 SkyWalking OTLP 与 Agent 模式差异。
+3. 将待测分支合入 `big-main`,记录合入后的精确 Commit SHA。
+4. 构建绑定 `big-main` SHA 的测试镜像。
+5. 在共享测试机使用独立容器、网络、数据卷和动态端口运行三个原型。
+6. 生成中文测试报告并保存在本地私有目录,不提交开源仓库。
+
+每个阶段使用独立的小提交并保留在同一实现分支;前一阶段的范围测试通过后再进入下一
+阶段。公开 Issue 和 PR 统一在阶段五创建。
+
+### 阶段五:社区交付(最后执行)
+
+该阶段必须在远端验证全部通过后执行:
+
+1. 创建新的可观测性建设 Issue,说明它承接 #597 中的“通用日志关联与链路追踪”部分。
+2. 搜索索引可靠异步交付继续作为独立问题,不混入新的可观测性 Issue。
+3. 从经过验证的实现分支创建新的 PR,并关联新 Issue。
+4. PR 只包含公开代码、配置、自动化测试和公开部署说明;不得包含测试机地址、凭证、
+ 私有端口、原始远端日志或本地中文测试报告。
+5. 在 #597、#644 及其他被替代的关联项中回复:
+ - 原问题是否真实存在。
+ - 为什么不采用原 PR 的实现。
+ - 新方案的边界和主要改动。
+ - 已完成的自动化及远端验证摘要。
+ - 新 Issue 和替代 PR 的链接。
+6. 确认维护者需要的信息完整后,关闭已被替代的 PR;不在验证完成前抢先关闭。
+7. #597 等关联 Issue 只根据剩余问题是否已有明确承接决定关闭、缩小范围或继续保留,
+ 不因替代 PR 创建而自动关闭。
+8. 新 PR 通过 Review 和 CI 后,确认 PR Head 仍等于已验证的功能 SHA,且该 SHA 可从已
+ 测试的 `big-main` SHA 到达;满足后才允许更新 `main`。
+9. 如果 Review 或 CI 修复改变了代码、配置或测试脚本,则原验证证据失效:先将新 SHA
+ 合入 `big-main`,重新构建镜像并完成受影响的远端验证,再更新 `main`。
+
+## 13. 验证方案
+
+### 13.1 自动化测试
+
+至少覆盖:
+
+- 未传 Request ID 时自动生成。
+- 合法 Request ID 被保留。
+- 空值、超长值和非法字符被替换。
+- Filter 正常、异常退出后都清理上下文。
+- API 响应、审计和日志中的 Request ID 一致。
+- JSON 只输出允许的关联字段。
+- Trace 采样率在测试中设为 `1.0` 后可稳定断言。
+- `@Async` 线程恢复父上下文。
+- 连续复用同一线程执行不同请求时不串号。
+- `CallerRunsPolicy` 下上下文正确恢复。
+- `none`、`otel-sdk`、`external-agent` 的 Spring Context 互斥。
+- 未配置 OTLP endpoint 时不创建网络导出。
+- 内部 Scanner 请求携带 `traceparent`。
+- 外部 HTTP 请求不携带 `traceparent`。
+
+### 13.2 远端原型
+
+#### 原型 A:none
+
+- 不部署 Collector。
+- SkillHub 正常启动并完成核心 Smoke Test。
+- 日志存在 `request.id`,不存在伪造的 Trace 字段。
+
+#### 原型 B:otel-sdk
+
+- SkillHub → Collector → SkyWalking 跑通。
+- JSON 日志进入 Elasticsearch/Kibana。
+- Kibana 与 SkyWalking 能用同一 `trace.id` 查询。
+- Collector 停止后 SkillHub API 和异步任务继续工作。
+
+#### 原型 C:external-agent
+
+- SkyWalking Java Agent 提供原生 Trace。
+- 应用内 OTel Exporter 不工作。
+- 日志能用 SkyWalking Trace ID 关联。
+- 不产生双 Trace、重复 Span 或两个冲突的 Trace ID。
+
+### 13.3 远端测试场景
+
+- HTTP 成功、4xx、5xx 和未认证请求。
+- Scanner 成功、超时和失败。
+- 异步事件正常执行和抛出异常。
+- 并发请求重复使用线程池。
+- Collector 启动、停止和恢复。
+- 日志采集器停止或消费变慢。
+- 采样率 `0.0`、`0.1` 和 `1.0`。
+- 容器收到 SIGTERM 后日志和 Trace 的关闭行为。
+- 日志中不出现 Authorization、Cookie、Token、密码和完整请求体。
+
+## 14. 验收标准
+
+以下条件全部满足后,一期才算完成:
+
+- [ ] 三种模式行为与本文一致。
+- [ ] 业务代码不再直接读取或写入 MDC。
+- [ ] Request ID 校验、响应和审计关联测试通过。
+- [ ] 日志字段符合约定,且不输出完整 MDC。
+- [ ] Spring 异步执行器上下文传播和隔离测试通过。
+- [ ] 内外部 HTTP 传播边界测试通过。
+- [ ] 无 OTLP endpoint 时不存在外部连接尝试。
+- [ ] Collector 中断不影响 SkillHub 业务结果。
+- [ ] OTel SDK 与 SkyWalking Agent 不会同时产生 Trace。
+- [ ] `make test-backend-app` 通过。
+- [ ] `make typecheck-web` 和 `make lint-web` 通过。
+- [ ] 基于 `big-main` 合入后精确 SHA 构建的远端三个原型通过。
+- [ ] 中文测试报告保存在本地私有目录。
+- [ ] 新的可观测性 Issue 和替代 PR 已创建并互相关联。
+- [ ] #597、#644 等关联项已获得清晰回复,被替代的旧 PR 已关闭。
+- [ ] 关联 Issue 已根据剩余范围分别关闭、缩小范围或保留,且状态理由清楚。
+- [ ] 新 PR Head 与已验证功能 SHA 一致,且可从已测试的 `big-main` SHA 到达。
+- [ ] 通过验证后才允许更新 `main`。
+
+## 15. 回滚
+
+出现问题时:
+
+1. 将 `SKILLHUB_TRACING_MODE` 改为 `none`。
+2. 删除 `MANAGEMENT_OTLP_TRACING_ENDPOINT`。
+3. 将 `SKILLHUB_LOG_FORMAT` 改为 `text`。
+4. 保留 Request ID 和原有文本日志能力。
+5. 通过滚动重启恢复,不进行运行时模式切换。
+
+Tracing 和结构化日志关闭后不得影响 SkillHub 的业务状态、数据库状态或任务执行语义。
+
+## 16. 已知限制
+
+- 10% Head Sampling 下,全量日志中的部分 `trace.id` 在 SkyWalking 中没有对应 Trace。
+- SkyWalking OTLP 模式的展示能力弱于原生 Java Agent。
+- 日志队列在背压时可能丢弃日志,这是保护业务线程的预期行为。
+- External Agent 提供哪些 MDC 字段取决于具体 Agent 和版本。
+- 一期只处理通用关联和传播,不保证搜索索引异步交付可靠性。
+
+## 17. 参考资料
+
+- [Spring Boot 3.2.3 Tracing](https://docs.spring.io/spring-boot/docs/3.2.3/reference/html/actuator.html#actuator.micrometer-tracing)
+- [Micrometer Tracing](https://docs.micrometer.io/tracing/reference/)
+- [OpenTelemetry Java OTLP Exporter](https://opentelemetry.io/docs/languages/java/exporters/)
+- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
+- [SkyWalking OpenTelemetry Trace](https://skywalking.apache.org/docs/main/v10.3.0/en/setup/backend/otlp-trace/)
+- [SkyWalking Logback Toolkit](https://skywalking.apache.org/docs/skywalking-java/next/en/setup/service-agent/java-agent/application-toolkit-logback-1.x/)
+- [Elastic ECS Tracing Fields](https://www.elastic.co/docs/reference/ecs/ecs-tracing)
+- [方案调研](./research/2026-07-31-observability-common-solutions.md)
diff --git a/docs/observability-decision-map.md b/docs/observability-decision-map.md
new file mode 100644
index 000000000..7ca4483b8
--- /dev/null
+++ b/docs/observability-decision-map.md
@@ -0,0 +1,124 @@
+# 通用可观测性决策图
+
+目标:为 SkillHub 建立独立、通用、可插拔的日志关联、指标和链路追踪基础设施。
+它观察 HTTP、线程池、定时任务和可靠任务等执行边界,但不进入业务模型和业务载荷。
+
+边界:
+
+- Servlet Filter、执行器装饰器、调度拦截器和任务执行拦截器负责建立/恢复上下文。
+- 业务代码不读写 MDC,不负责创建通用 Span,也不负责统计任务生命周期指标。
+- 使用 W3C Trace Context;日志后端、Metrics 后端和 Trace Exporter 均可替换。
+- 上下文是有长度限制的基础设施元数据,不进入业务 payload。
+- Collector、Exporter 或 Metrics 后端不可用时,主业务和任务内核继续工作。
+
+必须满足的不变量:
+
+- 每个执行边界都正确建立作用域并在 `finally` 清理,线程复用不得串号。
+- 日志稳定输出 `requestId`、`traceId`、`spanId`;任务执行时额外输出执行资源标识。
+- Trace 与 Metrics 可关闭、可替换;关闭后不得改变业务行为。
+- 指标只使用低基数维度,业务 ID 不进入标签。
+- 采集端不可用必须异步、限时、限队列并 fail-open。
+
+## #1:可观测性是否与业务和任务状态机彻底分离?
+
+Blocked by: 无
+Type: Grilling
+
+### Question
+
+可观测性是否只通过通用执行边界和生命周期信号接入,不进入业务处理器?
+
+### Answer
+
+已确认。可靠任务内核只发布通用生命周期信号;可观测性拦截器把执行资源标识加入日志、
+Span 和指标。搜索处理器只处理搜索,不认识 MDC、OpenTelemetry 或 Prometheus。
+
+## #2:通用关联身份和传播协议是什么?
+
+Blocked by: #1
+Type: Research
+
+### Question
+
+如何区分现有 `X-Request-Id`、W3C `traceId/spanId` 和执行资源标识,并跨 HTTP、线程池、
+调度器和持久化任务边界传播?
+
+### Answer
+
+已确定:
+
+- `requestId` 是 SkillHub 的请求/审计关联标识,不冒充分布式 Trace。
+- `traceId/spanId` 由 Tracer 生成,跨进程只使用 W3C `traceparent/tracestate`。
+- 定时任务或可靠任务的执行资源 ID 只作为当前执行作用域属性,不进入业务 payload。
+- HTTP、线程池、调度器和持久化 carrier 的注入/提取全部位于基础设施拦截器。
+- 不传播任意 MDC Map;baggage 默认关闭,任何允许项都必须低敏、限长、显式配置。
+- 无效或不可信的公网 Trace Context 按 W3C 规则丢弃,服务端控制采样。
+
+常见方案和候选组合见
+[Java / Spring 通用日志关联与链路追踪方案调研](./research/2026-07-31-observability-common-solutions.md)。
+
+## #3:采用 Micrometer Observation、OpenTelemetry API/SDK 还是 Java Agent?
+
+Blocked by: #2
+Type: Research
+
+### Question
+
+哪种组合最适配 Spring Boot 3.2.3,并同时支持无 Collector 运行、可选 OTLP 和稳定日志关联?
+
+### Answer
+
+已选择三模式:
+
+- `none`:不创建应用内 OTel SDK 或 Exporter,只保留 Request ID。
+- `otel-sdk`:使用 Micrometer Tracing + OTel Bridge;配置 OTLP endpoint 时才导出。
+- `external-agent`:应用内使用 NOOP Tracer,由部署环境提供唯一的外部 Agent。
+
+应用代码只依赖 Micrometer/Observation 边界,不依赖 OTel SDK 或 SkyWalking API。
+自动配置测试已证明三种模式互斥,错误的 endpoint/mode 组合会在启动时失败。
+
+## #4:如何证明上下文传播、日志输出和故障降级正确?
+
+Blocked by: #3
+Type: Prototype
+
+### Question
+
+验证线程复用隔离、嵌套作用域、异步/调度/持久化任务边界、采样、Exporter 超时、
+Collector 中断、队列打满和关闭观测能力等场景。
+
+### Answer
+
+本地原型已证明:
+
+- Request ID Scope 在线程复用、嵌套 Scope、异常退出和 `CallerRunsPolicy` 下均能恢复并
+ 清理。
+- Micrometer 手工 Span 和 Observation 均能随 `skillhubEventExecutor` 传播。
+- Scanner 使用 Spring 管理的 `WebClient.Builder` 传播 W3C `traceparent`。
+- 面向用户配置的 GitLab 外部 Client 不传播 Trace Context。
+- `none / otel-sdk / external-agent` 的应用上下文和 Exporter 条件符合设计。
+
+Collector 中断、日志背压、采样率和关闭行为仍由 `big-main` 精确 SHA 镜像的远端原型验证。
+
+## #5:如何形成可部署闭环?
+
+Blocked by: #4
+Type: Research
+
+### Question
+
+确定 stdout 格式、可选 JSON、Prometheus 或 OTLP Metrics、Trace Exporter、暴露边界、
+低基数告警和运维文档。
+
+### Answer
+
+已确定最小交付:
+
+- 文本日志用于本地开发,ECS 风格 JSON stdout 用于部署环境。
+- JSON 日志只输出白名单关联字段,通过有界非阻塞 AsyncAppender 保护业务线程。
+- Trace 可经 OTLP Collector 路由到 SkyWalking;需要 SkyWalking 原生能力时改用唯一的
+ Java Agent。
+- Prometheus 继续作为可选 Metrics 后端,不是本期链路关联的前置条件。
+
+部署配置和三模式操作说明写入 `docs/09-deployment.md`;远端实测结果只保存在本地私有
+中文报告中。
diff --git a/docs/research/2026-07-31-observability-common-solutions.md b/docs/research/2026-07-31-observability-common-solutions.md
new file mode 100644
index 000000000..92dbbee6b
--- /dev/null
+++ b/docs/research/2026-07-31-observability-common-solutions.md
@@ -0,0 +1,120 @@
+# Java / Spring 通用日志关联与链路追踪方案调研
+
+调研时间:2026-07-31
+适用基线:SkillHub,Spring Boot 3.2.3、Java 21、Logback、Micrometer Actuator
+
+## 结论
+
+当前 Java/Spring 生态已经基本收敛到以下组合:
+
+1. 使用 W3C `traceparent` / `tracestate` 作为跨进程传播协议。
+2. Spring 应用内使用 Micrometer Observation/Tracing,底层桥接 OpenTelemetry。
+3. 云原生或需要广覆盖自动插桩时使用 OpenTelemetry Java Agent。
+4. 使用 OTLP 把 Trace 发往 Collector,再由 Collector 路由到 Tempo、Jaeger、Zipkin、
+ SkyWalking 或商业后端。
+5. 日志只消费当前上下文中的 `traceId` / `spanId`,业务代码不操作 MDC。
+
+Spring Cloud Sleuth、手写 MDC/TID、TLog/TTL 和厂商 Agent 仍能见到,但不应作为
+SkillHub 新机制的协议核心。
+
+## 常见方案比较
+
+| 方案 | 常见使用场景 | 优点 | 主要缺口 |
+|---|---|---|---|
+| Filter + MDC + TaskDecorator | 单体应用、只要求按 ID 查日志 | 简单、无采集端 | 没有真实 Span;容易漏线程/客户端边界;手写传播易串号 |
+| Micrometer Tracing + OTel bridge | Spring Boot 3.x 应用内建观测 | Spring 官方路径;自动日志关联;便于自定义基础设施 Observation | 覆盖依赖 Spring 已观测的组件;线程池仍要正确配置上下文传播 |
+| OpenTelemetry Java Agent | Kubernetes、统一运维、需要 JDBC/Redis/HTTP 等广覆盖 | OTel 官方对 Spring Boot 的默认建议;零代码;覆盖面最大 | 需要部署 Agent;必须实测启动/CPU/内存开销;自定义持久化任务边界仍需扩展 |
+| OpenTelemetry Spring Boot Starter | Native Image、不能挂 Agent、需要应用 YAML 配置 | OTel SDK 原生集成;适合 Agent 不可用场景 | OTel 官方不把它作为普通 Spring Boot 的默认选择;需要单独管理 OTel BOM |
+| SkyWalking/Elastic/Pinpoint 等 Agent | 已统一采购或部署特定 APM 的企业 | 自动插桩成熟、开箱 UI | 协议和后端绑定更强;不适合作为开源产品内部 API |
+| Spring Cloud Sleuth | Spring Boot 2.x 历史项目 | 旧生态成熟 | 官方明确不支持 Spring Boot 3.x,核心已迁移到 Micrometer Tracing |
+
+## 官方事实
+
+### Spring Boot
+
+- Spring Boot 3.2.3 Actuator 为 Micrometer Tracing 提供依赖管理和自动配置。
+- OTel 组合使用 `micrometer-tracing-bridge-otel`;OTLP 使用
+ `opentelemetry-exporter-otlp`。
+- 启用 Micrometer Tracing 后,Spring Boot 默认把 `traceId`、`spanId` 放入 MDC,并
+ 支持通过 `logging.pattern.correlation` 固定日志格式。
+- Spring Boot 3.2.3 默认产生 W3C 上下文,并可消费 W3C、B3、B3 Multi;新设计应只
+ 产生 W3C,兼容消费策略可单独配置。
+- 自动 HTTP 传播依赖 Spring 自动配置的 HTTP Client Builder;自行 `new` 客户端会
+ 绕过传播。
+- Spring Framework 6.1 提供 `ContextPropagatingTaskDecorator`,用于恢复日志和
+ Observation 上下文;官方同时提醒大量极小任务会有传播开销。
+
+### OpenTelemetry
+
+- OTel 官方把 Java Agent 列为普通 Spring Boot 应用的默认零代码方案,因为它比
+ Spring Boot Starter 提供更多开箱插桩。
+- Starter 主要面向 Native Image、Agent 启动开销不满足要求、已有其他 Java Agent,
+ 或需要通过 Spring 配置文件管理 OTel 的场景。
+- Java Agent 覆盖 Spring Web MVC、JDBC、Lettuce、Java Executors、Logback 等
+ SkillHub 关键边界。
+- Agent 的 Logback MDC 默认键为 `trace_id`、`span_id`、`trace_flags`;Micrometer
+ 默认键为 `traceId`、`spanId`。若支持两种运行模式,必须统一日志字段,不能让查询方
+ 感知两套命名。
+- OTel 官方要求在目标部署环境实测 Agent 开销,没有通用的固定开销数字;采样率、
+ JDBC/Redis Span 数量和资源限制都会影响结果。
+
+### W3C Trace Context
+
+- `traceparent` / `tracestate` 是厂商中立的传播协议。
+- Header 必须按标准校验;无效上下文应丢弃并创建新 Trace。
+- Trace Context 不得携带用户身份、IP、Token 或其他敏感信息。
+- 公网调用方可伪造 sampled 标志,因此采样和费用控制必须由服务端约束。
+
+## 开源项目观察
+
+- OpenTelemetry Demo 的 Java 服务直接在镜像中挂载
+ `opentelemetry-javaagent.jar`,通过标准 `OTEL_*` 配置连接 Collector,代表
+ 云原生 Agent 路径。
+- Spring Petclinic Microservices 使用 Spring Boot tracing starter 和 Zipkin 后端,
+ 代表 Spring 原生集成路径。后端选择不同,但应用侧仍依赖 Spring 观测抽象。
+- RuoYi-Cloud-Plus 预留 SkyWalking Java Agent 和 OAP/UI,代表厂商 Agent 路径;
+ 适用于组织已统一使用 SkyWalking 的情况,不适合作为 SkillHub 的内部协议。
+
+## 对 SkillHub 的候选结论
+
+应用代码的稳定边界应是 Spring 的 Observation/Tracing 抽象与 W3C 协议,而不是某个
+日志或 APM 产品:
+
+```text
+HTTP / Executor / Scheduler / Reliable Task boundary
+ │
+ Observability interceptor
+ │
+ Micrometer Observation / Tracing facade
+ │
+ OpenTelemetry bridge + W3C
+ │
+ optional OTLP exporter / Collector
+```
+
+候选主运行模式:
+
+- 应用内使用 Micrometer Tracing + OpenTelemetry bridge,保证 Spring Boot 3.2.3
+ 原生整合、统一 MDC 字段和自定义基础设施 Observation。
+- OTLP Exporter 默认关闭;开启后只负责异步导出,不改变请求结果。
+- Java Agent 作为高级部署模式,用于获得 JDBC、Redis、第三方 HTTP Client 等更广
+ 自动插桩。Agent 与应用内自动插桩不得同时启用,除非原型证明不会产生重复 Span。
+- 无 Trace SDK/Agent 时仍保留 `requestId` 日志关联;Trace 是增强能力,不是业务前置条件。
+
+最终选择仍需原型验证:同一请求的 Span 是否重复、线程池上下文是否串号、Collector
+中断是否影响延迟、日志字段是否一致、关闭 tracing 后业务行为是否完全不变。
+
+## 参考资料
+
+- [Spring Boot 3.2.3 Tracing](https://docs.spring.io/spring-boot/docs/3.2.3/reference/html/actuator.html#actuator.micrometer-tracing)
+- [Spring Boot current Tracing](https://docs.spring.io/spring-boot/reference/actuator/tracing.html)
+- [Spring Framework 6.1 ContextPropagatingTaskDecorator](https://docs.spring.io/spring-framework/docs/6.1.4/javadoc-api/org/springframework/core/task/support/ContextPropagatingTaskDecorator.html)
+- [OpenTelemetry Java Agent](https://opentelemetry.io/docs/zero-code/java/agent/)
+- [OpenTelemetry Spring Boot Starter](https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/)
+- [OpenTelemetry Java supported libraries](https://opentelemetry.io/docs/zero-code/java/agent/supported-libraries/)
+- [OpenTelemetry Java Agent performance](https://opentelemetry.io/docs/zero-code/java/agent/performance/)
+- [W3C Trace Context](https://www.w3.org/TR/trace-context/)
+- [Spring Cloud Sleuth end-of-line notice](https://docs.spring.io/spring-cloud-sleuth/docs/current/reference/html/)
+- [OpenTelemetry Demo](https://github.com/open-telemetry/opentelemetry-demo)
+- [Spring Petclinic Microservices](https://github.com/spring-petclinic/spring-petclinic-microservices)
+- [RuoYi-Cloud-Plus](https://github.com/dromara/RuoYi-Cloud-Plus)
From e9a913e30b81516c1dff67f4e9c249ced38a29d6 Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Fri, 31 Jul 2026 15:51:31 +0800
Subject: [PATCH 05/10] fix(observability): tighten tracing integration
boundaries
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.env.release.example | 2 +-
compose.release.yml | 2 +-
docs/09-deployment.md | 10 +-
...6-07-31-observability-construction-plan.md | 12 +-
docs/observability-decision-map.md | 24 +--
docs/observability-developer-guide.md | 138 ++++++++++++++++++
.../auth/oauth/GitLabClaimsExtractor.java | 2 +
7 files changed, 171 insertions(+), 19 deletions(-)
create mode 100644 docs/observability-developer-guide.md
diff --git a/.env.release.example b/.env.release.example
index 1b8e38e69..3f8380b2c 100644
--- a/.env.release.example
+++ b/.env.release.example
@@ -56,7 +56,7 @@ SESSION_COOKIE_SECURE=false
# Observability defaults require no Collector or tracing backend.
# Use json in container deployments when stdout is collected centrally.
SKILLHUB_TRACING_MODE=none
-SKILLHUB_LOG_FORMAT=text
+SKILLHUB_LOG_FORMAT=json
SKILLHUB_LOG_ASYNC_QUEUE_SIZE=1024
SKILLHUB_SERVICE_VERSION=unknown
SKILLHUB_SERVICE_ENVIRONMENT=production
diff --git a/compose.release.yml b/compose.release.yml
index db306bda8..e9cb2a443 100644
--- a/compose.release.yml
+++ b/compose.release.yml
@@ -89,7 +89,7 @@ services:
SKILLHUB_SECURITY_SCANNER_MODE: upload
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none}
- SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-text}
+ SKILLHUB_LOG_FORMAT: ${SKILLHUB_LOG_FORMAT:-json}
SKILLHUB_LOG_ASYNC_QUEUE_SIZE: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:-1024}
SKILLHUB_SERVICE_VERSION: ${SKILLHUB_SERVICE_VERSION:-unknown}
SKILLHUB_SERVICE_ENVIRONMENT: ${SKILLHUB_SERVICE_ENVIRONMENT:-production}
diff --git a/docs/09-deployment.md b/docs/09-deployment.md
index 847391c76..6a6a52762 100644
--- a/docs/09-deployment.md
+++ b/docs/09-deployment.md
@@ -331,13 +331,14 @@ override 或部署平台环境变量把上述 `SPRING_SECURITY_*` 变量注入 `
```dotenv
SKILLHUB_TRACING_MODE=none
-SKILLHUB_LOG_FORMAT=text
+SKILLHUB_LOG_FORMAT=json
SKILLHUB_SERVICE_VERSION=v0.2.15
SKILLHUB_SERVICE_ENVIRONMENT=production
```
-部署环境建议将 `SKILLHUB_LOG_FORMAT` 设为 `json`,由 Filebeat、Fluent Bit 或容器平台
-采集 stdout。SkillHub 不直接连接 Elasticsearch。JSON 日志使用以下稳定字段:
+发布 Compose 默认使用 ECS 风格 JSON,由 Filebeat、Fluent Bit 或容器平台采集 stdout。
+本地源码开发仍可使用 `SKILLHUB_LOG_FORMAT=text`。SkillHub 不直接连接 Elasticsearch。
+JSON 日志使用以下稳定字段:
- `request.id`:SkillHub 请求、响应和审计关联 ID。
- `trace.id`、`span.id`:当前存在有效 Trace 时输出。
@@ -447,6 +448,9 @@ SkyWalking 中没有被保留的 Trace。这是头部采样的预期行为。
关闭 Trace 和 JSON 日志不会改变请求、数据库或异步任务的业务语义。
+开发者接入统一标准的最小步骤、内部/外部 HTTP Client 传播边界和扩展点见:
+[可观测性开发者接入指南](./observability-developer-guide.md)。
+
## 11 安全扫描服务
如果要启用 `skill-scanner` 后端链路,当前仓库建议按下面的方式部署:
diff --git a/docs/2026-07-31-observability-construction-plan.md b/docs/2026-07-31-observability-construction-plan.md
index 688528352..cbbe85feb 100644
--- a/docs/2026-07-31-observability-construction-plan.md
+++ b/docs/2026-07-31-observability-construction-plan.md
@@ -35,7 +35,8 @@ Issue #597 中“搜索索引可靠异步交付”应作为独立问题处理,
Elasticsearch/Kibana。
7. Trace 可以选择通过 OTLP Collector 接入 SkyWalking。
8. Collector、SkyWalking、Elasticsearch 或日志采集器不可用时,SkillHub 业务继续运行。
-9. 同一进程只能有一个实际生效的 Tracer。
+9. SkillHub 应用配置只能启用一个应用内 Tracer;`external-agent` 模式下唯一外部
+ Agent 由部署参数和发布检查保证。
本方案按多个小阶段、小提交实施和验证,全部通过后再统一创建一个替代 PR。
@@ -104,14 +105,15 @@ skillhub:
必须保证:
- `none` 和 `external-agent` 不创建应用内 OTel Span。
-- `otel-sdk` 不支持同时启用 SkyWalking、OTel 或其他外部 Tracing Agent。
+- `otel-sdk` 不支持同时启用 SkyWalking、OTel 或其他外部 Tracing Agent;应用只能校验
+ 自身 endpoint/mode 冲突,不能可靠识别任意 JVM Agent。
- `external-agent` 不创建 OTLP Exporter。
- SkillHub 配置能够识别的冲突应在启动时失败;任意 Java Agent 无法被应用可靠识别,因此
部署检查和原型测试还必须验证实际 JVM 参数中只有一个 Tracer。
-一期实现并验证三种模式的互斥边界和日志关联。`external-agent` 只验证 SkyWalking Agent
-接管 Trace 后不会与应用内 OTel Tracer 冲突;SkyWalking 特有高级能力不进入 SkillHub
-核心代码。
+一期实现并验证三种模式的应用上下文互斥边界和日志关联。`external-agent` 只验证
+SkyWalking Agent 接管 Trace 时应用内 OTel Tracer/Exporter 不工作;“只挂载一个外部
+Agent”属于部署验收项。SkyWalking 特有高级能力不进入 SkillHub 核心代码。
## 6. 关联字段契约
diff --git a/docs/observability-decision-map.md b/docs/observability-decision-map.md
index 7ca4483b8..3f9ad9188 100644
--- a/docs/observability-decision-map.md
+++ b/docs/observability-decision-map.md
@@ -1,11 +1,14 @@
# 通用可观测性决策图
目标:为 SkillHub 建立独立、通用、可插拔的日志关联、指标和链路追踪基础设施。
-它观察 HTTP、线程池、定时任务和可靠任务等执行边界,但不进入业务模型和业务载荷。
+当前实现覆盖 HTTP、SkillHub 管理的线程池和明确接入的内部 HTTP Client;定时任务、
+Redis Stream 消费循环和 Reclaimer 是独立后台边界,不继承 HTTP 上下文。
+这些边界不进入业务模型和业务载荷。
边界:
-- Servlet Filter、执行器装饰器、调度拦截器和任务执行拦截器负责建立/恢复上下文。
+- Servlet Filter、执行器装饰器和明确接入的 Client Builder 负责建立/恢复上下文。
+- 定时任务和 Redis Stream 目前只输出自身执行日志,不自动继承请求或 Trace 上下文。
- 业务代码不读写 MDC,不负责创建通用 Span,也不负责统计任务生命周期指标。
- 使用 W3C Trace Context;日志后端、Metrics 后端和 Trace Exporter 均可替换。
- 上下文是有长度限制的基础设施元数据,不进入业务 payload。
@@ -13,8 +16,9 @@
必须满足的不变量:
-- 每个执行边界都正确建立作用域并在 `finally` 清理,线程复用不得串号。
-- 日志稳定输出 `requestId`、`traceId`、`spanId`;任务执行时额外输出执行资源标识。
+- 每个已纳入本期的执行边界都正确建立作用域并在 `finally` 清理,线程复用不得串号。
+- 日志稳定输出 `requestId`、`traceId`、`spanId`(存在时);任务执行资源标识不由本期
+ 可观测性自动生成。
- Trace 与 Metrics 可关闭、可替换;关闭后不得改变业务行为。
- 指标只使用低基数维度,业务 ID 不进入标签。
- 采集端不可用必须异步、限时、限队列并 fail-open。
@@ -40,8 +44,8 @@ Type: Research
### Question
-如何区分现有 `X-Request-Id`、W3C `traceId/spanId` 和执行资源标识,并跨 HTTP、线程池、
-调度器和持久化任务边界传播?
+如何区分现有 `X-Request-Id`、W3C `traceId/spanId` 和执行资源标识,并跨 HTTP、线程池
+边界传播?
### Answer
@@ -50,7 +54,7 @@ Type: Research
- `requestId` 是 SkillHub 的请求/审计关联标识,不冒充分布式 Trace。
- `traceId/spanId` 由 Tracer 生成,跨进程只使用 W3C `traceparent/tracestate`。
- 定时任务或可靠任务的执行资源 ID 只作为当前执行作用域属性,不进入业务 payload。
-- HTTP、线程池、调度器和持久化 carrier 的注入/提取全部位于基础设施拦截器。
+- HTTP 和线程池 carrier 的注入/提取位于基础设施拦截器;持久化任务不携带 HTTP Trace。
- 不传播任意 MDC Map;baggage 默认关闭,任何允许项都必须低敏、限长、显式配置。
- 无效或不可信的公网 Trace Context 按 W3C 规则丢弃,服务端控制采样。
@@ -84,8 +88,9 @@ Type: Prototype
### Question
-验证线程复用隔离、嵌套作用域、异步/调度/持久化任务边界、采样、Exporter 超时、
-Collector 中断、队列打满和关闭观测能力等场景。
+验证线程复用隔离、嵌套作用域、异步任务边界、采样、Exporter 超时、Collector 中断、
+队列打满和关闭观测能力等场景。调度任务和 Redis Stream 的独立后台边界只验证不继承
+请求上下文。
### Answer
@@ -97,6 +102,7 @@ Collector 中断、队列打满和关闭观测能力等场景。
- Scanner 使用 Spring 管理的 `WebClient.Builder` 传播 W3C `traceparent`。
- 面向用户配置的 GitLab 外部 Client 不传播 Trace Context。
- `none / otel-sdk / external-agent` 的应用上下文和 Exporter 条件符合设计。
+- `@Scheduled` 和 Redis Stream/Reclaimer 不继承请求上下文,保持独立后台执行边界。
Collector 中断、日志背压、采样率和关闭行为仍由 `big-main` 精确 SHA 镜像的远端原型验证。
diff --git a/docs/observability-developer-guide.md b/docs/observability-developer-guide.md
new file mode 100644
index 000000000..205a921c7
--- /dev/null
+++ b/docs/observability-developer-guide.md
@@ -0,0 +1,138 @@
+# 可观测性开发者接入指南
+
+本文说明 SkillHub 代码如何接入统一的日志关联和链路追踪标准。
+开发者不需要直接操作 MDC、OpenTelemetry SDK 或 SkyWalking API。
+
+## 1. 统一标准
+
+| 信息 | 来源 | 日志字段 | 传播方式 |
+|---|---|---|---|
+| 请求关联 ID | `RequestIdFilter` / `RequestIdAccessor` | `request.id` | `X-Request-Id` |
+| 分布式 Trace ID | Micrometer Tracing | `trace.id` | W3C `traceparent` |
+| Span ID | Micrometer Tracing | `span.id` | 当前 Trace Scope |
+
+`request.id` 是 SkillHub 的请求/审计关联标识,不等同于 `trace.id`。
+请求没有链路追踪时仍应保留 `request.id`。
+
+## 2. 运行模式
+
+通过 `SKILLHUB_TRACING_MODE` 选择一种模式,修改后重启应用:
+
+- `none`:默认模式。无应用内 OTel SDK 和 OTLP 导出,只保留 `request.id`。
+- `otel-sdk`:使用 Micrometer Tracing + OTel Bridge;配置
+ `MANAGEMENT_OTLP_TRACING_ENDPOINT` 后才向 Collector 导出。
+- `external-agent`:应用内 Tracer 为 NOOP,由部署环境提供唯一的外部 Agent。
+ SkillHub 只能校验自身配置,不能识别任意 JVM Agent;唯一 Agent 是部署检查项。
+
+`none`/`external-agent` 不能配置 OTLP endpoint;`otel-sdk` 与外部 Tracing Agent
+不得在同一进程中叠加。
+
+## 3. 开发者接入方式
+
+### 3.1 普通 HTTP 请求
+
+不需要增加代码。`RequestIdFilter` 会生成或校验 `X-Request-Id`,并在请求结束时清理
+线程上下文。Micrometer Tracing 负责在 `otel-sdk` 模式下创建 HTTP Observation 和 Trace。
+
+业务代码不要:
+
+- `MDC.put` / `MDC.remove` 写入请求关联字段;
+- 手工解析或拼接 `traceparent`;
+- 在日志中输出完整 MDC Map。
+
+### 3.2 Spring 异步任务
+
+优先使用已有的 `skillhubEventExecutor`:
+
+```java
+@Async("skillhubEventExecutor")
+public void handleEvent(SkillPublishedEvent event) {
+ // 直接记录日志即可,request.id/trace.id/span.id 会按提交时的上下文恢复
+}
+```
+
+新增 Spring 管理的线程池时,注入统一的
+`ContextPropagatingTaskDecorator`,不要自己复制 MDC:
+
+```java
+@Bean
+ThreadPoolTaskExecutor myExecutor(
+ ContextPropagatingTaskDecorator contextDecorator
+) {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setTaskDecorator(contextDecorator);
+ executor.initialize();
+ return executor;
+}
+```
+
+该装饰器负责捕获、恢复和清理 `RequestIdAccessor` 与 OTel Observation Scope。
+
+### 3.3 内部 HTTP 服务
+
+内部服务调用必须使用 Spring 管理的 `WebClient.Builder`,这样 `otel-sdk` 模式下会
+自动传播 W3C Trace Context:
+
+```java
+@Bean
+HttpClient scannerClient(
+ WebClient.Builder builder
+) {
+ return new WebClientHttpClient(builder.build());
+}
+```
+
+Scanner 是当前已接入的内部客户端。新增内部客户端时,应补一个测试,断言请求包含合法
+的 `traceparent`。
+
+### 3.4 外部 HTTP 服务
+
+面向用户配置的 GitLab、第三方 API 等外部服务不要复用内部观测 Builder,也不要手工
+删除 Header。使用明确不接入 SkillHub Observation 的客户端,并补测试断言请求不包含
+`traceparent`。
+
+### 3.5 定时任务和 Redis Stream
+
+当前实现把定时任务、Redis Stream 消费循环和 Reclaimer 视为独立后台执行边界:
+
+- 不继承任意 HTTP 请求的 `request.id` 或 `trace.id`;
+- 不把 HTTP Trace Context 写入 Redis 业务载荷;
+- 日志仍可使用 ECS 格式和固定服务字段;
+- 若未来需要任务级关联,应增加独立的任务执行 ID/Observation carrier,并单独设计
+ 持久化与重试语义。
+
+因此,不要假设在 `@Scheduled` 或 Stream consumer 中能自动查到发起 HTTP 请求的 Trace。
+
+## 4. 可扩展点
+
+| 扩展需求 | 应扩展的位置 | 不应修改的位置 |
+|---|---|---|
+| 新增请求关联来源 | `RequestIdFilter` / `RequestIdAccessor` | 业务 Controller、DTO |
+| 新增线程上下文 | `RequestIdThreadLocalAccessor` / `ContextRegistry` | 每个任务的 `MDC` 代码 |
+| 新增 Tracing 后端 | Micrometer Bridge / Collector 配置 | 业务服务 |
+| 新增日志字段 | `SkillHubEcsEncoder` 白名单 | “输出全部 MDC” |
+| 新增内部 HTTP 客户端 | Spring `WebClient.Builder` + propagation test | URL 正则删 Header |
+| 新增外部 HTTP 客户端 | 独立客户端构建入口 + no-propagation test | 依赖全局默认行为 |
+
+## 5. 接入验收清单
+
+新增一个执行边界或客户端时,至少补充:
+
+1. `none` 模式下业务结果不变;
+2. `otel-sdk` 模式下内部调用的 `traceparent` 合法;
+3. 外部调用不携带 `traceparent`;
+4. 线程复用后上下文被清理,不发生串号;
+5. 日志只出现 `request.id`、`trace.id`、`span.id` 等白名单字段;
+6. Collector 不可用时不影响业务结果。
+
+运行后端验证使用:
+
+```bash
+make test-backend-app
+```
+
+部署级变更再运行:
+
+```bash
+make staging
+```
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 7a744a30e..6f0a87b40 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
@@ -3,6 +3,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
@@ -31,6 +32,7 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
* 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.
*/
+ @Autowired
public GitLabClaimsExtractor() {
this(RestClient.builder());
}
From 3f14d6e3ead11374075768db0b7210d66f96c60e Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Mon, 3 Aug 2026 11:57:18 +0800
Subject: [PATCH 06/10] fix(observability): harden operational log privacy
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.../exception/GlobalExceptionHandler.java | 25 ++---
.../skillhub/filter/RequestLoggingFilter.java | 20 ----
.../exception/GlobalExceptionHandlerTest.java | 102 +++++++++++++++++-
.../filter/RequestLoggingFilterTest.java | 17 +--
4 files changed, 121 insertions(+), 43 deletions(-)
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java
index 712411862..c2c807b68 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java
@@ -1,7 +1,6 @@
package com.iflytek.skillhub.exception;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
-import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
@@ -113,11 +112,11 @@ public ResponseEntity> handleSessionInvalidated(
public ResponseEntity> handleStorageAccess(StorageAccessException ex, HttpServletRequest request) {
metrics.incrementStorageAccessFailure(ex.getOperation());
logger.warn(
- "Object storage unavailable [requestId={}, method={}, path={}, userId={}, operation={}, key={}]",
+ "Object storage unavailable [requestId={}, method={}, path={}, authentication={}, operation={}, key={}]",
requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
- resolveUserId(request),
+ resolveAuthenticationState(request),
ex.getOperation(),
ex.getKey(),
ex
@@ -142,11 +141,11 @@ public ResponseEntity> handleAsyncRequestTimeout(AsyncRequestTimeoutException
@ExceptionHandler(Exception.class)
public ResponseEntity> handleGlobalException(Exception ex, HttpServletRequest request) {
logger.error(
- "Unhandled API exception [requestId={}, method={}, path={}, userId={}]",
+ "Unhandled API exception [requestId={}, method={}, path={}, authentication={}]",
requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
- resolveUserId(request),
+ resolveAuthenticationState(request),
ex
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
@@ -155,12 +154,12 @@ public ResponseEntity> handleGlobalException(Exception ex, Htt
private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) {
logger.info(
- "API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]",
+ "API request failed [requestId={}, status={}, method={}, path={}, authentication={}, code={}]",
requestIdAccessor.current(),
status.value(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
- resolveUserId(request),
+ resolveAuthenticationState(request),
messageCode
);
}
@@ -173,13 +172,11 @@ private ResponseEntity> renderLocalizedError(LocalizedMessage
apiResponseFactory.error(status.value(), error.messageCode(), error.messageArgs()));
}
- private String resolveUserId(HttpServletRequest request) {
- if (!(request.getUserPrincipal() instanceof Authentication authentication)) {
- return "anonymous";
+ private String resolveAuthenticationState(HttpServletRequest request) {
+ if (request.getUserPrincipal() instanceof Authentication authentication
+ && authentication.isAuthenticated()) {
+ return "authenticated";
}
- if (authentication.getPrincipal() instanceof PlatformPrincipal principal) {
- return principal.userId();
- }
- return authentication.getName();
+ return "anonymous";
}
}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java
index d46a1e49d..1de3809b2 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java
@@ -16,7 +16,6 @@
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
-import java.io.UnsupportedEncodingException;
import java.util.Set;
/**
@@ -27,8 +26,6 @@
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
- private static final int MAX_LOG_BODY_LENGTH = 200;
-
private static final Set SKIP_PREFIXES = Set.of(
"/actuator", "/favicon.ico", "/assets/"
);
@@ -85,11 +82,6 @@ private void logRequest(ContentCachingRequestWrapper request, ContentCachingResp
sb.append(" | UA: ").append(truncate(userAgent, 80));
}
- String requestBody = getRequestBody(request);
- if (requestBody != null && !requestBody.isBlank()) {
- sb.append(" | Body: ").append(requestBody);
- }
-
log.info(sb.toString());
}
@@ -117,18 +109,6 @@ private void prepareSseResponse(HttpServletResponse response) {
response.setHeader("X-Accel-Buffering", "no");
}
- private String getRequestBody(ContentCachingRequestWrapper request) {
- byte[] buf = request.getContentAsByteArray();
- if (buf.length > 0) {
- try {
- return truncate(new String(buf, request.getCharacterEncoding()), MAX_LOG_BODY_LENGTH);
- } catch (UnsupportedEncodingException e) {
- return "[unknown encoding]";
- }
- }
- return null;
- }
-
private String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
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 6aae4cb05..b29c54289 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
@@ -4,28 +4,44 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
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 com.iflytek.skillhub.storage.StorageAccessException;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.LoggerFactory;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
+ private static final String STABLE_USER_ID = "stable-user-123";
+
+ private final Logger logger =
+ (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
@Mock
private SensitiveLogSanitizer sensitiveLogSanitizer;
@@ -36,12 +52,14 @@ class GlobalExceptionHandlerTest {
private HttpServletRequest request;
private GlobalExceptionHandler handler;
+ private ListAppender appender;
+ private RequestIdAccessor requestIdAccessor;
@BeforeEach
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out");
- RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
+ requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC),
@@ -55,6 +73,14 @@ void setUp() {
);
}
+ @AfterEach
+ void tearDown() {
+ if (appender != null) {
+ logger.detachAppender(appender);
+ appender.stop();
+ }
+ }
+
@Test
void handleAsyncRequestTimeout_shouldReturnNoContentForSseRequests() {
when(request.getRequestURI()).thenReturn("/api/v1/notifications/sse");
@@ -67,6 +93,7 @@ void handleAsyncRequestTimeout_shouldReturnNoContentForSseRequests() {
@Test
void handleAsyncRequestTimeout_shouldReturnApiEnvelopeForNonSseRequests() {
+ attachAppender();
when(request.getRequestURI()).thenReturn("/api/v1/publish");
when(request.getMethod()).thenReturn("POST");
when(sensitiveLogSanitizer.sanitizeRequestTarget(request)).thenReturn("/api/v1/publish");
@@ -78,6 +105,53 @@ void handleAsyncRequestTimeout_shouldReturnApiEnvelopeForNonSseRequests() {
ApiResponse> body = (ApiResponse>) response.getBody();
assertThat(body.code()).isEqualTo(408);
assertThat(body.msg()).isEqualTo("Request timed out");
+ assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
+ .contains("authentication=anonymous")
+ .doesNotContain("userId="));
+ }
+
+ @Test
+ void handleGlobalException_shouldLogAuthenticationWithoutStableUserId() {
+ authenticateRequest();
+ attachAppender();
+ when(request.getMethod()).thenReturn("GET");
+ when(sensitiveLogSanitizer.sanitizeRequestTarget(request))
+ .thenReturn("/api/v1/skills/sensitive");
+
+ try (RequestIdAccessor.Scope ignored = requestIdAccessor.open("request-123")) {
+ ResponseEntity> response = handler.handleGlobalException(
+ new RuntimeException("boom"), request);
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+
+ assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
+ .contains("requestId=request-123")
+ .contains("authentication=authenticated")
+ .doesNotContain(STABLE_USER_ID)
+ .doesNotContain("userId="));
+ }
+
+ @Test
+ void handleStorageAccess_shouldLogAuthenticationWithoutStableUserId() {
+ authenticateRequest();
+ attachAppender();
+ when(request.getMethod()).thenReturn("GET");
+ when(sensitiveLogSanitizer.sanitizeRequestTarget(request))
+ .thenReturn("/api/v1/skills/test/download");
+
+ StorageAccessException exception = new StorageAccessException(
+ "download", "skills/test.zip", new RuntimeException("unavailable"));
+ try (RequestIdAccessor.Scope ignored = requestIdAccessor.open("request-123")) {
+ ResponseEntity> response =
+ handler.handleStorageAccess(exception, request);
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
+ }
+
+ assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
+ .contains("requestId=request-123")
+ .contains("authentication=authenticated")
+ .doesNotContain(STABLE_USER_ID)
+ .doesNotContain("userId="));
}
@Test
@@ -100,4 +174,30 @@ void handleSessionInvalidated_shouldRethrowNonSessionException() {
assertThatThrownBy(() -> handler.handleSessionInvalidated(ex, request))
.isSameAs(ex);
}
+
+ private void authenticateRequest() {
+ PlatformPrincipal principal = new PlatformPrincipal(
+ STABLE_USER_ID,
+ "User",
+ "user@example.com",
+ null,
+ "local",
+ Set.of("USER")
+ );
+ when(request.getUserPrincipal()).thenReturn(
+ new UsernamePasswordAuthenticationToken(principal, null, List.of()));
+ }
+
+ private void attachAppender() {
+ logger.setLevel(Level.INFO);
+ appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ }
+
+ private List loggedMessages() {
+ return appender.list.stream()
+ .map(ILoggingEvent::getFormattedMessage)
+ .toList();
+ }
}
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java
index 11ec0aecb..18e6f88a7 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java
@@ -36,16 +36,17 @@ void tearDown() {
}
@Test
- void doFilterInternal_truncatesLongRequestBodyAndOmitsResponseBody()
+ void doFilterInternal_omitsRequestAndResponseBodies()
throws ServletException, IOException {
RequestLoggingFilter filter = new RequestLoggingFilter();
- String longBody = "x".repeat(5_000);
+ String requestBody = "{\"username\":\"alice\",\"password\":\"super-secret\"}";
+ String responseBody = "x".repeat(5_000);
attachAppender();
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/test");
request.setCharacterEncoding(StandardCharsets.UTF_8.name());
request.setContentType("application/json");
- request.setContent(longBody.getBytes(StandardCharsets.UTF_8));
+ request.setContent(requestBody.getBytes(StandardCharsets.UTF_8));
MockHttpServletResponse response = new MockHttpServletResponse();
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
@@ -53,17 +54,17 @@ void doFilterInternal_truncatesLongRequestBodyAndOmitsResponseBody()
FilterChain filterChain = (req, res) -> {
req.getReader().lines().count();
res.setContentType("application/json");
- res.getWriter().write(longBody);
+ res.getWriter().write(responseBody);
};
filter.doFilter(request, response, filterChain);
List loggedMessages = loggedMessages();
- assertThat(loggedMessages).anySatisfy(message ->
- assertThat(message).contains("Body: " + "x".repeat(200) + "...[truncated]"));
- assertThat(loggedMessages).noneMatch(message -> message.contains("Body: " + longBody));
+ assertThat(loggedMessages).anyMatch(message -> message.contains("POST /api/test"));
+ assertThat(loggedMessages).noneMatch(message -> message.contains("Body:"));
+ assertThat(loggedMessages).noneMatch(message -> message.contains("super-secret"));
assertThat(loggedMessages).noneMatch(message -> message.contains("Response Body:"));
- assertThat(response.getContentAsString()).isEqualTo(longBody);
+ assertThat(response.getContentAsString()).isEqualTo(responseBody);
}
@Test
From 5058cc3387554d85e87e54862461ceb73c997c18 Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Mon, 3 Aug 2026 14:46:03 +0800
Subject: [PATCH 07/10] feat(observability): propagate message trace context
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
...6-07-31-observability-construction-plan.md | 16 +-
docs/observability-decision-map.md | 25 +-
docs/observability-developer-guide.md | 28 ++-
.../skillhub/config/RedisStreamConfig.java | 14 +-
.../skillhub/filter/RequestIdFilter.java | 5 +-
.../observability/MessageCarrierAdapter.java | 15 ++
.../MessageObservationSupport.java | 161 +++++++++++++
.../observability/RequestIdAccessor.java | 15 ++
.../stream/AbstractStreamConsumer.java | 34 ++-
.../stream/RedisStreamMessageCarrier.java | 34 +++
.../stream/RedissonScanTaskProducer.java | 17 +-
.../skillhub/stream/ScanTaskConsumer.java | 20 +-
.../iflytek/skillhub/stream/package-info.java | 4 +
.../MessageObservationSupportTest.java | 217 ++++++++++++++++++
.../stream/AbstractStreamConsumerTest.java | 41 +++-
.../RedissonScanTaskProducerLoggingTest.java | 9 +-
.../stream/RedissonScanTaskProducerTest.java | 37 ++-
.../stream/ScanTaskConsumerLoggingTest.java | 6 +-
.../ScanTaskConsumerPathSafetyTest.java | 6 +-
.../skillhub/stream/ScanTaskConsumerTest.java | 6 +-
20 files changed, 655 insertions(+), 55 deletions(-)
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/MessageCarrierAdapter.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/MessageObservationSupport.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedisStreamMessageCarrier.java
create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/package-info.java
create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/MessageObservationSupportTest.java
diff --git a/docs/2026-07-31-observability-construction-plan.md b/docs/2026-07-31-observability-construction-plan.md
index cbbe85feb..d03651a07 100644
--- a/docs/2026-07-31-observability-construction-plan.md
+++ b/docs/2026-07-31-observability-construction-plan.md
@@ -282,12 +282,16 @@ MANAGEMENT_OTLP_TRACING_ENDPOINT=http://otel-collector:4318/v1/traces
测试必须重复复用同一工作线程,证明不同请求之间不会串号。
-### 10.2 长生命周期后台线程
+### 10.2 消息队列与长生命周期后台线程
-Redis Stream 消费循环、Reclaimer 和其他长生命周期线程不继承应用启动线程或任意请求的
-MDC。需要追踪具体任务时,由通用任务执行边界建立新的上下文。
+Redis Stream 消费循环和 Reclaimer 不继承应用启动线程或任意请求的 MDC。Producer 通过
+通用消息 Observation 把 W3C Trace Context 与受控 Request ID 注入 transport metadata;
+Consumer/Reclaimer 逐条提取、建立 Scope,并在处理结束后清理。Scanner HTTP 调用自然成为
+Consumer Span 的子调用。
-一期不把 HTTP Trace Context 写入搜索业务 payload,也不改造可靠任务状态机。
+上下文不写入 `ScanTask` 或搜索业务 payload,也不改变可靠任务状态机。普通定时任务没有
+上游 carrier,仍建立独立执行上下文;长期延迟任务使用稳定任务 ID 或 Span Link,不维持
+超长父 Span。
### 10.3 HTTP 出站
@@ -434,6 +438,8 @@ Kibana 使用 `trace.id` 查询日志,SkyWalking 使用同一个 Trace ID 查
- `none`、`otel-sdk`、`external-agent` 的 Spring Context 互斥。
- 未配置 OTLP endpoint 时不创建网络导出。
- 内部 Scanner 请求携带 `traceparent`。
+- Redis Stream Producer/Consumer 保持同一 Trace 和 Request ID,处理结束后线程不串号。
+- 重试发布和 Reclaimer 重新消费仍能恢复消息关联上下文。
- 外部 HTTP 请求不携带 `traceparent`。
### 13.2 远端原型
@@ -463,6 +469,7 @@ Kibana 使用 `trace.id` 查询日志,SkyWalking 使用同一个 Trace ID 查
- HTTP 成功、4xx、5xx 和未认证请求。
- Scanner 成功、超时和失败。
- 异步事件正常执行和抛出异常。
+- Redis Stream 正常消费、失败重试、Pending Reclaim 和重复投递。
- 并发请求重复使用线程池。
- Collector 启动、停止和恢复。
- 日志采集器停止或消费变慢。
@@ -479,6 +486,7 @@ Kibana 使用 `trace.id` 查询日志,SkyWalking 使用同一个 Trace ID 查
- [ ] Request ID 校验、响应和审计关联测试通过。
- [ ] 日志字段符合约定,且不输出完整 MDC。
- [ ] Spring 异步执行器上下文传播和隔离测试通过。
+- [ ] Redis Stream 消息上下文传播、重试、Reclaimer 和隔离测试通过。
- [ ] 内外部 HTTP 传播边界测试通过。
- [ ] 无 OTLP endpoint 时不存在外部连接尝试。
- [ ] Collector 中断不影响 SkillHub 业务结果。
diff --git a/docs/observability-decision-map.md b/docs/observability-decision-map.md
index 3f9ad9188..fc9c039a1 100644
--- a/docs/observability-decision-map.md
+++ b/docs/observability-decision-map.md
@@ -1,14 +1,15 @@
# 通用可观测性决策图
目标:为 SkillHub 建立独立、通用、可插拔的日志关联、指标和链路追踪基础设施。
-当前实现覆盖 HTTP、SkillHub 管理的线程池和明确接入的内部 HTTP Client;定时任务、
-Redis Stream 消费循环和 Reclaimer 是独立后台边界,不继承 HTTP 上下文。
-这些边界不进入业务模型和业务载荷。
+当前实现覆盖 HTTP、SkillHub 管理的线程池、Redis Stream 和明确接入的内部 HTTP Client;
+普通定时任务没有上游 carrier,仍是独立后台边界。Redis Stream 上下文只进入 transport
+metadata,不进入业务模型和业务载荷。
边界:
-- Servlet Filter、执行器装饰器和明确接入的 Client Builder 负责建立/恢复上下文。
-- 定时任务和 Redis Stream 目前只输出自身执行日志,不自动继承请求或 Trace 上下文。
+- Servlet Filter、执行器装饰器、消息 Observation 和明确接入的 Client Builder 负责
+ 建立/恢复上下文。
+- Redis Stream Producer 注入、Consumer/Reclaimer 逐条提取;定时任务不继承任意请求。
- 业务代码不读写 MDC,不负责创建通用 Span,也不负责统计任务生命周期指标。
- 使用 W3C Trace Context;日志后端、Metrics 后端和 Trace Exporter 均可替换。
- 上下文是有长度限制的基础设施元数据,不进入业务 payload。
@@ -54,7 +55,8 @@ Type: Research
- `requestId` 是 SkillHub 的请求/审计关联标识,不冒充分布式 Trace。
- `traceId/spanId` 由 Tracer 生成,跨进程只使用 W3C `traceparent/tracestate`。
- 定时任务或可靠任务的执行资源 ID 只作为当前执行作用域属性,不进入业务 payload。
-- HTTP 和线程池 carrier 的注入/提取位于基础设施拦截器;持久化任务不携带 HTTP Trace。
+- HTTP、线程池和消息 carrier 的注入/提取位于基础设施层;消息上下文是 transport
+ metadata,不是任务业务字段。
- 不传播任意 MDC Map;baggage 默认关闭,任何允许项都必须低敏、限长、显式配置。
- 无效或不可信的公网 Trace Context 按 W3C 规则丢弃,服务端控制采样。
@@ -88,9 +90,9 @@ Type: Prototype
### Question
-验证线程复用隔离、嵌套作用域、异步任务边界、采样、Exporter 超时、Collector 中断、
-队列打满和关闭观测能力等场景。调度任务和 Redis Stream 的独立后台边界只验证不继承
-请求上下文。
+验证线程复用隔离、嵌套作用域、异步任务边界、消息传播、采样、Exporter 超时、
+Collector 中断、队列打满和关闭观测能力等场景。调度任务验证不继承请求上下文;
+Redis Stream 验证逐条注入、提取和清理。
### Answer
@@ -99,10 +101,13 @@ Type: Prototype
- Request ID Scope 在线程复用、嵌套 Scope、异常退出和 `CallerRunsPolicy` 下均能恢复并
清理。
- Micrometer 手工 Span 和 Observation 均能随 `skillhubEventExecutor` 传播。
+- Redis Stream Producer/Consumer 通过通用消息 Observation 传播 W3C Trace Context 和
+ 受控 Request ID,Reclaimer 从原消息重新提取。
- Scanner 使用 Spring 管理的 `WebClient.Builder` 传播 W3C `traceparent`。
- 面向用户配置的 GitLab 外部 Client 不传播 Trace Context。
- `none / otel-sdk / external-agent` 的应用上下文和 Exporter 条件符合设计。
-- `@Scheduled` 和 Redis Stream/Reclaimer 不继承请求上下文,保持独立后台执行边界。
+- `@Scheduled` 保持独立后台执行边界;Redis Stream/Reclaimer 不继承线程上下文,而是
+ 从每条消息的 transport metadata 恢复。
Collector 中断、日志背压、采样率和关闭行为仍由 `big-main` 精确 SHA 镜像的远端原型验证。
diff --git a/docs/observability-developer-guide.md b/docs/observability-developer-guide.md
index 205a921c7..405105edd 100644
--- a/docs/observability-developer-guide.md
+++ b/docs/observability-developer-guide.md
@@ -91,17 +91,27 @@ Scanner 是当前已接入的内部客户端。新增内部客户端时,应补
删除 Header。使用明确不接入 SkillHub Observation 的客户端,并补测试断言请求不包含
`traceparent`。
-### 3.5 定时任务和 Redis Stream
+### 3.5 Redis Stream 和定时任务
-当前实现把定时任务、Redis Stream 消费循环和 Reclaimer 视为独立后台执行边界:
+Redis Stream 已通过 `MessageObservationSupport` 接入通用消息传播:
-- 不继承任意 HTTP 请求的 `request.id` 或 `trace.id`;
-- 不把 HTTP Trace Context 写入 Redis 业务载荷;
-- 日志仍可使用 ECS 格式和固定服务字段;
-- 若未来需要任务级关联,应增加独立的任务执行 ID/Observation carrier,并单独设计
- 持久化与重试语义。
+- Producer 把 `traceparent`、`tracestate` 和受控的 `skillhub.request_id` 写入 Stream
+ transport metadata,不修改 `ScanTask` 等业务对象;
+- `AbstractStreamConsumer` 逐条提取上下文并建立 `CONSUMER` Observation,在 `finally`
+ 中恢复线程原状态;
+- Consumer 内部调用 Scanner 时,Spring 管理的 `WebClient` 自动创建同一 Trace 的子 Span;
+- 重试发布发生在当前 Consumer Scope 内,新消息继续携带关联上下文;Reclaimer 处理原消息
+ 时重新从消息提取,不继承 Reclaimer 线程的上下文;
+- `none` 和 `external-agent` 模式仍传播 Request ID;应用保证完整 W3C Trace 的模式是
+ `otel-sdk`,外部 Agent 的跨 Stream Trace 能力取决于对应 Agent 插件。
-因此,不要假设在 `@Scheduled` 或 Stream consumer 中能自动查到发起 HTTP 请求的 Trace。
+新增 Redis Stream Consumer 应继承 `AbstractStreamConsumer`,新增 Producer 应调用
+`MessageObservationSupport.observePublish`。其他消息中间件只实现自身 carrier 的
+`MessageCarrierAdapter`;传播核心不依赖 Redis、Redisson 或 `Map`。不要在业务 DTO、MDC
+或日志代码中复制上下文。
+
+普通 `@Scheduled` 任务没有上游消息 carrier,仍是独立后台边界;需要长期任务关联时应使用
+稳定任务 ID,而不是把任意历史 HTTP Span 保持为超长父 Span。
## 4. 可扩展点
@@ -113,6 +123,7 @@ Scanner 是当前已接入的内部客户端。新增内部客户端时,应补
| 新增日志字段 | `SkillHubEcsEncoder` 白名单 | “输出全部 MDC” |
| 新增内部 HTTP 客户端 | Spring `WebClient.Builder` + propagation test | URL 正则删 Header |
| 新增外部 HTTP 客户端 | 独立客户端构建入口 + no-propagation test | 依赖全局默认行为 |
+| 新增消息队列边界 | `MessageObservationSupport` + `MessageCarrierAdapter` | 业务 DTO、手工 MDC/OTel API |
## 5. 接入验收清单
@@ -124,6 +135,7 @@ Scanner 是当前已接入的内部客户端。新增内部客户端时,应补
4. 线程复用后上下文被清理,不发生串号;
5. 日志只出现 `request.id`、`trace.id`、`span.id` 等白名单字段;
6. Collector 不可用时不影响业务结果。
+7. 消息 Producer/Consumer 使用同一 Trace,Request ID 不串号,重试和 Reclaimer 不丢关联。
运行后端验证使用:
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java
index 8c479cfeb..87ca07624 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java
@@ -4,6 +4,7 @@
import com.iflytek.skillhub.domain.security.SecurityScanService;
import com.iflytek.skillhub.domain.security.SecurityScanner;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
+import com.iflytek.skillhub.observability.MessageObservationSupport;
import com.iflytek.skillhub.storage.ObjectStorageService;
import com.iflytek.skillhub.stream.RedissonScanTaskProducer;
import com.iflytek.skillhub.stream.ScanTaskConsumer;
@@ -37,8 +38,11 @@ public class RedisStreamConfig {
private Duration reclaimInterval;
@Bean
- public RedissonScanTaskProducer redisScanTaskProducer(RedissonClient redissonClient) {
- return new RedissonScanTaskProducer(redissonClient, streamKey);
+ public RedissonScanTaskProducer redisScanTaskProducer(
+ RedissonClient redissonClient,
+ MessageObservationSupport messageObservationSupport
+ ) {
+ return new RedissonScanTaskProducer(redissonClient, streamKey, messageObservationSupport);
}
@Bean
@@ -47,7 +51,8 @@ public ScanTaskConsumer scanTaskConsumer(RedissonClient redissonClient,
SecurityScanService securityScanService,
SkillVersionRepository skillVersionRepository,
ScanTaskProducer scanTaskProducer,
- ObjectStorageService objectStorageService) {
+ ObjectStorageService objectStorageService,
+ MessageObservationSupport messageObservationSupport) {
return new ScanTaskConsumer(
redissonClient,
streamKey,
@@ -60,7 +65,8 @@ public ScanTaskConsumer scanTaskConsumer(RedissonClient redissonClient,
reclaimEnabled,
reclaimMinIdle,
reclaimBatchSize,
- reclaimInterval
+ reclaimInterval,
+ messageObservationSupport
);
}
}
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 f5b88bc31..d3122e44b 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
@@ -12,7 +12,6 @@
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
@@ -23,8 +22,6 @@
public class RequestIdFilter extends OncePerRequestFilter {
private static final String REQUEST_ID_HEADER = "X-Request-Id";
- private static final Pattern VALID_REQUEST_ID =
- Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$");
private final RequestIdAccessor requestIdAccessor;
@@ -36,7 +33,7 @@ public RequestIdFilter(RequestIdAccessor requestIdAccessor) {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestId = request.getHeader(REQUEST_ID_HEADER);
- if (requestId == null || !VALID_REQUEST_ID.matcher(requestId).matches()) {
+ if (!RequestIdAccessor.isValid(requestId)) {
requestId = UUID.randomUUID().toString();
}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/MessageCarrierAdapter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/MessageCarrierAdapter.java
new file mode 100644
index 000000000..2fb263184
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/MessageCarrierAdapter.java
@@ -0,0 +1,15 @@
+package com.iflytek.skillhub.observability;
+
+import io.micrometer.observation.transport.Propagator;
+
+/**
+ * Adapts transport-specific message headers to the common observation boundary.
+ */
+public interface MessageCarrierAdapter
+ extends Propagator.Getter, Propagator.Setter {
+
+ /**
+ * Removes every value associated with a transport header.
+ */
+ void remove(C carrier, String key);
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/MessageObservationSupport.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/MessageObservationSupport.java
new file mode 100644
index 000000000..4e84e1bd6
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/MessageObservationSupport.java
@@ -0,0 +1,161 @@
+package com.iflytek.skillhub.observability;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.observation.transport.Kind;
+import io.micrometer.observation.transport.ReceiverContext;
+import io.micrometer.observation.transport.SenderContext;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Supplier;
+
+/**
+ * Propagates tracing and request correlation across asynchronous message transports.
+ *
+ * The message carrier owns transport metadata. Business payloads remain independent from
+ * Micrometer, OpenTelemetry, MDC, and a concrete tracing backend.
+ */
+@Component
+public class MessageObservationSupport {
+
+ public static final String REQUEST_ID_FIELD = "skillhub.request_id";
+
+ private static final Set OWNED_TRANSPORT_FIELDS = Set.of(
+ REQUEST_ID_FIELD,
+ "traceparent",
+ "tracestate",
+ "baggage"
+ );
+
+ private final ObservationRegistry observationRegistry;
+ private final RequestIdAccessor requestIdAccessor;
+
+ public MessageObservationSupport(
+ ObservationRegistry observationRegistry,
+ RequestIdAccessor requestIdAccessor
+ ) {
+ this.observationRegistry = observationRegistry;
+ this.requestIdAccessor = requestIdAccessor;
+ }
+
+ /**
+ * Observes a message publish operation and injects the current transport context.
+ */
+ public T observePublish(
+ String messagingSystem,
+ String destination,
+ C carrier,
+ MessageCarrierAdapter carrierAdapter,
+ Supplier action
+ ) {
+ validateArguments(messagingSystem, destination, carrier, action);
+ Objects.requireNonNull(carrierAdapter, "carrierAdapter must not be null");
+ OWNED_TRANSPORT_FIELDS.forEach(field -> carrierAdapter.remove(carrier, field));
+ String requestId = requestIdAccessor.current();
+ if (RequestIdAccessor.isValid(requestId)) {
+ carrierAdapter.set(carrier, REQUEST_ID_FIELD, requestId);
+ }
+
+ SenderContext senderContext = new SenderContext<>(carrierAdapter, Kind.PRODUCER);
+ senderContext.setCarrier(carrier);
+ senderContext.setRemoteServiceName(messagingSystem);
+ return observe(
+ "skillhub.message.publish",
+ destination + " publish",
+ messagingSystem,
+ destination,
+ "publish",
+ senderContext,
+ action
+ );
+ }
+
+ /**
+ * Extracts a message transport context and observes processing inside its scope.
+ */
+ public T observeProcess(
+ String messagingSystem,
+ String destination,
+ C carrier,
+ MessageCarrierAdapter carrierAdapter,
+ Supplier action
+ ) {
+ validateArguments(messagingSystem, destination, carrier, action);
+ Objects.requireNonNull(carrierAdapter, "carrierAdapter must not be null");
+ ReceiverContext receiverContext = new ReceiverContext<>(carrierAdapter, Kind.CONSUMER);
+ receiverContext.setCarrier(carrier);
+ receiverContext.setRemoteServiceName(messagingSystem);
+
+ String propagatedRequestId = carrierAdapter.get(carrier, REQUEST_ID_FIELD);
+ RequestIdAccessor.Scope requestIdScope = requestIdAccessor.openNullable(
+ RequestIdAccessor.isValid(propagatedRequestId) ? propagatedRequestId : null
+ );
+ try (requestIdScope) {
+ return observe(
+ "skillhub.message.process",
+ destination + " process",
+ messagingSystem,
+ destination,
+ "process",
+ receiverContext,
+ action
+ );
+ }
+ }
+
+ /**
+ * Marks the currently active message Observation as failed when processing handles the
+ * exception without rethrowing it.
+ */
+ public void recordCurrentError(Throwable error) {
+ Objects.requireNonNull(error, "error must not be null");
+ Observation currentObservation = observationRegistry.getCurrentObservation();
+ if (currentObservation != null) {
+ currentObservation.error(error);
+ }
+ }
+
+ private T observe(
+ String observationName,
+ String contextualName,
+ String messagingSystem,
+ String destination,
+ String operation,
+ Observation.Context transportContext,
+ Supplier action
+ ) {
+ Observation observation = Observation
+ .createNotStarted(observationName, () -> transportContext, observationRegistry)
+ .contextualName(contextualName)
+ .lowCardinalityKeyValue("messaging.system", messagingSystem)
+ .lowCardinalityKeyValue("messaging.operation.type", operation)
+ .highCardinalityKeyValue("messaging.destination.name", destination)
+ .start();
+ try (Observation.Scope ignored = observation.openScope()) {
+ return action.get();
+ } catch (RuntimeException | Error error) {
+ observation.error(error);
+ throw error;
+ } finally {
+ observation.stop();
+ }
+ }
+
+ private void validateArguments(
+ String messagingSystem,
+ String destination,
+ Object carrier,
+ Supplier> action
+ ) {
+ if (messagingSystem == null || messagingSystem.isBlank()) {
+ throw new IllegalArgumentException("messagingSystem must not be blank");
+ }
+ if (destination == null || destination.isBlank()) {
+ throw new IllegalArgumentException("destination must not be blank");
+ }
+ Objects.requireNonNull(carrier, "carrier must not be null");
+ Objects.requireNonNull(action, "action must not be null");
+ }
+}
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
index ecdfb8038..d6589b88c 100644
--- 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
@@ -4,6 +4,7 @@
import org.springframework.stereotype.Component;
import java.util.Objects;
+import java.util.regex.Pattern;
/**
* Holds the current SkillHub request identifier independently from the logging implementation.
@@ -16,6 +17,9 @@ public class RequestIdAccessor {
public static final String MDC_KEY = "requestId";
+ private static final Pattern VALID_REQUEST_ID =
+ Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$");
+
private final ThreadLocal currentRequestId = new ThreadLocal<>();
/**
@@ -25,6 +29,13 @@ public String current() {
return currentRequestId.get();
}
+ /**
+ * Returns whether a value is safe to use as a request identifier across transport boundaries.
+ */
+ public static boolean isValid(String requestId) {
+ return requestId != null && VALID_REQUEST_ID.matcher(requestId).matches();
+ }
+
/**
* Opens a nested request identifier scope on the current thread.
*/
@@ -34,6 +45,10 @@ public Scope open(String requestId) {
throw new IllegalArgumentException("requestId must not be blank");
}
+ return openNullable(requestId);
+ }
+
+ Scope openNullable(String requestId) {
String previousRequestId = currentRequestId.get();
replace(requestId);
return new Scope(previousRequestId, requestId);
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java
index 229af8514..01a73e77d 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java
@@ -1,5 +1,6 @@
package com.iflytek.skillhub.stream;
+import com.iflytek.skillhub.observability.MessageObservationSupport;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.redisson.api.AutoClaimResult;
@@ -39,6 +40,7 @@ public abstract class AbstractStreamConsumer {
private final Duration reclaimMinIdle;
private final int reclaimBatchSize;
private final Duration reclaimInterval;
+ private final MessageObservationSupport messageObservationSupport;
private final AtomicBoolean running = new AtomicBoolean(false);
private RStream stream;
@@ -47,8 +49,18 @@ public abstract class AbstractStreamConsumer {
protected AbstractStreamConsumer(RedissonClient redissonClient,
String streamKey,
- String groupName) {
- this(redissonClient, streamKey, groupName, true, Duration.ofMinutes(2), 20, Duration.ofSeconds(30));
+ String groupName,
+ MessageObservationSupport messageObservationSupport) {
+ this(
+ redissonClient,
+ streamKey,
+ groupName,
+ true,
+ Duration.ofMinutes(2),
+ 20,
+ Duration.ofSeconds(30),
+ messageObservationSupport
+ );
}
protected AbstractStreamConsumer(RedissonClient redissonClient,
@@ -57,7 +69,8 @@ protected AbstractStreamConsumer(RedissonClient redissonClient,
boolean reclaimEnabled,
Duration reclaimMinIdle,
int reclaimBatchSize,
- Duration reclaimInterval) {
+ Duration reclaimInterval,
+ MessageObservationSupport messageObservationSupport) {
this.redissonClient = redissonClient;
this.streamKey = streamKey;
this.groupName = groupName;
@@ -65,6 +78,7 @@ protected AbstractStreamConsumer(RedissonClient redissonClient,
this.reclaimMinIdle = reclaimMinIdle;
this.reclaimBatchSize = reclaimBatchSize;
this.reclaimInterval = reclaimInterval;
+ this.messageObservationSupport = messageObservationSupport;
this.consumerName = consumerPrefix() + "-" + UUID.randomUUID().toString().substring(0, 8);
}
@@ -195,6 +209,19 @@ private void processMessages(Map> messages)
}
void handleMessage(StreamMessageId messageId, Map data) {
+ messageObservationSupport.observeProcess(
+ RedisStreamMessageCarrier.MESSAGING_SYSTEM,
+ streamKey,
+ data,
+ RedisStreamMessageCarrier.ADAPTER,
+ () -> {
+ handleMessageInScope(messageId, data);
+ return null;
+ }
+ );
+ }
+
+ private void handleMessageInScope(StreamMessageId messageId, Map data) {
T payload = parsePayload(messageId.toString(), data);
if (payload == null) {
acknowledge(messageId);
@@ -208,6 +235,7 @@ void handleMessage(StreamMessageId messageId, Map data) {
markCompleted(payload);
acknowledge(messageId);
} catch (Exception e) {
+ messageObservationSupport.recordCurrentError(e);
handleFailure(payload, retryCount, e);
acknowledge(messageId);
}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedisStreamMessageCarrier.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedisStreamMessageCarrier.java
new file mode 100644
index 000000000..acbb349b1
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedisStreamMessageCarrier.java
@@ -0,0 +1,34 @@
+package com.iflytek.skillhub.stream;
+
+import com.iflytek.skillhub.observability.MessageCarrierAdapter;
+
+import java.util.Map;
+
+/**
+ * Adapts Redis Stream field maps to the transport-neutral message observation boundary.
+ */
+final class RedisStreamMessageCarrier {
+
+ static final String MESSAGING_SYSTEM = "redis";
+
+ static final MessageCarrierAdapter
*/
@Component
public class MessageObservationSupport {
+ /**
+ * Reserved transport field for log correlation when distributed tracing is disabled.
+ */
public static final String REQUEST_ID_FIELD = "skillhub.request_id";
+ // The propagation boundary owns these fields. Removing existing values prevents business
+ // metadata from forging a parent trace or leaking stale context into a newly published message.
private static final Set OWNED_TRANSPORT_FIELDS = Set.of(
REQUEST_ID_FIELD,
"traceparent",
@@ -53,6 +60,9 @@ public T observePublish(
validateArguments(messagingSystem, destination, carrier, action);
Objects.requireNonNull(carrierAdapter, "carrierAdapter must not be null");
OWNED_TRANSPORT_FIELDS.forEach(field -> carrierAdapter.remove(carrier, field));
+
+ // Request ID is propagated independently because it must remain useful in modes where no
+ // tracing handler is registered. Micrometer injects W3C trace fields when tracing is active.
String requestId = requestIdAccessor.current();
if (RequestIdAccessor.isValid(requestId)) {
carrierAdapter.set(carrier, REQUEST_ID_FIELD, requestId);
@@ -63,10 +73,11 @@ public T observePublish(
senderContext.setRemoteServiceName(messagingSystem);
return observe(
"skillhub.message.publish",
- destination + " publish",
+ "publish " + destination,
messagingSystem,
destination,
"publish",
+ "send",
senderContext,
action
);
@@ -88,6 +99,9 @@ public T observeProcess(
receiverContext.setCarrier(carrier);
receiverContext.setRemoteServiceName(messagingSystem);
+ // Micrometer scopes the extracted trace context when the Observation starts. Scope the
+ // independently propagated Request ID over the same processing boundary and restore both
+ // before the worker thread is reused.
String propagatedRequestId = carrierAdapter.get(carrier, REQUEST_ID_FIELD);
RequestIdAccessor.Scope requestIdScope = requestIdAccessor.openNullable(
RequestIdAccessor.isValid(propagatedRequestId) ? propagatedRequestId : null
@@ -95,10 +109,11 @@ public T observeProcess(
try (requestIdScope) {
return observe(
"skillhub.message.process",
- destination + " process",
+ "process " + destination,
messagingSystem,
destination,
"process",
+ "process",
receiverContext,
action
);
@@ -122,7 +137,8 @@ private T observe(
String contextualName,
String messagingSystem,
String destination,
- String operation,
+ String operationName,
+ String operationType,
Observation.Context transportContext,
Supplier action
) {
@@ -130,7 +146,8 @@ private T observe(
.createNotStarted(observationName, () -> transportContext, observationRegistry)
.contextualName(contextualName)
.lowCardinalityKeyValue("messaging.system", messagingSystem)
- .lowCardinalityKeyValue("messaging.operation.type", operation)
+ .lowCardinalityKeyValue("messaging.operation.name", operationName)
+ .lowCardinalityKeyValue("messaging.operation.type", operationType)
.highCardinalityKeyValue("messaging.destination.name", destination)
.start();
try (Observation.Scope ignored = observation.openScope()) {
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java
index 01a73e77d..67ab8fef5 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java
@@ -205,6 +205,7 @@ private void processMessages(Map> messages)
if (messages == null || messages.isEmpty()) {
return;
}
+ // Scope each entry independently because one XREADGROUP batch may contain unrelated traces.
messages.forEach(this::handleMessage);
}
@@ -243,6 +244,8 @@ private void handleMessageInScope(StreamMessageId messageId, Map
private void handleFailure(T payload, int retryCount, Exception e) {
if (retryCount < MAX_RETRY_COUNT) {
+ // Retry publication remains inside the current consumer scope, so the new producer
+ // span and message carrier continue the original trace.
retryMessage(payload, retryCount + 1);
return;
}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedisStreamMessageCarrier.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedisStreamMessageCarrier.java
index acbb349b1..0752d1fcb 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedisStreamMessageCarrier.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedisStreamMessageCarrier.java
@@ -6,6 +6,10 @@
/**
* Adapts Redis Stream field maps to the transport-neutral message observation boundary.
+ *
+ * Redis Stream entries do not have a separate header collection, so reserved propagation fields
+ * share the entry map with business fields. {@code MessageObservationSupport} owns and sanitizes
+ * those reserved fields; business payload types remain unaware of them.
*/
final class RedisStreamMessageCarrier {
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedissonScanTaskProducer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedissonScanTaskProducer.java
index 144693f1f..691669c55 100644
--- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedissonScanTaskProducer.java
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/RedissonScanTaskProducer.java
@@ -50,6 +50,7 @@ public void publishScanTask(ScanTask task) {
}
RStream stream = redissonClient.getStream(streamKey, StringCodec.INSTANCE);
+ // Starting the producer Observation injects trusted propagation fields before XADD.
StreamMessageId messageId = messageObservationSupport.observePublish(
RedisStreamMessageCarrier.MESSAGING_SYSTEM,
streamKey,
diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/MessageObservationSupportTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/MessageObservationSupportTest.java
index 02f114df8..cc1b585d7 100644
--- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/MessageObservationSupportTest.java
+++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/MessageObservationSupportTest.java
@@ -1,7 +1,9 @@
package com.iflytek.skillhub.observability;
import com.iflytek.skillhub.observability.tracing.SkillHubTracingConfiguration;
+import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
@@ -151,6 +153,74 @@ void shouldRemoveCallerSuppliedTransportContextBeforePublishingWithoutTracer() {
.isEqualTo("trusted-request");
}
+ @Test
+ void shouldUseOpenTelemetryMessagingOperationSemantics() {
+ ObservationRegistry observationRegistry = ObservationRegistry.create();
+ List stoppedContexts = new ArrayList<>();
+ observationRegistry.observationConfig().observationHandler(
+ new ObservationHandler() {
+ @Override
+ public void onStop(Observation.Context context) {
+ stoppedContexts.add(context);
+ }
+
+ @Override
+ public boolean supportsContext(Observation.Context context) {
+ return true;
+ }
+ }
+ );
+ MessageObservationSupport support = new MessageObservationSupport(
+ observationRegistry,
+ new RequestIdAccessor()
+ );
+ TestCarrier carrier = new TestCarrier();
+
+ support.observePublish(
+ "redis",
+ "skillhub:scan:requests",
+ carrier,
+ TEST_CARRIER_ADAPTER,
+ () -> null
+ );
+ support.observeProcess(
+ "redis",
+ "skillhub:scan:requests",
+ carrier,
+ TEST_CARRIER_ADAPTER,
+ () -> null
+ );
+
+ Observation.Context publish = findContext(stoppedContexts, "skillhub.message.publish");
+ assertThat(publish.getContextualName()).isEqualTo("publish skillhub:scan:requests");
+ assertThat(lowCardinalityValue(publish, "messaging.operation.name")).isEqualTo("publish");
+ assertThat(lowCardinalityValue(publish, "messaging.operation.type")).isEqualTo("send");
+
+ Observation.Context process = findContext(stoppedContexts, "skillhub.message.process");
+ assertThat(process.getContextualName()).isEqualTo("process skillhub:scan:requests");
+ assertThat(lowCardinalityValue(process, "messaging.operation.name")).isEqualTo("process");
+ assertThat(lowCardinalityValue(process, "messaging.operation.type")).isEqualTo("process");
+ }
+
+ private Observation.Context findContext(
+ List contexts,
+ String observationName
+ ) {
+ return contexts.stream()
+ .filter(context -> observationName.equals(context.getName()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private String lowCardinalityValue(Observation.Context context, String key) {
+ for (KeyValue keyValue : context.getLowCardinalityKeyValues()) {
+ if (key.equals(keyValue.getKey())) {
+ return keyValue.getValue();
+ }
+ }
+ return null;
+ }
+
private ContextValues currentValues(RequestIdAccessor requestIdAccessor, Tracer tracer) {
Span currentSpan = tracer.currentSpan();
return new ContextValues(
From 41e0776b4c2815b832d475440d31a9c99c1b6e69 Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Mon, 3 Aug 2026 16:36:52 +0800
Subject: [PATCH 09/10] test(auth): isolate security context between tests
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
.../skillhub/auth/oauth/OAuth2LoginHandlersTest.java | 7 +++++++
.../auth/token/ApiTokenAuthenticationFilterTest.java | 6 ++++++
2 files changed, 13 insertions(+)
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java
index 52c0077bd..750d29d33 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java
@@ -1,12 +1,14 @@
package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.http.HttpSession;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
@@ -22,6 +24,11 @@
class OAuth2LoginHandlersTest {
+ @AfterEach
+ void clearSecurityContext() {
+ SecurityContextHolder.clearContext();
+ }
+
@Test
void successHandler_redirectsToStoredReturnTo() throws Exception {
OAuthLoginFlowService oauthLoginFlowService = mock(OAuthLoginFlowService.class);
diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java
index d9f030a9d..fa9fb2cf9 100644
--- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java
+++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilterTest.java
@@ -10,6 +10,7 @@
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
@@ -43,6 +44,11 @@ class ApiTokenAuthenticationFilterTest {
scopeService
);
+ @BeforeEach
+ void initializeSecurityContext() {
+ SecurityContextHolder.clearContext();
+ }
+
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
From 9290fe3ca222964335f94949506a40849fffc5aa Mon Sep 17 00:00:00 2001
From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Date: Mon, 3 Aug 2026 19:25:19 +0800
Subject: [PATCH 10/10] fix(observability): skip otlp exporter without endpoint
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---
...cingModeAutoConfigurationImportFilter.java | 17 ++++++++++---
.../SkillHubTracingConfigurationTest.java | 18 ++++++++++++++
...ModeAutoConfigurationImportFilterTest.java | 24 +++++++++++++++++++
3 files changed, 56 insertions(+), 3 deletions(-)
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
index 55a33fda6..d2aa538a8 100644
--- 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
@@ -19,7 +19,10 @@ public final class TracingModeAutoConfigurationImportFilter
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.OpenTelemetryAutoConfiguration"
+ );
+
+ private static final Set OTLP_EXPORT_AUTO_CONFIGURATIONS = Set.of(
"org.springframework.boot.actuate.autoconfigure.tracing.otlp.OtlpAutoConfiguration"
);
@@ -34,12 +37,16 @@ public boolean[] match(
&& "otel-sdk".equalsIgnoreCase(
environment.getProperty(TRACING_MODE_PROPERTY, "none")
);
+ boolean otlpExportEnabled = otelSdkEnabled
+ && hasText(environment.getProperty("management.otlp.tracing.endpoint"));
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));
+ && ((otelSdkEnabled
+ || !OTEL_AUTO_CONFIGURATIONS.contains(autoConfigurationClass))
+ && (otlpExportEnabled
+ || !OTLP_EXPORT_AUTO_CONFIGURATIONS.contains(autoConfigurationClass)));
}
return matches;
}
@@ -48,4 +55,8 @@ public boolean[] match(
public void setEnvironment(Environment environment) {
this.environment = environment;
}
+
+ private static boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
}
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
index 1c5913ce5..99e72a0e8 100644
--- 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
@@ -63,6 +63,24 @@ void otelSdkModeWithoutEndpointShouldCreateInProcessTracerOnly() {
});
}
+ @Test
+ void otelSdkModeWithEmptyEndpointShouldCreateInProcessTracerOnly() {
+ contextRunner
+ .withPropertyValues(
+ "skillhub.observability.tracing-mode=otel-sdk",
+ "management.otlp.tracing.endpoint=",
+ "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
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
index 6cfbb7d32..e465010ae 100644
--- 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
@@ -47,9 +47,33 @@ void shouldEnableApplicationOtelOnlyForOtelSdkMode() {
"otel-sdk"
));
+ assertThat(matches()).containsExactly(true, true, false, true, false);
+ }
+
+ @Test
+ void shouldEnableOtlpExporterOnlyWhenEndpointHasText() {
+ filter.setEnvironment(new MockEnvironment()
+ .withProperty(
+ TracingModeAutoConfigurationImportFilter.TRACING_MODE_PROPERTY,
+ "otel-sdk"
+ )
+ .withProperty("management.otlp.tracing.endpoint", "http://127.0.0.1:4318/v1/traces"));
+
assertThat(matches()).containsExactly(true, true, true, true, false);
}
+ @Test
+ void shouldExcludeOtlpExporterWhenEndpointIsEmpty() {
+ filter.setEnvironment(new MockEnvironment()
+ .withProperty(
+ TracingModeAutoConfigurationImportFilter.TRACING_MODE_PROPERTY,
+ "otel-sdk"
+ )
+ .withProperty("management.otlp.tracing.endpoint", ""));
+
+ assertThat(matches()).containsExactly(true, true, false, true, false);
+ }
+
private boolean[] matches() {
return filter.match(
new String[]{