mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-09-13 08:25:00 +00:00
feat: 新增通用链路追踪模块与 RAG 对话全链路埋点
- 新增 ruoyi-common-trace 模块:TraceContext 上下文、TraceNodeAspect 注解切面、数据库双表持久化 - ChatServiceFacade / KnowledgeRetrievalServiceImpl 完成 RAG 对话 retrieval → rerank → llm-call 全链路采集 - 新增 TraceController 提供列表/详情/节点查询 API 及数据统计 - 支持 trace.enabled 开关、payload 长度截断、错误安全降级 - TraceNodeTemplate 模板封装标准节点生命周期,减少重复样板代码
This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
<module>ruoyi-common-tenant</module>
|
||||
<module>ruoyi-common-websocket</module>
|
||||
<module>ruoyi-common-sse</module>
|
||||
<module>ruoyi-common-trace</module>
|
||||
</modules>
|
||||
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
|
||||
@@ -186,6 +186,13 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 链路追踪模块 -->
|
||||
<dependency>
|
||||
<groupId>org.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common-trace</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
42
ruoyi-common/ruoyi-common-trace/pom.xml
Normal file
42
ruoyi-common/ruoyi-common-trace/pom.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<groupId>org.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>ruoyi-common-trace</artifactId>
|
||||
|
||||
<description>
|
||||
ruoyi-common-trace 通用链路追踪
|
||||
</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common-mybatis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.ruoyi</groupId>
|
||||
<artifactId>ruoyi-common-json</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.ruoyi.common.trace.annotation;
|
||||
|
||||
import org.ruoyi.common.trace.constant.TraceConstants;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Trace 方法节点标记。
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface TraceNode {
|
||||
|
||||
/**
|
||||
* 节点名称。
|
||||
*/
|
||||
String name() default "";
|
||||
|
||||
/**
|
||||
* 节点类型。
|
||||
*/
|
||||
String type() default TraceConstants.NODE_METHOD;
|
||||
|
||||
/**
|
||||
* 安全输入摘要。
|
||||
*/
|
||||
String input() default "";
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.ruoyi.common.trace.aspect;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.ruoyi.common.trace.annotation.TraceNode;
|
||||
import org.ruoyi.common.trace.config.TraceProperties;
|
||||
import org.ruoyi.common.trace.constant.TraceConstants;
|
||||
import org.ruoyi.common.trace.core.TraceContext;
|
||||
import org.ruoyi.common.trace.service.TraceRecordService;
|
||||
import org.ruoyi.common.trace.util.TracePayloadUtils;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Trace 方法节点采集切面。
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
@RequiredArgsConstructor
|
||||
public class TraceNodeAspect {
|
||||
|
||||
private final TraceRecordService traceRecordService;
|
||||
private final TraceProperties traceProperties;
|
||||
|
||||
@Around("@annotation(traceNode)")
|
||||
public Object aroundNode(ProceedingJoinPoint joinPoint, TraceNode traceNode) throws Throwable {
|
||||
if (!traceProperties.isEnabled() || StrUtil.isBlank(TraceContext.getTraceId())) {
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
|
||||
String nodeId = IdUtil.getSnowflakeNextIdStr();
|
||||
String traceId = TraceContext.getTraceId();
|
||||
long startMillis = System.currentTimeMillis();
|
||||
Date startTime = new Date();
|
||||
|
||||
org.ruoyi.common.trace.domain.TraceNode node = new org.ruoyi.common.trace.domain.TraceNode();
|
||||
node.setTraceId(traceId);
|
||||
node.setNodeId(nodeId);
|
||||
node.setParentNodeId(TraceContext.currentNodeId());
|
||||
node.setDepth(TraceContext.depth());
|
||||
node.setNodeName(StrUtil.blankToDefault(traceNode.name(), method.getName()));
|
||||
node.setNodeType(StrUtil.blankToDefault(traceNode.type(), TraceConstants.NODE_METHOD));
|
||||
node.setClassName(method.getDeclaringClass().getName());
|
||||
node.setMethodName(method.getName());
|
||||
node.setStatus(TraceConstants.STATUS_RUNNING);
|
||||
node.setStartTime(startTime);
|
||||
node.setInputPayload(StrUtil.isBlank(traceNode.input()) ? null : traceNode.input());
|
||||
|
||||
try {
|
||||
traceRecordService.startNode(node);
|
||||
} catch (Exception ex) {
|
||||
log.warn("写入 trace 节点失败,traceId={}", traceId, ex);
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
TraceContext.pushNode(nodeId);
|
||||
try {
|
||||
Object result = joinPoint.proceed();
|
||||
safeFinishNode(traceId, nodeId, TraceConstants.STATUS_SUCCESS, null,
|
||||
TracePayloadUtils.output(result, traceProperties), startMillis);
|
||||
return result;
|
||||
} catch (Throwable ex) {
|
||||
safeFinishNode(traceId, nodeId, TraceConstants.STATUS_ERROR,
|
||||
TracePayloadUtils.error(ex, traceProperties), null, startMillis);
|
||||
throw ex;
|
||||
} finally {
|
||||
TraceContext.popNode();
|
||||
}
|
||||
}
|
||||
|
||||
private void safeFinishNode(String traceId, String nodeId, String status, String errorMessage, String outputPayload, long startMillis) {
|
||||
try {
|
||||
traceRecordService.finishNode(traceId, nodeId, status, errorMessage, outputPayload,
|
||||
new Date(), System.currentTimeMillis() - startMillis);
|
||||
} catch (Exception ex) {
|
||||
log.warn("更新 trace 节点失败,traceId={}, nodeId={}", traceId, nodeId, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.ruoyi.common.trace.config;
|
||||
|
||||
import org.ruoyi.common.trace.aspect.TraceNodeAspect;
|
||||
import org.ruoyi.common.trace.service.TraceRecordService;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* 通用链路追踪自动配置。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(TraceProperties.class)
|
||||
public class TraceAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnProperty(prefix = "trace", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public TraceNodeAspect traceNodeAspect(TraceRecordService traceRecordService, TraceProperties traceProperties) {
|
||||
return new TraceNodeAspect(traceRecordService, traceProperties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.ruoyi.common.trace.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 通用链路追踪配置。
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "trace")
|
||||
public class TraceProperties {
|
||||
|
||||
/**
|
||||
* 是否启用链路追踪。
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* payload 记录策略。
|
||||
*/
|
||||
private Payload payload = new Payload();
|
||||
|
||||
@Data
|
||||
public static class Payload {
|
||||
|
||||
/**
|
||||
* 是否记录截断后的详情。默认 false,只记录摘要。
|
||||
*/
|
||||
private boolean recordDetail = false;
|
||||
|
||||
/**
|
||||
* input payload 最大长度。
|
||||
*/
|
||||
private int maxInputLength = 1000;
|
||||
|
||||
/**
|
||||
* output payload 最大长度。
|
||||
*/
|
||||
private int maxOutputLength = 2000;
|
||||
|
||||
/**
|
||||
* 错误信息最大长度。
|
||||
*/
|
||||
private int maxErrorLength = 1000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.ruoyi.common.trace.constant;
|
||||
|
||||
/**
|
||||
* 通用链路追踪常量。
|
||||
*/
|
||||
public final class TraceConstants {
|
||||
|
||||
private TraceConstants() {
|
||||
}
|
||||
|
||||
public static final String STATUS_RUNNING = "RUNNING";
|
||||
public static final String STATUS_SUCCESS = "SUCCESS";
|
||||
public static final String STATUS_ERROR = "ERROR";
|
||||
public static final String STATUS_CANCELLED = "CANCELLED";
|
||||
|
||||
public static final String NODE_METHOD = "METHOD";
|
||||
public static final String NODE_HTTP = "HTTP";
|
||||
public static final String NODE_DB = "DB";
|
||||
public static final String NODE_CACHE = "CACHE";
|
||||
public static final String NODE_TASK = "TASK";
|
||||
public static final String NODE_STREAM = "STREAM";
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package org.ruoyi.common.trace.constant;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 链路追踪中文展示常量。
|
||||
* <p>
|
||||
* 将技术标识映射为用户可读的中文标签,供前端直接展示。
|
||||
*/
|
||||
public final class TraceDisplayConstants {
|
||||
|
||||
private TraceDisplayConstants() {
|
||||
}
|
||||
|
||||
// ======================== 节点类型中文映射 ========================
|
||||
|
||||
/**
|
||||
* 通用节点类型中文标签。
|
||||
*/
|
||||
public static final Map<String, String> NODE_TYPE_LABELS = Map.ofEntries(
|
||||
Map.entry("RETRIEVAL", "知识检索"),
|
||||
Map.entry("RERANK", "重排序"),
|
||||
Map.entry("LLM_CALL", "LLM 调用"),
|
||||
Map.entry("STREAM", "流式输出"),
|
||||
Map.entry("METHOD", "方法调用"),
|
||||
Map.entry("HTTP", "HTTP 请求"),
|
||||
Map.entry("DB", "数据库"),
|
||||
Map.entry("CACHE", "缓存操作"),
|
||||
Map.entry("TASK", "异步任务"),
|
||||
Map.entry("ROOT", "根节点")
|
||||
);
|
||||
|
||||
/**
|
||||
* 业务类型中文标签。
|
||||
*/
|
||||
public static final Map<String, String> BUSINESS_TYPE_LABELS = Map.ofEntries(
|
||||
Map.entry("RAG_CHAT", "RAG 对话"),
|
||||
Map.entry("API", "API 调用"),
|
||||
Map.entry("SCHEDULED", "定时任务")
|
||||
);
|
||||
|
||||
/**
|
||||
* 状态中文标签。
|
||||
*/
|
||||
public static final Map<String, String> STATUS_LABELS = Map.ofEntries(
|
||||
Map.entry("RUNNING", "运行中"),
|
||||
Map.entry("SUCCESS", "成功"),
|
||||
Map.entry("ERROR", "失败"),
|
||||
Map.entry("CANCELLED", "已取消"),
|
||||
Map.entry("TIMEOUT", "超时")
|
||||
);
|
||||
|
||||
// ======================== 工具方法 ========================
|
||||
|
||||
/**
|
||||
* 获取节点类型中文标签,未匹配时返回原始值。
|
||||
*/
|
||||
public static String nodeTypeLabel(String nodeType) {
|
||||
if (nodeType == null) {
|
||||
return "-";
|
||||
}
|
||||
return NODE_TYPE_LABELS.getOrDefault(nodeType.toUpperCase(), nodeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取业务类型中文标签,未匹配时返回原始值。
|
||||
*/
|
||||
public static String businessTypeLabel(String businessType) {
|
||||
if (businessType == null) {
|
||||
return "-";
|
||||
}
|
||||
return BUSINESS_TYPE_LABELS.getOrDefault(businessType.toUpperCase(), businessType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态中文标签,未匹配时返回原始值。
|
||||
*/
|
||||
public static String statusLabel(String status) {
|
||||
if (status == null) {
|
||||
return "未知";
|
||||
}
|
||||
return STATUS_LABELS.getOrDefault(status.toUpperCase(), status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将技术节点名称转为可读展示名。
|
||||
* <p>
|
||||
* 支持 kebab-case / snake_case / camelCase → 首字母大写空格分隔。
|
||||
*/
|
||||
public static String prettifyNodeName(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return "-";
|
||||
}
|
||||
String trimmed = raw.trim();
|
||||
// 已知映射优先
|
||||
Map<String, String> known = Map.ofEntries(
|
||||
Map.entry("rag-chat", "RAG 流式对话"),
|
||||
Map.entry("rag-stream-chat", "RAG 流式对话"),
|
||||
Map.entry("retrieval-engine", "知识库检索"),
|
||||
Map.entry("multi-channel-retrieval", "多路召回"),
|
||||
Map.entry("context-build", "上下文组装"),
|
||||
Map.entry("prompt-render", "Prompt 渲染"),
|
||||
Map.entry("query-rewrite-and-split", "问题改写与拆分"),
|
||||
Map.entry("intent-resolve", "意图识别"),
|
||||
Map.entry("guidance-detect", "歧义引导"),
|
||||
Map.entry("conversation-title-gen", "会话标题生成"),
|
||||
Map.entry("user-first-packet", "用户感知首包"),
|
||||
Map.entry("llm-first-packet", "LLM 首包"),
|
||||
Map.entry("llm-chat-routing", "LLM 路由调度"),
|
||||
Map.entry("llm-stream-routing", "LLM 流式路由")
|
||||
);
|
||||
if (known.containsKey(trimmed)) {
|
||||
return known.get(trimmed);
|
||||
}
|
||||
// 通用格式化: 按 [-_] 分割,每段首字母大写
|
||||
String[] parts = trimmed.split("[-_\\s]+");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String part : parts) {
|
||||
if (part.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (sb.length() > 0) {
|
||||
sb.append(' ');
|
||||
}
|
||||
sb.append(Character.toUpperCase(part.charAt(0)));
|
||||
if (part.length() > 1) {
|
||||
sb.append(part.substring(1));
|
||||
}
|
||||
}
|
||||
return sb.length() > 0 ? sb.toString() : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断状态是否为失败。
|
||||
*/
|
||||
public static boolean isFailed(String status) {
|
||||
if (status == null) {
|
||||
return false;
|
||||
}
|
||||
String upper = status.toUpperCase();
|
||||
return "ERROR".equals(upper) || "FAILED".equals(upper) || "TIMEOUT".equals(upper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断状态是否为成功。
|
||||
*/
|
||||
public static boolean isSuccess(String status) {
|
||||
if (status == null) {
|
||||
return false;
|
||||
}
|
||||
return "SUCCESS".equalsIgnoreCase(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断状态是否为运行中。
|
||||
*/
|
||||
public static boolean isRunning(String status) {
|
||||
if (status == null) {
|
||||
return false;
|
||||
}
|
||||
return "RUNNING".equalsIgnoreCase(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.ruoyi.common.trace.core;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.trace.config.TraceProperties;
|
||||
import org.ruoyi.common.trace.constant.TraceConstants;
|
||||
import org.ruoyi.common.trace.service.TraceRecordService;
|
||||
import org.ruoyi.common.trace.util.TracePayloadUtils;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* 默认流式 trace 节点实现。
|
||||
*/
|
||||
@Slf4j
|
||||
public class DefaultTraceStreamSpan implements TraceStreamSpan {
|
||||
|
||||
private final TraceRecordService traceRecordService;
|
||||
private final TraceProperties traceProperties;
|
||||
private final String traceId;
|
||||
private final String nodeId;
|
||||
private final long startMillis;
|
||||
private final AtomicBoolean finished = new AtomicBoolean(false);
|
||||
private final AtomicBoolean detached = new AtomicBoolean(false);
|
||||
|
||||
public DefaultTraceStreamSpan(TraceRecordService traceRecordService,
|
||||
TraceProperties traceProperties,
|
||||
String traceId,
|
||||
String nodeId,
|
||||
long startMillis) {
|
||||
this.traceRecordService = traceRecordService;
|
||||
this.traceProperties = traceProperties;
|
||||
this.traceId = traceId;
|
||||
this.nodeId = nodeId;
|
||||
this.startMillis = startMillis;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void detach() {
|
||||
if (detached.compareAndSet(false, true)) {
|
||||
TraceContext.popNode();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finishSuccess() {
|
||||
finish(TraceConstants.STATUS_SUCCESS, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finishError(Throwable throwable) {
|
||||
finish(TraceConstants.STATUS_ERROR, TracePayloadUtils.error(throwable, traceProperties));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finishCancelledIfRunning() {
|
||||
finish(TraceConstants.STATUS_CANCELLED, null);
|
||||
}
|
||||
|
||||
private void finish(String status, String errorMessage) {
|
||||
if (!finished.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
traceRecordService.finishNode(traceId, nodeId, status, errorMessage, null,
|
||||
new Date(), System.currentTimeMillis() - startMillis);
|
||||
} catch (Exception e) {
|
||||
log.warn("结束 trace stream span 失败,traceId={}, nodeId={}", traceId, nodeId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package org.ruoyi.common.trace.core;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
|
||||
/**
|
||||
* 通用链路追踪上下文。
|
||||
*/
|
||||
public final class TraceContext {
|
||||
|
||||
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> BUSINESS_TYPE = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> BUSINESS_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Deque<String>> NODE_STACK = new ThreadLocal<>();
|
||||
|
||||
private TraceContext() {
|
||||
}
|
||||
|
||||
public static TraceScope begin(String traceId, String businessType, String businessId, Long userId, String tenantId) {
|
||||
TraceScope scope = new TraceScope(getTraceId(), getBusinessType(), getBusinessId(), getUserId(), getTenantId());
|
||||
TRACE_ID.set(traceId);
|
||||
BUSINESS_TYPE.set(businessType);
|
||||
BUSINESS_ID.set(businessId);
|
||||
USER_ID.set(userId);
|
||||
TENANT_ID.set(tenantId);
|
||||
NODE_STACK.remove();
|
||||
return scope;
|
||||
}
|
||||
|
||||
static void restore(String traceId, String businessType, String businessId, Long userId, String tenantId) {
|
||||
setOrRemove(TRACE_ID, traceId);
|
||||
setOrRemove(BUSINESS_TYPE, businessType);
|
||||
setOrRemove(BUSINESS_ID, businessId);
|
||||
setOrRemove(USER_ID, userId);
|
||||
setOrRemove(TENANT_ID, tenantId);
|
||||
NODE_STACK.remove();
|
||||
}
|
||||
|
||||
private static <T> void setOrRemove(ThreadLocal<T> holder, T value) {
|
||||
if (value == null) {
|
||||
holder.remove();
|
||||
} else {
|
||||
holder.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getTraceId() {
|
||||
return TRACE_ID.get();
|
||||
}
|
||||
|
||||
public static String getBusinessType() {
|
||||
return BUSINESS_TYPE.get();
|
||||
}
|
||||
|
||||
public static String getBusinessId() {
|
||||
return BUSINESS_ID.get();
|
||||
}
|
||||
|
||||
public static Long getUserId() {
|
||||
return USER_ID.get();
|
||||
}
|
||||
|
||||
public static String getTenantId() {
|
||||
return TENANT_ID.get();
|
||||
}
|
||||
|
||||
public static String currentNodeId() {
|
||||
Deque<String> stack = NODE_STACK.get();
|
||||
return stack == null ? null : stack.peek();
|
||||
}
|
||||
|
||||
public static int depth() {
|
||||
Deque<String> stack = NODE_STACK.get();
|
||||
return stack == null ? 0 : stack.size();
|
||||
}
|
||||
|
||||
public static void pushNode(String nodeId) {
|
||||
Deque<String> stack = NODE_STACK.get();
|
||||
if (stack == null) {
|
||||
stack = new ArrayDeque<>();
|
||||
NODE_STACK.set(stack);
|
||||
}
|
||||
stack.push(nodeId);
|
||||
}
|
||||
|
||||
public static void popNode() {
|
||||
Deque<String> stack = NODE_STACK.get();
|
||||
if (stack == null || stack.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
stack.pop();
|
||||
if (stack.isEmpty()) {
|
||||
NODE_STACK.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为未来的跨线程上下文传播保留的拷贝入口,避免共享可变栈。
|
||||
*/
|
||||
public static Deque<String> copyNodeStack() {
|
||||
Deque<String> stack = NODE_STACK.get();
|
||||
return stack == null ? new ArrayDeque<>() : new ArrayDeque<>(stack);
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
TRACE_ID.remove();
|
||||
BUSINESS_TYPE.remove();
|
||||
BUSINESS_ID.remove();
|
||||
USER_ID.remove();
|
||||
TENANT_ID.remove();
|
||||
NODE_STACK.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package org.ruoyi.common.trace.core;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.core.utils.StringUtils;
|
||||
import org.ruoyi.common.trace.config.TraceProperties;
|
||||
import org.ruoyi.common.trace.constant.TraceConstants;
|
||||
import org.ruoyi.common.trace.domain.TraceNode;
|
||||
import org.ruoyi.common.trace.service.TraceRecordService;
|
||||
import org.ruoyi.common.trace.util.TracePayloadUtils;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* 链路追踪节点模板,封装节点创建、上下文压栈、执行、结束、出栈的标准生命周期。
|
||||
* <p>
|
||||
* 用于同步方法内的 trace 埋点,消除手写 start/finish/pop 的重复代码。
|
||||
* 对于需要异步结束的场景(如流式响应),请使用 {@link DefaultTraceStreamSpan}。
|
||||
*
|
||||
* @see DefaultTraceStreamSpan
|
||||
*/
|
||||
@Slf4j
|
||||
public final class TraceNodeTemplate {
|
||||
|
||||
private TraceNodeTemplate() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 trace 节点上下文中执行业务逻辑,成功后使用 outputBuilder 生成输出摘要。
|
||||
*
|
||||
* @param traceRecordService 记录服务
|
||||
* @param traceProperties 配置
|
||||
* @param nodeName 节点名称
|
||||
* @param nodeType 节点类型
|
||||
* @param className 类名
|
||||
* @param methodName 方法名
|
||||
* @param inputPayload 输入摘要
|
||||
* @param action 业务逻辑
|
||||
* @param successOutput 成功时从结果构建输出摘要
|
||||
* @param <T> 业务返回值类型
|
||||
* @return 业务执行结果
|
||||
*/
|
||||
public static <T> T withNode(
|
||||
TraceRecordService traceRecordService,
|
||||
TraceProperties traceProperties,
|
||||
String nodeName,
|
||||
String nodeType,
|
||||
String className,
|
||||
String methodName,
|
||||
String inputPayload,
|
||||
NodeAction<T> action,
|
||||
Function<T, String> successOutput) {
|
||||
|
||||
if (!traceProperties.isEnabled() || StringUtils.isBlank(TraceContext.getTraceId())) {
|
||||
return unwrap(action);
|
||||
}
|
||||
|
||||
String traceId = TraceContext.getTraceId();
|
||||
String nodeId = UUID.randomUUID().toString().replace("-", "");
|
||||
long startMillis = System.currentTimeMillis();
|
||||
|
||||
TraceNode node = buildNode(traceId, nodeId, nodeName, nodeType,
|
||||
className, methodName, inputPayload, startMillis);
|
||||
|
||||
try {
|
||||
traceRecordService.startNode(node);
|
||||
} catch (Exception e) {
|
||||
log.warn("写入 trace 节点失败,traceId={}, nodeId={}", traceId, nodeId, e);
|
||||
return unwrap(action);
|
||||
}
|
||||
|
||||
TraceContext.pushNode(nodeId);
|
||||
try {
|
||||
T result = action.execute();
|
||||
String output = successOutput != null && result != null ? successOutput.apply(result) : null;
|
||||
finishNode(traceRecordService, traceProperties, traceId, nodeId,
|
||||
TraceConstants.STATUS_SUCCESS, null, output, startMillis);
|
||||
return result;
|
||||
} catch (Throwable ex) {
|
||||
finishNode(traceRecordService, traceProperties, traceId, nodeId,
|
||||
TraceConstants.STATUS_ERROR, ex, null, startMillis);
|
||||
throw rethrow(ex);
|
||||
} finally {
|
||||
TraceContext.popNode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 trace 节点上下文中执行业务逻辑(无输出摘要)。
|
||||
*/
|
||||
public static <T> T withNode(
|
||||
TraceRecordService traceRecordService,
|
||||
TraceProperties traceProperties,
|
||||
String nodeName,
|
||||
String nodeType,
|
||||
String className,
|
||||
String methodName,
|
||||
String inputPayload,
|
||||
NodeAction<T> action) {
|
||||
return withNode(traceRecordService, traceProperties, nodeName, nodeType,
|
||||
className, methodName, inputPayload, action, null);
|
||||
}
|
||||
|
||||
// ======================== 内部工具方法 ========================
|
||||
|
||||
private static TraceNode buildNode(String traceId, String nodeId, String nodeName,
|
||||
String nodeType, String className, String methodName,
|
||||
String inputPayload, long startMillis) {
|
||||
TraceNode node = new TraceNode();
|
||||
node.setTraceId(traceId);
|
||||
node.setNodeId(nodeId);
|
||||
node.setParentNodeId(TraceContext.currentNodeId());
|
||||
node.setDepth(TraceContext.depth());
|
||||
node.setNodeName(nodeName);
|
||||
node.setNodeType(nodeType);
|
||||
node.setClassName(className);
|
||||
node.setMethodName(methodName);
|
||||
node.setStatus(TraceConstants.STATUS_RUNNING);
|
||||
node.setStartTime(new Date(startMillis));
|
||||
node.setInputPayload(inputPayload);
|
||||
return node;
|
||||
}
|
||||
|
||||
private static void finishNode(TraceRecordService service, TraceProperties props,
|
||||
String traceId, String nodeId, String status,
|
||||
Throwable error, String outputPayload, long startMillis) {
|
||||
try {
|
||||
service.finishNode(traceId, nodeId, status,
|
||||
TracePayloadUtils.error(error, props), outputPayload,
|
||||
new Date(), System.currentTimeMillis() - startMillis);
|
||||
} catch (Exception e) {
|
||||
log.warn("结束 trace 节点失败,traceId={}, nodeId={}", traceId, nodeId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T unwrap(NodeAction<T> action) {
|
||||
try {
|
||||
return action.execute();
|
||||
} catch (RuntimeException | Error e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T extends Throwable> T rethrow(Throwable t) throws T {
|
||||
throw (T) t;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可抛出 Throwable 的业务动作。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface NodeAction<T> {
|
||||
T execute() throws Throwable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.ruoyi.common.trace.core;
|
||||
|
||||
/**
|
||||
* Trace 上下文作用域。
|
||||
*/
|
||||
public final class TraceScope implements AutoCloseable {
|
||||
|
||||
private final String previousTraceId;
|
||||
private final String previousBusinessType;
|
||||
private final String previousBusinessId;
|
||||
private final Long previousUserId;
|
||||
private final String previousTenantId;
|
||||
|
||||
TraceScope(String previousTraceId,
|
||||
String previousBusinessType,
|
||||
String previousBusinessId,
|
||||
Long previousUserId,
|
||||
String previousTenantId) {
|
||||
this.previousTraceId = previousTraceId;
|
||||
this.previousBusinessType = previousBusinessType;
|
||||
this.previousBusinessId = previousBusinessId;
|
||||
this.previousUserId = previousUserId;
|
||||
this.previousTenantId = previousTenantId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
TraceContext.restore(previousTraceId, previousBusinessType, previousBusinessId, previousUserId, previousTenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.ruoyi.common.trace.core;
|
||||
|
||||
/**
|
||||
* 可跨回调结束的流式 trace 节点。
|
||||
*/
|
||||
public interface TraceStreamSpan {
|
||||
|
||||
void detach();
|
||||
|
||||
void finishSuccess();
|
||||
|
||||
void finishError(Throwable throwable);
|
||||
|
||||
void finishCancelledIfRunning();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.ruoyi.common.trace.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 链路追踪节点记录 trace_node。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("trace_node")
|
||||
public class TraceNode extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id")
|
||||
private Long id;
|
||||
private String traceId;
|
||||
private String nodeId;
|
||||
private String parentNodeId;
|
||||
private String nodeName;
|
||||
private String nodeType;
|
||||
private Integer depth;
|
||||
private Integer sortOrder;
|
||||
private String className;
|
||||
private String methodName;
|
||||
private String status;
|
||||
private Date startTime;
|
||||
private Date endTime;
|
||||
private Long durationMs;
|
||||
private String errorMessage;
|
||||
private String inputPayload;
|
||||
private String outputPayload;
|
||||
private String metadata;
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.ruoyi.common.trace.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 链路追踪运行记录 trace_run。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("trace_run")
|
||||
public class TraceRun extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id")
|
||||
private Long id;
|
||||
private String traceId;
|
||||
private String traceName;
|
||||
private String businessType;
|
||||
private String businessId;
|
||||
private Long userId;
|
||||
private String tenantId;
|
||||
private String status;
|
||||
private Date startTime;
|
||||
private Date endTime;
|
||||
private Long durationMs;
|
||||
private String errorMessage;
|
||||
private String metadata;
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.ruoyi.common.trace.domain.bo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
|
||||
import org.ruoyi.common.trace.domain.TraceRun;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 链路追踪运行记录查询对象。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@AutoMapper(target = TraceRun.class, reverseConvertGenerate = false)
|
||||
public class TraceRunBo extends BaseEntity {
|
||||
|
||||
private Long id;
|
||||
private String traceId;
|
||||
private String traceName;
|
||||
private String businessType;
|
||||
private String businessId;
|
||||
private Long userId;
|
||||
private String tenantId;
|
||||
private String status;
|
||||
private Date startTime;
|
||||
private Date endTime;
|
||||
private Long durationMs;
|
||||
private String errorMessage;
|
||||
private String metadata;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.ruoyi.common.trace.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 链路追踪详情视图对象。
|
||||
* <p>
|
||||
* 包含运行信息、节点树以及统计摘要。
|
||||
*/
|
||||
@Data
|
||||
public class TraceDetailVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private TraceRunVo run;
|
||||
private List<TraceNodeVo> nodes;
|
||||
private TraceStatistics statistics;
|
||||
|
||||
/**
|
||||
* 链路追踪统计摘要,帮助快速了解整体执行情况。
|
||||
*/
|
||||
@Data
|
||||
public static class TraceStatistics implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 总节点数 */
|
||||
private int totalNodes;
|
||||
|
||||
/** 成功节点数 */
|
||||
private int successCount;
|
||||
|
||||
/** 失败节点数 */
|
||||
private int failedCount;
|
||||
|
||||
/** 运行中节点数 */
|
||||
private int runningCount;
|
||||
|
||||
/** 最大调用深度 */
|
||||
private int maxDepth;
|
||||
|
||||
/** 平均耗时 (ms) */
|
||||
private long avgDurationMs;
|
||||
|
||||
/** 总链路耗时 (ms) */
|
||||
private long totalDurationMs;
|
||||
|
||||
/** 慢节点 Top N */
|
||||
private List<SlowNodeInfo> topSlowNodes = new ArrayList<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 慢节点简要信息。
|
||||
*/
|
||||
@Data
|
||||
public static class SlowNodeInfo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 节点 ID */
|
||||
private String nodeId;
|
||||
|
||||
/** 节点展示名称 */
|
||||
private String nodeDisplayName;
|
||||
|
||||
/** 节点类型中文标签 */
|
||||
private String nodeTypeLabel;
|
||||
|
||||
/** 耗时 (ms) */
|
||||
private long durationMs;
|
||||
|
||||
/** 占总耗时百分比 */
|
||||
private double percentOfTotal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.ruoyi.common.trace.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.ruoyi.common.trace.domain.TraceNode;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 链路追踪节点记录视图对象。
|
||||
* <p>
|
||||
* 除实体映射字段外,还提供前端可直接展示的显示标签和解析后的 payload 对象。
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = TraceNode.class)
|
||||
public class TraceNodeVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String traceId;
|
||||
private String nodeId;
|
||||
private String parentNodeId;
|
||||
private String nodeName;
|
||||
private String nodeType;
|
||||
private Integer depth;
|
||||
private Integer sortOrder;
|
||||
private String className;
|
||||
private String methodName;
|
||||
private String status;
|
||||
private Date startTime;
|
||||
private Date endTime;
|
||||
private Long durationMs;
|
||||
private String errorMessage;
|
||||
|
||||
/** 原始 payload 字符串(兼容旧版),新代码请使用 parsedInput */
|
||||
private String inputPayload;
|
||||
|
||||
/** 原始 payload 字符串(兼容旧版),新代码请使用 parsedOutput */
|
||||
private String outputPayload;
|
||||
|
||||
/** 原始 metadata 字符串(兼容旧版),新代码请使用 parsedMetadata */
|
||||
private String metadata;
|
||||
|
||||
private List<TraceNodeVo> children;
|
||||
|
||||
// ======================== 展示用计算字段 ========================
|
||||
|
||||
/** 节点类型中文标签,如 "知识检索"、"LLM 调用" */
|
||||
@JsonProperty("nodeTypeLabel")
|
||||
private String nodeTypeLabel;
|
||||
|
||||
/** 状态中文标签,如 "成功"、"失败"、"运行中" */
|
||||
@JsonProperty("statusLabel")
|
||||
private String statusLabel;
|
||||
|
||||
/** 节点展示名称(中文友好),从 nodeName 转换 */
|
||||
@JsonProperty("nodeDisplayName")
|
||||
private String nodeDisplayName;
|
||||
|
||||
// ======================== 解析后的 payload ========================
|
||||
|
||||
/** input payload 解析为 Map,前端可直接读取结构化字段 */
|
||||
@JsonProperty("parsedInput")
|
||||
private Map<String, Object> parsedInput;
|
||||
|
||||
/** output payload 解析为 Map,前端可直接读取结构化字段 */
|
||||
@JsonProperty("parsedOutput")
|
||||
private Map<String, Object> parsedOutput;
|
||||
|
||||
/** metadata 解析为 Map */
|
||||
@JsonProperty("parsedMetadata")
|
||||
private Map<String, Object> parsedMetadata;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.ruoyi.common.trace.domain.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.ruoyi.common.trace.domain.TraceRun;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 链路追踪运行记录视图对象。
|
||||
* <p>
|
||||
* 除实体映射字段外,还提供前端可直接展示的显示标签和解析后的 metadata 对象。
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = TraceRun.class)
|
||||
public class TraceRunVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String traceId;
|
||||
private String traceName;
|
||||
private String businessType;
|
||||
private String businessId;
|
||||
private Long userId;
|
||||
private String tenantId;
|
||||
private String status;
|
||||
private Date startTime;
|
||||
private Date endTime;
|
||||
private Long durationMs;
|
||||
private String errorMessage;
|
||||
|
||||
/** 原始 metadata 字符串(兼容旧版),新代码请使用 parsedMetadata */
|
||||
private String metadata;
|
||||
|
||||
// ======================== 展示用计算字段 ========================
|
||||
|
||||
/** 状态中文标签,如 "成功"、"失败"、"运行中" */
|
||||
@JsonProperty("statusLabel")
|
||||
private String statusLabel;
|
||||
|
||||
/** 业务类型中文标签,如 "RAG 对话" */
|
||||
@JsonProperty("businessTypeLabel")
|
||||
private String businessTypeLabel;
|
||||
|
||||
/** metadata 解析为 Map */
|
||||
@JsonProperty("parsedMetadata")
|
||||
private Map<String, Object> parsedMetadata;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.ruoyi.common.trace.mapper;
|
||||
|
||||
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.ruoyi.common.trace.domain.TraceNode;
|
||||
import org.ruoyi.common.trace.domain.vo.TraceNodeVo;
|
||||
|
||||
/**
|
||||
* 链路追踪节点记录 Mapper。
|
||||
*/
|
||||
public interface TraceNodeMapper extends BaseMapperPlus<TraceNode, TraceNodeVo> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.ruoyi.common.trace.mapper;
|
||||
|
||||
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.ruoyi.common.trace.domain.TraceRun;
|
||||
import org.ruoyi.common.trace.domain.vo.TraceRunVo;
|
||||
|
||||
/**
|
||||
* 链路追踪运行记录 Mapper。
|
||||
*/
|
||||
public interface TraceRunMapper extends BaseMapperPlus<TraceRun, TraceRunVo> {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.ruoyi.common.trace.service;
|
||||
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.common.trace.domain.TraceNode;
|
||||
import org.ruoyi.common.trace.domain.TraceRun;
|
||||
import org.ruoyi.common.trace.domain.bo.TraceRunBo;
|
||||
import org.ruoyi.common.trace.domain.vo.TraceDetailVo;
|
||||
import org.ruoyi.common.trace.domain.vo.TraceNodeVo;
|
||||
import org.ruoyi.common.trace.domain.vo.TraceRunVo;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 链路追踪记录服务。
|
||||
*/
|
||||
public interface TraceRecordService {
|
||||
|
||||
void startRun(TraceRun run);
|
||||
|
||||
void finishRun(String traceId, String status, String errorMessage, Date endTime, long durationMs);
|
||||
|
||||
void startNode(TraceNode node);
|
||||
|
||||
void finishNode(String traceId, String nodeId, String status, String errorMessage, String outputPayload, Date endTime, long durationMs);
|
||||
|
||||
TableDataInfo<TraceRunVo> pageRuns(TraceRunBo bo, PageQuery pageQuery);
|
||||
|
||||
TraceRunVo getRun(String traceId);
|
||||
|
||||
List<TraceNodeVo> listNodes(String traceId);
|
||||
|
||||
TraceDetailVo getDetail(String traceId);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package org.ruoyi.common.trace.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.common.core.utils.StringUtils;
|
||||
import org.ruoyi.common.mybatis.core.page.PageQuery;
|
||||
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
|
||||
import org.ruoyi.common.trace.constant.TraceDisplayConstants;
|
||||
import org.ruoyi.common.trace.domain.TraceNode;
|
||||
import org.ruoyi.common.trace.domain.TraceRun;
|
||||
import org.ruoyi.common.trace.domain.bo.TraceRunBo;
|
||||
import org.ruoyi.common.trace.domain.vo.TraceDetailVo;
|
||||
import org.ruoyi.common.trace.domain.vo.TraceNodeVo;
|
||||
import org.ruoyi.common.trace.domain.vo.TraceRunVo;
|
||||
import org.ruoyi.common.trace.mapper.TraceNodeMapper;
|
||||
import org.ruoyi.common.trace.mapper.TraceRunMapper;
|
||||
import org.ruoyi.common.trace.service.TraceRecordService;
|
||||
import org.ruoyi.common.trace.util.TracePayloadUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 链路追踪记录服务实现。
|
||||
* <p>
|
||||
* 在自动映射的基础上,对 VO 进行二次加工:补充展示标签、解析 payload 为结构化对象、计算统计信息。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TraceRecordServiceImpl implements TraceRecordService {
|
||||
|
||||
private static final int TOP_SLOW_NODES_LIMIT = 5;
|
||||
|
||||
private final TraceRunMapper traceRunMapper;
|
||||
private final TraceNodeMapper traceNodeMapper;
|
||||
|
||||
@Override
|
||||
public void startRun(TraceRun run) {
|
||||
try {
|
||||
traceRunMapper.insert(run);
|
||||
} catch (Exception e) {
|
||||
log.warn("写入 trace run 失败,traceId={}", run == null ? null : run.getTraceId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finishRun(String traceId, String status, String errorMessage, Date endTime, long durationMs) {
|
||||
try {
|
||||
TraceRun update = new TraceRun();
|
||||
update.setStatus(status);
|
||||
update.setErrorMessage(errorMessage);
|
||||
update.setEndTime(endTime);
|
||||
update.setDurationMs(durationMs);
|
||||
traceRunMapper.update(update, Wrappers.lambdaUpdate(TraceRun.class).eq(TraceRun::getTraceId, traceId));
|
||||
} catch (Exception e) {
|
||||
log.warn("更新 trace run 失败,traceId={}", traceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startNode(TraceNode node) {
|
||||
try {
|
||||
traceNodeMapper.insert(node);
|
||||
} catch (Exception e) {
|
||||
log.warn("写入 trace node 失败,traceId={}, nodeId={}",
|
||||
node == null ? null : node.getTraceId(), node == null ? null : node.getNodeId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finishNode(String traceId, String nodeId, String status, String errorMessage, String outputPayload, Date endTime, long durationMs) {
|
||||
try {
|
||||
TraceNode update = new TraceNode();
|
||||
update.setStatus(status);
|
||||
update.setErrorMessage(errorMessage);
|
||||
update.setOutputPayload(outputPayload);
|
||||
update.setEndTime(endTime);
|
||||
update.setDurationMs(durationMs);
|
||||
traceNodeMapper.update(update, Wrappers.lambdaUpdate(TraceNode.class)
|
||||
.eq(TraceNode::getTraceId, traceId)
|
||||
.eq(TraceNode::getNodeId, nodeId));
|
||||
} catch (Exception e) {
|
||||
log.warn("更新 trace node 失败,traceId={}, nodeId={}", traceId, nodeId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TableDataInfo<TraceRunVo> pageRuns(TraceRunBo bo, PageQuery pageQuery) {
|
||||
LambdaQueryWrapper<TraceRun> wrapper = buildRunWrapper(bo == null ? new TraceRunBo() : bo);
|
||||
if (StringUtils.isBlank(pageQuery.getOrderByColumn())) {
|
||||
wrapper.orderByDesc(TraceRun::getStartTime);
|
||||
}
|
||||
Page<TraceRunVo> page = traceRunMapper.selectVoPage(pageQuery.build(), wrapper);
|
||||
if (page.getRecords() != null) {
|
||||
page.getRecords().forEach(this::enrichRunVo);
|
||||
}
|
||||
return TableDataInfo.build(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TraceRunVo getRun(String traceId) {
|
||||
TraceRunVo run = traceRunMapper.selectVoOne(Wrappers.lambdaQuery(TraceRun.class).eq(TraceRun::getTraceId, traceId));
|
||||
if (run != null) {
|
||||
enrichRunVo(run);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TraceNodeVo> listNodes(String traceId) {
|
||||
List<TraceNodeVo> nodes = traceNodeMapper.selectVoList(Wrappers.lambdaQuery(TraceNode.class)
|
||||
.eq(TraceNode::getTraceId, traceId)
|
||||
.orderByAsc(TraceNode::getStartTime)
|
||||
.orderByAsc(TraceNode::getId));
|
||||
if (nodes != null) {
|
||||
nodes.forEach(this::enrichNodeVo);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TraceDetailVo getDetail(String traceId) {
|
||||
TraceDetailVo detail = new TraceDetailVo();
|
||||
TraceRunVo run = getRun(traceId);
|
||||
detail.setRun(run);
|
||||
|
||||
List<TraceNodeVo> nodes = listNodes(traceId);
|
||||
if (nodes != null) {
|
||||
nodes.forEach(this::enrichNodeVo);
|
||||
}
|
||||
// 返回扁平列表,前端自行按 parentNodeId 建树
|
||||
detail.setNodes(nodes != null ? nodes : new ArrayList<>());
|
||||
|
||||
// 计算统计信息
|
||||
if (nodes != null) {
|
||||
detail.setStatistics(buildStatistics(nodes, run));
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ======================== VO 加工 ========================
|
||||
|
||||
/**
|
||||
* 为 TraceRunVo 补充展示标签和解析后的 metadata。
|
||||
*/
|
||||
private void enrichRunVo(TraceRunVo vo) {
|
||||
if (vo == null) {
|
||||
return;
|
||||
}
|
||||
vo.setStatusLabel(TraceDisplayConstants.statusLabel(vo.getStatus()));
|
||||
vo.setBusinessTypeLabel(TraceDisplayConstants.businessTypeLabel(vo.getBusinessType()));
|
||||
vo.setParsedMetadata(TracePayloadUtils.parseJsonToMap(vo.getMetadata()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 TraceNodeVo 补充展示标签和解析后的 payload。
|
||||
*/
|
||||
private void enrichNodeVo(TraceNodeVo vo) {
|
||||
if (vo == null) {
|
||||
return;
|
||||
}
|
||||
vo.setNodeTypeLabel(TraceDisplayConstants.nodeTypeLabel(vo.getNodeType()));
|
||||
vo.setStatusLabel(TraceDisplayConstants.statusLabel(vo.getStatus()));
|
||||
vo.setNodeDisplayName(TraceDisplayConstants.prettifyNodeName(vo.getNodeName()));
|
||||
vo.setParsedInput(TracePayloadUtils.parseJsonToMap(vo.getInputPayload()));
|
||||
vo.setParsedOutput(TracePayloadUtils.parseJsonToMap(vo.getOutputPayload()));
|
||||
vo.setParsedMetadata(TracePayloadUtils.parseJsonToMap(vo.getMetadata()));
|
||||
}
|
||||
|
||||
// ======================== 统计计算 ========================
|
||||
private TraceDetailVo.TraceStatistics buildStatistics(List<TraceNodeVo> nodes, TraceRunVo run) {
|
||||
TraceDetailVo.TraceStatistics stats = new TraceDetailVo.TraceStatistics();
|
||||
|
||||
int total = nodes == null ? 0 : nodes.size();
|
||||
int success = 0;
|
||||
int failed = 0;
|
||||
int running = 0;
|
||||
int maxDepth = 0;
|
||||
long totalDuration = 0;
|
||||
|
||||
for (TraceNodeVo node : nodes) {
|
||||
if (node == null) {
|
||||
continue;
|
||||
}
|
||||
if (TraceDisplayConstants.isSuccess(node.getStatus())) {
|
||||
success++;
|
||||
} else if (TraceDisplayConstants.isFailed(node.getStatus())) {
|
||||
failed++;
|
||||
} else if (TraceDisplayConstants.isRunning(node.getStatus())) {
|
||||
running++;
|
||||
}
|
||||
|
||||
int depth = node.getDepth() == null ? 0 : node.getDepth();
|
||||
if (depth > maxDepth) {
|
||||
maxDepth = depth;
|
||||
}
|
||||
|
||||
long dur = node.getDurationMs() == null ? 0 : node.getDurationMs();
|
||||
totalDuration += dur;
|
||||
}
|
||||
|
||||
stats.setTotalNodes(total);
|
||||
stats.setSuccessCount(success);
|
||||
stats.setFailedCount(failed);
|
||||
stats.setRunningCount(running);
|
||||
stats.setMaxDepth(maxDepth);
|
||||
stats.setAvgDurationMs(total > 0 ? totalDuration / total : 0);
|
||||
stats.setTotalDurationMs(run != null && run.getDurationMs() != null ? run.getDurationMs() : totalDuration);
|
||||
|
||||
// 慢节点 Top N
|
||||
long totalMs = stats.getTotalDurationMs();
|
||||
List<TraceDetailVo.SlowNodeInfo> topSlow = nodes.stream()
|
||||
.filter(n -> n != null && n.getDurationMs() != null && n.getDurationMs() > 0)
|
||||
.sorted(Comparator.comparingLong(TraceNodeVo::getDurationMs).reversed())
|
||||
.limit(TOP_SLOW_NODES_LIMIT)
|
||||
.map(n -> {
|
||||
TraceDetailVo.SlowNodeInfo info = new TraceDetailVo.SlowNodeInfo();
|
||||
info.setNodeId(n.getNodeId());
|
||||
info.setNodeDisplayName(n.getNodeDisplayName() != null ? n.getNodeDisplayName() : n.getNodeName());
|
||||
info.setNodeTypeLabel(n.getNodeTypeLabel());
|
||||
info.setDurationMs(n.getDurationMs());
|
||||
info.setPercentOfTotal(totalMs > 0 ? Math.round(n.getDurationMs() * 1000.0 / totalMs) / 10.0 : 0);
|
||||
return info;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
stats.setTopSlowNodes(topSlow);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ======================== 查询辅助 ========================
|
||||
|
||||
private LambdaQueryWrapper<TraceRun> buildRunWrapper(TraceRunBo bo) {
|
||||
Map<String, Object> params = bo.getParams();
|
||||
return new LambdaQueryWrapper<TraceRun>()
|
||||
.eq(StringUtils.isNotBlank(bo.getTraceId()), TraceRun::getTraceId, bo.getTraceId())
|
||||
.like(StringUtils.isNotBlank(bo.getTraceName()), TraceRun::getTraceName, bo.getTraceName())
|
||||
.eq(StringUtils.isNotBlank(bo.getBusinessType()), TraceRun::getBusinessType, bo.getBusinessType())
|
||||
.eq(StringUtils.isNotBlank(bo.getBusinessId()), TraceRun::getBusinessId, bo.getBusinessId())
|
||||
.eq(StringUtils.isNotBlank(bo.getStatus()), TraceRun::getStatus, bo.getStatus())
|
||||
.eq(bo.getUserId() != null, TraceRun::getUserId, bo.getUserId())
|
||||
.eq(StringUtils.isNotBlank(bo.getTenantId()), TraceRun::getTenantId, bo.getTenantId())
|
||||
.between(params.get("beginTime") != null && params.get("endTime") != null,
|
||||
TraceRun::getStartTime, params.get("beginTime"), params.get("endTime"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.ruoyi.common.trace.util;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import org.ruoyi.common.json.handler.BigNumberSerializer;
|
||||
import org.ruoyi.common.trace.config.TraceProperties;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Trace payload 序列化、截断与解析工具。
|
||||
*/
|
||||
public final class TracePayloadUtils {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER;
|
||||
|
||||
static {
|
||||
OBJECT_MAPPER = new ObjectMapper();
|
||||
SimpleModule jsSafeModule = new SimpleModule("js-safe-numbers");
|
||||
jsSafeModule.addSerializer(Long.class, BigNumberSerializer.INSTANCE);
|
||||
jsSafeModule.addSerializer(Long.TYPE, BigNumberSerializer.INSTANCE);
|
||||
jsSafeModule.addSerializer(BigInteger.class, BigNumberSerializer.INSTANCE);
|
||||
jsSafeModule.addSerializer(BigDecimal.class, ToStringSerializer.instance);
|
||||
OBJECT_MAPPER.registerModule(jsSafeModule);
|
||||
}
|
||||
|
||||
private TracePayloadUtils() {
|
||||
}
|
||||
|
||||
public static String input(Object value, TraceProperties properties) {
|
||||
if (value == null || properties == null || !properties.getPayload().isRecordDetail()) {
|
||||
return null;
|
||||
}
|
||||
return truncate(asString(value), properties.getPayload().getMaxInputLength());
|
||||
}
|
||||
|
||||
public static String output(Object value, TraceProperties properties) {
|
||||
if (value == null || properties == null || !properties.getPayload().isRecordDetail()) {
|
||||
return null;
|
||||
}
|
||||
return truncate(asString(value), properties.getPayload().getMaxOutputLength());
|
||||
}
|
||||
|
||||
public static String error(Throwable throwable, TraceProperties properties) {
|
||||
if (throwable == null) {
|
||||
return null;
|
||||
}
|
||||
int maxLength = properties == null ? 1000 : properties.getPayload().getMaxErrorLength();
|
||||
String message = throwable.getClass().getSimpleName() + ": " + (throwable.getMessage() == null ? "" : throwable.getMessage());
|
||||
return truncate(message, maxLength);
|
||||
}
|
||||
|
||||
public static String toJson(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{\"summary\":\"payload serialization failed\"}";
|
||||
}
|
||||
}
|
||||
|
||||
public static String truncate(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (maxLength <= 0 || value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JSON 字符串解析为 Map,用于前端展示结构化数据。
|
||||
* 解析失败时返回空 Map,不影响主流程。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Map<String, Object> parseJsonToMap(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {});
|
||||
} catch (JsonProcessingException e) {
|
||||
// 非 JSON 字符串,返回包含原始文本的 Map
|
||||
return Collections.singletonMap("_raw", json);
|
||||
}
|
||||
}
|
||||
|
||||
private static String asString(Object value) {
|
||||
return value instanceof String str ? str : toJson(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.ruoyi.common.trace.config.TraceAutoConfiguration
|
||||
@@ -0,0 +1,134 @@
|
||||
package org.ruoyi.common.trace.aspect;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.ruoyi.common.trace.annotation.TraceNode;
|
||||
import org.ruoyi.common.trace.config.TraceProperties;
|
||||
import org.ruoyi.common.trace.constant.TraceConstants;
|
||||
import org.ruoyi.common.trace.core.TraceContext;
|
||||
import org.ruoyi.common.trace.service.TraceRecordService;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Date;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TraceNodeAspectTest {
|
||||
|
||||
@Mock
|
||||
private TraceRecordService traceRecordService;
|
||||
|
||||
@Mock
|
||||
private ProceedingJoinPoint joinPoint;
|
||||
|
||||
@Mock
|
||||
private MethodSignature methodSignature;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TraceContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldProceedWithoutRecordingWhenNoTraceContext() throws Throwable {
|
||||
TraceNodeAspect aspect = new TraceNodeAspect(traceRecordService, new TraceProperties());
|
||||
Method method = SampleService.class.getDeclaredMethod("sample");
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
|
||||
Object result = aspect.aroundNode(joinPoint, method.getAnnotation(TraceNode.class));
|
||||
|
||||
assertEquals("ok", result);
|
||||
verify(traceRecordService, never()).startNode(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordSuccessNode() throws Throwable {
|
||||
TraceProperties properties = new TraceProperties();
|
||||
properties.getPayload().setRecordDetail(true);
|
||||
TraceNodeAspect aspect = new TraceNodeAspect(traceRecordService, properties);
|
||||
Method method = SampleService.class.getDeclaredMethod("sample");
|
||||
prepareSignature(method);
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
|
||||
try (var ignored = TraceContext.begin("trace-1", "TEST", "biz-1", 1L, "000000")) {
|
||||
Object result = aspect.aroundNode(joinPoint, method.getAnnotation(TraceNode.class));
|
||||
|
||||
assertEquals("ok", result);
|
||||
assertNull(TraceContext.currentNodeId());
|
||||
}
|
||||
|
||||
ArgumentCaptor<org.ruoyi.common.trace.domain.TraceNode> nodeCaptor =
|
||||
ArgumentCaptor.forClass(org.ruoyi.common.trace.domain.TraceNode.class);
|
||||
verify(traceRecordService).startNode(nodeCaptor.capture());
|
||||
org.ruoyi.common.trace.domain.TraceNode node = nodeCaptor.getValue();
|
||||
assertEquals("trace-1", node.getTraceId());
|
||||
assertEquals("sample-node", node.getNodeName());
|
||||
assertEquals(TraceConstants.NODE_METHOD, node.getNodeType());
|
||||
assertEquals("safe-input", node.getInputPayload());
|
||||
verify(traceRecordService).finishNode(eq("trace-1"), eq(node.getNodeId()), eq(TraceConstants.STATUS_SUCCESS),
|
||||
isNull(), eq("ok"), any(Date.class), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRethrowBusinessExceptionAfterErrorRecord() throws Throwable {
|
||||
TraceNodeAspect aspect = new TraceNodeAspect(traceRecordService, new TraceProperties());
|
||||
Method method = SampleService.class.getDeclaredMethod("sample");
|
||||
prepareSignature(method);
|
||||
IllegalArgumentException error = new IllegalArgumentException("bad");
|
||||
when(joinPoint.proceed()).thenThrow(error);
|
||||
|
||||
try (var ignored = TraceContext.begin("trace-1", "TEST", "biz-1", 1L, "000000")) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> aspect.aroundNode(joinPoint, method.getAnnotation(TraceNode.class)));
|
||||
}
|
||||
|
||||
ArgumentCaptor<org.ruoyi.common.trace.domain.TraceNode> nodeCaptor =
|
||||
ArgumentCaptor.forClass(org.ruoyi.common.trace.domain.TraceNode.class);
|
||||
verify(traceRecordService).startNode(nodeCaptor.capture());
|
||||
verify(traceRecordService).finishNode(eq("trace-1"), eq(nodeCaptor.getValue().getNodeId()), eq(TraceConstants.STATUS_ERROR),
|
||||
eq("IllegalArgumentException: bad"), isNull(), any(Date.class), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeFailureShouldNotAffectBusinessReturn() throws Throwable {
|
||||
TraceNodeAspect aspect = new TraceNodeAspect(traceRecordService, new TraceProperties());
|
||||
Method method = SampleService.class.getDeclaredMethod("sample");
|
||||
prepareSignature(method);
|
||||
when(joinPoint.proceed()).thenReturn("ok");
|
||||
doThrow(new IllegalStateException("db down")).when(traceRecordService).startNode(any());
|
||||
|
||||
try (var ignored = TraceContext.begin("trace-1", "TEST", "biz-1", 1L, "000000")) {
|
||||
assertEquals("ok", aspect.aroundNode(joinPoint, method.getAnnotation(TraceNode.class)));
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareSignature(Method method) {
|
||||
when(joinPoint.getSignature()).thenReturn(methodSignature);
|
||||
when(methodSignature.getMethod()).thenReturn(method);
|
||||
}
|
||||
|
||||
private static class SampleService {
|
||||
|
||||
@TraceNode(name = "sample-node", input = "safe-input")
|
||||
String sample() {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.ruoyi.common.trace.core;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.ruoyi.common.trace.config.TraceProperties;
|
||||
import org.ruoyi.common.trace.constant.TraceConstants;
|
||||
import org.ruoyi.common.trace.service.TraceRecordService;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DefaultTraceStreamSpanTest {
|
||||
|
||||
@Mock
|
||||
private TraceRecordService traceRecordService;
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TraceContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void finishShouldBeIdempotent() {
|
||||
DefaultTraceStreamSpan span = new DefaultTraceStreamSpan(traceRecordService, new TraceProperties(), "trace-1", "node-1", System.currentTimeMillis());
|
||||
|
||||
span.finishSuccess();
|
||||
span.finishError(new IllegalStateException("ignored"));
|
||||
|
||||
verify(traceRecordService, times(1)).finishNode(eq("trace-1"), eq("node-1"), eq(TraceConstants.STATUS_SUCCESS),
|
||||
isNull(), isNull(), any(Date.class), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void finishErrorShouldRecordTruncatedError() {
|
||||
TraceProperties properties = new TraceProperties();
|
||||
properties.getPayload().setMaxErrorLength(8);
|
||||
DefaultTraceStreamSpan span = new DefaultTraceStreamSpan(traceRecordService, properties, "trace-1", "node-1", System.currentTimeMillis());
|
||||
|
||||
span.finishError(new IllegalStateException("abcdef"));
|
||||
|
||||
ArgumentCaptor<String> errorCaptor = ArgumentCaptor.forClass(String.class);
|
||||
verify(traceRecordService).finishNode(eq("trace-1"), eq("node-1"), eq(TraceConstants.STATUS_ERROR),
|
||||
errorCaptor.capture(), isNull(), any(Date.class), anyLong());
|
||||
assertEquals("IllegalS", errorCaptor.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detachShouldPopOnlyOnce() {
|
||||
TraceContext.pushNode("root");
|
||||
TraceContext.pushNode("stream");
|
||||
DefaultTraceStreamSpan span = new DefaultTraceStreamSpan(traceRecordService, new TraceProperties(), "trace-1", "stream", System.currentTimeMillis());
|
||||
|
||||
span.detach();
|
||||
span.detach();
|
||||
|
||||
assertEquals("root", TraceContext.currentNodeId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeFailureShouldNotEscape() {
|
||||
doThrow(new IllegalStateException("db down")).when(traceRecordService)
|
||||
.finishNode(eq("trace-1"), eq("node-1"), eq(TraceConstants.STATUS_SUCCESS), isNull(), isNull(), any(Date.class), anyLong());
|
||||
DefaultTraceStreamSpan span = new DefaultTraceStreamSpan(traceRecordService, new TraceProperties(), "trace-1", "node-1", System.currentTimeMillis());
|
||||
|
||||
span.finishSuccess();
|
||||
|
||||
assertNull(TraceContext.currentNodeId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.ruoyi.common.trace.core;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Deque;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class TraceContextTest {
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TraceContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void beginShouldExposeTraceAndBusinessIdentity() {
|
||||
try (TraceScope ignored = TraceContext.begin("trace-1", "RAG_CHAT", "session-1", 10L, "000000")) {
|
||||
assertEquals("trace-1", TraceContext.getTraceId());
|
||||
assertEquals("RAG_CHAT", TraceContext.getBusinessType());
|
||||
assertEquals("session-1", TraceContext.getBusinessId());
|
||||
assertEquals(10L, TraceContext.getUserId());
|
||||
assertEquals("000000", TraceContext.getTenantId());
|
||||
}
|
||||
assertNull(TraceContext.getTraceId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nodeStackShouldTrackParentAndDepth() {
|
||||
TraceContext.pushNode("root");
|
||||
assertEquals("root", TraceContext.currentNodeId());
|
||||
assertEquals(1, TraceContext.depth());
|
||||
|
||||
TraceContext.pushNode("child");
|
||||
assertEquals("child", TraceContext.currentNodeId());
|
||||
assertEquals(2, TraceContext.depth());
|
||||
|
||||
TraceContext.popNode();
|
||||
assertEquals("root", TraceContext.currentNodeId());
|
||||
|
||||
TraceContext.popNode();
|
||||
assertNull(TraceContext.currentNodeId());
|
||||
assertEquals(0, TraceContext.depth());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyNodeStackShouldReturnIndependentDeque() {
|
||||
TraceContext.pushNode("root");
|
||||
Deque<String> copy = TraceContext.copyNodeStack();
|
||||
copy.push("child");
|
||||
|
||||
assertEquals("root", TraceContext.currentNodeId());
|
||||
assertEquals("child", copy.peek());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.ruoyi.common.trace.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ruoyi.common.trace.config.TraceProperties;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class TracePayloadUtilsTest {
|
||||
|
||||
@Test
|
||||
void truncateShouldRespectMaxLength() {
|
||||
assertEquals("abc", TracePayloadUtils.truncate("abcdef", 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncateShouldHandleNull() {
|
||||
assertNull(TracePayloadUtils.truncate(null, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toJsonShouldSerializeMap() {
|
||||
String json = TracePayloadUtils.toJson(Map.of("count", 2));
|
||||
assertTrue(json.contains("\"count\":2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailEnabledShouldTruncateInputAndOutput() {
|
||||
TraceProperties properties = new TraceProperties();
|
||||
properties.getPayload().setRecordDetail(true);
|
||||
properties.getPayload().setMaxInputLength(4);
|
||||
properties.getPayload().setMaxOutputLength(5);
|
||||
|
||||
assertEquals("1234", TracePayloadUtils.input("123456", properties));
|
||||
assertEquals("12345", TracePayloadUtils.output("1234567", properties));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user