diff --git a/daxpay-platform/daxpay-platform-common/common-request-context/src/main/java/cn/daxpay/open/platform/common/request/context/filter/TraceIdFilter.java b/daxpay-platform/daxpay-platform-common/common-request-context/src/main/java/cn/daxpay/open/platform/common/request/context/filter/TraceIdFilter.java index 2154b672d..69d3877d7 100644 --- a/daxpay-platform/daxpay-platform-common/common-request-context/src/main/java/cn/daxpay/open/platform/common/request/context/filter/TraceIdFilter.java +++ b/daxpay-platform/daxpay-platform-common/common-request-context/src/main/java/cn/daxpay/open/platform/common/request/context/filter/TraceIdFilter.java @@ -3,8 +3,6 @@ package cn.daxpay.open.platform.common.request.context.filter; import cn.daxpay.open.platform.common.request.context.local.RequestContextStorage; import cn.daxpay.open.platform.core.code.CommonCode; import cn.daxpay.open.platform.core.code.WebHeaderCode; -import cn.hutool.core.lang.Validator; -import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -21,13 +19,16 @@ import java.io.IOException; /// # 追踪ID过滤器 /// +/// 基于 OpenTelemetry 自动注入的 MDC traceId, 将其透传到 ThreadLocal 与响应 header。 +/// 本过滤器不再自行生成 traceId, 完全依赖 OTel ServerHttpObservationFilter 的注入。 +/// 执行顺序晚于 OTel Filter(HIGHEST_PRECEDENCE + 1), 以确保 MDC 中已有 traceId。 @Slf4j @Component @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) public class TraceIdFilter extends OncePerRequestFilter implements Ordered { - /// 过滤器优先级 - public static final int ORDER = Ordered.HIGHEST_PRECEDENCE + 1; + /// 过滤器优先级: 晚于 OTel ServerHttpObservationFilter, 确保 MDC 已注入 traceId + public static final int ORDER = Ordered.HIGHEST_PRECEDENCE + 10; @Override public int getOrder() { @@ -38,34 +39,18 @@ public class TraceIdFilter extends OncePerRequestFilter implements Ordered { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { - String traceId = extractOrGenerateTraceId(request); - RequestContextStorage.put(WebHeaderCode.X_TRACE_ID, traceId); - MDC.put(CommonCode.TRACE_ID, traceId); - response.setHeader(WebHeaderCode.X_TRACE_ID, traceId); + // 读取 OTel ServerHttpObservationFilter 已注入的 traceId + String traceId = MDC.get(CommonCode.TRACE_ID); + if (StrUtil.isNotBlank(traceId)) { + // 透传到 ThreadLocal, 供业务代码通过 RequestContextHolder 访问 + RequestContextStorage.put(WebHeaderCode.X_TRACE_ID, traceId); + // 写入响应 header, 便于前端/调用方关联 + response.setHeader(WebHeaderCode.X_TRACE_ID, traceId); + } chain.doFilter(request, response); } finally { - MDC.clear(); + // 注意: 不要调用 MDC.clear(), OTel 自行管理 MDC 生命周期 + RequestContextStorage.clear(); } } - - /// 从请求头提取或生成追踪ID - private String extractOrGenerateTraceId(HttpServletRequest request) { - String traceId = request.getHeader(WebHeaderCode.X_TRACE_ID); - if (isValidTraceId(traceId)) { - return traceId; - } - return String.valueOf(IdUtil.getSnowflakeNextId()); - } - - /// 验证追踪ID的有效性 - private boolean isValidTraceId(String traceId) { - if (StrUtil.isBlank(traceId)) { - return false; - } - if (!Validator.isNumber(traceId)) { - return false; - } - int length = traceId.length(); - return length >= 10 && length <= 20; - } } diff --git a/daxpay-platform/daxpay-platform-common/common-spring/src/main/java/cn/daxpay/open/platform/common/spring/configuration/RestClientConfiguration.java b/daxpay-platform/daxpay-platform-common/common-spring/src/main/java/cn/daxpay/open/platform/common/spring/configuration/RestClientConfiguration.java index 4d056c2ae..99c24ff76 100644 --- a/daxpay-platform/daxpay-platform-common/common-spring/src/main/java/cn/daxpay/open/platform/common/spring/configuration/RestClientConfiguration.java +++ b/daxpay-platform/daxpay-platform-common/common-spring/src/main/java/cn/daxpay/open/platform/common/spring/configuration/RestClientConfiguration.java @@ -1,6 +1,7 @@ package cn.daxpay.open.platform.common.spring.configuration; import cn.daxpay.open.platform.common.config.properties.PlatformCommonProperties; +import cn.hutool.core.util.StrUtil; import lombok.RequiredArgsConstructor; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; @@ -12,16 +13,32 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; /// # RestClient 配置 /// +/// 使用 Spring Boot 自动配置的 [RestClient.Builder], 以便 OpenTelemetry 的 +/// ClientRequestObservation 拦截器自动追加(W3C traceparent 头自动透传)。 +/// 另通过 BusinessContextInterceptor 透传业务上下文(国际化语言、终端编码)。 @Configuration @RequiredArgsConstructor public class RestClientConfiguration { private final PlatformCommonProperties platformCommonProperties; + /// 业务上下文 header 名(与 WebHeaderCode 保持一致, 此处为避免底层模块依赖循环而硬编码) + private static final String HEADER_ACCEPT_LANGUAGE = "accept-language"; + private static final String HEADER_X_CLIENT_CODE = "x-client-code"; + @Bean public CloseableHttpClient httpClient() { var rest = platformCommonProperties.getSpring().getRest(); @@ -55,18 +72,58 @@ public class RestClientConfiguration { .build(); } - // 4. 将 HttpClient 适配为 Spring 的 ClientHttpRequestFactory + // 将 HttpClient 适配为 Spring 的 ClientHttpRequestFactory @Bean public HttpComponentsClientHttpRequestFactory httpRequestFactory(CloseableHttpClient httpClient) { return new HttpComponentsClientHttpRequestFactory(httpClient); } - // 5. 创建 RestClient Bean + /// 创建 RestClient Bean + /// + /// 使用自动配置的 RestClient.Builder 而非静态 [RestClient.builder], + /// 以便 OTel 拦截器自动注入(W3C traceparent 头自动透传)。 @Bean - public RestClient restClient(HttpComponentsClientHttpRequestFactory httpRequestFactory) { - return RestClient.builder() + public RestClient restClient(RestClient.Builder restClientBuilder, + HttpComponentsClientHttpRequestFactory httpRequestFactory) { + return restClientBuilder .requestFactory(httpRequestFactory) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .requestInterceptor(new BusinessContextInterceptor()) .build(); } + + /// 业务上下文透传拦截器 + /// + /// OTel 仅自动透传 W3C traceparent, 业务上下文(国际化语言、终端编码)需手动透传。 + /// 通过 Spring 原生 [RequestContextHolder] 获取当前请求, 避免模块循环依赖。 + static class BusinessContextInterceptor implements ClientHttpRequestInterceptor { + @Override + public ClientHttpResponse intercept(org.springframework.http.HttpRequest request, + byte[] body, + ClientHttpRequestExecution execution) throws IOException { + HttpServletRequest currentRequest = getCurrentRequest(); + if (currentRequest != null) { + // 透传国际化语言(子应用异常消息按语言返回) + String language = currentRequest.getHeader(HEADER_ACCEPT_LANGUAGE); + if (StrUtil.isNotBlank(language)) { + request.getHeaders().set(HttpHeaders.ACCEPT_LANGUAGE, language); + } + // 透传终端编码(运营端/H5/小程序/API) + String clientCode = currentRequest.getHeader(HEADER_X_CLIENT_CODE); + if (StrUtil.isNotBlank(clientCode)) { + request.getHeaders().set(HEADER_X_CLIENT_CODE, clientCode); + } + } + return execution.execute(request, body); + } + + /// 获取当前线程绑定的 HttpServletRequest(若存在) + private static HttpServletRequest getCurrentRequest() { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes servletAttrs) { + return servletAttrs.getRequest(); + } + return null; + } + } } diff --git a/daxpay-start/pom.xml b/daxpay-start/pom.xml index 05a441f2d..3bea82aeb 100644 --- a/daxpay-start/pom.xml +++ b/daxpay-start/pom.xml @@ -34,6 +34,16 @@ org.springframework.boot spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-opentelemetry + org.projectlombok diff --git a/daxpay-start/src/main/resources/application.yml b/daxpay-start/src/main/resources/application.yml index 4a50ab4c1..2c22495b9 100644 --- a/daxpay-start/src/main/resources/application.yml +++ b/daxpay-start/src/main/resources/application.yml @@ -13,14 +13,24 @@ spring: multipart: # 上传文件大小限制为100M max-file-size: 100MB - management: - endpoints: - web: - exposure: - include: health,info - endpoint: - health: - show-details: always +# 监控端点与链路追踪(actuator 配置必须放在顶层, 不能放在 spring: 下) +management: + endpoints: + web: + exposure: + include: health,info,httpexchanges + endpoint: + health: + show-details: always + # 链路追踪配置(仅日志关联, 不导出到后端) + tracing: + sampling: + probability: 1.0 + opentelemetry: + tracing: + export: + otlp: + enabled: false # ORM mybatis-plus: mapper-locations: classpath*:mapper/**/*Mapper.xml diff --git a/daxpay-start/src/main/resources/logback-spring.xml b/daxpay-start/src/main/resources/logback-spring.xml index f20686f9c..a7182395c 100644 --- a/daxpay-start/src/main/resources/logback-spring.xml +++ b/daxpay-start/src/main/resources/logback-spring.xml @@ -14,7 +14,7 @@ - %yellow(%date{yyyy-MM-dd HH:mm:ssS})| %highlight(%-5level[%X{traceId:-}]) %boldYellow(%thread) - %boldGreen(%logger{36})| %msg%n + %yellow(%date{yyyy-MM-dd HH:mm:ssS})| %highlight(%-5level[%X{traceId:-},%X{spanId:-}]) %boldYellow(%thread) - %boldGreen(%logger{36})| %msg%n @@ -27,7 +27,7 @@ UTF-8 - %date{yyyy-MM-dd HH:mm:ssS} | %-5level[%X{traceId:-}] %thread - %logger{36}| %msg%n + %date{yyyy-MM-dd HH:mm:ssS} | %-5level[%X{traceId:-},%X{spanId:-}] %thread - %logger{36}| %msg%n