feat(observability): establish request correlation boundary

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 10:49:32 +08:00
parent c1b44d01be
commit 893c0d49ca
37 changed files with 721 additions and 75 deletions

View file

@ -26,6 +26,11 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>

View file

@ -21,12 +21,12 @@ 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.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 class ClawHubCompatAppService {
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 class ClawHubCompatAppService {
this.auditLogService = auditLogService;
this.compatSkillLookupService = compatSkillLookupService;
this.skillStarService = skillStarService;
this.requestIdAccessor = requestIdAccessor;
}
public ClawHubSearchResponse search(String q,
@ -430,7 +433,7 @@ public class ClawHubCompatAppService {
"COMPAT_PUBLISH",
"SKILL_VERSION",
versionId,
MDC.get("requestId"),
requestIdAccessor.current(),
clientIp,
userAgent,
detailJson

View file

@ -6,8 +6,8 @@ import com.iflytek.skillhub.domain.audit.AuditLogService;
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 class DeviceAuthWebController extends BaseApiController {
"DEVICE_AUTHORIZE",
"DEVICE_CODE",
null,
MDC.get("requestId"),
requestIdAccessor.current(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
"{\"userCode\":\"" + request.userCode() + "\"}"

View file

@ -21,6 +21,7 @@ import com.iflytek.skillhub.dto.UpdateProfileRequest;
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 class UserProfileController extends BaseApiController {
UpdateProfileResult result = userProfileService.updateProfile(
principal.userId(),
changes,
httpRequest.getHeader("X-Request-Id"),
requestIdAccessor.current(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent")
);

View file

@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.PageResponse;
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 class AdminProfileReviewController extends BaseApiController {
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 class AdminProfileReviewController extends BaseApiController {
id,
principal.userId(),
request.comment(),
httpRequest.getHeader("X-Request-Id"),
requestIdAccessor.current(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent"));
return ok("response.success.updated",

View file

@ -5,8 +5,8 @@ import com.iflytek.skillhub.controller.BaseApiController;
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 class AdminSearchController extends BaseApiController {
"REBUILD_SEARCH_INDEX",
"SEARCH_INDEX",
null,
MDC.get("requestId"),
requestIdAccessor.current(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"),
"{\"scope\":\"ALL\"}"

View file

@ -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 <T> ApiResponse<T> 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<Void> 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<Void> 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());
}
}

View file

@ -7,12 +7,12 @@ import com.iflytek.skillhub.dto.ApiResponseFactory;
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 class GlobalExceptionHandler {
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 class GlobalExceptionHandler {
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 class GlobalExceptionHandler {
public ResponseEntity<ApiResponse<Void>> 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 class GlobalExceptionHandler {
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),

View file

@ -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.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 class IdempotencyInterceptor implements HandlerInterceptor {
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 class IdempotencyInterceptor implements HandlerInterceptor {
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 class IdempotencyInterceptor implements HandlerInterceptor {
private void writeDuplicateResponse(HttpServletResponse response) throws Exception {
ApiResponse<Void> 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();
}
}

View file

@ -1,10 +1,10 @@
package com.iflytek.skillhub.filter;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@ -12,6 +12,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Ensures every request has a request identifier for logs, responses, and downstream audit
@ -22,23 +23,27 @@ import java.util.UUID;
public class RequestIdFilter extends OncePerRequestFilter {
private static final String REQUEST_ID_HEADER = "X-Request-Id";
private static final String REQUEST_ID_MDC_KEY = "requestId";
private static final Pattern VALID_REQUEST_ID =
Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$");
private final RequestIdAccessor requestIdAccessor;
public RequestIdFilter(RequestIdAccessor requestIdAccessor) {
this.requestIdAccessor = requestIdAccessor;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String requestId = request.getHeader(REQUEST_ID_HEADER);
if (requestId == null || requestId.isBlank()) {
if (requestId == null || !VALID_REQUEST_ID.matcher(requestId).matches()) {
requestId = UUID.randomUUID().toString();
}
MDC.put(REQUEST_ID_MDC_KEY, requestId);
response.setHeader(REQUEST_ID_HEADER, requestId);
try {
try (RequestIdAccessor.Scope ignored = requestIdAccessor.open(requestId)) {
filterChain.doFilter(request, response);
} finally {
MDC.remove(REQUEST_ID_MDC_KEY);
}
}
}

View file

@ -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.
*
* <p>The thread-local value is authoritative. MDC is maintained only as a mirror for log
* correlation.</p>
*/
@Component
public class RequestIdAccessor {
public static final String MDC_KEY = "requestId";
private final ThreadLocal<String> 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;
}
}
}

View file

@ -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<ILoggingEvent> {
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<String, String> 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();
}
}

View file

@ -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<ILoggingEvent> 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<ILoggingEvent> provider =
new GlobalCustomFieldsJsonProvider<>();
provider.setCustomFieldsNode(fields);
return provider;
}
}

View file

@ -0,0 +1,4 @@
/**
* Structured logging adapters for SkillHub correlation fields.
*/
package com.iflytek.skillhub.observability.logging;

View file

@ -0,0 +1,4 @@
/**
* Application-level observability context and integration boundaries.
*/
package com.iflytek.skillhub.observability;

View file

@ -4,11 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
@ -26,13 +26,16 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
private final RequestIdAccessor requestIdAccessor;
public ApiAccessDeniedHandler(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
SensitiveLogSanitizer sensitiveLogSanitizer,
RequestIdAccessor requestIdAccessor) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
this.requestIdAccessor = requestIdAccessor;
}
@Override
@ -45,7 +48,7 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler {
: null;
logger.info(
"Forbidden API request [requestId={}, method={}, path={}, reason={}, detail={}]",
MDC.get("requestId"),
requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
accessDeniedException.getClass().getSimpleName(),

View file

@ -3,11 +3,11 @@ package com.iflytek.skillhub.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
@ -25,13 +25,16 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
private final ApiResponseFactory apiResponseFactory;
private final SensitiveLogSanitizer sensitiveLogSanitizer;
private final RequestIdAccessor requestIdAccessor;
public ApiAuthenticationEntryPoint(ObjectMapper objectMapper,
ApiResponseFactory apiResponseFactory,
SensitiveLogSanitizer sensitiveLogSanitizer) {
SensitiveLogSanitizer sensitiveLogSanitizer,
RequestIdAccessor requestIdAccessor) {
this.objectMapper = objectMapper;
this.apiResponseFactory = apiResponseFactory;
this.sensitiveLogSanitizer = sensitiveLogSanitizer;
this.requestIdAccessor = requestIdAccessor;
}
@Override
@ -40,7 +43,7 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint {
AuthenticationException authException) throws IOException {
logger.info(
"Unauthorized API request [requestId={}, method={}, path={}, reason={}]",
MDC.get("requestId"),
requestIdAccessor.current(),
request.getMethod(),
sensitiveLogSanitizer.sanitizeRequestTarget(request),
authException.getClass().getSimpleName()

View file

@ -11,9 +11,9 @@ import com.iflytek.skillhub.dto.AdminLabelUpdateRequest;
import com.iflytek.skillhub.dto.LabelDefinitionResponse;
import com.iflytek.skillhub.dto.LabelSortOrderUpdateRequest;
import com.iflytek.skillhub.dto.LabelTranslationResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.List;
import java.util.Set;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
@ -27,17 +27,20 @@ public class LabelAdminAppService {
private final AuditLogService auditLogService;
private final RbacService rbacService;
private final LabelSearchSyncService labelSearchSyncService;
private final RequestIdAccessor requestIdAccessor;
public LabelAdminAppService(LabelDefinitionService labelDefinitionService,
SkillLabelService skillLabelService,
AuditLogService auditLogService,
RbacService rbacService,
LabelSearchSyncService labelSearchSyncService) {
LabelSearchSyncService labelSearchSyncService,
RequestIdAccessor requestIdAccessor) {
this.labelDefinitionService = labelDefinitionService;
this.skillLabelService = skillLabelService;
this.auditLogService = auditLogService;
this.rbacService = rbacService;
this.labelSearchSyncService = labelSearchSyncService;
this.requestIdAccessor = requestIdAccessor;
}
public List<LabelDefinitionResponse> listAll() {
@ -153,7 +156,7 @@ public class LabelAdminAppService {
action,
"LABEL",
targetId,
MDC.get("requestId"),
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson

View file

@ -12,11 +12,11 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.slf4j.MDC;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
@ -32,17 +32,20 @@ public class PromotionPortalAppService {
private final GovernanceQueryRepository governanceQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
public PromotionPortalAppService(PromotionService promotionService,
PromotionRequestRepository promotionRequestRepository,
GovernanceQueryRepository governanceQueryRepository,
RbacService rbacService,
AuditLogService auditLogService) {
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
this.promotionService = promotionService;
this.promotionRequestRepository = promotionRequestRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
public PromotionResponseDto submitPromotion(Long sourceSkillId,
@ -235,7 +238,7 @@ public class PromotionPortalAppService {
action,
"PROMOTION_REQUEST",
targetId,
MDC.get("requestId"),
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson

View file

@ -13,11 +13,11 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.slf4j.MDC;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
@ -34,19 +34,22 @@ public class ReviewPortalAppService {
private final GovernanceQueryRepository governanceQueryRepository;
private final RbacService rbacService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
public ReviewPortalAppService(ReviewService reviewService,
ReviewTaskRepository reviewTaskRepository,
NamespaceRepository namespaceRepository,
GovernanceQueryRepository governanceQueryRepository,
RbacService rbacService,
AuditLogService auditLogService) {
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
this.reviewService = reviewService;
this.reviewTaskRepository = reviewTaskRepository;
this.namespaceRepository = namespaceRepository;
this.governanceQueryRepository = governanceQueryRepository;
this.rbacService = rbacService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
}
public ReviewTaskResponse submitReview(Long skillVersionId,
@ -256,7 +259,7 @@ public class ReviewPortalAppService {
action,
"REVIEW_TASK",
targetId,
MDC.get("requestId"),
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson

View file

@ -18,12 +18,12 @@ import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.dto.SkillLabelDto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
@ -42,6 +42,7 @@ public class SkillLabelAppService {
private final AuditLogService auditLogService;
private final LabelSearchSyncService labelSearchSyncService;
private final SkillSlugResolutionService skillSlugResolutionService;
private final RequestIdAccessor requestIdAccessor;
public SkillLabelAppService(NamespaceRepository namespaceRepository,
SkillRepository skillRepository,
@ -52,7 +53,8 @@ public class SkillLabelAppService {
RbacService rbacService,
AuditLogService auditLogService,
LabelSearchSyncService labelSearchSyncService,
SkillSlugResolutionService skillSlugResolutionService) {
SkillSlugResolutionService skillSlugResolutionService,
RequestIdAccessor requestIdAccessor) {
this.namespaceRepository = namespaceRepository;
this.skillRepository = skillRepository;
this.visibilityChecker = visibilityChecker;
@ -63,6 +65,7 @@ public class SkillLabelAppService {
this.auditLogService = auditLogService;
this.labelSearchSyncService = labelSearchSyncService;
this.skillSlugResolutionService = skillSlugResolutionService;
this.requestIdAccessor = requestIdAccessor;
}
public List<SkillLabelDto> listSkillLabels(String namespaceSlug,
@ -188,7 +191,7 @@ public class SkillLabelAppService {
action,
"SKILL",
targetId,
MDC.get("requestId"),
requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null,
detailJson

View file

@ -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:

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<springProperty name="activeAppender"
source="skillhub.observability.log-format"
defaultValue="text"/>
<springProperty name="asyncQueueSize"
source="skillhub.observability.log-async-queue-size"
defaultValue="1024"/>
<springProperty name="serviceName"
source="spring.application.name"
defaultValue="skillhub"/>
<springProperty name="serviceVersion"
source="skillhub.observability.service-version"
defaultValue="unknown"/>
<springProperty name="serviceEnvironment"
source="skillhub.observability.service-environment"
defaultValue="local"/>
<appender name="text" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<charset>${CONSOLE_LOG_CHARSET}</charset>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="json-console" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="com.iflytek.skillhub.observability.logging.SkillHubEcsEncoder">
<serviceName>${serviceName}</serviceName>
<serviceVersion>${serviceVersion}</serviceVersion>
<serviceEnvironment>${serviceEnvironment}</serviceEnvironment>
</encoder>
</appender>
<appender name="json" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>${asyncQueueSize}</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<includeCallerData>false</includeCallerData>
<appender-ref ref="json-console"/>
</appender>
<root level="INFO">
<appender-ref ref="${activeAppender}"/>
</root>
</configuration>

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.util.Map;
import java.util.Optional;
@ -40,7 +41,8 @@ class ClawHubCompatAppServiceTest {
multipartPackageExtractor,
auditLogService,
compatSkillLookupService,
skillStarService
skillStarService,
new RequestIdAccessor()
);
@Test

View file

@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserProfileService;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -54,9 +55,11 @@ class UserProfileControllerUnitTest {
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("response.success.read", Locale.getDefault(), "response.success.read");
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-19T08:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-03-19T08:00:00Z"), ZoneOffset.UTC),
requestIdAccessor
);
controller = new UserProfileController(
responseFactory,
@ -64,7 +67,8 @@ class UserProfileControllerUnitTest {
userAccountRepository,
changeRequestRepository,
platformSessionService,
fieldPolicyConfig
fieldPolicyConfig,
requestIdAccessor
);
given(fieldPolicyConfig.fieldPolicies()).willReturn(Map.of(
"displayName", new ProfileFieldPolicyConfig.FieldPolicy(true, false),

View file

@ -12,6 +12,7 @@ import com.iflytek.skillhub.notification.domain.Notification;
import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.service.NotificationService;
import com.iflytek.skillhub.notification.sse.SseEmitterManager;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
@ -42,7 +43,8 @@ class NotificationControllerTest {
messageSource.addMessage("response.success.read", java.util.Locale.getDefault(), "ok");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC),
new RequestIdAccessor()
);
controller = new NotificationController(notificationService, sseEmitterManager, new ObjectMapper(), responseFactory);
}

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.notification.domain.NotificationCategory;
import com.iflytek.skillhub.notification.domain.NotificationChannel;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService;
import com.iflytek.skillhub.notification.service.NotificationPreferenceService.PreferenceView;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
@ -41,7 +42,8 @@ class NotificationPreferenceControllerTest {
messageSource.addMessage("response.success.updated", java.util.Locale.getDefault(), "ok");
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-23T00:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-03-23T00:00:00Z"), ZoneOffset.UTC),
new RequestIdAccessor()
);
controller = new NotificationPreferenceController(preferenceService, responseFactory);
}

View file

@ -7,6 +7,7 @@ import static org.mockito.Mockito.when;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Clock;
@ -40,11 +41,18 @@ class GlobalExceptionHandlerTest {
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out");
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC),
requestIdAccessor
);
handler = new GlobalExceptionHandler(
responseFactory,
sensitiveLogSanitizer,
metrics,
requestIdAccessor
);
handler = new GlobalExceptionHandler(responseFactory, sensitiveLogSanitizer, metrics);
}
@Test

View file

@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpSession;
import java.time.Clock;
@ -47,7 +48,8 @@ class AuthContextFilterTest {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.auth.local.accountDisabled", Locale.ENGLISH, "This account has been disabled");
Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC);
ApiResponseFactory apiResponseFactory = new ApiResponseFactory(messageSource, clock);
ApiResponseFactory apiResponseFactory =
new ApiResponseFactory(messageSource, clock, new RequestIdAccessor());
filter = new AuthContextFilter(
namespaceMemberRepository,
userAccountRepository,

View file

@ -5,6 +5,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository;
import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
@ -38,6 +39,8 @@ class IdempotencyInterceptorTest {
@Mock
private ValueOperations<String, String> valueOperations;
@Mock
private RequestIdAccessor requestIdAccessor;
@Mock
private HttpServletRequest request;
@ -53,13 +56,20 @@ class IdempotencyInterceptorTest {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC);
interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper, clock);
interceptor = new IdempotencyInterceptor(
redisTemplate,
idempotencyRecordRepository,
objectMapper,
clock,
requestIdAccessor
);
}
@Test
void testNewRequestPassesThrough() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-123");
when(requestIdAccessor.current()).thenReturn("req-123");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("idempotency:req-123")).thenReturn(null);
when(idempotencyRecordRepository.findByRequestId("req-123")).thenReturn(Optional.empty());
@ -70,10 +80,28 @@ class IdempotencyInterceptorTest {
verify(idempotencyRecordRepository).save(any(IdempotencyRecord.class));
}
@Test
void testProvidedInvalidHeaderUsesEffectiveRequestContext() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("invalid request id");
when(requestIdAccessor.current()).thenReturn("generated-valid-id");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("idempotency:generated-valid-id")).thenReturn(null);
when(idempotencyRecordRepository.findByRequestId("generated-valid-id"))
.thenReturn(Optional.empty());
boolean result = interceptor.preHandle(request, response, new Object());
assertTrue(result);
verify(idempotencyRecordRepository).findByRequestId("generated-valid-id");
verify(idempotencyRecordRepository, never()).findByRequestId("invalid request id");
}
@Test
void testDuplicateRequestReturnsCachedResponse() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-456");
when(requestIdAccessor.current()).thenReturn("req-456");
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.get("idempotency:req-456")).thenReturn("COMPLETED");
@ -114,6 +142,7 @@ class IdempotencyInterceptorTest {
void testAfterCompletionUpdatesRecord() throws Exception {
when(request.getMethod()).thenReturn("POST");
when(request.getHeader("X-Request-Id")).thenReturn("req-789");
when(requestIdAccessor.current()).thenReturn("req-789");
when(response.getStatus()).thenReturn(200);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);

View file

@ -1,14 +1,19 @@
package com.iflytek.skillhub.filter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@ -23,7 +28,8 @@ class RequestIdFilterTest {
void shouldGenerateRequestIdWhenNotProvided() throws Exception {
mockMvc.perform(get("/api/v1/health"))
.andExpect(status().isOk())
.andExpect(header().exists("X-Request-Id"));
.andExpect(header().exists("X-Request-Id"))
.andExpect(jsonPath("$.requestId").isNotEmpty());
}
@Test
@ -32,6 +38,50 @@ class RequestIdFilterTest {
mockMvc.perform(get("/api/v1/health")
.header("X-Request-Id", requestId))
.andExpect(status().isOk())
.andExpect(header().string("X-Request-Id", requestId));
.andExpect(header().string("X-Request-Id", requestId))
.andExpect(jsonPath("$.requestId").value(requestId));
}
@Test
void shouldPreserveRequestIdAtMaximumLength() throws Exception {
String requestId = "a".repeat(64);
mockMvc.perform(get("/api/v1/health")
.header("X-Request-Id", requestId))
.andExpect(status().isOk())
.andExpect(header().string("X-Request-Id", requestId))
.andExpect(jsonPath("$.requestId").value(requestId));
}
@ParameterizedTest
@ValueSource(strings = {
"",
"-starts-with-symbol",
"contains space",
"contains/slash",
"包含中文"
})
void shouldReplaceInvalidRequestId(String requestId) throws Exception {
assertInvalidRequestIdIsReplaced(requestId);
}
@Test
void shouldReplaceRequestIdLongerThanMaximumLength() throws Exception {
assertInvalidRequestIdIsReplaced("a".repeat(65));
}
private void assertInvalidRequestIdIsReplaced(String invalidRequestId) throws Exception {
MvcResult result = mockMvc.perform(get("/api/v1/health")
.header("X-Request-Id", invalidRequestId))
.andExpect(status().isOk())
.andExpect(header().exists("X-Request-Id"))
.andExpect(jsonPath("$.requestId").isNotEmpty())
.andReturn();
String effectiveRequestId = result.getResponse().getHeader("X-Request-Id");
assertThat(effectiveRequestId)
.isNotEqualTo(invalidRequestId)
.matches("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$");
assertThat(result.getResponse().getContentAsString()).contains(effectiveRequestId);
}
}

View file

@ -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(" "));
}
}

View file

@ -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));
}
}

View file

@ -9,6 +9,7 @@ import com.iflytek.skillhub.auth.token.ApiTokenScopeService;
import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.FilterChain;
import java.time.Clock;
import java.time.Instant;
@ -19,7 +20,6 @@ import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.mock.web.MockHttpServletRequest;
@ -33,28 +33,32 @@ class ApiAccessDeniedHandlerTest {
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private ApiAccessDeniedHandler handler;
private RequestIdAccessor.Scope requestIdScope;
@BeforeEach
void setUp() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
messageSource.setDefaultEncoding("UTF-8");
RequestIdAccessor requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC)
Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC),
requestIdAccessor
);
handler = new ApiAccessDeniedHandler(
objectMapper,
responseFactory,
new SensitiveLogSanitizer()
new SensitiveLogSanitizer(),
requestIdAccessor
);
MDC.put("requestId", "req-610");
requestIdScope = requestIdAccessor.open("req-610");
LocaleContextHolder.setLocale(Locale.ENGLISH);
}
@AfterEach
void tearDown() {
MDC.clear();
requestIdScope.close();
LocaleContextHolder.resetLocaleContext();
SecurityContextHolder.clearContext();
}

View file

@ -14,6 +14,7 @@ import com.iflytek.skillhub.dto.LabelDefinitionResponse;
import com.iflytek.skillhub.dto.LabelSortOrderItemRequest;
import com.iflytek.skillhub.dto.LabelSortOrderUpdateRequest;
import com.iflytek.skillhub.dto.LabelTranslationItemRequest;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.time.Instant;
import java.util.List;
import java.util.Set;
@ -40,7 +41,8 @@ class LabelAdminAppServiceTest {
skillLabelService,
auditLogService,
rbacService,
labelSearchSyncService
labelSearchSyncService,
new RequestIdAccessor()
);
@Test

View file

@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.PromotionRequestRepository;
import com.iflytek.skillhub.domain.review.PromotionService;
import com.iflytek.skillhub.dto.PromotionResponseDto;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import java.lang.reflect.Field;
import java.util.Set;
@ -47,7 +48,8 @@ class PromotionPortalAppServiceTest {
promotionRequestRepository,
governanceQueryRepository,
rbacService,
auditLogService
auditLogService,
new RequestIdAccessor()
);
}

View file

@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
@ -70,7 +71,8 @@ class SkillLabelAppServiceTest {
rbacService,
auditLogService,
labelSearchSyncService,
skillSlugResolutionService
skillSlugResolutionService,
new RequestIdAccessor()
);
}