feat: 引入 OpenTelemetry 实现主子应用全链路追踪

- daxpay-start 引入 actuator + opentelemetry 依赖
- application.yml 新增 tracing 配置(OTLP 关闭, 全采样), 修正 management 配置位置
- TraceIdFilter 简化为读取 MDC(OTel 注入) 并写入 ThreadLocal + 响应头
- RestClientConfiguration 改用注入 RestClient.Builder, 新增业务上下文透传拦截器
- logback pattern 补充 spanId
This commit is contained in:
DaxPay Dev
2026-06-22 18:14:26 +08:00
parent 675b2fd19e
commit f0359ead55
5 changed files with 106 additions and 44 deletions

View File

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

View File

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

View File

@@ -34,6 +34,16 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 监控端点 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 链路追踪(OpenTelemetry, 仅日志关联, 不导出) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>

View File

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

View File

@@ -14,7 +14,7 @@
<!-- 控制台 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>%yellow(%date{yyyy-MM-dd HH:mm:ssS})| %highlight(%-5level[%X{traceId:-}]) %boldYellow(%thread) - %boldGreen(%logger{36})| %msg%n</pattern>
<pattern>%yellow(%date{yyyy-MM-dd HH:mm:ssS})| %highlight(%-5level[%X{traceId:-},%X{spanId:-}]) %boldYellow(%thread) - %boldGreen(%logger{36})| %msg%n</pattern>
</layout>
</appender>
<!-- log格式日志收集 -->
@@ -27,7 +27,7 @@
</rollingPolicy>
<encoder>
<charset>UTF-8</charset>
<pattern>%date{yyyy-MM-dd HH:mm:ssS} | %-5level[%X{traceId:-}] %thread - %logger{36}| %msg%n</pattern>
<pattern>%date{yyyy-MM-dd HH:mm:ssS} | %-5level[%X{traceId:-},%X{spanId:-}] %thread - %logger{36}| %msg%n</pattern>
</encoder>
</appender>